mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
Merge pull request #446 from zhouyongyou/fix/json-fullwidth-characters
fix(decision): handle fullwidth JSON characters from AI responses
This commit is contained in:
+142
-19
@@ -7,10 +7,22 @@ import (
|
||||
"nofx/market"
|
||||
"nofx/mcp"
|
||||
"nofx/pool"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 预编译正则表达式(性能优化:避免每次调用时重新编译)
|
||||
var (
|
||||
// ✅ 安全的正則:精確匹配 ```json 代碼塊
|
||||
// 使用反引號 + 拼接避免轉義問題
|
||||
reJSONFence = regexp.MustCompile(`(?is)` + "```json\\s*(\\[\\s*\\{.*?\\}\\s*\\])\\s*```")
|
||||
reJSONArray = regexp.MustCompile(`(?is)\[\s*\{.*?\}\s*\]`)
|
||||
reArrayHead = regexp.MustCompile(`^\[\s*\{`)
|
||||
reArrayOpenSpace = regexp.MustCompile(`^\[\s+\{`)
|
||||
reInvisibleRunes = regexp.MustCompile("[\u200B\u200C\u200D\uFEFF]")
|
||||
)
|
||||
|
||||
// PositionInfo 持仓信息
|
||||
type PositionInfo struct {
|
||||
Symbol string `json:"symbol"`
|
||||
@@ -212,10 +224,31 @@ func fetchMarketDataForContext(ctx *Context) error {
|
||||
|
||||
// calculateMaxCandidates 根据账户状态计算需要分析的候选币种数量
|
||||
func calculateMaxCandidates(ctx *Context) int {
|
||||
// 直接返回候选池的全部币种数量
|
||||
// 因为候选池已经在 auto_trader.go 中筛选过了
|
||||
// 固定分析前20个评分最高的币种(来自AI500)
|
||||
return len(ctx.CandidateCoins)
|
||||
// ⚠️ 重要:限制候选币种数量,避免 Prompt 过大
|
||||
// 根据持仓数量动态调整:持仓越少,可以分析更多候选币
|
||||
const (
|
||||
maxCandidatesWhenEmpty = 30 // 无持仓时最多分析30个候选币
|
||||
maxCandidatesWhenHolding1 = 25 // 持仓1个时最多分析25个候选币
|
||||
maxCandidatesWhenHolding2 = 20 // 持仓2个时最多分析20个候选币
|
||||
maxCandidatesWhenHolding3 = 15 // 持仓3个时最多分析15个候选币(避免 Prompt 过大)
|
||||
)
|
||||
|
||||
positionCount := len(ctx.Positions)
|
||||
var maxCandidates int
|
||||
|
||||
switch positionCount {
|
||||
case 0:
|
||||
maxCandidates = maxCandidatesWhenEmpty
|
||||
case 1:
|
||||
maxCandidates = maxCandidatesWhenHolding1
|
||||
case 2:
|
||||
maxCandidates = maxCandidatesWhenHolding2
|
||||
default: // 3+ 持仓
|
||||
maxCandidates = maxCandidatesWhenHolding3
|
||||
}
|
||||
|
||||
// 返回实际候选币数量和上限中的较小值
|
||||
return min(len(ctx.CandidateCoins), maxCandidates)
|
||||
}
|
||||
|
||||
// buildSystemPromptWithCustom 构建包含自定义内容的 System Prompt
|
||||
@@ -442,25 +475,44 @@ func extractCoTTrace(response string) string {
|
||||
|
||||
// extractDecisions 提取JSON决策列表
|
||||
func extractDecisions(response string) ([]Decision, error) {
|
||||
// 直接查找JSON数组 - 找第一个完整的JSON数组
|
||||
arrayStart := strings.Index(response, "[")
|
||||
if arrayStart == -1 {
|
||||
return nil, fmt.Errorf("无法找到JSON数组起始")
|
||||
// 预清洗:去零宽/BOM
|
||||
s := removeInvisibleRunes(response)
|
||||
s = strings.TrimSpace(s)
|
||||
|
||||
// 🔧 關鍵修復:在正則匹配之前就先修復全角字符!
|
||||
// 否則正則表達式 \[ 無法匹配全角的 [
|
||||
s = fixMissingQuotes(s)
|
||||
|
||||
// 1) 优先从 ```json 代码块中提取
|
||||
if m := reJSONFence.FindStringSubmatch(s); m != nil && len(m) > 1 {
|
||||
jsonContent := strings.TrimSpace(m[1])
|
||||
jsonContent = compactArrayOpen(jsonContent) // 把 "[ {" 规整为 "[{"
|
||||
jsonContent = fixMissingQuotes(jsonContent) // 二次修復(防止 regex 提取後還有全角)
|
||||
if err := validateJSONFormat(jsonContent); err != nil {
|
||||
return nil, fmt.Errorf("JSON格式验证失败: %w\nJSON内容: %s\n完整响应:\n%s", err, jsonContent, response)
|
||||
}
|
||||
var decisions []Decision
|
||||
if err := json.Unmarshal([]byte(jsonContent), &decisions); err != nil {
|
||||
return nil, fmt.Errorf("JSON解析失败: %w\nJSON内容: %s", err, jsonContent)
|
||||
}
|
||||
return decisions, nil
|
||||
}
|
||||
|
||||
// 从 [ 开始,匹配括号找到对应的 ]
|
||||
arrayEnd := findMatchingBracket(response, arrayStart)
|
||||
if arrayEnd == -1 {
|
||||
return nil, fmt.Errorf("无法找到JSON数组结束")
|
||||
// 2) 退而求其次:全文寻找首个对象数组
|
||||
// 注意:此時 s 已經過 fixMissingQuotes(),全角字符已轉換為半角
|
||||
jsonContent := strings.TrimSpace(reJSONArray.FindString(s))
|
||||
if jsonContent == "" {
|
||||
return nil, fmt.Errorf("无法找到JSON数组起始(已嘗試修復全角字符)\n原始響應前200字符: %s", s[:min(200, len(s))])
|
||||
}
|
||||
|
||||
jsonContent := strings.TrimSpace(response[arrayStart : arrayEnd+1])
|
||||
// 🔧 規整格式(此時全角字符已在前面修復過)
|
||||
jsonContent = compactArrayOpen(jsonContent)
|
||||
jsonContent = fixMissingQuotes(jsonContent) // 二次修復(防止 regex 提取後還有殘留全角)
|
||||
|
||||
// 🔧 修复常见的JSON格式错误:缺少引号的字段值
|
||||
// 匹配: "reasoning": 内容"} 或 "reasoning": 内容} (没有引号)
|
||||
// 修复为: "reasoning": "内容"}
|
||||
// 使用简单的字符串扫描而不是正则表达式
|
||||
jsonContent = fixMissingQuotes(jsonContent)
|
||||
// 🔧 验证 JSON 格式(检测常见错误)
|
||||
if err := validateJSONFormat(jsonContent); err != nil {
|
||||
return nil, fmt.Errorf("JSON格式验证失败: %w\nJSON内容: %s\n完整响应:\n%s", err, jsonContent, response)
|
||||
}
|
||||
|
||||
// 解析JSON
|
||||
var decisions []Decision
|
||||
@@ -471,15 +523,86 @@ func extractDecisions(response string) ([]Decision, error) {
|
||||
return decisions, nil
|
||||
}
|
||||
|
||||
// fixMissingQuotes 替换中文引号为英文引号(避免输入法自动转换)
|
||||
// fixMissingQuotes 替换中文引号和全角字符为英文引号和半角字符(避免AI输出全角JSON字符导致解析失败)
|
||||
func fixMissingQuotes(jsonStr string) string {
|
||||
// 替换中文引号
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "\u201c", "\"") // "
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "\u201d", "\"") // "
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "\u2018", "'") // '
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "\u2019", "'") // '
|
||||
|
||||
// ⚠️ 替换全角括号、冒号、逗号(防止AI输出全角JSON字符)
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "[", "[") // U+FF3B 全角左方括号
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "]", "]") // U+FF3D 全角右方括号
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "{", "{") // U+FF5B 全角左花括号
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "}", "}") // U+FF5D 全角右花括号
|
||||
jsonStr = strings.ReplaceAll(jsonStr, ":", ":") // U+FF1A 全角冒号
|
||||
jsonStr = strings.ReplaceAll(jsonStr, ",", ",") // U+FF0C 全角逗号
|
||||
|
||||
// ⚠️ 替换CJK标点符号(AI在中文上下文中也可能输出这些)
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "【", "[") // CJK左方头括号 U+3010
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "】", "]") // CJK右方头括号 U+3011
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "〔", "[") // CJK左龟壳括号 U+3014
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "〕", "]") // CJK右龟壳括号 U+3015
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "、", ",") // CJK顿号 U+3001
|
||||
|
||||
// ⚠️ 替换全角空格为半角空格(JSON中不应该有全角空格)
|
||||
jsonStr = strings.ReplaceAll(jsonStr, " ", " ") // U+3000 全角空格
|
||||
|
||||
return jsonStr
|
||||
}
|
||||
|
||||
// validateJSONFormat 验证 JSON 格式,检测常见错误
|
||||
func validateJSONFormat(jsonStr string) error {
|
||||
trimmed := strings.TrimSpace(jsonStr)
|
||||
|
||||
// 允许 [ 和 { 之间存在任意空白(含零宽)
|
||||
if !reArrayHead.MatchString(trimmed) {
|
||||
// 检查是否是纯数字/范围数组(常见错误)
|
||||
if strings.HasPrefix(trimmed, "[") && !strings.Contains(trimmed[:min(20, len(trimmed))], "{") {
|
||||
return fmt.Errorf("不是有效的决策数组(必须包含对象 {}),实际内容: %s", trimmed[:min(50, len(trimmed))])
|
||||
}
|
||||
return fmt.Errorf("JSON 必须以 [{ 开头(允许空白),实际: %s", trimmed[:min(20, len(trimmed))])
|
||||
}
|
||||
|
||||
// 检查是否包含范围符号 ~(LLM 常见错误)
|
||||
if strings.Contains(jsonStr, "~") {
|
||||
return fmt.Errorf("JSON 中不可包含范围符号 ~,所有数字必须是精确的单一值")
|
||||
}
|
||||
|
||||
// 检查是否包含千位分隔符(如 98,000)
|
||||
// 使用简单的模式匹配:数字+逗号+3位数字
|
||||
for i := 0; i < len(jsonStr)-4; i++ {
|
||||
if jsonStr[i] >= '0' && jsonStr[i] <= '9' &&
|
||||
jsonStr[i+1] == ',' &&
|
||||
jsonStr[i+2] >= '0' && jsonStr[i+2] <= '9' &&
|
||||
jsonStr[i+3] >= '0' && jsonStr[i+3] <= '9' &&
|
||||
jsonStr[i+4] >= '0' && jsonStr[i+4] <= '9' {
|
||||
return fmt.Errorf("JSON 数字不可包含千位分隔符逗号,发现: %s", jsonStr[i:min(i+10, len(jsonStr))])
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// min 返回两个整数中的较小值
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// removeInvisibleRunes 去除零宽字符和 BOM,避免肉眼看不见的前缀破坏校验
|
||||
func removeInvisibleRunes(s string) string {
|
||||
return reInvisibleRunes.ReplaceAllString(s, "")
|
||||
}
|
||||
|
||||
// compactArrayOpen 规整开头的 "[ {" → "[{"
|
||||
func compactArrayOpen(s string) string {
|
||||
return reArrayOpenSpace.ReplaceAllString(strings.TrimSpace(s), "[{")
|
||||
}
|
||||
|
||||
// validateDecisions 验证所有决策(需要账户信息和杠杆配置)
|
||||
func validateDecisions(decisions []Decision, accountEquity float64, btcEthLeverage, altcoinLeverage int) error {
|
||||
for i, decision := range decisions {
|
||||
|
||||
Reference in New Issue
Block a user