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:
Your Name
2026-05-08 13:49:12 +08:00
parent dbdf13ea42
commit ba054f04cf
37 changed files with 4617 additions and 0 deletions

351
scripts/fetch_openrouter.go Normal file
View 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 将采集结果写入 PostgreSQLmodels + 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
}

View 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 为空")
}
}

View 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
View 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

View 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": {}
}
]
}

View 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", &currentTask.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
View 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
View 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
View File

@@ -0,0 +1,40 @@
#!/bin/bash
# verify_t34.sh — 验收 T-3.4Explorer 接入真实 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
View 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