Files
MoFin/json_validator.py
T
知微 7cebcba13a Implement proposal #19: Score-and-Retry + JSON format validation for 知微 pipeline
- 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
2026-07-08 01:18:48 +08:00

779 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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-100100=完美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()