#!/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() # 检测 ... 包裹 struct_match = re.search( r'\s*(.*?)\s*', 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", }, ] # 用于 的 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): """安全解析 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()