feat(phase1): OpenRouter采集器接入PostgreSQL,数据链路闭环
- 将 fetch_openrouter.go 的 summarize() 实现为 PostgreSQL upsert - 新增 -db 参数和 DATABASE_URL 环境变量支持 - 打通 models + model_prices 表的最小可运行链路 - 创建 llm_intelligence 数据库并运行 migration - 前端 Explorer 验证 T-3.2~T-3.5 全部通过 - 日报生成器正常产出 Markdown 和 latest_models.json
This commit is contained in:
351
scripts/fetch_openrouter.go
Normal file
351
scripts/fetch_openrouter.go
Normal file
@@ -0,0 +1,351 @@
|
||||
// fetch_openrouter.go - OpenRouter 模型数据采集器
|
||||
// Phase 1 单数据源采集器,抓取模型基础信息与价格信息
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// Config 采集配置
|
||||
type Config struct {
|
||||
APIKey string
|
||||
APIURL string
|
||||
OutPath string
|
||||
MaxRetries int
|
||||
TimeoutSec int
|
||||
// PostgreSQL 连接参数(新增)
|
||||
DBConn string // e.g. "host=/var/run/postgresql dbname=llm_intelligence sslmode=disable"
|
||||
}
|
||||
|
||||
// OpenRouter API 响应结构(仅关键字段)
|
||||
type APIResponse struct {
|
||||
Data []ModelInfo `json:"data"`
|
||||
}
|
||||
|
||||
type ModelInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Created int64 `json:"created,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
ContextLength int `json:"context_length,omitempty"`
|
||||
Capabilities []string `json:"capabilities,omitempty"`
|
||||
Pricing ModelPricing `json:"pricing,omitempty"`
|
||||
}
|
||||
|
||||
type ModelPricing struct {
|
||||
Input float64 `json:"input,omitempty"`
|
||||
Output float64 `json:"output,omitempty"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg := parseArgs()
|
||||
if err := run(cfg); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "采集失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func parseArgs() Config {
|
||||
apiKey := flag.String("api-key", "", "OpenRouter API Key(建议通过环境变量注入)")
|
||||
apiURL := flag.String("api-url", "https://openrouter.ai/api/v1/models", "API 地址")
|
||||
outPath := flag.String("out", "models.json", "输出文件路径")
|
||||
maxRetries := flag.Int("retry", 3, "最大重试次数")
|
||||
timeoutSec := flag.Int("timeout", 30, "请求超时(秒)")
|
||||
dbConn := flag.String("db", os.Getenv("DATABASE_URL"), "PostgreSQL 连接字符串(默认从 DATABASE_URL 环境变量读取)")
|
||||
flag.Parse()
|
||||
return Config{
|
||||
APIKey: *apiKey,
|
||||
APIURL: *apiURL,
|
||||
OutPath: *outPath,
|
||||
MaxRetries: *maxRetries,
|
||||
TimeoutSec: *timeoutSec,
|
||||
DBConn: *dbConn,
|
||||
}
|
||||
}
|
||||
|
||||
func run(cfg Config) error {
|
||||
models, err := fetchModels(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 优先写入 PostgreSQL;若配置了 DBConn 则入库
|
||||
if cfg.DBConn != "" {
|
||||
if err := summarizeDB(cfg.DBConn, models); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "警告: PostgreSQL 写入失败: %v\n", err)
|
||||
fmt.Fprintln(os.Stderr, "降级为仅写入 JSON")
|
||||
}
|
||||
}
|
||||
return summarize(cfg.OutPath, models)
|
||||
}
|
||||
|
||||
// fetchModels 抓取 OpenRouter 模型列表
|
||||
func fetchModels(cfg Config) ([]ModelInfo, error) {
|
||||
// 无 API Key 时返回模拟数据(写入由后续 summarize 统一处理)
|
||||
if cfg.APIKey == "" {
|
||||
fmt.Println("警告: 未提供 API Key,使用模拟数据")
|
||||
return []ModelInfo{
|
||||
{ID: "openai/gpt-4o", ContextLength: 128000,
|
||||
Pricing: ModelPricing{Input: 2.5, Output: 10.0}},
|
||||
{ID: "anthropic/claude-3.5-sonnet:free", ContextLength: 200000,
|
||||
Pricing: ModelPricing{}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: time.Duration(cfg.TimeoutSec) * time.Second}
|
||||
req, err := http.NewRequest("GET", cfg.APIURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("构造请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
var resp *http.Response
|
||||
for i := 0; i <= cfg.MaxRetries; i++ {
|
||||
resp, err = client.Do(req)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if i < cfg.MaxRetries {
|
||||
time.Sleep(time.Duration(i+1) * time.Second)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("非 200 响应: %d %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取响应失败: %w", err)
|
||||
}
|
||||
|
||||
// 健壮解析,兼容字段缺失和结构差异
|
||||
models, err := parseModels(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("JSON 解析失败: %w", err)
|
||||
}
|
||||
|
||||
// TODO: 字段标准化映射(OpenRouter id → 标准厂商名、模型名)
|
||||
return models, nil
|
||||
}
|
||||
|
||||
// parseModels 健壮解析模型列表,兼容字段缺失/类型不一致/嵌套结构差异
|
||||
func parseModels(raw []byte) ([]ModelInfo, error) {
|
||||
var wrapper struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &wrapper); err != nil {
|
||||
return nil, fmt.Errorf("解析 data 字段失败: %w", err)
|
||||
}
|
||||
// data 为数组,每元素字段可能不同,统一用 map[string]any 兼容
|
||||
var rawItems []any
|
||||
if err := json.Unmarshal(wrapper.Data, &rawItems); err != nil {
|
||||
return nil, fmt.Errorf("解析模型数组失败: %w", err)
|
||||
}
|
||||
|
||||
models := make([]ModelInfo, 0, len(rawItems))
|
||||
for _, item := range rawItems {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue // 跳过非法条目
|
||||
}
|
||||
model := ModelInfo{
|
||||
ID: getString(m, "id"),
|
||||
Name: getString(m, "name"),
|
||||
}
|
||||
if model.ID == "" {
|
||||
continue // id 为必填
|
||||
}
|
||||
|
||||
// pricing 可能为嵌套对象(如 {openrouter: {input: 1}}),尝试多路径取值
|
||||
if p, ok := m["pricing"].(map[string]any); ok {
|
||||
model.Pricing.Input = getPrice(p, "input", "prompt")
|
||||
model.Pricing.Output = getPrice(p, "output", "completion")
|
||||
}
|
||||
|
||||
model.ContextLength = getInt(m, "context_length")
|
||||
model.Description = getString(m, "description")
|
||||
model.Created = getInt64(m, "created")
|
||||
|
||||
if caps, ok := m["capabilities"].([]any); ok {
|
||||
for _, c := range caps {
|
||||
if s, ok := c.(string); ok {
|
||||
model.Capabilities = append(model.Capabilities, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
models = append(models, model)
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
|
||||
func getString(m map[string]any, key string) string {
|
||||
if v, ok := m[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getInt(m map[string]any, key string) int {
|
||||
if v, ok := m[key].(float64); ok {
|
||||
return int(v)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func getInt64(m map[string]any, key string) int64 {
|
||||
if v, ok := m[key].(float64); ok {
|
||||
return int64(v)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// getPrice 多路径取值,兼容不同嵌套结构(如 {input:1} 或 {openrouter:{input:1}})
|
||||
func getPrice(m map[string]any, keys ...string) float64 {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k].(float64); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// summarize 输出采集摘要到 JSON 文件(保持向后兼容)
|
||||
func summarize(outPath string, models []ModelInfo) error {
|
||||
return writeJSON(outPath, models)
|
||||
}
|
||||
|
||||
// summarizeDB 将采集结果写入 PostgreSQL(models + model_prices 表)
|
||||
func summarizeDB(connStr string, models []ModelInfo) error {
|
||||
db, err := sql.Open("postgres", connStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("连接数据库失败: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
return fmt.Errorf("ping 数据库失败: %w", err)
|
||||
}
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("开启事务失败: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
now := time.Now()
|
||||
insertedModels := 0
|
||||
insertedPrices := 0
|
||||
|
||||
for _, m := range models {
|
||||
isFree := len(m.ID) > 5 && m.ID[len(m.ID)-5:] == ":free"
|
||||
// upsert models 表
|
||||
var modelID int64
|
||||
err := tx.QueryRow(`
|
||||
INSERT INTO models (source, external_id, name, description, context_length, capabilities, created_at_source, is_free, status, raw_payload, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
ON CONFLICT (external_id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
description = EXCLUDED.description,
|
||||
context_length = EXCLUDED.context_length,
|
||||
capabilities = EXCLUDED.capabilities,
|
||||
created_at_source = EXCLUDED.created_at_source,
|
||||
is_free = EXCLUDED.is_free,
|
||||
status = EXCLUDED.status,
|
||||
raw_payload = EXCLUDED.raw_payload,
|
||||
updated_at = $12
|
||||
RETURNING id
|
||||
`, "openrouter", m.ID, m.Name, m.Description, m.ContextLength,
|
||||
jsonCapabilities(m.Capabilities), m.Created, isFree, "active",
|
||||
rawPayload(m), now, now).Scan(&modelID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("写入 models 失败 (%s): %w", m.ID, err)
|
||||
}
|
||||
insertedModels++
|
||||
|
||||
// upsert model_prices 表(当天有效日期)
|
||||
effectiveDate := now.Format("2006-01-02")
|
||||
_, err = tx.Exec(`
|
||||
INSERT INTO model_prices (model_id, source, currency, input_price_per_mtok, output_price_per_mtok, effective_date, source_url, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (model_id, source, currency, effective_date) DO UPDATE SET
|
||||
input_price_per_mtok = EXCLUDED.input_price_per_mtok,
|
||||
output_price_per_mtok = EXCLUDED.output_price_per_mtok,
|
||||
created_at = EXCLUDED.created_at
|
||||
`, modelID, "openrouter", "USD", m.Pricing.Input, m.Pricing.Output, effectiveDate, "https://openrouter.ai/api/v1/models", now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("写入 model_prices 失败 (%s): %w", m.ID, err)
|
||||
}
|
||||
insertedPrices++
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("提交事务失败: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("PostgreSQL 写入完成: %d models, %d prices\n", insertedModels, insertedPrices)
|
||||
return nil
|
||||
}
|
||||
|
||||
func jsonCapabilities(caps []string) []byte {
|
||||
if len(caps) == 0 {
|
||||
return []byte("[]")
|
||||
}
|
||||
b, _ := json.Marshal(caps)
|
||||
return b
|
||||
}
|
||||
|
||||
func rawPayload(m ModelInfo) []byte {
|
||||
b, _ := json.Marshal(m)
|
||||
return b
|
||||
}
|
||||
|
||||
// writeJSON 统一写入 JSON 文件(含摘要信息)
|
||||
func writeJSON(outPath string, models []ModelInfo) error {
|
||||
total := len(models)
|
||||
var freeCnt, paidCnt int
|
||||
for _, m := range models {
|
||||
if len(m.ID) > 5 && m.ID[len(m.ID)-5:] == ":free" {
|
||||
freeCnt++
|
||||
} else if m.Pricing.Input > 0 || m.Pricing.Output > 0 {
|
||||
paidCnt++
|
||||
}
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf("采集完成: 共 %d 模型(免费 %d / 付费 %d)\n", total, freeCnt, paidCnt)
|
||||
fmt.Print(summary)
|
||||
|
||||
out, err := os.Create(outPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建输出文件失败: %w", err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
enc := json.NewEncoder(out)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(map[string]any{
|
||||
"generated_at": time.Now().Format(time.RFC3339),
|
||||
"total": total,
|
||||
"free": freeCnt,
|
||||
"paid": paidCnt,
|
||||
"models": models,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("写入 JSON 失败: %w", err)
|
||||
}
|
||||
fmt.Printf("结果已写入: %s\n", outPath)
|
||||
return nil
|
||||
}
|
||||
98
scripts/fetch_openrouter_test.go
Normal file
98
scripts/fetch_openrouter_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Test 1: parseModels 正确解析 name、context_length、capabilities、pricing input/prompt 和 output/completion
|
||||
func TestParseModels(t *testing.T) {
|
||||
// 从样例文件读取,而非内联 JSON
|
||||
samplePath := filepath.Join("testdata", "openrouter_models_sample.json")
|
||||
raw, err := os.ReadFile(samplePath)
|
||||
if err != nil {
|
||||
t.Fatalf("读取样例文件失败: %v", err)
|
||||
}
|
||||
|
||||
models, err := parseModels(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parseModels 失败: %v", err)
|
||||
}
|
||||
if len(models) != 3 {
|
||||
t.Fatalf("期望 3 条,实际 %d", len(models))
|
||||
}
|
||||
|
||||
// 第一条:完整字段
|
||||
m := models[0]
|
||||
if m.ID != "openai/gpt-4o" {
|
||||
t.Errorf("ID 错误: %s", m.ID)
|
||||
}
|
||||
if m.Name != "GPT-4o" {
|
||||
t.Errorf("Name 错误: %s", m.Name)
|
||||
}
|
||||
if m.ContextLength != 128000 {
|
||||
t.Errorf("ContextLength 错误: %d", m.ContextLength)
|
||||
}
|
||||
if len(m.Capabilities) != 3 {
|
||||
t.Errorf("Capabilities 长度错误: %d", len(m.Capabilities))
|
||||
}
|
||||
if m.Pricing.Input != 2.5 {
|
||||
t.Errorf("Pricing.Input 错误: %f", m.Pricing.Input)
|
||||
}
|
||||
if m.Pricing.Output != 10.0 {
|
||||
t.Errorf("Pricing.Output 错误: %f", m.Pricing.Output)
|
||||
}
|
||||
|
||||
// 第二条:pricing 用 prompt/completion 别名回退
|
||||
m2 := models[1]
|
||||
if m2.Pricing.Input != 0.1 {
|
||||
t.Errorf("Input 回退 prompt 失败: %f", m2.Pricing.Input)
|
||||
}
|
||||
if m2.Pricing.Output != 0.3 {
|
||||
t.Errorf("Output 回退 completion 失败: %f", m2.Pricing.Output)
|
||||
}
|
||||
|
||||
// 第三条:空 pricing
|
||||
m3 := models[2]
|
||||
if m3.Pricing.Input != 0 || m3.Pricing.Output != 0 {
|
||||
t.Errorf("空 pricing 未返回 0: input=%f output=%f", m3.Pricing.Input, m3.Pricing.Output)
|
||||
}
|
||||
}
|
||||
|
||||
// Test 2: run 无 API Key 时写入临时文件,JSON 含 total 和 models 字段
|
||||
func TestRunNoAPIKey(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
outPath := filepath.Join(tmpDir, "models.json")
|
||||
|
||||
cfg := Config{OutPath: outPath}
|
||||
err := run(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("run 失败: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(outPath)
|
||||
if err != nil {
|
||||
t.Fatalf("读取输出文件失败: %v", err)
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
t.Fatalf("JSON 解析失败: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := result["total"]; !ok {
|
||||
t.Error("JSON 缺少 total 字段")
|
||||
}
|
||||
if _, ok := result["models"]; !ok {
|
||||
t.Error("JSON 缺少 models 字段")
|
||||
}
|
||||
models, ok := result["models"].([]any)
|
||||
if !ok {
|
||||
t.Fatal("models 字段类型错误")
|
||||
}
|
||||
if len(models) == 0 {
|
||||
t.Error("models 为空")
|
||||
}
|
||||
}
|
||||
189
scripts/generate_daily_report.go
Normal file
189
scripts/generate_daily_report.go
Normal file
@@ -0,0 +1,189 @@
|
||||
// generate_daily_report.go - 日报生成器
|
||||
// 读取 fetch_openrouter.go 产出的 JSON,输出 Markdown 报告到 reports/daily/
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ReportInput fetch_openrouter.go JSON 输出结构
|
||||
type ReportInput struct {
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
Total int `json:"total"`
|
||||
Free int `json:"free"`
|
||||
Paid int `json:"paid"`
|
||||
Models []ModelRow `json:"models"`
|
||||
}
|
||||
|
||||
type ModelRow struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
ContextLength int `json:"context_length,omitempty"`
|
||||
Capabilities []string `json:"capabilities,omitempty"`
|
||||
Pricing ModelPricing `json:"pricing,omitempty"`
|
||||
}
|
||||
|
||||
type ModelPricing struct {
|
||||
Input float64 `json:"input"`
|
||||
Output float64 `json:"output"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
jsonPath := flag.String("json", "models.json", "采集器 JSON 输出路径")
|
||||
outDir := flag.String("out", "reports/daily", "报告输出目录")
|
||||
topN := flag.Int("top", 10, "免费/低价 TOP N 模型数量")
|
||||
flag.Parse()
|
||||
|
||||
if err := run(*jsonPath, *outDir, *topN); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "日报生成失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(jsonPath, outDir string, topN int) error {
|
||||
data, err := os.ReadFile(jsonPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取 JSON 文件失败: %w", err)
|
||||
}
|
||||
|
||||
var input ReportInput
|
||||
if err := json.Unmarshal(data, &input); err != nil {
|
||||
return fmt.Errorf("解析 JSON 失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建输出目录
|
||||
if err := os.MkdirAll(outDir, 0755); err != nil {
|
||||
return fmt.Errorf("创建输出目录失败: %w", err)
|
||||
}
|
||||
|
||||
// 按价格升序排列,取最便宜的 topN
|
||||
var paidModels []ModelRow
|
||||
for _, m := range input.Models {
|
||||
if m.Pricing.Input > 0 {
|
||||
paidModels = append(paidModels, m)
|
||||
}
|
||||
}
|
||||
sort.Slice(paidModels, func(i, j int) bool {
|
||||
return paidModels[i].Pricing.Input < paidModels[j].Pricing.Input
|
||||
})
|
||||
if len(paidModels) > topN {
|
||||
paidModels = paidModels[:topN]
|
||||
}
|
||||
|
||||
// 按上下文长度降序排列,取最大的 topN
|
||||
var freeModels []ModelRow
|
||||
for _, m := range input.Models {
|
||||
if m.Pricing.Input == 0 && m.Pricing.Output == 0 {
|
||||
freeModels = append(freeModels, m)
|
||||
}
|
||||
}
|
||||
sort.Slice(freeModels, func(i, j int) bool {
|
||||
return freeModels[i].ContextLength > freeModels[j].ContextLength
|
||||
})
|
||||
if len(freeModels) > topN {
|
||||
freeModels = freeModels[:topN]
|
||||
}
|
||||
|
||||
// 从 generated_at 推导报告日期,格式如 2026-05-05T08:00:00Z → 2026-05-05
|
||||
var date string
|
||||
if input.GeneratedAt != "" {
|
||||
t, err := time.Parse(time.RFC3339, input.GeneratedAt)
|
||||
if err == nil {
|
||||
date = t.Format("2006-01-02")
|
||||
} else {
|
||||
date = time.Now().Format("2006-01-02")
|
||||
}
|
||||
} else {
|
||||
date = time.Now().Format("2006-01-02")
|
||||
}
|
||||
filename := fmt.Sprintf("daily_report_%s.md", date)
|
||||
outPath := filepath.Join(outDir, filename)
|
||||
|
||||
f, err := os.Create(outPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建报告文件失败: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// 写入 Markdown
|
||||
fmt.Fprintln(f, "# LLM Intelligence Hub - 每日报告")
|
||||
fmt.Fprintf(f, "**报告日期**: %s \n", date)
|
||||
fmt.Fprintf(f, "**原始采集时间**: %s \n", input.GeneratedAt)
|
||||
fmt.Fprintln(f)
|
||||
fmt.Fprintln(f, "## 概览")
|
||||
fmt.Fprintln(f)
|
||||
fmt.Fprintf(f, "| 指标 | 数值 |\n|------|------|\n")
|
||||
fmt.Fprintf(f, "| 模型总数 | %d |\n", input.Total)
|
||||
fmt.Fprintf(f, "| 免费模型 | %d |\n", input.Free)
|
||||
fmt.Fprintf(f, "| 付费模型 | %d |\n", input.Paid)
|
||||
fmt.Fprintln(f)
|
||||
|
||||
fmt.Fprintln(f, "## 免费模型 TOP "+fmt.Sprint(topN)+"(按上下文长度排序)")
|
||||
fmt.Fprintln(f)
|
||||
if len(freeModels) > 0 {
|
||||
fmt.Fprintln(f, "| 模型 | 上下文长度 | 特性 |")
|
||||
fmt.Fprintln(f, "|------|------------|------|")
|
||||
for _, m := range freeModels {
|
||||
caps := "无"
|
||||
if len(m.Capabilities) > 0 {
|
||||
caps = strings.Join(m.Capabilities, ", ")
|
||||
}
|
||||
fmt.Fprintf(f, "| %s | %d | %s |\n", m.ID, m.ContextLength, caps)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintln(f, "_暂无免费模型数据_")
|
||||
}
|
||||
fmt.Fprintln(f)
|
||||
|
||||
fmt.Fprintln(f, "## 低价模型 TOP "+fmt.Sprint(topN)+"(按输入价格升序,$/M Token)")
|
||||
fmt.Fprintln(f)
|
||||
if len(paidModels) > 0 {
|
||||
fmt.Fprintln(f, "| 模型 | 输入价格 | 输出价格 | 上下文长度 |")
|
||||
fmt.Fprintln(f, "|------|---------|---------|------------|")
|
||||
for _, m := range paidModels {
|
||||
fmt.Fprintf(f, "| %s | %.4f | %.4f | %d |\n",
|
||||
m.ID, m.Pricing.Input, m.Pricing.Output, m.ContextLength)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintln(f, "_暂无付费模型数据_")
|
||||
}
|
||||
fmt.Fprintln(f)
|
||||
|
||||
fmt.Fprintf(f, "\n---\n_由 LLM Intelligence Hub 自动生成 %s_\n", date)
|
||||
|
||||
// T-3.5.1: 同步写入 latest_models.json(供 Explorer 优先读取)
|
||||
// 路径基于 outDir 稳定推导:outDir/../../frontend/src/data/latest_models.json
|
||||
latestPath := filepath.Join(outDir, "..", "..", "frontend", "src", "data", "latest_models.json")
|
||||
if err := os.MkdirAll(filepath.Dir(latestPath), 0755); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "警告: 创建 latest_models.json 目录失败: %v\n", err)
|
||||
} else {
|
||||
// T-3.5.1 补丁: 规范化免费模型 pricing 字段,空对象 {} 显式写出 input/output=0
|
||||
for i := range input.Models {
|
||||
p := &input.Models[i].Pricing
|
||||
if p.Input == 0 && p.Output == 0 {
|
||||
*p = ModelPricing{Input: 0, Output: 0}
|
||||
}
|
||||
}
|
||||
lf, err := os.Create(latestPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "警告: 写入 latest_models.json 失败: %v\n", err)
|
||||
} else {
|
||||
enc := json.NewEncoder(lf)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(input); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "警告: JSON Encode latest_models.json 失败: %v\n", err)
|
||||
}
|
||||
lf.Close()
|
||||
fmt.Printf("latest_models.json 已同步写入: %s\n", latestPath)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
5
scripts/test.sh
Executable file
5
scripts/test.sh
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
# scripts/test.sh - 执行 fetch_openrouter 单元测试
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
go test -v fetch_openrouter.go fetch_openrouter_test.go
|
||||
33
scripts/testdata/openrouter_models_sample.json
vendored
Normal file
33
scripts/testdata/openrouter_models_sample.json
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "openai/gpt-4o",
|
||||
"name": "GPT-4o",
|
||||
"created": 1717556344,
|
||||
"description": "Most intelligent model for complex tasks",
|
||||
"context_length": 128000,
|
||||
"capabilities": ["vision", "function_calling", "json_mode"],
|
||||
"pricing": {
|
||||
"input": 2.5,
|
||||
"output": 10.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "deepseek-ai/DeepSeek-V3",
|
||||
"created": 1716931200,
|
||||
"context_length": 64000,
|
||||
"pricing": {
|
||||
"prompt": 0.1,
|
||||
"completion": 0.3
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mistralai/Mistral-7B:free",
|
||||
"name": "Mistral-7B Free",
|
||||
"created": 1715308800,
|
||||
"context_length": 32768,
|
||||
"capabilities": ["text"],
|
||||
"pricing": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
327
scripts/verification_executor.go
Normal file
327
scripts/verification_executor.go
Normal file
@@ -0,0 +1,327 @@
|
||||
// verification_executor.go
|
||||
// Reads TASKS.md, runs each task's verification.command,
|
||||
// matches expected_evidence, outputs pass/fail report.
|
||||
//
|
||||
// Usage: go run scripts/verification_executor.go [--dry-run] [--task T-Q2-1.1]
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Verification struct {
|
||||
Mode string
|
||||
Command string
|
||||
ExpectedEvidence string
|
||||
TimeoutSeconds int
|
||||
}
|
||||
|
||||
type TaskResult struct {
|
||||
TaskID string
|
||||
TaskName string
|
||||
Verified bool
|
||||
Command string
|
||||
ExitCode int
|
||||
Stdout string
|
||||
Stderr string
|
||||
Error string
|
||||
Reason string
|
||||
}
|
||||
|
||||
func main() {
|
||||
dryRun := flag.Bool("dry-run", false, "print commands without executing")
|
||||
taskFilter := flag.String("task", "", "filter by task ID (e.g. T-Q2-1.1)")
|
||||
tasksPathFlag := flag.String("tasks", "", "path to TASKS.md")
|
||||
flag.Parse()
|
||||
|
||||
tasksPath := resolveTasksPath(*tasksPathFlag)
|
||||
|
||||
f, err := os.Open(tasksPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "open TASKS.md: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
tasks := parseTasks(f)
|
||||
if *taskFilter != "" {
|
||||
var filtered []taskEntry
|
||||
for _, t := range tasks {
|
||||
if t.ID == *taskFilter {
|
||||
filtered = append(filtered, t)
|
||||
}
|
||||
}
|
||||
tasks = filtered
|
||||
}
|
||||
|
||||
fmt.Printf("=== Verification Report (%s) ===\n", time.Now().Format("2006-01-02 15:04"))
|
||||
fmt.Printf("Tasks checked: %d | Dry-run: %v | TASKS: %s\n\n", len(tasks), *dryRun, tasksPath)
|
||||
|
||||
var passed, failed int
|
||||
var results []TaskResult
|
||||
|
||||
for _, t := range tasks {
|
||||
r := verifyTask(t, *dryRun)
|
||||
results = append(results, r)
|
||||
if r.Verified {
|
||||
passed++
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
|
||||
for _, r := range results {
|
||||
icon := "✅"
|
||||
if !r.Verified {
|
||||
icon = "❌"
|
||||
}
|
||||
fmt.Printf("%s [%s] %s\n", icon, r.TaskID, r.TaskName)
|
||||
if r.Error != "" {
|
||||
fmt.Printf(" ERROR: %s\n", r.Error)
|
||||
} else {
|
||||
if r.Command != "" {
|
||||
fmt.Printf(" cmd: %s\n", r.Command)
|
||||
}
|
||||
if r.ExitCode != 0 && r.Stdout != "" {
|
||||
fmt.Printf(" output: %s\n", strings.TrimSpace(r.Stdout))
|
||||
} else if r.Reason != "" {
|
||||
fmt.Printf(" reason: %s\n", r.Reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\n=== Summary: %d passed, %d failed ===\n", passed, failed)
|
||||
if failed > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveTasksPath(flagValue string) string {
|
||||
candidates := []string{}
|
||||
if flagValue != "" {
|
||||
candidates = append(candidates, flagValue)
|
||||
}
|
||||
if envValue := os.Getenv("TASKS_PATH"); envValue != "" {
|
||||
candidates = append(candidates, envValue)
|
||||
}
|
||||
|
||||
if wd, err := os.Getwd(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(wd, "TASKS.md"),
|
||||
filepath.Join(wd, "..", "TASKS.md"),
|
||||
)
|
||||
}
|
||||
|
||||
if _, sourcePath, _, ok := runtime.Caller(0); ok {
|
||||
scriptDir := filepath.Dir(sourcePath)
|
||||
candidates = append(candidates, filepath.Join(scriptDir, "..", "TASKS.md"))
|
||||
}
|
||||
|
||||
candidates = append(candidates, "/home/long/.openclaw/workspace/TASKS.md")
|
||||
|
||||
seen := map[string]struct{}{}
|
||||
for _, candidate := range candidates {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
cleaned := filepath.Clean(candidate)
|
||||
if _, ok := seen[cleaned]; ok {
|
||||
continue
|
||||
}
|
||||
seen[cleaned] = struct{}{}
|
||||
if _, err := os.Stat(cleaned); err == nil {
|
||||
return cleaned
|
||||
}
|
||||
}
|
||||
|
||||
if flagValue != "" {
|
||||
return filepath.Clean(flagValue)
|
||||
}
|
||||
if envValue := os.Getenv("TASKS_PATH"); envValue != "" {
|
||||
return filepath.Clean(envValue)
|
||||
}
|
||||
return "/home/long/.openclaw/workspace/TASKS.md"
|
||||
}
|
||||
|
||||
type taskEntry struct {
|
||||
ID string
|
||||
Name string
|
||||
Verification Verification
|
||||
HasVerification bool
|
||||
}
|
||||
|
||||
func parseTasks(f *os.File) []taskEntry {
|
||||
var tasks []taskEntry
|
||||
var currentTask *taskEntry
|
||||
inVerification := false
|
||||
scanner := bufio.NewScanner(f)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
// Match task header: ### T-1.1 🔶 Phase 1 范围冻结
|
||||
taskRe := regexp.MustCompile(`^### (T-[A-Za-z0-9.-]+)\s+[^\s]+\s+(.+)`)
|
||||
if m := taskRe.FindStringSubmatch(line); m != nil {
|
||||
if currentTask != nil {
|
||||
tasks = append(tasks, *currentTask)
|
||||
}
|
||||
currentTask = &taskEntry{ID: m[1], Name: m[2]}
|
||||
inVerification = false
|
||||
continue
|
||||
}
|
||||
|
||||
if currentTask == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for verification block
|
||||
if strings.Contains(line, "**verification**") || strings.Contains(line, "**verification**:") {
|
||||
inVerification = true
|
||||
currentTask.HasVerification = true
|
||||
continue
|
||||
}
|
||||
|
||||
if !inVerification {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse verification fields (indented under **verification**)
|
||||
// - mode: `artifact_present`
|
||||
modeRe := regexp.MustCompile(`^\s+- mode:\s+` + "`" + `([^` + "`" + `]+)` + "`")
|
||||
if m := modeRe.FindStringSubmatch(line); m != nil {
|
||||
currentTask.Verification.Mode = m[1]
|
||||
continue
|
||||
}
|
||||
|
||||
cmdRe := regexp.MustCompile(`^\s+- command:\s+` + "`" + `([^` + "`" + `]+)` + "`")
|
||||
if m := cmdRe.FindStringSubmatch(line); m != nil {
|
||||
currentTask.Verification.Command = m[1]
|
||||
continue
|
||||
}
|
||||
|
||||
expRe := regexp.MustCompile(`^\s+- expected_evidence:\s+` + "`" + `([^` + "`" + `]+)` + "`")
|
||||
if m := expRe.FindStringSubmatch(line); m != nil {
|
||||
currentTask.Verification.ExpectedEvidence = m[1]
|
||||
continue
|
||||
}
|
||||
|
||||
timeoutRe := regexp.MustCompile(`^\s+- timeout_seconds:\s+(\d+)`)
|
||||
if m := timeoutRe.FindStringSubmatch(line); m != nil {
|
||||
fmt.Sscanf(m[1], "%d", ¤tTask.Verification.TimeoutSeconds)
|
||||
continue
|
||||
}
|
||||
|
||||
// Blank line or new top-level field ends verification block
|
||||
if strings.TrimSpace(line) == "" || (strings.HasPrefix(strings.TrimSpace(line), "**") && !strings.Contains(line, "verification")) {
|
||||
inVerification = false
|
||||
}
|
||||
}
|
||||
|
||||
if currentTask != nil {
|
||||
tasks = append(tasks, *currentTask)
|
||||
}
|
||||
|
||||
return tasks
|
||||
}
|
||||
|
||||
func verifyTask(t taskEntry, dryRun bool) TaskResult {
|
||||
r := TaskResult{TaskID: t.ID, TaskName: t.Name}
|
||||
|
||||
if !t.HasVerification {
|
||||
r.Reason = "no verification block"
|
||||
r.Verified = true // No verification = trivially pass
|
||||
return r
|
||||
}
|
||||
|
||||
if t.Verification.Command == "" {
|
||||
r.Reason = "verification.command is empty"
|
||||
r.Verified = false
|
||||
return r
|
||||
}
|
||||
|
||||
r.Command = t.Verification.Command
|
||||
|
||||
if t.Verification.TimeoutSeconds == 0 {
|
||||
t.Verification.TimeoutSeconds = 30
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
r.Stdout = "(dry-run, command not executed)"
|
||||
r.Verified = true
|
||||
return r
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(t.Verification.TimeoutSeconds)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "sh", "-c", t.Verification.Command)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
r.ExitCode = 0
|
||||
if err != nil {
|
||||
r.ExitCode = -1
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
r.Error = fmt.Sprintf("timeout after %ds", t.Verification.TimeoutSeconds)
|
||||
} else {
|
||||
r.Error = err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
r.Stdout = stdout.String()
|
||||
r.Stderr = stderr.String()
|
||||
|
||||
if r.ExitCode != 0 && t.Verification.Mode == "test_pass" {
|
||||
r.Verified = false
|
||||
return r
|
||||
}
|
||||
|
||||
// Match expected_evidence
|
||||
if t.Verification.ExpectedEvidence != "" {
|
||||
evidence := t.Verification.ExpectedEvidence
|
||||
matched := false
|
||||
|
||||
if strings.HasPrefix(evidence, "[") && strings.HasSuffix(evidence, "]") {
|
||||
// Regex range like [4-9]
|
||||
re := regexp.MustCompile(`\[(\d+)-(\d+)\]`)
|
||||
if m := re.FindStringSubmatch(evidence); m != nil {
|
||||
var lo, hi int
|
||||
fmt.Sscanf(m[1], "%d", &lo)
|
||||
fmt.Sscanf(m[2], "%d", &hi)
|
||||
reOut := regexp.MustCompile(fmt.Sprintf(`^\s*(\d+)\s*$`))
|
||||
if numMatch := reOut.FindStringSubmatch(strings.TrimSpace(r.Stdout)); numMatch != nil {
|
||||
var n int
|
||||
fmt.Sscanf(numMatch[1], "%d", &n)
|
||||
matched = n >= lo && n <= hi
|
||||
}
|
||||
}
|
||||
} else if strings.Contains(r.Stdout, evidence) {
|
||||
matched = true
|
||||
}
|
||||
|
||||
r.Verified = matched
|
||||
if !matched {
|
||||
r.Reason = fmt.Sprintf("expected_evidence '%s' not found in output", evidence)
|
||||
}
|
||||
} else if r.ExitCode == 0 {
|
||||
r.Verified = true
|
||||
} else {
|
||||
r.Verified = false
|
||||
r.Reason = fmt.Sprintf("exit code %d", r.ExitCode)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
47
scripts/verify_t32.sh
Executable file
47
scripts/verify_t32.sh
Executable file
@@ -0,0 +1,47 @@
|
||||
#!/bin/bash
|
||||
# verify_t32.sh — 验收 T-3.2:表格渲染、免费标签、图表占位区块
|
||||
set -e
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
FILE="$PROJECT_ROOT/frontend/src/pages/Explorer.tsx"
|
||||
|
||||
echo "=== T-3.2 验收检查 ==="
|
||||
|
||||
# T-3.2.3: 表格渲染(价格列 + isFree 列)
|
||||
if grep -q 'inputPrice.*MT' "$FILE" && \
|
||||
grep -q 'badge bg-success' "$FILE"; then
|
||||
echo "table PASS — inputPrice 和 isFree badge 同时存在"
|
||||
else
|
||||
echo "table FAIL"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# T-3.2.2: 卡片视图价格 + 免费标签
|
||||
if grep -q 'inputPrice.*MT.*outputPrice' "$FILE"; then
|
||||
echo "badge PASS — 卡片价格渲染存在"
|
||||
else
|
||||
echo "badge FAIL"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# T-3.2.4a: 必须保持为合法 React 占位实现
|
||||
if grep -q '<script' "$FILE" || \
|
||||
grep -q 'dangerouslySetInnerHTML' "$FILE" || \
|
||||
grep -q 'style="' "$FILE"; then
|
||||
echo "react FAIL — 发现组件内 script / dangerouslySetInnerHTML / 非法 style 字符串"
|
||||
exit 1
|
||||
else
|
||||
echo "react PASS — 未发现明显无效的 React 占位实现"
|
||||
fi
|
||||
|
||||
# T-3.2.4: 价格趋势占位图区块
|
||||
if grep -q 'price-trend-chart' "$FILE"; then
|
||||
echo "chart PASS — price-trend-chart 占位区块存在"
|
||||
else
|
||||
echo "chart FAIL"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "all PASS"
|
||||
exit 0
|
||||
56
scripts/verify_t33.sh
Executable file
56
scripts/verify_t33.sh
Executable file
@@ -0,0 +1,56 @@
|
||||
#!/bin/bash
|
||||
# verify_t33.sh — 验收 T-3.3:筛选过滤逻辑(严格版)
|
||||
set -e
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
FILE="$PROJECT_ROOT/frontend/src/pages/Explorer.tsx"
|
||||
|
||||
echo "=== T-3.3 验收检查 ==="
|
||||
|
||||
# T-3.3.1: filterModels 函数存在
|
||||
if grep -q 'function filterModels' "$FILE"; then
|
||||
echo "filterModels PASS — filterModels 函数已定义"
|
||||
else
|
||||
echo "filterModels FAIL"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# T-3.3.1: 组件声明存在
|
||||
if grep -q 'const ExplorerPage: React.FC = () =>' "$FILE"; then
|
||||
echo "ExplorerPage PASS — 组件声明存在"
|
||||
else
|
||||
echo "ExplorerPage FAIL — 缺少组件声明"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# T-3.3.2: filteredResults 共享变量存在
|
||||
if grep -q 'const filteredResults' "$FILE"; then
|
||||
echo "filteredResults PASS — 过滤结果收敛为 shared variable"
|
||||
else
|
||||
echo "filteredResults FAIL"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# T-3.3.2: filterModels 在 JSX 中未被重复调用(只在 filteredResults 赋值处出现一次)
|
||||
# 允许出现 1 次(在赋值语句中),不允许在 JSX 渲染分支中出现
|
||||
call_count=$(grep -c 'filterModels(getMockModels(), filters)' "$FILE" || true)
|
||||
if [ "$call_count" -eq 1 ]; then
|
||||
echo "shared-var PASS — filterModels 仅在 filteredResults 赋值处调用一次"
|
||||
else
|
||||
echo "shared-var FAIL — filterModels 调用次数: $call_count(期望 1)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# T-3.3.2: filteredResults 被双视图共用(卡片和表格分支都用它)
|
||||
filtered_card=$(grep -c 'filteredResults.map.*card\|filteredResults.length.*card' "$FILE" || true)
|
||||
if grep -q 'filteredResults.length === 0' "$FILE" && \
|
||||
grep -q 'filteredResults.map' "$FILE"; then
|
||||
echo "dual-view PASS — filteredResults 同时被空判断和渲染分支引用"
|
||||
else
|
||||
echo "dual-view FAIL"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "all PASS"
|
||||
exit 0
|
||||
40
scripts/verify_t34.sh
Executable file
40
scripts/verify_t34.sh
Executable file
@@ -0,0 +1,40 @@
|
||||
#!/bin/bash
|
||||
# verify_t34.sh — 验收 T-3.4:Explorer 接入真实 Schema JSON
|
||||
set -e
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
FILE="$PROJECT_ROOT/frontend/src/pages/Explorer.tsx"
|
||||
JSON="$PROJECT_ROOT/frontend/src/data/models.json"
|
||||
|
||||
echo "=== T-3.4 验收检查 ==="
|
||||
|
||||
# T-3.4.1: JSON schema 验证
|
||||
python3 -c "
|
||||
import json
|
||||
d=json.load(open('$JSON'))
|
||||
assert all(k in d for k in ['generated_at','total','free','paid','models']), 'missing top keys'
|
||||
assert all('pricing' in m and 'input' in m['pricing'] and 'output' in m['pricing'] for m in d['models']), 'missing pricing fields'
|
||||
print('json-schema OK')
|
||||
" && echo "json-schema PASS — JSON 含 generated_at/total/free/paid/models,且 models 含 pricing.input/output" \
|
||||
|| { echo "json-schema FAIL"; exit 1; }
|
||||
|
||||
# T-3.4.2: mapAPIResponseToModels 映射函数存在
|
||||
if grep -q 'mapAPIResponseToModels' "$FILE"; then
|
||||
echo "mapping PASS — mapAPIResponseToModels 函数存在"
|
||||
else
|
||||
echo "mapping FAIL"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# T-3.4.3: getMockModels 改为从 JSON 加载
|
||||
if grep -q "models.json" "$FILE" && \
|
||||
! grep -q "provider.*OpenAI\|provider.*Anthropic\|provider.*DeepSeek" "$FILE"; then
|
||||
echo "import PASS — getMockModels 引用 models.json,无硬编码 provider"
|
||||
else
|
||||
echo "import FAIL — 仍有硬编码 mock 数据"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "all PASS"
|
||||
exit 0
|
||||
69
scripts/verify_t35.sh
Executable file
69
scripts/verify_t35.sh
Executable file
@@ -0,0 +1,69 @@
|
||||
#!/bin/bash
|
||||
# verify_t35.sh — 验收 T-3.5:日报生成器同步产出 latest_models.json + Explorer fallback
|
||||
set -e
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
REPORT="$PROJECT_ROOT/scripts/generate_daily_report.go"
|
||||
EXPLORER="$PROJECT_ROOT/frontend/src/pages/Explorer.tsx"
|
||||
LATEST="$PROJECT_ROOT/frontend/src/data/latest_models.json"
|
||||
|
||||
echo "=== T-3.5 验收检查 ==="
|
||||
|
||||
# T-3.5.1: generate_daily_report.go 含 latest_models.json 写入,且路径从 outDir 推导而非硬编码相对 cwd
|
||||
if grep -q 'latest_models.json' "$REPORT" && \
|
||||
grep -q 'outDir.*frontend.*latest_models.json\|filepath.Join.*outDir.*latest' "$REPORT"; then
|
||||
echo "report-json-write PASS — latest_models.json 写入且路径从 outDir 推导"
|
||||
else
|
||||
echo "report-json-write FAIL"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# T-3.5.2: Explorer.tsx 含 latest_models.json 优先加载和 models.json fallback
|
||||
if grep -q 'latest_models.json' "$EXPLORER" && \
|
||||
grep -q 'models.json' "$EXPLORER"; then
|
||||
echo "explorer-fallback PASS — latest 优先 + models fallback 同时存在"
|
||||
else
|
||||
echo "explorer-fallback FAIL"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# T-3.5.1 补丁验证: latest_models.json 免费模型 pricing 字段完整性
|
||||
if [ ! -f "$LATEST" ]; then
|
||||
echo "pricing-normalized FAIL — latest_models.json 不存在"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if python3 - "$LATEST" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
path = sys.argv[1]
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
free_models = [
|
||||
model for model in data.get("models", [])
|
||||
if isinstance(model.get("id"), str) and model["id"].endswith(":free")
|
||||
]
|
||||
if not free_models:
|
||||
raise SystemExit(1)
|
||||
|
||||
for model in free_models:
|
||||
pricing = model.get("pricing")
|
||||
if not isinstance(pricing, dict):
|
||||
raise SystemExit(1)
|
||||
if "input" not in pricing or "output" not in pricing:
|
||||
raise SystemExit(1)
|
||||
if pricing["input"] != 0 or pricing["output"] != 0:
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
then
|
||||
echo "pricing-normalized PASS — 免费模型 pricing.input/output 均显式为 0"
|
||||
else
|
||||
echo "pricing-normalized FAIL — 免费模型 pricing 字段缺失或未显式归一为 0"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "all PASS"
|
||||
exit 0
|
||||
Reference in New Issue
Block a user