feat: 微信公众号技能包重命名(weixin_public_account)并增强功能
- 将 wechat_official_account 重命名为 weixin_public_account,符合中文命名规范 - 新增 DOCX 文档生成技能、聊天历史、请求 ID 中间件 - 增强工作流、热点服务、文章服务等模块功能 - 前端同步重命名组件和 API - 新增架构文档 AR13/AR14、专员文档更新 - 补充测试用例(seed_specialists_test, db_migration_test) Co-Authored-AI: yes
This commit is contained in:
@@ -0,0 +1,427 @@
|
||||
// Package routetest 提供「AI 通路测试」能力。
|
||||
//
|
||||
// 与线上定时探针(config/route_health.go 的 30 分钟健康检查)互补:
|
||||
// 探针只做最简连通性验证;本包用于**开发/运维**对某个具体模型做一次真实业务调用,
|
||||
// 逐个候选模型发独立请求并记录 request/response 原始报文,验证"真的能跑出结果"。
|
||||
//
|
||||
// 设计约束:
|
||||
// - 本包的所有测试调用**绝不扣算力点、不写入 ai_call_log**(纯后台诊断,避免污染用量/计费)。
|
||||
// - 支持 chat / embed / image 三类路由,按路由 Category 分发。
|
||||
package routetest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"eai_agentplatform/backend/internal/config"
|
||||
)
|
||||
|
||||
// 默认测试超时与默认测试提示词(真实业务类提示词,而非 "ping")。
|
||||
const (
|
||||
defaultTestTimeout = 60 * time.Second
|
||||
// defaultChatProbe 对话类默认测试提示词
|
||||
defaultChatProbe = "你好,请用一句简短的话回复我(用于连通性测试)。"
|
||||
// 模型发现请求的 base 路径:/models
|
||||
modelsPath = "/models"
|
||||
)
|
||||
|
||||
// TestOptions 单次 / 批量通路测试入参
|
||||
type TestOptions struct {
|
||||
// Prompt 业务测试提示词;为空时使用各分类的默认提示词。
|
||||
Prompt string
|
||||
// Models 待测试的候选模型(传空则使用 route 自身 model;批量测试时传候选列表)。
|
||||
Models []string
|
||||
// Size image 类测试的图片尺寸(默认 1024x1024)。
|
||||
Size string
|
||||
}
|
||||
|
||||
// ResultItem 单个模型的测试结果(对应每个模型一个结果行)
|
||||
type ResultItem struct {
|
||||
Model string `json:"model"`
|
||||
Ok bool `json:"ok"`
|
||||
StatusCode int `json:"status_code"`
|
||||
LatencyMs int64 `json:"latency_ms"`
|
||||
Error string `json:"error,omitempty"`
|
||||
// ModelEcho 服务端回显的 model 字段(用于确认实际被路由到的模型)
|
||||
ModelEcho string `json:"model_echo,omitempty"`
|
||||
// Summary text 类测试返回正文摘要 / image 类成功标志
|
||||
Summary string `json:"summary,omitempty"`
|
||||
// OutputPreview 产物预览:image 类返回 data-url(base64),text 类返回正文片段
|
||||
OutputPreview string `json:"output_preview,omitempty"`
|
||||
// RawRequest / RawResponse 原始报文(JSON 字符串,供诊断展开)
|
||||
RawRequest string `json:"raw_request,omitempty"`
|
||||
RawResponse string `json:"raw_response,omitempty"`
|
||||
}
|
||||
|
||||
// Result 一次通路测试的整体返回
|
||||
type Result struct {
|
||||
RouteID string `json:"route_id"`
|
||||
Provider string `json:"provider"`
|
||||
Category string `json:"category"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
TestedAt string `json:"tested_at"`
|
||||
Succeeded bool `json:"succeeded"`
|
||||
Items []ResultItem `json:"items"`
|
||||
}
|
||||
|
||||
// Do 对指定 RouteConfig 发起通路测试。
|
||||
// - opts.Models 为空 → 仅测试 route 自身 model(单模型测试)。
|
||||
// - opts.Models 非空 → 逐个模型测试(批量/候选 试跑)。
|
||||
func Do(route *config.RouteConfig, opts *TestOptions) *Result {
|
||||
if route == nil {
|
||||
return &Result{Succeeded: false, Items: []ResultItem{{Ok: false, Error: "路由不存在"}}}
|
||||
}
|
||||
r := &Result{
|
||||
RouteID: route.RouteID,
|
||||
Provider: route.Provider,
|
||||
Category: route.Category,
|
||||
BaseURL: route.BaseURL,
|
||||
Endpoint: route.Endpoint,
|
||||
TestedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
models := opts.Models
|
||||
if len(models) == 0 {
|
||||
models = []string{route.Model}
|
||||
}
|
||||
size := opts.Size
|
||||
if size == "" {
|
||||
size = "1024x1024"
|
||||
}
|
||||
for _, model := range models {
|
||||
item := testOne(route, model, opts.Prompt, size)
|
||||
r.Items = append(r.Items, item)
|
||||
}
|
||||
for _, it := range r.Items {
|
||||
if it.Ok {
|
||||
r.Succeeded = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// testOne 对单个模型(+ 某条路由)+ 真实请求做测试,返回该模型独立结果行。
|
||||
func testOne(route *config.RouteConfig, model string, prompt, size string) ResultItem {
|
||||
item := ResultItem{Model: model}
|
||||
if strings.TrimSpace(route.BaseURL) == "" {
|
||||
item.Error = "base_url 未配置"
|
||||
return item
|
||||
}
|
||||
if requiresAPIKey(route) && strings.TrimSpace(route.APIKey) == "" {
|
||||
item.Error = "API Key 未配置"
|
||||
return item
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
switch route.Category {
|
||||
case "image":
|
||||
testImage(route, model, prompt, size, &item)
|
||||
case "video":
|
||||
testVideo(route, model, prompt, &item)
|
||||
case "embed":
|
||||
testEmbed(route, model, prompt, &item)
|
||||
default:
|
||||
testChat(route, model, prompt, &item)
|
||||
}
|
||||
item.LatencyMs = time.Since(start).Milliseconds()
|
||||
if item.Error != "" {
|
||||
item.Ok = false
|
||||
} else {
|
||||
item.Ok = true
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
// httpPost 发一次 POST,返回状态码、原始响应体、错误。用于记录原始报文。
|
||||
func httpPost(route *config.RouteConfig, path string, body any, out any) (int, string, error) {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("请求序列化失败: %w", err)
|
||||
}
|
||||
fullURL := strings.TrimRight(route.BaseURL, "/") + path
|
||||
req, err := http.NewRequest(http.MethodPost, fullURL, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("构造请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if strings.TrimSpace(route.APIKey) != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+route.APIKey)
|
||||
}
|
||||
client := &http.Client{Timeout: defaultTestTimeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("服务不可达: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return resp.StatusCode, "", fmt.Errorf("读取响应失败: %w", err)
|
||||
}
|
||||
rawStr := string(data)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return resp.StatusCode, rawStr, fmt.Errorf("返回 %d: %s", resp.StatusCode, truncate(rawStr, 200))
|
||||
}
|
||||
if len(data) > 0 && out != nil {
|
||||
if err := json.Unmarshal(data, out); err != nil {
|
||||
return resp.StatusCode, rawStr, fmt.Errorf("响应解析失败: %w", err)
|
||||
}
|
||||
}
|
||||
return resp.StatusCode, rawStr, nil
|
||||
}
|
||||
|
||||
// httpGet 发一次 GET,用于 /models 模型发现。
|
||||
func httpGet(route *config.RouteConfig, path string) ([]string, error) {
|
||||
fullURL := strings.TrimRight(route.BaseURL, "/") + path
|
||||
req, err := http.NewRequest(http.MethodGet, fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(route.APIKey) != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+route.APIKey)
|
||||
}
|
||||
client := &http.Client{Timeout: defaultTestTimeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("返回 %d", resp.StatusCode)
|
||||
}
|
||||
var out struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
models := make([]string, 0, len(out.Data))
|
||||
for _, d := range out.Data {
|
||||
if strings.TrimSpace(d.ID) != "" {
|
||||
models = append(models, d.ID)
|
||||
}
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
|
||||
// testChat 对话类单模型测试。
|
||||
func testChat(route *config.RouteConfig, model, prompt string, item *ResultItem) {
|
||||
if strings.TrimSpace(prompt) == "" {
|
||||
prompt = defaultChatProbe
|
||||
}
|
||||
body := map[string]any{
|
||||
"model": model,
|
||||
"messages": []map[string]string{{"role": "user", "content": prompt}},
|
||||
"stream": false,
|
||||
}
|
||||
var out struct {
|
||||
Model string `json:"model"`
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
reqStr, _ := json.Marshal(body)
|
||||
item.RawRequest = string(reqStr)
|
||||
code, rawRes, err := httpPost(route, "/chat/completions", body, &out)
|
||||
item.StatusCode = code
|
||||
item.RawResponse = truncate(rawRes, 4000)
|
||||
if err != nil {
|
||||
item.Error = err.Error()
|
||||
return
|
||||
}
|
||||
if len(out.Choices) == 0 || strings.TrimSpace(out.Choices[0].Message.Content) == "" {
|
||||
item.Error = "返回空正文"
|
||||
return
|
||||
}
|
||||
item.ModelEcho = out.Model
|
||||
content := strings.TrimSpace(out.Choices[0].Message.Content)
|
||||
item.Summary = truncate(content, 120)
|
||||
item.OutputPreview = truncate(content, 2000)
|
||||
}
|
||||
|
||||
// testEmbed 向量类单模型测试。
|
||||
func testEmbed(route *config.RouteConfig, model, prompt string, item *ResultItem) {
|
||||
input := "ping"
|
||||
if strings.TrimSpace(prompt) != "" {
|
||||
input = prompt
|
||||
}
|
||||
body := map[string]any{"model": model, "input": input}
|
||||
var out struct {
|
||||
Data []struct {
|
||||
Embedding []float64 `json:"embedding"`
|
||||
} `json:"data"`
|
||||
}
|
||||
reqStr, _ := json.Marshal(body)
|
||||
item.RawRequest = string(reqStr)
|
||||
code, rawRes, err := httpPost(route, "/embeddings", body, &out)
|
||||
item.StatusCode = code
|
||||
item.RawResponse = truncate(rawRes, 4000)
|
||||
if err != nil {
|
||||
item.Error = err.Error()
|
||||
return
|
||||
}
|
||||
if len(out.Data) == 0 || len(out.Data[0].Embedding) == 0 {
|
||||
item.Error = "返回空 embedding"
|
||||
return
|
||||
}
|
||||
item.ModelEcho = model
|
||||
item.Summary = fmt.Sprintf("返回 %d 维向量", len(out.Data[0].Embedding))
|
||||
}
|
||||
|
||||
// testImage 生图类单模型测试;成功时以 data-url 预览产物。
|
||||
func testImage(route *config.RouteConfig, model, prompt, size string, item *ResultItem) {
|
||||
if strings.TrimSpace(prompt) == "" {
|
||||
prompt = "生成一张简洁的产品展示图(用于连通性测试)"
|
||||
}
|
||||
body := map[string]any{
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"size": size,
|
||||
"n": 1,
|
||||
"response_format": "b64_json",
|
||||
}
|
||||
var out struct {
|
||||
Data []struct {
|
||||
B64JSON string `json:"b64_json"`
|
||||
URL string `json:"url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
reqStr, _ := json.Marshal(body)
|
||||
item.RawRequest = string(reqStr)
|
||||
code, rawRes, err := httpPost(route, "/images/generations", body, &out)
|
||||
item.StatusCode = code
|
||||
item.RawResponse = truncate(rawRes, 4000)
|
||||
if err != nil {
|
||||
item.Error = err.Error()
|
||||
return
|
||||
}
|
||||
if len(out.Data) == 0 {
|
||||
item.Error = "返回空 data"
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(out.Data[0].B64JSON) != "" {
|
||||
item.ModelEcho = model
|
||||
item.Summary = "生成成功"
|
||||
item.OutputPreview = "data:image/png;base64," + out.Data[0].B64JSON
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(out.Data[0].URL) != "" {
|
||||
item.ModelEcho = model
|
||||
item.Summary = "生成成功(URL 模式)"
|
||||
item.OutputPreview = out.Data[0].URL
|
||||
return
|
||||
}
|
||||
item.Error = "响应缺少 b64_json / url"
|
||||
}
|
||||
|
||||
// testVideo 对视频生成模型做一次真实调用(OpenAI 兼容 POST /videos/generations)。
|
||||
// 视频响应通常回传 data[].url(视频文件地址)。
|
||||
func testVideo(route *config.RouteConfig, model, prompt string, item *ResultItem) {
|
||||
if strings.TrimSpace(prompt) == "" {
|
||||
prompt = "请生成一段简洁的演示视频(用于连通性测试)"
|
||||
}
|
||||
body := map[string]any{
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"n": 1,
|
||||
}
|
||||
var out struct {
|
||||
Data []struct {
|
||||
URL string `json:"url"`
|
||||
B64JSON string `json:"b64_json"`
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
reqStr, _ := json.Marshal(body)
|
||||
item.RawRequest = string(reqStr)
|
||||
code, rawRes, err := httpPost(route, "/videos/generations", body, &out)
|
||||
item.StatusCode = code
|
||||
item.RawResponse = truncate(rawRes, 4000)
|
||||
if err != nil {
|
||||
item.Error = err.Error()
|
||||
return
|
||||
}
|
||||
if len(out.Data) == 0 {
|
||||
item.Error = "返回空 data"
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(out.Data[0].URL) != "" {
|
||||
item.ModelEcho = model
|
||||
item.Summary = "生成成功(URL 模式)"
|
||||
item.OutputPreview = out.Data[0].URL
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(out.Data[0].B64JSON) != "" {
|
||||
item.ModelEcho = model
|
||||
item.Summary = "生成成功"
|
||||
item.OutputPreview = "data:video/mp4;base64," + out.Data[0].B64JSON
|
||||
return
|
||||
}
|
||||
item.Error = "响应缺少 url / b64_json"
|
||||
}
|
||||
|
||||
// DiscoverModels 拉取 provider 端 /models 获取可用模型列表(自动发现)。
|
||||
// 返回按字母排序的模型 ID 列表;不可达或失败时返回 nil + error。
|
||||
func DiscoverModels(route *config.RouteConfig) ([]string, error) {
|
||||
if route == nil {
|
||||
return nil, fmt.Errorf("路由不存在")
|
||||
}
|
||||
models, err := httpGet(route, modelsPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(models)
|
||||
return models, nil
|
||||
}
|
||||
|
||||
// MergeCandidates 将配置/已知候选与自动发现的模型合并去重。
|
||||
// 优先保留调用方给定顺序;用于「候选逐个试跑」。
|
||||
func MergeCandidates(known, discovered []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(known)+len(discovered))
|
||||
for _, m := range append(append([]string{}, known...), discovered...) {
|
||||
m = strings.TrimSpace(m)
|
||||
if m == "" || seen[m] {
|
||||
continue
|
||||
}
|
||||
seen[m] = true
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// requiresAPIKey 判断该路由是否必须携带 API Key(只有云端 provider 才强制)。
|
||||
// 本地服务(ollama / llamacpp 等)无需 key 也应能完成测试。
|
||||
func requiresAPIKey(route *config.RouteConfig) bool {
|
||||
if route == nil {
|
||||
return false
|
||||
}
|
||||
baseURL := strings.ToLower(strings.TrimSpace(route.BaseURL))
|
||||
if strings.Contains(baseURL, "openrouter.ai") || strings.Contains(baseURL, "openai.com") {
|
||||
return true
|
||||
}
|
||||
provider := strings.ToLower(strings.TrimSpace(route.Provider))
|
||||
return provider == "openrouter" || provider == "openai"
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if n <= 0 || len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "...[截断]"
|
||||
}
|
||||
Reference in New Issue
Block a user