- Create json_validator.py utility module: - validate_json_syntax(): JSON syntax scoring (0-100) with auto-fix - validate_with_schema(): Multi-layer validation (syntax + schema) - score_and_retry(): Generic retry wrapper with error feedback - safe_parse_structured(): Safe <structured_data> extraction - FormatErrorLibrary: Persistent error tracking + stats + auto recommendations - CLI entry point for standalone use - Integrate into strategy_lifecycle.py: - safe_json_load() now uses json_validator for auto-fix + error logging - Falls back to legacy repair on ImportError - Integrate into update_data.py: - parse_report() uses safe_parse_structured() instead of bare json.loads() - Errors logged to FormatErrorLibrary silently - Convert strategy_lifecycle.py to Unix line endings
779 lines
26 KiB
Python
779 lines
26 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
json_validator.py — 知微股票分析 Pipeline JSON 格式校验器
|
||
|
||
为「打分-重试 + 格式校验」双重循环提供基础设施。
|
||
|
||
核心功能:
|
||
1. validate_json_syntax(text) → (score, errors, parsed)
|
||
json.loads() + 详细错误分类(语法错误/缺失字段/截断)
|
||
|
||
2. validate_with_schema(text, schema_fn) → (score, errors, parsed)
|
||
在语法校验上叠Schema校验:字段存在性、类型正确性、非空约束
|
||
|
||
3. score_and_retry(llm_generate_fn, max_retries=3, threshold=90) → result
|
||
通用重试包装器:不合格自动重试 + 错误反馈注入
|
||
|
||
4. FormatErrorLibrary — 格式错误持久化档案库
|
||
记录每次错误 → 按类型统计 → 触发自动改进建议
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
import re
|
||
import sys
|
||
import time
|
||
from datetime import datetime, timedelta
|
||
from pathlib import Path
|
||
from collections import defaultdict, Counter
|
||
|
||
|
||
# ── 错误类型分类 ──────────────────────────────────────────────
|
||
|
||
ERROR_SYNTAX = "syntax_error" # JSON 语法解析失败
|
||
ERROR_MISSING_FIELD = "missing_field" # Schema 必填字段缺失
|
||
ERROR_WRONG_TYPE = "wrong_type" # 字段类型不符
|
||
ERROR_EMPTY_FIELD = "empty_field" # 关键字段为空/null
|
||
ERROR_TRUNCATION = "truncation" # 输出截断(在 JSON 外检测)
|
||
ERROR_UNEXPECTED = "unexpected_error" # 其他
|
||
|
||
|
||
# ── 错误档案库路径 ────────────────────────────────────────────
|
||
|
||
FORMAT_ERROR_LIBRARY_PATH = Path(
|
||
os.environ.get(
|
||
"FORMAT_ERROR_LIBRARY_PATH",
|
||
"/home/hmo/MoFin/data/format_error_library.json"
|
||
)
|
||
)
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# 核心校验器
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
def validate_json_syntax(text):
|
||
"""JSON 语法校验 + 评分。
|
||
|
||
Args:
|
||
text: 要校验的字符串(可能包含 ```json ... ``` 或纯 JSON)
|
||
|
||
Returns:
|
||
(score, errors, parsed)
|
||
score: 0-100,100=完美JSON,<90=需要重试
|
||
errors: [{'type': str, 'detail': str, 'line': int}, ...]
|
||
parsed: 解析成功时返回 dict/list,失败返回 None
|
||
"""
|
||
errors = []
|
||
parsed = None
|
||
|
||
if not text or not text.strip():
|
||
errors.append({
|
||
"type": ERROR_SYNTAX,
|
||
"detail": "空输出",
|
||
"line": 0,
|
||
})
|
||
return 0, errors, None
|
||
|
||
# 尝试提取 JSON 块(支持 ```json ... ``` 包裹)
|
||
raw = text.strip()
|
||
json_block = raw
|
||
|
||
# 检测 markdown 代码块
|
||
code_match = re.search(
|
||
r'```(?:json)?\s*\n?(.*?)```', raw, re.DOTALL
|
||
)
|
||
if code_match:
|
||
json_block = code_match.group(1).strip()
|
||
|
||
# 检测 <structured_data>...</structured_data> 包裹
|
||
struct_match = re.search(
|
||
r'<structured_data>\s*(.*?)\s*</structured_data>', raw, re.DOTALL
|
||
)
|
||
if struct_match:
|
||
json_block = struct_match.group(1).strip()
|
||
|
||
# 尝试括号定位:从第一个 { 到最后一个 }
|
||
brace_start = json_block.find("{")
|
||
brace_end = json_block.rfind("}")
|
||
if brace_start >= 0 and brace_end > brace_start:
|
||
json_block = json_block[brace_start:brace_end + 1]
|
||
|
||
# 预处理修复常见问题
|
||
fixed = _auto_fix_json(json_block)
|
||
|
||
# 尝试解析
|
||
try:
|
||
parsed = json.loads(fixed)
|
||
except json.JSONDecodeError as e:
|
||
line_no = e.lineno if hasattr(e, 'lineno') else 0
|
||
col_no = e.colno if hasattr(e, 'colno') else 0
|
||
msg = str(e)
|
||
|
||
# 分类错误类型
|
||
err_type = _classify_syntax_error(msg, json_block)
|
||
|
||
errors.append({
|
||
"type": err_type,
|
||
"detail": f"L{line_no}:{col_no} {msg[:200]}",
|
||
"line": line_no,
|
||
})
|
||
|
||
# 尝试 best-effort 修复后再次解析
|
||
repaired = _deep_fix_json(json_block)
|
||
if repaired:
|
||
try:
|
||
parsed = json.loads(repaired)
|
||
errors.append({
|
||
"type": "repaired",
|
||
"detail": f"自动修复后解析成功 (原始: {msg[:100]})",
|
||
"line": 0,
|
||
})
|
||
except json.JSONDecodeError:
|
||
pass
|
||
|
||
# 评分
|
||
score = _compute_score(errors, parsed, text)
|
||
|
||
return score, errors, parsed
|
||
|
||
|
||
def _auto_fix_json(text):
|
||
"""浅层自动修复常见 JSON 问题。"""
|
||
if not text or not text.strip():
|
||
return text
|
||
|
||
fixed = text
|
||
|
||
# 修复1: 尾随逗号在数组/对象末尾
|
||
fixed = re.sub(r',\s*}', '}', fixed)
|
||
fixed = re.sub(r',\s*\]', ']', fixed)
|
||
|
||
# 修复2: 单引号代替双引号(只在键名位置)
|
||
# 匹配 'key': 或 'key' :
|
||
fixed = re.sub(r"'([^']+)'(\s*:)", r'"\1"\2', fixed)
|
||
|
||
# 修复3: 字符串内未转义的换行符
|
||
result = []
|
||
in_str = False
|
||
escape = False
|
||
for ch in fixed:
|
||
if escape:
|
||
result.append(ch)
|
||
escape = False
|
||
continue
|
||
if ch == '\\':
|
||
result.append(ch)
|
||
escape = True
|
||
continue
|
||
if ch == '"' and not escape:
|
||
in_str = not in_str
|
||
result.append(ch)
|
||
continue
|
||
if in_str and ch in '\n\r':
|
||
result.append('\\n')
|
||
else:
|
||
result.append(ch)
|
||
|
||
fixed = ''.join(result)
|
||
|
||
# 修复4: 多余尾部括号
|
||
fixed = fixed.rstrip('}') + '}'
|
||
|
||
return fixed
|
||
|
||
|
||
def _deep_fix_json(text):
|
||
"""深度修复:尝试补全截断的 JSON。"""
|
||
if not text:
|
||
return None
|
||
|
||
fixed = text.strip()
|
||
|
||
# 如果是截断的(以不完整的状态结尾)
|
||
# 尝试补全括号
|
||
open_braces = fixed.count('{') - fixed.count('}')
|
||
open_brackets = fixed.count('[') - fixed.count(']')
|
||
|
||
if open_braces > 0:
|
||
fixed += '}' * open_braces
|
||
if open_brackets > 0:
|
||
fixed += ']' * open_brackets
|
||
|
||
# 如果缺少闭合引号
|
||
in_str = False
|
||
for ch in fixed:
|
||
if ch == '"':
|
||
in_str = not in_str
|
||
if in_str:
|
||
fixed += '"'
|
||
|
||
# 尝试补全末尾逗号导致的问题
|
||
fixed = re.sub(r',$', '', fixed.rstrip())
|
||
|
||
# 补齐尾部的 }]
|
||
if fixed.rstrip().endswith(','):
|
||
fixed = fixed.rstrip()[:-1]
|
||
|
||
# 确保对象闭合
|
||
if '{' in fixed and '}' not in fixed[fixed.rfind('{'):]:
|
||
fixed += '}'
|
||
|
||
try:
|
||
json.loads(fixed)
|
||
return fixed
|
||
except json.JSONDecodeError:
|
||
return None
|
||
|
||
|
||
def _classify_syntax_error(msg, text):
|
||
"""对 JSONDecodeError 消息分类。"""
|
||
msg_lower = msg.lower()
|
||
|
||
if 'unexpected EOF' in msg_lower or 'unterminated' in msg_lower:
|
||
return ERROR_TRUNCATION
|
||
if 'expect' in msg_lower and 'property name' in msg_lower:
|
||
return ERROR_MISSING_FIELD
|
||
if 'trailing' in msg_lower:
|
||
return ERROR_SYNTAX
|
||
if 'invalid' in msg_lower and 'control character' in msg_lower:
|
||
return ERROR_SYNTAX
|
||
return ERROR_SYNTAX
|
||
|
||
|
||
def _compute_score(errors, parsed, raw_text):
|
||
"""计算格式合规性评分 (0-100)。
|
||
|
||
扣分规则:
|
||
- 语法错误: -40(致命,必须重试)
|
||
- 截断: -50(致命,输出不完整)
|
||
- 自动修复后成功: -20(能修复但说明输出质量不高)
|
||
- 空输出: -100(完全无用)
|
||
"""
|
||
if parsed is not None and not errors:
|
||
return 100
|
||
|
||
if not errors:
|
||
return 100
|
||
|
||
score = 100
|
||
for err in errors:
|
||
t = err["type"]
|
||
if t == ERROR_SYNTAX:
|
||
score -= 40
|
||
elif t == ERROR_TRUNCATION:
|
||
score -= 50
|
||
elif t == ERROR_MISSING_FIELD:
|
||
score -= 30
|
||
elif t == "repaired":
|
||
score -= 20
|
||
elif t == ERROR_EMPTY_FIELD:
|
||
score -= 15
|
||
|
||
return max(0, score)
|
||
|
||
|
||
def validate_with_schema(text, schema=None):
|
||
"""JSON 语法 + Schema 双层校验。
|
||
|
||
schema: 可选的自定义校验函数 schema(parsed) -> [(field, type, detail)]
|
||
或内置 check 列表 dict 格式:
|
||
[{"field": "...", "check": callable, "desc": "..."}]
|
||
或 None(只做语法校验)
|
||
"""
|
||
score, errors, parsed = validate_json_syntax(text)
|
||
|
||
if parsed is None:
|
||
return score, errors, parsed
|
||
|
||
if schema is None:
|
||
return score, errors, parsed
|
||
|
||
# Schema 校验
|
||
import collections
|
||
schema_errors = []
|
||
if callable(schema):
|
||
raw_errors = schema(parsed)
|
||
if isinstance(raw_errors, collections.abc.Iterable):
|
||
for se in raw_errors:
|
||
field, err_type, detail = se[:3]
|
||
schema_errors.append({
|
||
"type": err_type or ERROR_MISSING_FIELD,
|
||
"detail": detail,
|
||
"line": 0,
|
||
})
|
||
elif isinstance(schema, (list, tuple)):
|
||
for rule in schema:
|
||
field = rule.get("field", "?")
|
||
check = rule.get("check")
|
||
desc = rule.get("desc", "")
|
||
if check and not check(parsed):
|
||
errors.append({
|
||
"type": rule.get("severity", ERROR_MISSING_FIELD),
|
||
"detail": f"[{field}] {desc}",
|
||
"line": 0,
|
||
})
|
||
|
||
# 重算分数
|
||
score = _compute_score(errors, parsed, text)
|
||
return score, errors, parsed
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# 通用于 decisions.json 的 Schema 校验规则
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
# 股票分析结果的 Schema 定义
|
||
STOCK_ANALYSIS_SCHEMA = [
|
||
{
|
||
"field": "code",
|
||
"check": lambda d: bool(d.get("code")),
|
||
"desc": "股票代码必须存在",
|
||
"severity": "missing_field",
|
||
},
|
||
{
|
||
"field": "action",
|
||
"check": lambda d: bool(d.get("action")),
|
||
"desc": "操作建议(action)必须存在",
|
||
"severity": "missing_field",
|
||
},
|
||
{
|
||
"field": "stop_loss",
|
||
"check": lambda d: (d.get("stop_loss") or 0) > 0,
|
||
"desc": "止损(stop_loss)必须大于0",
|
||
"severity": "missing_field",
|
||
},
|
||
{
|
||
"field": "take_profit",
|
||
"check": lambda d: (d.get("take_profit") or 0) > 0,
|
||
"desc": "止盈(take_profit)必须存在且大于0",
|
||
"severity": "missing_field",
|
||
},
|
||
{
|
||
"field": "entry_low",
|
||
"check": lambda d: (d.get("entry_low") or 0) > 0,
|
||
"desc": "买入区下沿(entry_low)必须大于0",
|
||
"severity": "missing_field",
|
||
},
|
||
{
|
||
"field": "entry_high",
|
||
"check": lambda d: (d.get("entry_high") or 0) > 0,
|
||
"desc": "买入区上沿(entry_high)必须大于0",
|
||
"severity": "missing_field",
|
||
},
|
||
{
|
||
"field": "entry_range",
|
||
"check": lambda d: (d.get("entry_low") or 0) < (d.get("entry_high") or 0),
|
||
"desc": "买入区下沿 < 上沿",
|
||
"severity": "syntax_error",
|
||
},
|
||
{
|
||
"field": "rr_ratio",
|
||
"check": lambda d: (d.get("rr_ratio") or 0) >= 0,
|
||
"desc": "盈亏比(rr_ratio)必须非负",
|
||
"severity": "missing_field",
|
||
},
|
||
{
|
||
"field": "timing_signal",
|
||
"check": lambda d: bool(d.get("timing_signal")),
|
||
"desc": "时机信号(timing_signal)必须存在",
|
||
"severity": "missing_field",
|
||
},
|
||
]
|
||
|
||
# 用于 <structured_data> 的 Schema
|
||
STRUCTURED_DATA_SCHEMA = [
|
||
{
|
||
"field": "holdings",
|
||
"check": lambda d: isinstance(d.get("holdings"), list),
|
||
"desc": "holdings 必须是数组",
|
||
"severity": "missing_field",
|
||
},
|
||
{
|
||
"field": "type",
|
||
"check": lambda d: bool(d.get("type")),
|
||
"desc": "type 字段必须存在",
|
||
"severity": "missing_field",
|
||
},
|
||
]
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# Score-and-Retry 循环
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
def score_and_retry(llm_generate_fn, max_retries=3, threshold=90,
|
||
schema=None, feedback_prefix="[Format Feedback]",
|
||
on_retry=None, on_failure=None):
|
||
"""通用打分-重试循环包装器。
|
||
|
||
Args:
|
||
llm_generate_fn: 调用函数,接收 attempt + previous_errors 字典
|
||
签名: llm_generate_fn(attempt, context)
|
||
返回生成的文本
|
||
max_retries: 最大重试次数(含首次,即最多调用 max_retries+1 次)
|
||
threshold: 合格分数线 (0-100)
|
||
schema: 可选 Schema 校验规则
|
||
feedback_prefix: 错误反馈前缀
|
||
on_retry: 可选回调 on_retry(attempt, score, errors)
|
||
on_failure: 可选回调 on_failure(attempts, best_raw, all_errors)
|
||
|
||
Returns:
|
||
(parsed, score, errors, raw)
|
||
"""
|
||
best_score = 0
|
||
best_raw = None
|
||
best_parsed = None
|
||
all_errors = []
|
||
|
||
for attempt in range(max_retries + 1):
|
||
context = {
|
||
"attempt": attempt,
|
||
"previous_errors": all_errors[-1] if all_errors else [],
|
||
"feedback": "",
|
||
}
|
||
|
||
# 构建带反馈的调用
|
||
if attempt > 0 and all_errors:
|
||
feedback = _build_feedback(all_errors[-1], feedback_prefix)
|
||
context["feedback"] = feedback
|
||
|
||
raw = llm_generate_fn(attempt, context)
|
||
score, errors, parsed = validate_with_schema(raw, schema=schema)
|
||
|
||
all_errors.append(errors)
|
||
|
||
if on_retry:
|
||
on_retry(attempt, score, errors)
|
||
|
||
# 保留最佳结果
|
||
if score > best_score:
|
||
best_score = score
|
||
best_raw = raw
|
||
best_parsed = parsed
|
||
|
||
if score >= threshold and parsed is not None:
|
||
# 验收通过
|
||
_record_format_success(attempt, score)
|
||
return parsed, score, errors, raw
|
||
|
||
if attempt < max_retries:
|
||
# 记录失败(用于重试)
|
||
_record_format_error(raw, errors, attempt)
|
||
|
||
# 所有重试用尽
|
||
if on_failure:
|
||
on_failure(max_retries, best_raw, all_errors)
|
||
|
||
_record_format_failure(best_raw, all_errors, max_retries)
|
||
|
||
# 返回 best_effort
|
||
if best_parsed is not None:
|
||
return best_parsed, best_score, all_errors[-1], best_raw
|
||
|
||
return None, best_score, all_errors[-1], best_raw
|
||
|
||
|
||
def _build_feedback(errors, prefix):
|
||
"""将错误列表格式化为模型可读的反馈文本。"""
|
||
parts = [f"{prefix} 上一轮输出存在以下格式问题,请在本次修正:"]
|
||
|
||
for err in errors:
|
||
t = err.get("type", "unknown")
|
||
d = err.get("detail", "")
|
||
parts.append(f" - [{t}] {d[:150]}")
|
||
|
||
if not errors:
|
||
parts.append(" - 通用格式要求:输出必须是合法的 JSON")
|
||
|
||
return "\n".join(parts)
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# Format Error Library
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
def _ensure_library_path():
|
||
FORMAT_ERROR_LIBRARY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
|
||
def _load_library():
|
||
_ensure_library_path()
|
||
if FORMAT_ERROR_LIBRARY_PATH.exists():
|
||
try:
|
||
return json.loads(FORMAT_ERROR_LIBRARY_PATH.read_text())
|
||
except (json.JSONDecodeError, Exception):
|
||
pass
|
||
return {
|
||
"records": [],
|
||
"stats": {},
|
||
"created_at": datetime.now().isoformat(),
|
||
"last_updated": datetime.now().isoformat(),
|
||
}
|
||
|
||
|
||
def _save_library(lib):
|
||
lib["last_updated"] = datetime.now().isoformat()
|
||
FORMAT_ERROR_LIBRARY_PATH.write_text(
|
||
json.dumps(lib, ensure_ascii=False, indent=2)
|
||
)
|
||
|
||
|
||
def _record_format_error(raw, errors, attempt):
|
||
"""记录一次格式错误到档案库。"""
|
||
lib = _load_library()
|
||
|
||
record = {
|
||
"timestamp": datetime.now().isoformat(),
|
||
"attempt": attempt + 1,
|
||
"raw_preview": raw[:500] if raw else "",
|
||
"errors": errors,
|
||
"error_types": [e.get("type", "unknown") for e in errors],
|
||
}
|
||
lib["records"].append(record)
|
||
|
||
# 更新统计
|
||
for err in errors:
|
||
t = err.get("type", "unknown")
|
||
# 按天统计
|
||
today = datetime.now().strftime("%Y-%m-%d")
|
||
key = f"{today}:{t}"
|
||
lib.setdefault("stats", {}).setdefault("daily", {}).setdefault(key, 0)
|
||
lib["stats"]["daily"][key] = lib["stats"]["daily"].get(key, 0) + 1
|
||
|
||
# 容量控制:保留最近 1000 条
|
||
if len(lib["records"]) > 1000:
|
||
lib["records"] = lib["records"][-1000:]
|
||
|
||
_save_library(lib)
|
||
|
||
|
||
def _record_format_success(attempt, score):
|
||
"""记录一次成功的格式校验。"""
|
||
lib = _load_library()
|
||
today = datetime.now().strftime("%Y-%m-%d")
|
||
key = f"{today}:success"
|
||
lib.setdefault("stats", {}).setdefault("daily", {}).setdefault(key, 0)
|
||
lib["stats"]["daily"][key] = lib["stats"]["daily"].get(key, 0) + 1
|
||
lib.setdefault("stats", {}).setdefault("total_calls", 0)
|
||
lib["stats"]["total_calls"] = lib["stats"].get("total_calls", 0) + 1
|
||
_save_library(lib)
|
||
|
||
|
||
def _record_format_failure(raw, all_errors, max_retries):
|
||
"""记录超出最大重试次数的完全失败。"""
|
||
lib = _load_library()
|
||
today = datetime.now().strftime("%Y-%m-%d")
|
||
key = f"{today}:total_failure"
|
||
lib.setdefault("stats", {}).setdefault("daily", {}).setdefault(key, 0)
|
||
lib["stats"]["daily"][key] = lib["stats"]["daily"].get(key, 0) + 1
|
||
lib.setdefault("stats", {}).setdefault("total_calls", 0)
|
||
lib["stats"]["total_calls"] = lib["stats"].get("total_calls", 0) + 1
|
||
lib["stats"]["total_failures"] = lib["stats"].get("total_failures", 0) + 1
|
||
_save_library(lib)
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# 统计与报告
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
def get_error_report(days=7):
|
||
"""生成格式错误统计报告。"""
|
||
lib = _load_library()
|
||
|
||
daily = lib.get("stats", {}).get("daily", {})
|
||
total_calls = lib.get("stats", {}).get("total_calls", 0)
|
||
total_failures = lib.get("stats", {}).get("total_failures", 0)
|
||
|
||
# 按错误类型聚合(近 N 天)
|
||
type_count = Counter()
|
||
today = datetime.now()
|
||
for key, count in daily.items():
|
||
try:
|
||
date_str, err_type = key.split(":", 1)
|
||
date = datetime.strptime(date_str, "%Y-%m-%d")
|
||
if (today - date).days <= days:
|
||
type_count[err_type] += count
|
||
except (ValueError, IndexError):
|
||
pass
|
||
|
||
# 总调用数
|
||
total_typed = sum(type_count.values())
|
||
success_count = type_count.get("success", 0)
|
||
|
||
report = {
|
||
"period_days": days,
|
||
"total_calls": total_calls,
|
||
"total_failures": total_failures,
|
||
"period_calls": total_typed,
|
||
"period_success": success_count,
|
||
"period_error_rate": (
|
||
round((1 - success_count / max(total_typed, 1)) * 100, 1)
|
||
if total_typed > 0 else 0
|
||
),
|
||
"error_breakdown": dict(type_count.most_common()),
|
||
"recent_records": lib["records"][-20:],
|
||
}
|
||
|
||
return report
|
||
|
||
|
||
def get_auto_fix_recommendations():
|
||
"""根据错误统计自动生成修复建议。
|
||
|
||
规则:
|
||
- missing_field 连续 7 天 > 5 次/天 → 检查 prompt 字段排序
|
||
- syntax_error 连续 7 天 > 3 次/天 → 建议启用 response_format
|
||
- truncation > 3 次/周 → 检查 max_tokens
|
||
"""
|
||
lib = _load_library()
|
||
daily = lib.get("stats", {}).get("daily", {})
|
||
|
||
# 近 7 天统计
|
||
today = datetime.now()
|
||
week_errors = defaultdict(int)
|
||
for key, count in daily.items():
|
||
try:
|
||
date_str, err_type = key.split(":", 1)
|
||
date = datetime.strptime(date_str, "%Y-%m-%d")
|
||
if (today - date).days <= 7:
|
||
week_errors[err_type] += count
|
||
except (ValueError, IndexError):
|
||
pass
|
||
|
||
recommendations = []
|
||
|
||
if week_errors.get("missing_field", 0) > 35: # 5次/天 * 7天
|
||
recommendations.append(
|
||
"missing_field 频繁(近7天{}次)→ 检查 prompt 中字段是否过于靠后,"
|
||
"考虑将 schema 定义提前到 prompt 前部".format(
|
||
week_errors.get("missing_field", 0)
|
||
)
|
||
)
|
||
|
||
if week_errors.get("syntax_error", 0) > 21: # 3次/天 * 7天
|
||
recommendations.append(
|
||
"syntax_error 频繁(近7天{}次)→ 建议启用 response_format=json_object "
|
||
"或 constrained decoding".format(
|
||
week_errors.get("syntax_error", 0)
|
||
)
|
||
)
|
||
|
||
if week_errors.get("truncation", 0) > 3:
|
||
recommendations.append(
|
||
"truncation {}次/周 → 检查 max_tokens 是否足够,建议调高 20%".format(
|
||
week_errors.get("truncation", 0)
|
||
)
|
||
)
|
||
|
||
total_errors = sum(v for k, v in week_errors.items() if k != "success")
|
||
total_calls = week_errors.get("success", 0) + total_errors
|
||
error_rate = total_errors / max(total_calls, 1) * 100
|
||
|
||
if error_rate > 5 and total_calls > 20:
|
||
recommendations.append(
|
||
"总错误率 {:.1f}%({}次/{}次)→ 循环软约束已达极限,建议接入 "
|
||
"response_format=json_object 或 outlines JSON Schema generation".format(
|
||
error_rate, total_errors, total_calls
|
||
)
|
||
)
|
||
|
||
return recommendations
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# 便捷包装器 — 用于 update_data.py 等下游
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
def safe_parse_structured(text, schema=None, log_errors=True):
|
||
"""安全解析 <structured_data> JSON,带评分重试语义。
|
||
|
||
用于 update_data.py 的 parse_report() 替代 json.loads()。
|
||
|
||
Args:
|
||
text: markdown 文本
|
||
schema: 可选 Schema
|
||
log_errors: 是否记录到 FormatErrorLibrary
|
||
|
||
Returns:
|
||
(parsed, score, errors)
|
||
parsed 为 None 时代表完全失败
|
||
"""
|
||
score, errors, parsed = validate_with_schema(text, schema=schema)
|
||
|
||
if log_errors and errors and parsed is None:
|
||
_record_format_error(text, errors, 0)
|
||
|
||
return parsed, score, errors
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# CLI 入口 — 可直接作为工具使用
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
def main():
|
||
"""CLI: 校验从 stdin 或文件传入的 JSON。"""
|
||
import argparse
|
||
|
||
parser = argparse.ArgumentParser(
|
||
description="JSON 格式校验器 — 知微股票分析 Pipeline"
|
||
)
|
||
parser.add_argument("file", nargs="?", help="要校验的文件路径(默认从 stdin 读)")
|
||
parser.add_argument("--schema", choices=["decisions", "structured", "none"],
|
||
default="none", help="Schema 校验模式")
|
||
parser.add_argument("--report", action="store_true",
|
||
help="生成格式错误统计报告")
|
||
parser.add_argument("--recommend", action="store_true",
|
||
help="生成自动修复建议")
|
||
parser.add_argument("--days", type=int, default=7,
|
||
help="统计报告的天数范围(默认 7)")
|
||
|
||
args = parser.parse_args()
|
||
|
||
if args.report:
|
||
report = get_error_report(days=args.days)
|
||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||
return
|
||
|
||
if args.recommend:
|
||
recs = get_auto_fix_recommendations()
|
||
if recs:
|
||
print("## 自动修复建议\n")
|
||
for r in recs:
|
||
print(f"- {r}")
|
||
else:
|
||
print("无待修复问题。格式错误率在可接受范围内。")
|
||
return
|
||
|
||
# 读取输入
|
||
if args.file:
|
||
with open(args.file, "r", encoding="utf-8") as f:
|
||
text = f.read()
|
||
else:
|
||
text = sys.stdin.read()
|
||
|
||
# 选择 Schema
|
||
schema = None
|
||
if args.schema == "decisions":
|
||
schema = STOCK_ANALYSIS_SCHEMA
|
||
elif args.schema == "structured":
|
||
schema = STRUCTURED_DATA_SCHEMA
|
||
|
||
score, errors, parsed = validate_with_schema(text, schema=schema)
|
||
|
||
result = {
|
||
"score": score,
|
||
"passed": score >= 90,
|
||
"errors": errors,
|
||
"parsed_preview": str(parsed)[:300] if parsed else None,
|
||
}
|
||
|
||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||
|
||
if errors:
|
||
sys.exit(1)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|