Add snapshot, signature, and drift guard support for Vertex AI, Cloudflare Workers AI, and Perplexity API, backed by a queryable audit table and recent-window view. This commit also wires the audit query layer into daily signal materialization and report generation so structure drift becomes a first-class signal instead of a log-only artifact.
979 lines
28 KiB
Go
979 lines
28 KiB
Go
//go:build llm_script
|
||
|
||
package main
|
||
|
||
import (
|
||
"database/sql"
|
||
"encoding/json"
|
||
"flag"
|
||
"fmt"
|
||
"log/slog"
|
||
"os"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
_ "github.com/lib/pq"
|
||
)
|
||
|
||
type signalModelInfo struct {
|
||
Name string
|
||
ProviderName string
|
||
ProviderCountry string
|
||
ContextLength int
|
||
InputPrice float64
|
||
OutputPrice float64
|
||
Currency string
|
||
IsFree bool
|
||
OperatorName string
|
||
OperatorType string
|
||
}
|
||
|
||
type signalDailySignals struct {
|
||
NewModels int `json:"new_models"`
|
||
PriceChanges int `json:"price_changes"`
|
||
OfficialFree int `json:"official_free"`
|
||
AggregatorFree int `json:"aggregator_free"`
|
||
UnknownFree int `json:"unknown_free"`
|
||
}
|
||
|
||
type signalModelEvent struct {
|
||
EventType string `json:"event_type"`
|
||
ModelName string `json:"model_name"`
|
||
ProviderName string `json:"provider_name"`
|
||
OperatorName string `json:"operator_name"`
|
||
Audience string `json:"audience"`
|
||
TrustLabel string `json:"trust_label"`
|
||
SourceKindLabel string `json:"source_kind_label"`
|
||
PrimarySource string `json:"primary_source"`
|
||
UpdatedAt string `json:"updated_at"`
|
||
EvidenceDetail string `json:"evidence_detail"`
|
||
Baseline string `json:"baseline"`
|
||
Summary string `json:"summary"`
|
||
Currency string `json:"currency"`
|
||
OldInputPrice float64 `json:"old_input_price"`
|
||
NewInputPrice float64 `json:"new_input_price"`
|
||
OldOutputPrice float64 `json:"old_output_price"`
|
||
NewOutputPrice float64 `json:"new_output_price"`
|
||
PriceChangePct float64 `json:"price_change_pct"`
|
||
Priority int `json:"priority"`
|
||
}
|
||
|
||
type signalPromoCampaignDefinition 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 dailySignalSnapshot struct {
|
||
SignalDate string
|
||
Status string
|
||
Signals signalDailySignals
|
||
EventCount int
|
||
PageMode string
|
||
EventTypeCounts map[string]int
|
||
TopEvents []signalModelEvent
|
||
SourceAudit string
|
||
}
|
||
|
||
type materializeDailySignalsConfig struct {
|
||
Date string
|
||
SourceAudit string
|
||
DryRun bool
|
||
}
|
||
|
||
var signalLogger *slog.Logger
|
||
|
||
const signalUSDToCNY = 7.25
|
||
|
||
func init() {
|
||
signalLogger = slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||
}
|
||
|
||
func main() {
|
||
loadSignalEnv()
|
||
|
||
var cfg materializeDailySignalsConfig
|
||
flag.StringVar(&cfg.Date, "date", signalDateValue(), "信号日期,格式 YYYY-MM-DD")
|
||
flag.StringVar(&cfg.SourceAudit, "source-audit", os.Getenv("SIGNAL_SOURCE_AUDIT"), "运行审计摘要")
|
||
flag.BoolVar(&cfg.DryRun, "dry-run", false, "仅计算并打印摘要,不写入数据库")
|
||
flag.Parse()
|
||
|
||
db, err := sql.Open("postgres", defaultSignalDSN())
|
||
if err != nil {
|
||
fmt.Fprintf(os.Stderr, "open db: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
defer db.Close()
|
||
|
||
if err := runMaterializeDailySignals(db, cfg); err != nil {
|
||
fmt.Fprintf(os.Stderr, "materialize_daily_signals: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
}
|
||
|
||
func loadSignalEnv() {
|
||
for _, path := range []string{".env.local", ".env"} {
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
for _, line := range strings.Split(string(data), "\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.Trim(strings.TrimSpace(value), `"'`)
|
||
if key == "" {
|
||
continue
|
||
}
|
||
if _, exists := os.LookupEnv(key); exists {
|
||
continue
|
||
}
|
||
_ = os.Setenv(key, value)
|
||
}
|
||
}
|
||
}
|
||
|
||
func defaultSignalDSN() string {
|
||
if dsn := os.Getenv("DATABASE_URL"); dsn != "" {
|
||
return dsn
|
||
}
|
||
return "postgres://long@/llm_intelligence?host=/var/run/postgresql"
|
||
}
|
||
|
||
func signalDateValue() string {
|
||
if value := strings.TrimSpace(os.Getenv("REPORT_DATE")); value != "" {
|
||
return value
|
||
}
|
||
return time.Now().Format("2006-01-02")
|
||
}
|
||
|
||
func runMaterializeDailySignals(db *sql.DB, cfg materializeDailySignalsConfig) error {
|
||
signals, err := loadSignalDailySignals(db, cfg.Date)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
freeSignals, err := loadSignalFreeBreakdown(db)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
signals.OfficialFree = freeSignals.OfficialFree
|
||
signals.AggregatorFree = freeSignals.AggregatorFree
|
||
signals.UnknownFree = freeSignals.UnknownFree
|
||
|
||
events, err := loadSignalModelEvents(db, cfg.Date)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
snapshot := dailySignalSnapshot{
|
||
SignalDate: cfg.Date,
|
||
Status: "generated",
|
||
Signals: signals,
|
||
EventCount: len(events),
|
||
PageMode: buildSignalPageMode(signals, events),
|
||
EventTypeCounts: summarizeSignalEventTypes(events),
|
||
TopEvents: events,
|
||
SourceAudit: strings.TrimSpace(cfg.SourceAudit),
|
||
}
|
||
|
||
if cfg.DryRun {
|
||
fmt.Printf("source=daily-signal-materializer date=%s new_models=%d price_changes=%d event_count=%d page_mode=%s dry_run=true\n",
|
||
snapshot.SignalDate, snapshot.Signals.NewModels, snapshot.Signals.PriceChanges, snapshot.EventCount, snapshot.PageMode)
|
||
return nil
|
||
}
|
||
|
||
if err := upsertDailySignalSnapshot(db, snapshot); err != nil {
|
||
return err
|
||
}
|
||
fmt.Printf("source=daily-signal-materializer date=%s new_models=%d price_changes=%d event_count=%d page_mode=%s dry_run=false\n",
|
||
snapshot.SignalDate, snapshot.Signals.NewModels, snapshot.Signals.PriceChanges, snapshot.EventCount, snapshot.PageMode)
|
||
return nil
|
||
}
|
||
|
||
func upsertDailySignalSnapshot(db *sql.DB, snapshot dailySignalSnapshot) error {
|
||
eventTypeCounts, err := json.Marshal(snapshot.EventTypeCounts)
|
||
if err != nil {
|
||
return fmt.Errorf("marshal event_type_counts: %w", err)
|
||
}
|
||
topEvents, err := json.Marshal(snapshot.TopEvents)
|
||
if err != nil {
|
||
return fmt.Errorf("marshal top_events: %w", err)
|
||
}
|
||
|
||
_, err = db.Exec(
|
||
`INSERT INTO daily_signal_snapshot (
|
||
signal_date, status, new_models, price_changes,
|
||
official_free, aggregator_free, unknown_free,
|
||
event_count, page_mode, event_type_counts, top_events, source_audit
|
||
) VALUES (
|
||
$1::date, $2, $3, $4,
|
||
$5, $6, $7,
|
||
$8, $9, $10::jsonb, $11::jsonb, $12
|
||
)
|
||
ON CONFLICT (signal_date)
|
||
DO UPDATE SET
|
||
status = EXCLUDED.status,
|
||
new_models = EXCLUDED.new_models,
|
||
price_changes = EXCLUDED.price_changes,
|
||
official_free = EXCLUDED.official_free,
|
||
aggregator_free = EXCLUDED.aggregator_free,
|
||
unknown_free = EXCLUDED.unknown_free,
|
||
event_count = EXCLUDED.event_count,
|
||
page_mode = EXCLUDED.page_mode,
|
||
event_type_counts = EXCLUDED.event_type_counts,
|
||
top_events = EXCLUDED.top_events,
|
||
source_audit = EXCLUDED.source_audit,
|
||
generated_at = CURRENT_TIMESTAMP,
|
||
updated_at = CURRENT_TIMESTAMP`,
|
||
snapshot.SignalDate, snapshot.Status, snapshot.Signals.NewModels, snapshot.Signals.PriceChanges,
|
||
snapshot.Signals.OfficialFree, snapshot.Signals.AggregatorFree, snapshot.Signals.UnknownFree,
|
||
snapshot.EventCount, snapshot.PageMode, string(eventTypeCounts), string(topEvents), signalNullIfBlank(snapshot.SourceAudit),
|
||
)
|
||
if err != nil {
|
||
return fmt.Errorf("upsert daily_signal_snapshot: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func loadSignalDailySignals(db *sql.DB, date string) (signalDailySignals, error) {
|
||
signals := signalDailySignals{}
|
||
|
||
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 loadSignalFreeBreakdown(db *sql.DB) (signalDailySignals, 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(mp.country, 'unknown') AS provider_country,
|
||
COALESCE(m.context_length, 0) AS context_length,
|
||
COALESCE(lp.input_price_per_mtok, 0) AS input_price,
|
||
COALESCE(lp.output_price_per_mtok, 0) AS output_price,
|
||
COALESCE(lp.currency, 'USD') AS currency,
|
||
COALESCE(lp.operator_name, 'OpenRouter') AS operator_name,
|
||
COALESCE(lp.operator_type, 'reseller') AS operator_type
|
||
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 COALESCE(lp.is_free, false) = true
|
||
`)
|
||
if err != nil {
|
||
return signalDailySignals{}, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
signals := signalDailySignals{}
|
||
for rows.Next() {
|
||
var model signalModelInfo
|
||
if err := rows.Scan(
|
||
&model.Name,
|
||
&model.ProviderName,
|
||
&model.ProviderCountry,
|
||
&model.ContextLength,
|
||
&model.InputPrice,
|
||
&model.OutputPrice,
|
||
&model.Currency,
|
||
&model.OperatorName,
|
||
&model.OperatorType,
|
||
); err != nil {
|
||
return signalDailySignals{}, err
|
||
}
|
||
switch classifySignalFreeSource(model) {
|
||
case "官方免费":
|
||
signals.OfficialFree++
|
||
case "聚合免费":
|
||
signals.AggregatorFree++
|
||
default:
|
||
signals.UnknownFree++
|
||
}
|
||
}
|
||
return signals, rows.Err()
|
||
}
|
||
|
||
func loadSignalModelEvents(db *sql.DB, date string) ([]signalModelEvent, error) {
|
||
var events []signalModelEvent
|
||
|
||
newModelEvents, err := loadSignalNewModelEvents(db, date)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
events = append(events, newModelEvents...)
|
||
|
||
releaseEvents, err := loadSignalOfficialReleaseEvents(db, date)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
events = append(events, releaseEvents...)
|
||
|
||
promoEvents, err := loadSignalPromoCampaignEvents(date)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
events = append(events, promoEvents...)
|
||
|
||
priceEvents, err := loadSignalPriceChangeEvents(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 dedupeSignalEvents(events), nil
|
||
}
|
||
|
||
func loadSignalPromoCampaignEvents(date string) ([]signalModelEvent, error) {
|
||
path, err := resolveSignalPromoCampaignDataPath()
|
||
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 []signalPromoCampaignDefinition
|
||
if err := json.Unmarshal(body, &definitions); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
events := make([]signalModelEvent, 0)
|
||
for _, definition := range definitions {
|
||
if definition.Date != date {
|
||
continue
|
||
}
|
||
events = append(events, signalModelEvent{
|
||
EventType: "promo_campaign",
|
||
ModelName: definition.ModelName,
|
||
ProviderName: definition.ProviderName,
|
||
OperatorName: definition.OperatorName,
|
||
Audience: signalFirstNonEmpty(definition.Audience, "适合计划利用活动窗口压低成本的团队"),
|
||
TrustLabel: signalFirstNonEmpty(definition.TrustLabel, "官方来源 / 一级证据"),
|
||
SourceKindLabel: signalFirstNonEmpty(definition.SourceKindLabel, "官方活动页"),
|
||
PrimarySource: definition.PrimarySource,
|
||
UpdatedAt: signalFormatEventUpdatedAt("", definition.Date),
|
||
EvidenceDetail: definition.EvidenceDetail,
|
||
Baseline: signalFirstNonEmpty(definition.Baseline, "活动窗口开启"),
|
||
Summary: definition.Summary,
|
||
Priority: signalMaxInt(definition.Priority, 115),
|
||
})
|
||
}
|
||
return events, nil
|
||
}
|
||
|
||
func resolveSignalPromoCampaignDataPath() (string, error) {
|
||
candidates := []string{
|
||
filepathJoin("scripts", "testdata", "report_promo_campaigns.json"),
|
||
filepathJoin("testdata", "report_promo_campaigns.json"),
|
||
}
|
||
for _, candidate := range candidates {
|
||
if _, err := os.Stat(candidate); err == nil {
|
||
return candidate, nil
|
||
}
|
||
}
|
||
return "", os.ErrNotExist
|
||
}
|
||
|
||
func loadSignalOfficialReleaseEvents(db *sql.DB, date string) ([]signalModelEvent, 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 []signalModelEvent
|
||
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 := signalModelInfo{
|
||
Name: modelName,
|
||
ProviderName: providerName,
|
||
ProviderCountry: providerCountry,
|
||
Currency: currency,
|
||
OperatorName: operatorName,
|
||
OperatorType: operatorType,
|
||
}
|
||
|
||
events = append(events, signalModelEvent{
|
||
EventType: "official_release",
|
||
ModelName: modelName,
|
||
ProviderName: providerName,
|
||
OperatorName: operatorName,
|
||
Audience: "适合需要复查默认选型与路线图判断的团队",
|
||
TrustLabel: buildSignalReleaseTrustLabel(model, dateConfidence),
|
||
SourceKindLabel: buildSignalReleaseSourceKindLabel(dateSourceKind, dateConfidence),
|
||
PrimarySource: sourceURL,
|
||
UpdatedAt: releaseDate.Format("2006-01-02 15:04"),
|
||
EvidenceDetail: buildSignalReleaseEvidenceDetail(dateSourceKind, dateConfidence),
|
||
Baseline: "官方首次发布",
|
||
Summary: fmt.Sprintf("%s 官方发布新模型,值得优先复查默认选型。", providerName),
|
||
Currency: currency,
|
||
Priority: 120,
|
||
})
|
||
}
|
||
return events, rows.Err()
|
||
}
|
||
|
||
func loadSignalNewModelEvents(db *sql.DB, date string) ([]signalModelEvent, 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 []signalModelEvent
|
||
for rows.Next() {
|
||
var model signalModelInfo
|
||
var createdAt time.Time
|
||
if err := rows.Scan(
|
||
&model.Name,
|
||
&model.ProviderName,
|
||
&model.OperatorName,
|
||
&model.OperatorType,
|
||
&model.Currency,
|
||
&model.InputPrice,
|
||
&model.OutputPrice,
|
||
&model.IsFree,
|
||
&model.ContextLength,
|
||
&model.ProviderCountry,
|
||
&createdAt,
|
||
); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
summary := "新模型进入情报池,值得重新评估当前默认选择。"
|
||
if model.IsFree {
|
||
summary = fmt.Sprintf("新模型首日可免费试用,需注意其免费来源属于%s。", classifySignalFreeSource(model))
|
||
} else if model.ContextLength >= 1024*256 {
|
||
summary = fmt.Sprintf("新模型带来 %s 长上下文,值得复查 Agent 和代码场景。", signalFormatContextWindowCompact(model.ContextLength))
|
||
}
|
||
|
||
events = append(events, signalModelEvent{
|
||
EventType: "new_model",
|
||
ModelName: model.Name,
|
||
ProviderName: model.ProviderName,
|
||
OperatorName: model.OperatorName,
|
||
Audience: "适合想尽快验证新模型价值的选型读者",
|
||
TrustLabel: buildSignalTrustLabel(model),
|
||
SourceKindLabel: "模型快照",
|
||
PrimarySource: buildSignalPrimarySource("region_pricing", model.OperatorName),
|
||
UpdatedAt: createdAt.Format("2006-01-02 15:04"),
|
||
EvidenceDetail: "models.created_at = 今日,且已存在最新价格快照",
|
||
Baseline: "首次出现",
|
||
Summary: summary,
|
||
Currency: model.Currency,
|
||
NewInputPrice: model.InputPrice,
|
||
NewOutputPrice: model.OutputPrice,
|
||
Priority: 85 + signalMinInt(model.ContextLength/(1024*128), 10),
|
||
})
|
||
}
|
||
return events, rows.Err()
|
||
}
|
||
|
||
func loadSignalPriceChangeEvents(db *sql.DB, date string) ([]signalModelEvent, 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 []signalModelEvent
|
||
for rows.Next() {
|
||
var (
|
||
model signalModelInfo
|
||
oldInputPrice float64
|
||
newInputPrice float64
|
||
oldOutputPrice float64
|
||
newOutputPrice float64
|
||
changedAt time.Time
|
||
)
|
||
if err := rows.Scan(
|
||
&model.Name,
|
||
&model.ProviderName,
|
||
&model.OperatorName,
|
||
&model.OperatorType,
|
||
&model.Currency,
|
||
&oldInputPrice,
|
||
&newInputPrice,
|
||
&oldOutputPrice,
|
||
&newOutputPrice,
|
||
&model.ProviderCountry,
|
||
&changedAt,
|
||
); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
changePct := signalSignedPriceChangePct(oldInputPrice, newInputPrice, oldOutputPrice, newOutputPrice)
|
||
if changePct == 0 {
|
||
continue
|
||
}
|
||
|
||
eventType := "price_increase"
|
||
summary := "价格上调已足以影响默认成本,需要确认备用模型。"
|
||
if changePct < 0 {
|
||
eventType = "price_cut"
|
||
summary = "价格下降已足以影响默认选型,值得重新评估同类模型。"
|
||
}
|
||
|
||
events = append(events, signalModelEvent{
|
||
EventType: eventType,
|
||
ModelName: model.Name,
|
||
ProviderName: model.ProviderName,
|
||
OperatorName: model.OperatorName,
|
||
Audience: buildSignalPriceEventAudience(changePct),
|
||
TrustLabel: buildSignalTrustLabel(model),
|
||
SourceKindLabel: "价格快照",
|
||
PrimarySource: "pricing_history",
|
||
UpdatedAt: changedAt.Format("2006-01-02 15:04"),
|
||
EvidenceDetail: buildSignalPriceEvidenceDetail(changePct, oldInputPrice, newInputPrice, model.Currency),
|
||
Baseline: fmt.Sprintf("较昨日 %+.0f%%", changePct),
|
||
Summary: summary,
|
||
Currency: model.Currency,
|
||
OldInputPrice: oldInputPrice,
|
||
NewInputPrice: newInputPrice,
|
||
OldOutputPrice: oldOutputPrice,
|
||
NewOutputPrice: newOutputPrice,
|
||
PriceChangePct: changePct,
|
||
Priority: 70 + signalMinInt(int(signalAbs(changePct)), 25),
|
||
})
|
||
}
|
||
return events, rows.Err()
|
||
}
|
||
|
||
func summarizeSignalEventTypes(events []signalModelEvent) map[string]int {
|
||
counts := make(map[string]int)
|
||
for _, event := range events {
|
||
counts[event.EventType]++
|
||
}
|
||
return counts
|
||
}
|
||
|
||
func dedupeSignalEvents(events []signalModelEvent) []signalModelEvent {
|
||
seen := make(map[string]struct{})
|
||
result := make([]signalModelEvent, 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 classifySignalFreeSource(model signalModelInfo) string {
|
||
switch model.OperatorType {
|
||
case "official", "cloud":
|
||
return "官方免费"
|
||
case "reseller":
|
||
if isSignalVerifiedAggregator(model.OperatorName) {
|
||
return "聚合免费"
|
||
}
|
||
}
|
||
return "待确认"
|
||
}
|
||
|
||
func isSignalVerifiedAggregator(name string) bool {
|
||
switch strings.ToLower(strings.TrimSpace(name)) {
|
||
case "openrouter", "siliconflow", "fireworks", "groq":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func buildSignalPageMode(signals signalDailySignals, events []signalModelEvent) string {
|
||
if hasSignalEventType(events, "official_release") || hasSignalEventType(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 hasSignalEventType(events []signalModelEvent, eventType string) bool {
|
||
for _, event := range events {
|
||
if event.EventType == eventType {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func buildSignalTrustLabel(model signalModelInfo) string {
|
||
switch model.OperatorType {
|
||
case "official", "cloud":
|
||
return "官方来源"
|
||
case "reseller":
|
||
if isSignalVerifiedAggregator(model.OperatorName) {
|
||
return "聚合来源"
|
||
}
|
||
}
|
||
return "待验证来源"
|
||
}
|
||
|
||
func buildSignalPrimarySource(sourceKind, operatorName string) string {
|
||
switch sourceKind {
|
||
case "region_pricing":
|
||
if operatorName == "" {
|
||
return "region_pricing"
|
||
}
|
||
return operatorName + " / region_pricing"
|
||
default:
|
||
return sourceKind
|
||
}
|
||
}
|
||
|
||
func buildSignalPriceEvidenceDetail(changePct, oldPrice, newPrice float64, currency string) string {
|
||
direction := "上涨"
|
||
if changePct < 0 {
|
||
direction = "下降"
|
||
}
|
||
return fmt.Sprintf(
|
||
"pricing_history 记录到输入价格由 %s 调整为 %s,较昨日%s %.0f%%",
|
||
signalFormatPrice(oldPrice, currency),
|
||
signalFormatPrice(newPrice, currency),
|
||
direction,
|
||
signalAbs(changePct),
|
||
)
|
||
}
|
||
|
||
func buildSignalReleaseSourceKindLabel(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 buildSignalReleaseEvidenceDetail(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 buildSignalReleaseTrustLabel(model signalModelInfo, dateConfidence string) string {
|
||
base := buildSignalTrustLabel(model)
|
||
switch dateConfidence {
|
||
case "official_primary":
|
||
return base + " / 一级证据"
|
||
case "secondary_authoritative":
|
||
return base + " / 二级佐证"
|
||
default:
|
||
return base
|
||
}
|
||
}
|
||
|
||
func buildSignalPriceEventAudience(changePct float64) string {
|
||
if changePct < 0 {
|
||
return "适合以成本为先、准备趁降价重排默认选型的团队"
|
||
}
|
||
return "适合需要提前准备替代模型和预算回退方案的团队"
|
||
}
|
||
|
||
func signalFormatEventUpdatedAt(value, fallbackDate string) string {
|
||
if strings.TrimSpace(value) != "" {
|
||
return value
|
||
}
|
||
if fallbackDate != "" {
|
||
return fallbackDate + " 00:00"
|
||
}
|
||
return "-"
|
||
}
|
||
|
||
func signalFormatPrice(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)
|
||
}
|
||
cny := price * signalUSDToCNY
|
||
if cny < 1 {
|
||
return fmt.Sprintf("¥%.2f", cny)
|
||
}
|
||
return fmt.Sprintf("¥%.1f", cny)
|
||
}
|
||
|
||
func signalFormatContextWindowCompact(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)
|
||
}
|
||
|
||
func signalSignedPriceChangePct(oldInput, newInput, oldOutput, newOutput float64) float64 {
|
||
inputPct := signalSignedChange(oldInput, newInput)
|
||
outputPct := signalSignedChange(oldOutput, newOutput)
|
||
if signalAbs(inputPct) >= signalAbs(outputPct) {
|
||
return inputPct
|
||
}
|
||
return outputPct
|
||
}
|
||
|
||
func signalSignedChange(oldValue, newValue float64) float64 {
|
||
if oldValue == 0 {
|
||
if newValue == 0 {
|
||
return 0
|
||
}
|
||
return 100
|
||
}
|
||
return ((newValue - oldValue) / oldValue) * 100
|
||
}
|
||
|
||
func signalFirstNonEmpty(values ...string) string {
|
||
for _, value := range values {
|
||
if strings.TrimSpace(value) != "" {
|
||
return value
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func signalAbs(v float64) float64 {
|
||
if v < 0 {
|
||
return -v
|
||
}
|
||
return v
|
||
}
|
||
|
||
func signalMinInt(a, b int) int {
|
||
if a < b {
|
||
return a
|
||
}
|
||
return b
|
||
}
|
||
|
||
func signalMaxInt(a, b int) int {
|
||
if a > b {
|
||
return a
|
||
}
|
||
return b
|
||
}
|
||
|
||
func filepathJoin(parts ...string) string {
|
||
return strings.Join(parts, string(os.PathSeparator))
|
||
}
|
||
|
||
func signalNullIfBlank(value string) any {
|
||
if strings.TrimSpace(value) == "" {
|
||
return nil
|
||
}
|
||
return value
|
||
}
|