马一丁

Repair and Optimize the Chart Rendering

@@ -11,6 +11,15 @@ import json @@ -11,6 +11,15 @@ import json
11 import os 11 import os
12 from pathlib import Path 12 from pathlib import Path
13 from typing import Any, Dict, List 13 from typing import Any, Dict, List
  14 +from loguru import logger
  15 +
  16 +from ReportEngine.utils.chart_validator import (
  17 + ChartValidator,
  18 + ChartRepairer,
  19 + create_chart_validator,
  20 + create_chart_repairer
  21 +)
  22 +from ReportEngine.utils.chart_repair_api import create_llm_repair_functions
14 23
15 24
16 class HTMLRenderer: 25 class HTMLRenderer:
@@ -65,6 +74,23 @@ class HTMLRenderer: @@ -65,6 +74,23 @@ class HTMLRenderer:
65 self.hero_kpi_signature: tuple | None = None 74 self.hero_kpi_signature: tuple | None = None
66 self._lib_cache: Dict[str, str] = {} 75 self._lib_cache: Dict[str, str] = {}
67 76
  77 + # 初始化图表验证和修复器
  78 + self.chart_validator = create_chart_validator()
  79 + llm_repair_fns = create_llm_repair_functions()
  80 + self.chart_repairer = create_chart_repairer(
  81 + validator=self.chart_validator,
  82 + llm_repair_fns=llm_repair_fns
  83 + )
  84 +
  85 + # 统计信息
  86 + self.chart_validation_stats = {
  87 + 'total': 0,
  88 + 'valid': 0,
  89 + 'repaired_locally': 0,
  90 + 'repaired_api': 0,
  91 + 'failed': 0
  92 + }
  93 +
68 @staticmethod 94 @staticmethod
69 def _get_lib_path() -> Path: 95 def _get_lib_path() -> Path:
70 """获取第三方库文件的目录路径""" 96 """获取第三方库文件的目录路径"""
@@ -124,6 +150,15 @@ class HTMLRenderer: @@ -124,6 +150,15 @@ class HTMLRenderer:
124 self.heading_label_map = self._compute_heading_labels(self.chapters) 150 self.heading_label_map = self._compute_heading_labels(self.chapters)
125 self.toc_entries = self._collect_toc_entries(self.chapters) 151 self.toc_entries = self._collect_toc_entries(self.chapters)
126 152
  153 + # 重置图表验证统计
  154 + self.chart_validation_stats = {
  155 + 'total': 0,
  156 + 'valid': 0,
  157 + 'repaired_locally': 0,
  158 + 'repaired_api': 0,
  159 + 'failed': 0
  160 + }
  161 +
127 metadata = self.metadata 162 metadata = self.metadata
128 theme_tokens = metadata.get("themeTokens") or self.document.get("themeTokens", {}) 163 theme_tokens = metadata.get("themeTokens") or self.document.get("themeTokens", {})
129 title = metadata.get("title") or metadata.get("query") or "智能舆情报告" 164 title = metadata.get("title") or metadata.get("query") or "智能舆情报告"
@@ -132,6 +167,10 @@ class HTMLRenderer: @@ -132,6 +167,10 @@ class HTMLRenderer:
132 167
133 head = self._render_head(title, theme_tokens) 168 head = self._render_head(title, theme_tokens)
134 body = self._render_body() 169 body = self._render_body()
  170 +
  171 + # 输出图表验证统计
  172 + self._log_chart_validation_stats()
  173 +
135 return f"<!DOCTYPE html>\n<html lang=\"zh-CN\" class=\"no-js\">\n{head}\n{body}\n</html>" 174 return f"<!DOCTYPE html>\n<html lang=\"zh-CN\" class=\"no-js\">\n{head}\n{body}\n</html>"
136 175
137 # ====== 头部 / 正文 ====== 176 # ====== 头部 / 正文 ======
@@ -1150,12 +1189,66 @@ class HTMLRenderer: @@ -1150,12 +1189,66 @@ class HTMLRenderer:
1150 """ 1189 """
1151 渲染Chart.js等交互组件的占位容器,并记录配置JSON。 1190 渲染Chart.js等交互组件的占位容器,并记录配置JSON。
1152 1191
  1192 + 在渲染前进行图表验证和修复:
  1193 + 1. 验证图表数据格式
  1194 + 2. 如果无效,尝试本地修复
  1195 + 3. 如果本地修复失败,尝试API修复
  1196 + 4. 如果所有修复都失败,使用原始数据(前端会降级处理)
  1197 +
1153 参数: 1198 参数:
1154 block: widget类型的block,包含widgetId/props/data。 1199 block: widget类型的block,包含widgetId/props/data。
1155 1200
1156 返回: 1201 返回:
1157 str: 含canvas与配置脚本的HTML。 1202 str: 含canvas与配置脚本的HTML。
1158 """ 1203 """
  1204 + # 统计
  1205 + widget_type = block.get('widgetType', '')
  1206 + is_chart = isinstance(widget_type, str) and widget_type.startswith('chart.js')
  1207 +
  1208 + if is_chart:
  1209 + self.chart_validation_stats['total'] += 1
  1210 +
  1211 + # 验证图表数据
  1212 + validation_result = self.chart_validator.validate(block)
  1213 +
  1214 + if not validation_result.is_valid:
  1215 + logger.warning(
  1216 + f"图表 {block.get('widgetId', 'unknown')} 验证失败: {validation_result.errors}"
  1217 + )
  1218 +
  1219 + # 尝试修复
  1220 + repair_result = self.chart_repairer.repair(block, validation_result)
  1221 +
  1222 + if repair_result.success and repair_result.repaired_block:
  1223 + # 修复成功,使用修复后的数据
  1224 + block = repair_result.repaired_block
  1225 + logger.info(
  1226 + f"图表 {block.get('widgetId', 'unknown')} 修复成功 "
  1227 + f"(方法: {repair_result.method}): {repair_result.changes}"
  1228 + )
  1229 +
  1230 + # 更新统计
  1231 + if repair_result.method == 'local':
  1232 + self.chart_validation_stats['repaired_locally'] += 1
  1233 + elif repair_result.method == 'api':
  1234 + self.chart_validation_stats['repaired_api'] += 1
  1235 + else:
  1236 + # 修复失败,使用原始数据,前端会尝试降级渲染
  1237 + logger.warning(
  1238 + f"图表 {block.get('widgetId', 'unknown')} 修复失败,"
  1239 + f"将使用原始数据(前端会尝试降级渲染或显示fallback)"
  1240 + )
  1241 + self.chart_validation_stats['failed'] += 1
  1242 + else:
  1243 + # 验证通过
  1244 + self.chart_validation_stats['valid'] += 1
  1245 + if validation_result.warnings:
  1246 + logger.info(
  1247 + f"图表 {block.get('widgetId', 'unknown')} 验证通过,"
  1248 + f"但有警告: {validation_result.warnings}"
  1249 + )
  1250 +
  1251 + # 渲染图表HTML
1159 self.chart_counter += 1 1252 self.chart_counter += 1
1160 canvas_id = f"chart-{self.chart_counter}" 1253 canvas_id = f"chart-{self.chart_counter}"
1161 config_id = f"chart-config-{self.chart_counter}" 1254 config_id = f"chart-config-{self.chart_counter}"
@@ -1220,6 +1313,39 @@ class HTMLRenderer: @@ -1220,6 +1313,39 @@ class HTMLRenderer:
1220 """ 1313 """
1221 return table_html 1314 return table_html
1222 1315
  1316 + def _log_chart_validation_stats(self):
  1317 + """输出图表验证统计信息"""
  1318 + stats = self.chart_validation_stats
  1319 + if stats['total'] == 0:
  1320 + return
  1321 +
  1322 + logger.info("=" * 60)
  1323 + logger.info("图表验证统计")
  1324 + logger.info("=" * 60)
  1325 + logger.info(f"总图表数量: {stats['total']}")
  1326 + logger.info(f" ✓ 验证通过: {stats['valid']} ({stats['valid']/stats['total']*100:.1f}%)")
  1327 +
  1328 + if stats['repaired_locally'] > 0:
  1329 + logger.info(
  1330 + f" ⚠ 本地修复: {stats['repaired_locally']} "
  1331 + f"({stats['repaired_locally']/stats['total']*100:.1f}%)"
  1332 + )
  1333 +
  1334 + if stats['repaired_api'] > 0:
  1335 + logger.info(
  1336 + f" ⚠ API修复: {stats['repaired_api']} "
  1337 + f"({stats['repaired_api']/stats['total']*100:.1f}%)"
  1338 + )
  1339 +
  1340 + if stats['failed'] > 0:
  1341 + logger.warning(
  1342 + f" ✗ 修复失败: {stats['failed']} "
  1343 + f"({stats['failed']/stats['total']*100:.1f}%) - "
  1344 + f"这些图表将使用降级渲染或显示fallback表格"
  1345 + )
  1346 +
  1347 + logger.info("=" * 60)
  1348 +
1223 # ====== 前置信息防护 ====== 1349 # ====== 前置信息防护 ======
1224 1350
1225 def _kpi_signature_from_items(self, items: Any) -> tuple | None: 1351 def _kpi_signature_from_items(self, items: Any) -> tuple | None:
@@ -2317,6 +2443,80 @@ function buildChartOptions(payload) { @@ -2317,6 +2443,80 @@ function buildChartOptions(payload) {
2317 return mergeOptions(baseOptions, overrideOptions); 2443 return mergeOptions(baseOptions, overrideOptions);
2318 } 2444 }
2319 2445
  2446 +function validateChartData(payload, type) {
  2447 + /**
  2448 + * 前端验证图表数据
  2449 + * 返回: { valid: boolean, errors: string[] }
  2450 + */
  2451 + const errors = [];
  2452 +
  2453 + if (!payload || typeof payload !== 'object') {
  2454 + errors.push('无效的payload');
  2455 + return { valid: false, errors };
  2456 + }
  2457 +
  2458 + const data = payload.data;
  2459 + if (!data || typeof data !== 'object') {
  2460 + errors.push('缺少data字段');
  2461 + return { valid: false, errors };
  2462 + }
  2463 +
  2464 + // 特殊图表类型(scatter, bubble)
  2465 + const specialTypes = { 'scatter': true, 'bubble': true };
  2466 + if (specialTypes[type]) {
  2467 + // 这些类型需要特殊的数据格式 {x, y} 或 {x, y, r}
  2468 + // 跳过标准验证
  2469 + return { valid: true, errors };
  2470 + }
  2471 +
  2472 + // 标准图表类型验证
  2473 + const datasets = data.datasets;
  2474 + if (!Array.isArray(datasets)) {
  2475 + errors.push('datasets必须是数组');
  2476 + return { valid: false, errors };
  2477 + }
  2478 +
  2479 + if (datasets.length === 0) {
  2480 + errors.push('datasets数组为空');
  2481 + return { valid: false, errors };
  2482 + }
  2483 +
  2484 + // 验证每个dataset
  2485 + for (let i = 0; i < datasets.length; i++) {
  2486 + const dataset = datasets[i];
  2487 + if (!dataset || typeof dataset !== 'object') {
  2488 + errors.push(`datasets[${i}]不是对象`);
  2489 + continue;
  2490 + }
  2491 +
  2492 + if (!Array.isArray(dataset.data)) {
  2493 + errors.push(`datasets[${i}].data不是数组`);
  2494 + } else if (dataset.data.length === 0) {
  2495 + errors.push(`datasets[${i}].data为空`);
  2496 + }
  2497 + }
  2498 +
  2499 + // 需要labels的图表类型
  2500 + const labelRequiredTypes = {
  2501 + 'line': true, 'bar': true, 'radar': true,
  2502 + 'polarArea': true, 'pie': true, 'doughnut': true
  2503 + };
  2504 +
  2505 + if (labelRequiredTypes[type]) {
  2506 + const labels = data.labels;
  2507 + if (!Array.isArray(labels)) {
  2508 + errors.push('缺少labels数组');
  2509 + } else if (labels.length === 0) {
  2510 + errors.push('labels数组为空');
  2511 + }
  2512 + }
  2513 +
  2514 + return {
  2515 + valid: errors.length === 0,
  2516 + errors
  2517 + };
  2518 +}
  2519 +
2320 function instantiateChart(ctx, payload, optionsTemplate, type) { 2520 function instantiateChart(ctx, payload, optionsTemplate, type) {
2321 if (!ctx) { 2521 if (!ctx) {
2322 return null; 2522 return null;
@@ -2358,9 +2558,17 @@ function hydrateCharts() { @@ -2358,9 +2558,17 @@ function hydrateCharts() {
2358 renderChartFallback(canvas, payload, 'Canvas 初始化失败'); 2558 renderChartFallback(canvas, payload, 'Canvas 初始化失败');
2359 return; 2559 return;
2360 } 2560 }
  2561 +
  2562 + // 前端数据验证
  2563 + const desiredType = chartTypes[0];
  2564 + const validation = validateChartData(payload, desiredType);
  2565 + if (!validation.valid) {
  2566 + console.warn('图表数据验证失败:', validation.errors);
  2567 + // 验证失败但仍然尝试渲染,因为可能会降级成功
  2568 + }
  2569 +
2361 const card = canvas.closest('.chart-card') || canvas.parentElement; 2570 const card = canvas.closest('.chart-card') || canvas.parentElement;
2362 const optionsTemplate = buildChartOptions(payload); 2571 const optionsTemplate = buildChartOptions(payload);
2363 - const desiredType = chartTypes[0];  
2364 let chartInstance = null; 2572 let chartInstance = null;
2365 let selectedType = null; 2573 let selectedType = null;
2366 let lastError; 2574 let lastError;
  1 +"""
  2 +图表API修复模块。
  3 +
  4 +提供调用4个Engine(ReportEngine, ForumEngine, InsightEngine, MediaEngine)的LLM API
  5 +来修复图表数据的功能。
  6 +"""
  7 +
  8 +from __future__ import annotations
  9 +
  10 +import json
  11 +from typing import Any, Dict, List, Optional
  12 +from loguru import logger
  13 +
  14 +from ReportEngine.utils.config import settings
  15 +
  16 +
  17 +# 图表修复提示词
  18 +CHART_REPAIR_SYSTEM_PROMPT = """你是一个专业的图表数据修复助手。你的任务是修复Chart.js图表数据中的格式错误,确保图表能够正常渲染。
  19 +
  20 +**Chart.js标准数据格式:**
  21 +
  22 +1. 标准图表(line, bar, pie, doughnut, radar, polarArea):
  23 +```json
  24 +{
  25 + "type": "widget",
  26 + "widgetType": "chart.js/bar",
  27 + "widgetId": "chart-001",
  28 + "props": {
  29 + "type": "bar",
  30 + "title": "图表标题",
  31 + "options": {
  32 + "responsive": true,
  33 + "plugins": {
  34 + "legend": {
  35 + "display": true
  36 + }
  37 + }
  38 + }
  39 + },
  40 + "data": {
  41 + "labels": ["A", "B", "C"],
  42 + "datasets": [
  43 + {
  44 + "label": "系列1",
  45 + "data": [10, 20, 30]
  46 + }
  47 + ]
  48 + }
  49 +}
  50 +```
  51 +
  52 +2. 特殊图表(scatter, bubble):
  53 +```json
  54 +{
  55 + "data": {
  56 + "datasets": [
  57 + {
  58 + "label": "系列1",
  59 + "data": [
  60 + {"x": 10, "y": 20},
  61 + {"x": 15, "y": 25}
  62 + ]
  63 + }
  64 + ]
  65 + }
  66 +}
  67 +```
  68 +
  69 +**修复原则:**
  70 +1. **宁愿不改,也不要改错** - 如果不确定如何修复,保持原始数据
  71 +2. **最小改动** - 只修复明确的错误,不要过度修改
  72 +3. **保持数据完整性** - 不要丢失原始数据
  73 +4. **验证修复结果** - 确保修复后符合Chart.js格式
  74 +
  75 +**常见错误及修复方法:**
  76 +1. 缺少labels字段 → 根据数据生成默认labels
  77 +2. datasets不是数组 → 转换为数组格式
  78 +3. 数据长度不匹配 → 截断或补null
  79 +4. 非数值数据 → 尝试转换或设为null
  80 +5. 缺少必需字段 → 添加默认值
  81 +
  82 +请根据错误信息修复图表数据,并返回修复后的完整widget block(JSON格式)。
  83 +"""
  84 +
  85 +
  86 +def build_chart_repair_prompt(
  87 + widget_block: Dict[str, Any],
  88 + validation_errors: List[str]
  89 +) -> str:
  90 + """
  91 + 构建图表修复提示词。
  92 +
  93 + Args:
  94 + widget_block: 原始widget block
  95 + validation_errors: 验证错误列表
  96 +
  97 + Returns:
  98 + str: 提示词
  99 + """
  100 + block_json = json.dumps(widget_block, ensure_ascii=False, indent=2)
  101 + errors_text = "\n".join(f"- {error}" for error in validation_errors)
  102 +
  103 + prompt = f"""请修复以下图表数据中的错误:
  104 +
  105 +**原始数据:**
  106 +```json
  107 +{block_json}
  108 +```
  109 +
  110 +**检测到的错误:**
  111 +{errors_text}
  112 +
  113 +**要求:**
  114 +1. 返回修复后的完整widget block(JSON格式)
  115 +2. 只修复明确的错误,保持其他数据不变
  116 +3. 确保修复后的数据符合Chart.js格式要求
  117 +4. 如果无法确定如何修复,保持原始数据
  118 +
  119 +**重要的输出格式要求:**
  120 +1. 只返回纯JSON对象,不要添加任何说明文字
  121 +2. 不要使用```json```标记包裹
  122 +3. 确保JSON语法完全正确
  123 +4. 所有字符串使用双引号
  124 +"""
  125 + return prompt
  126 +
  127 +
  128 +def create_llm_repair_functions() -> List:
  129 + """
  130 + 创建LLM修复函数列表。
  131 +
  132 + 返回4个Engine的修复函数:
  133 + 1. ReportEngine
  134 + 2. ForumEngine (通过ForumHost)
  135 + 3. InsightEngine
  136 + 4. MediaEngine
  137 +
  138 + Returns:
  139 + List[Callable]: 修复函数列表
  140 + """
  141 + repair_functions = []
  142 +
  143 + # 1. ReportEngine修复函数
  144 + if settings.REPORT_ENGINE_API_KEY and settings.REPORT_ENGINE_BASE_URL:
  145 + def repair_with_report_engine(widget_block: Dict[str, Any], errors: List[str]) -> Optional[Dict[str, Any]]:
  146 + """使用ReportEngine的LLM修复图表"""
  147 + try:
  148 + from llm_client import LLMClient
  149 +
  150 + client = LLMClient(
  151 + api_key=settings.REPORT_ENGINE_API_KEY,
  152 + base_url=settings.REPORT_ENGINE_BASE_URL,
  153 + model_name=settings.REPORT_ENGINE_MODEL_NAME or "gpt-4",
  154 + provider="openai"
  155 + )
  156 +
  157 + prompt = build_chart_repair_prompt(widget_block, errors)
  158 + response = client.invoke(
  159 + CHART_REPAIR_SYSTEM_PROMPT,
  160 + prompt,
  161 + temperature=0.0,
  162 + top_p=0.05
  163 + )
  164 +
  165 + if not response:
  166 + return None
  167 +
  168 + # 解析响应
  169 + repaired = json.loads(response)
  170 + return repaired
  171 +
  172 + except Exception as e:
  173 + logger.error(f"ReportEngine图表修复失败: {e}")
  174 + return None
  175 +
  176 + repair_functions.append(repair_with_report_engine)
  177 +
  178 + # 2. ForumEngine修复函数
  179 + if settings.FORUM_HOST_API_KEY and settings.FORUM_HOST_BASE_URL:
  180 + def repair_with_forum_engine(widget_block: Dict[str, Any], errors: List[str]) -> Optional[Dict[str, Any]]:
  181 + """使用ForumEngine的LLM修复图表"""
  182 + try:
  183 + from llm_client import LLMClient
  184 +
  185 + client = LLMClient(
  186 + api_key=settings.FORUM_HOST_API_KEY,
  187 + base_url=settings.FORUM_HOST_BASE_URL,
  188 + model_name=settings.FORUM_HOST_MODEL_NAME or "gpt-4",
  189 + provider="openai"
  190 + )
  191 +
  192 + prompt = build_chart_repair_prompt(widget_block, errors)
  193 + response = client.invoke(
  194 + CHART_REPAIR_SYSTEM_PROMPT,
  195 + prompt,
  196 + temperature=0.0,
  197 + top_p=0.05
  198 + )
  199 +
  200 + if not response:
  201 + return None
  202 +
  203 + repaired = json.loads(response)
  204 + return repaired
  205 +
  206 + except Exception as e:
  207 + logger.error(f"ForumEngine图表修复失败: {e}")
  208 + return None
  209 +
  210 + repair_functions.append(repair_with_forum_engine)
  211 +
  212 + # 3. InsightEngine修复函数
  213 + if settings.INSIGHT_ENGINE_API_KEY and settings.INSIGHT_ENGINE_BASE_URL:
  214 + def repair_with_insight_engine(widget_block: Dict[str, Any], errors: List[str]) -> Optional[Dict[str, Any]]:
  215 + """使用InsightEngine的LLM修复图表"""
  216 + try:
  217 + from llm_client import LLMClient
  218 +
  219 + client = LLMClient(
  220 + api_key=settings.INSIGHT_ENGINE_API_KEY,
  221 + base_url=settings.INSIGHT_ENGINE_BASE_URL,
  222 + model_name=settings.INSIGHT_ENGINE_MODEL_NAME or "gpt-4",
  223 + provider="openai"
  224 + )
  225 +
  226 + prompt = build_chart_repair_prompt(widget_block, errors)
  227 + response = client.invoke(
  228 + CHART_REPAIR_SYSTEM_PROMPT,
  229 + prompt,
  230 + temperature=0.0,
  231 + top_p=0.05
  232 + )
  233 +
  234 + if not response:
  235 + return None
  236 +
  237 + repaired = json.loads(response)
  238 + return repaired
  239 +
  240 + except Exception as e:
  241 + logger.error(f"InsightEngine图表修复失败: {e}")
  242 + return None
  243 +
  244 + repair_functions.append(repair_with_insight_engine)
  245 +
  246 + # 4. MediaEngine修复函数
  247 + if settings.MEDIA_ENGINE_API_KEY and settings.MEDIA_ENGINE_BASE_URL:
  248 + def repair_with_media_engine(widget_block: Dict[str, Any], errors: List[str]) -> Optional[Dict[str, Any]]:
  249 + """使用MediaEngine的LLM修复图表"""
  250 + try:
  251 + from llm_client import LLMClient
  252 +
  253 + client = LLMClient(
  254 + api_key=settings.MEDIA_ENGINE_API_KEY,
  255 + base_url=settings.MEDIA_ENGINE_BASE_URL,
  256 + model_name=settings.MEDIA_ENGINE_MODEL_NAME or "gpt-4",
  257 + provider="openai"
  258 + )
  259 +
  260 + prompt = build_chart_repair_prompt(widget_block, errors)
  261 + response = client.invoke(
  262 + CHART_REPAIR_SYSTEM_PROMPT,
  263 + prompt,
  264 + temperature=0.0,
  265 + top_p=0.05
  266 + )
  267 +
  268 + if not response:
  269 + return None
  270 +
  271 + repaired = json.loads(response)
  272 + return repaired
  273 +
  274 + except Exception as e:
  275 + logger.error(f"MediaEngine图表修复失败: {e}")
  276 + return None
  277 +
  278 + repair_functions.append(repair_with_media_engine)
  279 +
  280 + if not repair_functions:
  281 + logger.warning("未配置任何Engine API,图表API修复功能将不可用")
  282 +
  283 + return repair_functions
  1 +"""
  2 +图表验证和修复工具。
  3 +
  4 +提供对Chart.js图表数据的验证和修复能力:
  5 +1. 验证图表数据格式是否符合Chart.js要求
  6 +2. 本地规则修复常见问题
  7 +3. LLM API辅助修复复杂问题
  8 +4. 遵循"宁愿不改,也不要改错"的原则
  9 +
  10 +支持的图表类型:
  11 +- line (折线图)
  12 +- bar (柱状图)
  13 +- pie (饼图)
  14 +- doughnut (圆环图)
  15 +- radar (雷达图)
  16 +- polarArea (极地区域图)
  17 +- scatter (散点图)
  18 +"""
  19 +
  20 +from __future__ import annotations
  21 +
  22 +import copy
  23 +import json
  24 +from typing import Any, Dict, List, Optional, Tuple, Callable
  25 +from dataclasses import dataclass
  26 +from loguru import logger
  27 +
  28 +
  29 +@dataclass
  30 +class ValidationResult:
  31 + """验证结果"""
  32 + is_valid: bool
  33 + errors: List[str]
  34 + warnings: List[str]
  35 +
  36 + def has_critical_errors(self) -> bool:
  37 + """是否有严重错误(会导致渲染失败)"""
  38 + return not self.is_valid and len(self.errors) > 0
  39 +
  40 +
  41 +@dataclass
  42 +class RepairResult:
  43 + """修复结果"""
  44 + success: bool
  45 + repaired_block: Optional[Dict[str, Any]]
  46 + method: str # 'none', 'local', 'api'
  47 + changes: List[str]
  48 +
  49 + def has_changes(self) -> bool:
  50 + """是否有修改"""
  51 + return len(self.changes) > 0
  52 +
  53 +
  54 +class ChartValidator:
  55 + """
  56 + 图表验证器 - 验证Chart.js图表数据格式是否正确。
  57 +
  58 + 验证规则:
  59 + 1. 基本结构验证:widgetType, props, data字段
  60 + 2. 图表类型验证:支持的图表类型
  61 + 3. 数据格式验证:labels和datasets结构
  62 + 4. 数据一致性验证:labels和datasets长度匹配
  63 + 5. 数值类型验证:数据值类型正确
  64 + """
  65 +
  66 + # 支持的图表类型
  67 + SUPPORTED_CHART_TYPES = {
  68 + 'line', 'bar', 'pie', 'doughnut', 'radar', 'polarArea', 'scatter',
  69 + 'bubble', 'horizontalBar'
  70 + }
  71 +
  72 + # 需要labels的图表类型
  73 + LABEL_REQUIRED_TYPES = {
  74 + 'line', 'bar', 'radar', 'polarArea', 'pie', 'doughnut'
  75 + }
  76 +
  77 + # 需要数值数据的图表类型
  78 + NUMERIC_DATA_TYPES = {
  79 + 'line', 'bar', 'radar', 'polarArea', 'pie', 'doughnut'
  80 + }
  81 +
  82 + # 需要特殊数据格式的图表类型
  83 + SPECIAL_DATA_TYPES = {
  84 + 'scatter': {'x', 'y'},
  85 + 'bubble': {'x', 'y', 'r'}
  86 + }
  87 +
  88 + def __init__(self):
  89 + pass
  90 +
  91 + def validate(self, widget_block: Dict[str, Any]) -> ValidationResult:
  92 + """
  93 + 验证图表格式。
  94 +
  95 + Args:
  96 + widget_block: widget类型的block,包含widgetId/widgetType/props/data
  97 +
  98 + Returns:
  99 + ValidationResult: 验证结果
  100 + """
  101 + errors = []
  102 + warnings = []
  103 +
  104 + # 1. 基本结构验证
  105 + if not isinstance(widget_block, dict):
  106 + errors.append("widget_block必须是字典类型")
  107 + return ValidationResult(False, errors, warnings)
  108 +
  109 + # 2. 检查widgetType
  110 + widget_type = widget_block.get('widgetType', '')
  111 + if not widget_type or not isinstance(widget_type, str):
  112 + errors.append("缺少widgetType字段或类型不正确")
  113 + return ValidationResult(False, errors, warnings)
  114 +
  115 + # 检查是否是chart.js类型
  116 + if not widget_type.startswith('chart.js'):
  117 + # 不是图表类型,跳过验证
  118 + return ValidationResult(True, errors, warnings)
  119 +
  120 + # 3. 提取图表类型
  121 + chart_type = self._extract_chart_type(widget_block)
  122 + if not chart_type:
  123 + errors.append("无法确定图表类型")
  124 + return ValidationResult(False, errors, warnings)
  125 +
  126 + # 4. 检查是否支持该图表类型
  127 + if chart_type not in self.SUPPORTED_CHART_TYPES:
  128 + warnings.append(f"图表类型 '{chart_type}' 可能不被支持,将尝试降级渲染")
  129 +
  130 + # 5. 验证数据结构
  131 + data = widget_block.get('data')
  132 + if not isinstance(data, dict):
  133 + errors.append("data字段必须是字典类型")
  134 + return ValidationResult(False, errors, warnings)
  135 +
  136 + # 6. 根据图表类型验证数据
  137 + if chart_type in self.SPECIAL_DATA_TYPES:
  138 + # 特殊数据格式(scatter, bubble)
  139 + self._validate_special_data(data, chart_type, errors, warnings)
  140 + else:
  141 + # 标准数据格式(labels + datasets)
  142 + self._validate_standard_data(data, chart_type, errors, warnings)
  143 +
  144 + # 7. 验证props
  145 + props = widget_block.get('props')
  146 + if props is not None and not isinstance(props, dict):
  147 + warnings.append("props字段应该是字典类型")
  148 +
  149 + is_valid = len(errors) == 0
  150 + return ValidationResult(is_valid, errors, warnings)
  151 +
  152 + def _extract_chart_type(self, widget_block: Dict[str, Any]) -> Optional[str]:
  153 + """
  154 + 提取图表类型。
  155 +
  156 + 优先级:
  157 + 1. props.type
  158 + 2. widgetType中的类型(chart.js/bar -> bar)
  159 + 3. data.type
  160 + """
  161 + # 1. 从props中获取
  162 + props = widget_block.get('props') or {}
  163 + if isinstance(props, dict):
  164 + chart_type = props.get('type')
  165 + if chart_type and isinstance(chart_type, str):
  166 + return chart_type.lower()
  167 +
  168 + # 2. 从widgetType中提取
  169 + widget_type = widget_block.get('widgetType', '')
  170 + if '/' in widget_type:
  171 + chart_type = widget_type.split('/')[-1]
  172 + if chart_type:
  173 + return chart_type.lower()
  174 +
  175 + # 3. 从data中获取
  176 + data = widget_block.get('data') or {}
  177 + if isinstance(data, dict):
  178 + chart_type = data.get('type')
  179 + if chart_type and isinstance(chart_type, str):
  180 + return chart_type.lower()
  181 +
  182 + return None
  183 +
  184 + def _validate_standard_data(
  185 + self,
  186 + data: Dict[str, Any],
  187 + chart_type: str,
  188 + errors: List[str],
  189 + warnings: List[str]
  190 + ):
  191 + """验证标准数据格式(labels + datasets)"""
  192 + labels = data.get('labels')
  193 + datasets = data.get('datasets')
  194 +
  195 + # 验证labels
  196 + if chart_type in self.LABEL_REQUIRED_TYPES:
  197 + if not labels:
  198 + errors.append(f"{chart_type}类型图表必须包含labels字段")
  199 + elif not isinstance(labels, list):
  200 + errors.append("labels必须是数组类型")
  201 + elif len(labels) == 0:
  202 + warnings.append("labels数组为空,图表可能无法正常显示")
  203 +
  204 + # 验证datasets
  205 + if datasets is None:
  206 + errors.append("缺少datasets字段")
  207 + return
  208 +
  209 + if not isinstance(datasets, list):
  210 + errors.append("datasets必须是数组类型")
  211 + return
  212 +
  213 + if len(datasets) == 0:
  214 + errors.append("datasets数组为空")
  215 + return
  216 +
  217 + # 验证每个dataset
  218 + for idx, dataset in enumerate(datasets):
  219 + if not isinstance(dataset, dict):
  220 + errors.append(f"datasets[{idx}]必须是对象类型")
  221 + continue
  222 +
  223 + # 验证data字段
  224 + ds_data = dataset.get('data')
  225 + if ds_data is None:
  226 + errors.append(f"datasets[{idx}]缺少data字段")
  227 + continue
  228 +
  229 + if not isinstance(ds_data, list):
  230 + errors.append(f"datasets[{idx}].data必须是数组类型")
  231 + continue
  232 +
  233 + if len(ds_data) == 0:
  234 + warnings.append(f"datasets[{idx}].data数组为空")
  235 + continue
  236 +
  237 + # 验证数据长度一致性
  238 + if labels and isinstance(labels, list):
  239 + if len(ds_data) != len(labels):
  240 + warnings.append(
  241 + f"datasets[{idx}].data长度({len(ds_data)})与labels长度({len(labels)})不匹配"
  242 + )
  243 +
  244 + # 验证数值类型
  245 + if chart_type in self.NUMERIC_DATA_TYPES:
  246 + for data_idx, value in enumerate(ds_data):
  247 + if value is not None and not isinstance(value, (int, float)):
  248 + errors.append(
  249 + f"datasets[{idx}].data[{data_idx}]的值'{value}'不是有效的数值类型"
  250 + )
  251 + break # 只报告第一个错误
  252 +
  253 + def _validate_special_data(
  254 + self,
  255 + data: Dict[str, Any],
  256 + chart_type: str,
  257 + errors: List[str],
  258 + warnings: List[str]
  259 + ):
  260 + """验证特殊数据格式(scatter, bubble)"""
  261 + datasets = data.get('datasets')
  262 +
  263 + if not datasets:
  264 + errors.append("缺少datasets字段")
  265 + return
  266 +
  267 + if not isinstance(datasets, list):
  268 + errors.append("datasets必须是数组类型")
  269 + return
  270 +
  271 + if len(datasets) == 0:
  272 + errors.append("datasets数组为空")
  273 + return
  274 +
  275 + required_keys = self.SPECIAL_DATA_TYPES.get(chart_type, set())
  276 +
  277 + # 验证每个dataset
  278 + for idx, dataset in enumerate(datasets):
  279 + if not isinstance(dataset, dict):
  280 + errors.append(f"datasets[{idx}]必须是对象类型")
  281 + continue
  282 +
  283 + ds_data = dataset.get('data')
  284 + if ds_data is None:
  285 + errors.append(f"datasets[{idx}]缺少data字段")
  286 + continue
  287 +
  288 + if not isinstance(ds_data, list):
  289 + errors.append(f"datasets[{idx}].data必须是数组类型")
  290 + continue
  291 +
  292 + if len(ds_data) == 0:
  293 + warnings.append(f"datasets[{idx}].data数组为空")
  294 + continue
  295 +
  296 + # 验证数据点格式
  297 + for data_idx, point in enumerate(ds_data):
  298 + if not isinstance(point, dict):
  299 + errors.append(
  300 + f"datasets[{idx}].data[{data_idx}]必须是对象类型(包含{required_keys}字段)"
  301 + )
  302 + break
  303 +
  304 + # 检查必需的键
  305 + missing_keys = required_keys - set(point.keys())
  306 + if missing_keys:
  307 + errors.append(
  308 + f"datasets[{idx}].data[{data_idx}]缺少必需字段: {missing_keys}"
  309 + )
  310 + break
  311 +
  312 + # 验证数值类型
  313 + for key in required_keys:
  314 + value = point.get(key)
  315 + if value is not None and not isinstance(value, (int, float)):
  316 + errors.append(
  317 + f"datasets[{idx}].data[{data_idx}].{key}的值'{value}'不是有效的数值类型"
  318 + )
  319 + break
  320 +
  321 + def can_render(self, widget_block: Dict[str, Any]) -> bool:
  322 + """
  323 + 判断图表是否能正常渲染(快速检查)。
  324 +
  325 + Args:
  326 + widget_block: widget类型的block
  327 +
  328 + Returns:
  329 + bool: 是否能正常渲染
  330 + """
  331 + result = self.validate(widget_block)
  332 + return result.is_valid
  333 +
  334 +
  335 +class ChartRepairer:
  336 + """
  337 + 图表修复器 - 尝试修复图表数据。
  338 +
  339 + 修复策略:
  340 + 1. 本地规则修复:修复常见问题
  341 + 2. API修复:使用LLM修复复杂问题
  342 + 3. 验证修复结果:确保修复后能正常渲染
  343 + """
  344 +
  345 + def __init__(
  346 + self,
  347 + validator: ChartValidator,
  348 + llm_repair_fns: Optional[List[Callable]] = None
  349 + ):
  350 + """
  351 + 初始化修复器。
  352 +
  353 + Args:
  354 + validator: 图表验证器实例
  355 + llm_repair_fns: LLM修复函数列表(对应4个Engine)
  356 + """
  357 + self.validator = validator
  358 + self.llm_repair_fns = llm_repair_fns or []
  359 +
  360 + def repair(
  361 + self,
  362 + widget_block: Dict[str, Any],
  363 + validation_result: Optional[ValidationResult] = None
  364 + ) -> RepairResult:
  365 + """
  366 + 尝试修复图表数据。
  367 +
  368 + Args:
  369 + widget_block: widget类型的block
  370 + validation_result: 验证结果(可选,如果没有会先进行验证)
  371 +
  372 + Returns:
  373 + RepairResult: 修复结果
  374 + """
  375 + # 1. 如果没有验证结果,先验证
  376 + if validation_result is None:
  377 + validation_result = self.validator.validate(widget_block)
  378 +
  379 + # 2. 尝试本地修复(即使验证通过也尝试,因为可能有警告)
  380 + logger.info(f"尝试本地修复图表")
  381 + local_result = self.repair_locally(widget_block, validation_result)
  382 +
  383 + # 3. 验证修复结果
  384 + if local_result.has_changes():
  385 + repaired_validation = self.validator.validate(local_result.repaired_block)
  386 + if repaired_validation.is_valid:
  387 + logger.info(f"本地修复成功: {local_result.changes}")
  388 + return RepairResult(True, local_result.repaired_block, 'local', local_result.changes)
  389 + else:
  390 + logger.warning(f"本地修复后仍然无效: {repaired_validation.errors}")
  391 +
  392 + # 4. 如果本地修复失败且有严重错误,尝试API修复
  393 + if validation_result.has_critical_errors() and len(self.llm_repair_fns) > 0:
  394 + logger.info("本地修复失败,尝试API修复")
  395 + api_result = self.repair_with_api(widget_block, validation_result)
  396 +
  397 + if api_result.success:
  398 + # 验证修复结果
  399 + repaired_validation = self.validator.validate(api_result.repaired_block)
  400 + if repaired_validation.is_valid:
  401 + logger.info(f"API修复成功: {api_result.changes}")
  402 + return api_result
  403 + else:
  404 + logger.warning(f"API修复后仍然无效: {repaired_validation.errors}")
  405 +
  406 + # 5. 如果验证通过,返回原始或修复后的数据
  407 + if validation_result.is_valid:
  408 + if local_result.has_changes():
  409 + return RepairResult(True, local_result.repaired_block, 'local', local_result.changes)
  410 + else:
  411 + return RepairResult(True, widget_block, 'none', [])
  412 +
  413 + # 6. 所有修复都失败,返回原始数据
  414 + logger.warning("所有修复尝试失败,保持原始数据")
  415 + return RepairResult(False, widget_block, 'none', [])
  416 +
  417 + def repair_locally(
  418 + self,
  419 + widget_block: Dict[str, Any],
  420 + validation_result: ValidationResult
  421 + ) -> RepairResult:
  422 + """
  423 + 使用本地规则修复。
  424 +
  425 + 修复规则:
  426 + 1. 补全缺失的基本字段
  427 + 2. 修复数据类型错误
  428 + 3. 修复数据长度不匹配
  429 + 4. 清理无效数据
  430 + 5. 添加默认值
  431 + """
  432 + repaired = copy.deepcopy(widget_block)
  433 + changes = []
  434 +
  435 + # 1. 确保基本结构存在
  436 + if 'props' not in repaired or not isinstance(repaired.get('props'), dict):
  437 + repaired['props'] = {}
  438 + changes.append("添加缺失的props字段")
  439 +
  440 + if 'data' not in repaired or not isinstance(repaired.get('data'), dict):
  441 + repaired['data'] = {}
  442 + changes.append("添加缺失的data字段")
  443 +
  444 + # 2. 确保图表类型存在
  445 + chart_type = self.validator._extract_chart_type(repaired)
  446 + props = repaired['props']
  447 +
  448 + if not chart_type:
  449 + # 尝试从widgetType推断
  450 + widget_type = repaired.get('widgetType', '')
  451 + if '/' in widget_type:
  452 + chart_type = widget_type.split('/')[-1].lower()
  453 + props['type'] = chart_type
  454 + changes.append(f"从widgetType推断图表类型: {chart_type}")
  455 + else:
  456 + # 默认使用bar类型
  457 + chart_type = 'bar'
  458 + props['type'] = chart_type
  459 + changes.append("设置默认图表类型: bar")
  460 + elif 'type' not in props or not props['type']:
  461 + # chart_type存在但props中没有type字段,需要添加
  462 + props['type'] = chart_type
  463 + changes.append(f"将推断的图表类型添加到props: {chart_type}")
  464 +
  465 + # 3. 修复数据结构
  466 + data = repaired['data']
  467 +
  468 + # 确保datasets存在
  469 + if 'datasets' not in data or not isinstance(data.get('datasets'), list):
  470 + data['datasets'] = []
  471 + changes.append("添加缺失的datasets字段")
  472 +
  473 + # 如果datasets为空但data中有其他数据,尝试构造datasets
  474 + if len(data['datasets']) == 0:
  475 + constructed = self._try_construct_datasets(data, chart_type)
  476 + if constructed:
  477 + data['datasets'] = constructed
  478 + changes.append("从data中构造datasets")
  479 + elif 'labels' in data and isinstance(data.get('labels'), list) and len(data['labels']) > 0:
  480 + # 如果有labels但没有数据,创建一个空dataset
  481 + data['datasets'] = [{
  482 + 'label': '数据',
  483 + 'data': [0] * len(data['labels'])
  484 + }]
  485 + changes.append("根据labels创建默认dataset(使用零值)")
  486 +
  487 + # 确保labels存在(如果需要)
  488 + if chart_type in ChartValidator.LABEL_REQUIRED_TYPES:
  489 + if 'labels' not in data or not isinstance(data.get('labels'), list):
  490 + # 尝试根据datasets长度生成labels
  491 + if data['datasets'] and len(data['datasets']) > 0:
  492 + first_ds = data['datasets'][0]
  493 + if isinstance(first_ds, dict) and isinstance(first_ds.get('data'), list):
  494 + data_len = len(first_ds['data'])
  495 + data['labels'] = [f"项目 {i+1}" for i in range(data_len)]
  496 + changes.append(f"生成{data_len}个默认labels")
  497 +
  498 + # 4. 修复datasets中的数据
  499 + for idx, dataset in enumerate(data.get('datasets', [])):
  500 + if not isinstance(dataset, dict):
  501 + continue
  502 +
  503 + # 确保有data字段
  504 + if 'data' not in dataset or not isinstance(dataset.get('data'), list):
  505 + dataset['data'] = []
  506 + changes.append(f"为datasets[{idx}]添加空data数组")
  507 +
  508 + # 确保有label
  509 + if 'label' not in dataset:
  510 + dataset['label'] = f"系列 {idx + 1}"
  511 + changes.append(f"为datasets[{idx}]添加默认label")
  512 +
  513 + # 修复数据长度不匹配
  514 + labels = data.get('labels', [])
  515 + ds_data = dataset.get('data', [])
  516 + if isinstance(labels, list) and isinstance(ds_data, list):
  517 + if len(ds_data) < len(labels):
  518 + # 数据不够,补null
  519 + dataset['data'] = ds_data + [None] * (len(labels) - len(ds_data))
  520 + changes.append(f"datasets[{idx}]数据长度不足,补充null")
  521 + elif len(ds_data) > len(labels):
  522 + # 数据过多,截断
  523 + dataset['data'] = ds_data[:len(labels)]
  524 + changes.append(f"datasets[{idx}]数据长度过长,截断")
  525 +
  526 + # 转换非数值数据为数值(如果可能)
  527 + if chart_type in ChartValidator.NUMERIC_DATA_TYPES:
  528 + ds_data = dataset.get('data', [])
  529 + converted = False
  530 + for i, value in enumerate(ds_data):
  531 + if value is None:
  532 + continue
  533 + if not isinstance(value, (int, float)):
  534 + # 尝试转换
  535 + try:
  536 + if isinstance(value, str):
  537 + # 尝试转换字符串
  538 + ds_data[i] = float(value)
  539 + converted = True
  540 + except (ValueError, TypeError):
  541 + # 转换失败,设为null
  542 + ds_data[i] = None
  543 + converted = True
  544 + if converted:
  545 + changes.append(f"datasets[{idx}]包含非数值数据,已尝试转换")
  546 +
  547 + # 5. 验证修复结果
  548 + success = len(changes) > 0
  549 +
  550 + return RepairResult(success, repaired, 'local', changes)
  551 +
  552 + def _try_construct_datasets(
  553 + self,
  554 + data: Dict[str, Any],
  555 + chart_type: str
  556 + ) -> Optional[List[Dict[str, Any]]]:
  557 + """尝试从data中构造datasets"""
  558 + # 如果data直接包含数据数组,尝试构造
  559 + if 'values' in data and isinstance(data['values'], list):
  560 + return [{
  561 + 'label': '数据',
  562 + 'data': data['values']
  563 + }]
  564 +
  565 + # 如果data包含series字段
  566 + if 'series' in data and isinstance(data['series'], list):
  567 + datasets = []
  568 + for idx, series in enumerate(data['series']):
  569 + if isinstance(series, dict):
  570 + datasets.append({
  571 + 'label': series.get('name', f'系列 {idx + 1}'),
  572 + 'data': series.get('data', [])
  573 + })
  574 + elif isinstance(series, list):
  575 + datasets.append({
  576 + 'label': f'系列 {idx + 1}',
  577 + 'data': series
  578 + })
  579 + if datasets:
  580 + return datasets
  581 +
  582 + return None
  583 +
  584 + def repair_with_api(
  585 + self,
  586 + widget_block: Dict[str, Any],
  587 + validation_result: ValidationResult
  588 + ) -> RepairResult:
  589 + """
  590 + 使用API修复(调用4个Engine的LLM)。
  591 +
  592 + 策略:按顺序尝试不同的Engine,直到修复成功
  593 + """
  594 + if not self.llm_repair_fns:
  595 + return RepairResult(False, None, 'api', [])
  596 +
  597 + for idx, repair_fn in enumerate(self.llm_repair_fns):
  598 + try:
  599 + logger.info(f"尝试使用Engine {idx + 1}修复图表")
  600 + repaired = repair_fn(widget_block, validation_result.errors)
  601 +
  602 + if repaired and isinstance(repaired, dict):
  603 + # 验证修复结果
  604 + repaired_validation = self.validator.validate(repaired)
  605 + if repaired_validation.is_valid:
  606 + return RepairResult(
  607 + True,
  608 + repaired,
  609 + 'api',
  610 + [f"使用Engine {idx + 1}修复成功"]
  611 + )
  612 + except Exception as e:
  613 + logger.error(f"Engine {idx + 1}修复失败: {e}")
  614 + continue
  615 +
  616 + return RepairResult(False, None, 'api', [])
  617 +
  618 +
  619 +def create_chart_validator() -> ChartValidator:
  620 + """创建图表验证器实例"""
  621 + return ChartValidator()
  622 +
  623 +
  624 +def create_chart_repairer(
  625 + validator: Optional[ChartValidator] = None,
  626 + llm_repair_fns: Optional[List[Callable]] = None
  627 +) -> ChartRepairer:
  628 + """创建图表修复器实例"""
  629 + if validator is None:
  630 + validator = create_chart_validator()
  631 + return ChartRepairer(validator, llm_repair_fns)
  1 +"""
  2 +图表验证器和修复器的测试用例。
  3 +
  4 +运行测试:
  5 + python -m pytest ReportEngine/utils/test_chart_validator.py -v
  6 +"""
  7 +
  8 +import pytest
  9 +from ReportEngine.utils.chart_validator import (
  10 + ChartValidator,
  11 + ChartRepairer,
  12 + ValidationResult,
  13 + RepairResult,
  14 + create_chart_validator,
  15 + create_chart_repairer
  16 +)
  17 +
  18 +
  19 +class TestChartValidator:
  20 + """测试ChartValidator类"""
  21 +
  22 + def setup_method(self):
  23 + """每个测试前初始化"""
  24 + self.validator = create_chart_validator()
  25 +
  26 + def test_valid_bar_chart(self):
  27 + """测试有效的柱状图"""
  28 + widget_block = {
  29 + "type": "widget",
  30 + "widgetType": "chart.js/bar",
  31 + "widgetId": "chart-001",
  32 + "props": {
  33 + "type": "bar",
  34 + "title": "销售数据"
  35 + },
  36 + "data": {
  37 + "labels": ["一月", "二月", "三月"],
  38 + "datasets": [
  39 + {
  40 + "label": "销售额",
  41 + "data": [100, 200, 150]
  42 + }
  43 + ]
  44 + }
  45 + }
  46 +
  47 + result = self.validator.validate(widget_block)
  48 + assert result.is_valid
  49 + assert len(result.errors) == 0
  50 +
  51 + def test_valid_line_chart(self):
  52 + """测试有效的折线图"""
  53 + widget_block = {
  54 + "type": "widget",
  55 + "widgetType": "chart.js/line",
  56 + "widgetId": "chart-002",
  57 + "props": {
  58 + "type": "line"
  59 + },
  60 + "data": {
  61 + "labels": ["周一", "周二", "周三"],
  62 + "datasets": [
  63 + {
  64 + "label": "访问量",
  65 + "data": [50, 75, 60]
  66 + }
  67 + ]
  68 + }
  69 + }
  70 +
  71 + result = self.validator.validate(widget_block)
  72 + assert result.is_valid
  73 +
  74 + def test_valid_pie_chart(self):
  75 + """测试有效的饼图"""
  76 + widget_block = {
  77 + "widgetType": "chart.js/pie",
  78 + "props": {"type": "pie"},
  79 + "data": {
  80 + "labels": ["A", "B", "C"],
  81 + "datasets": [
  82 + {
  83 + "data": [30, 40, 30]
  84 + }
  85 + ]
  86 + }
  87 + }
  88 +
  89 + result = self.validator.validate(widget_block)
  90 + assert result.is_valid
  91 +
  92 + def test_missing_widgetType(self):
  93 + """测试缺少widgetType"""
  94 + widget_block = {
  95 + "props": {},
  96 + "data": {}
  97 + }
  98 +
  99 + result = self.validator.validate(widget_block)
  100 + assert not result.is_valid
  101 + assert "widgetType" in result.errors[0]
  102 +
  103 + def test_missing_data_field(self):
  104 + """测试缺少data字段"""
  105 + widget_block = {
  106 + "widgetType": "chart.js/bar",
  107 + "props": {"type": "bar"}
  108 + }
  109 +
  110 + result = self.validator.validate(widget_block)
  111 + assert not result.is_valid
  112 + assert "data" in result.errors[0]
  113 +
  114 + def test_missing_datasets(self):
  115 + """测试缺少datasets"""
  116 + widget_block = {
  117 + "widgetType": "chart.js/bar",
  118 + "props": {"type": "bar"},
  119 + "data": {
  120 + "labels": ["A", "B"]
  121 + }
  122 + }
  123 +
  124 + result = self.validator.validate(widget_block)
  125 + assert not result.is_valid
  126 + assert "datasets" in result.errors[0]
  127 +
  128 + def test_empty_datasets(self):
  129 + """测试空datasets"""
  130 + widget_block = {
  131 + "widgetType": "chart.js/bar",
  132 + "props": {"type": "bar"},
  133 + "data": {
  134 + "labels": ["A", "B"],
  135 + "datasets": []
  136 + }
  137 + }
  138 +
  139 + result = self.validator.validate(widget_block)
  140 + assert not result.is_valid
  141 + assert "空" in result.errors[0]
  142 +
  143 + def test_missing_labels_for_bar_chart(self):
  144 + """测试柱状图缺少labels"""
  145 + widget_block = {
  146 + "widgetType": "chart.js/bar",
  147 + "props": {"type": "bar"},
  148 + "data": {
  149 + "datasets": [
  150 + {
  151 + "label": "系列1",
  152 + "data": [10, 20, 30]
  153 + }
  154 + ]
  155 + }
  156 + }
  157 +
  158 + result = self.validator.validate(widget_block)
  159 + assert not result.is_valid
  160 + assert "labels" in result.errors[0]
  161 +
  162 + def test_invalid_data_type(self):
  163 + """测试数据类型错误"""
  164 + widget_block = {
  165 + "widgetType": "chart.js/bar",
  166 + "props": {"type": "bar"},
  167 + "data": {
  168 + "labels": ["A", "B"],
  169 + "datasets": [
  170 + {
  171 + "label": "系列1",
  172 + "data": ["abc", "def"] # 应该是数值
  173 + }
  174 + ]
  175 + }
  176 + }
  177 +
  178 + result = self.validator.validate(widget_block)
  179 + assert not result.is_valid
  180 + assert "数值类型" in result.errors[0]
  181 +
  182 + def test_data_length_mismatch_warning(self):
  183 + """测试数据长度不匹配(警告)"""
  184 + widget_block = {
  185 + "widgetType": "chart.js/bar",
  186 + "props": {"type": "bar"},
  187 + "data": {
  188 + "labels": ["A", "B", "C"],
  189 + "datasets": [
  190 + {
  191 + "label": "系列1",
  192 + "data": [10, 20] # 长度不匹配
  193 + }
  194 + ]
  195 + }
  196 + }
  197 +
  198 + result = self.validator.validate(widget_block)
  199 + # 长度不匹配是警告,不是错误
  200 + assert len(result.warnings) > 0
  201 + assert "不匹配" in result.warnings[0]
  202 +
  203 + def test_scatter_chart(self):
  204 + """测试散点图(特殊数据格式)"""
  205 + widget_block = {
  206 + "widgetType": "chart.js/scatter",
  207 + "props": {"type": "scatter"},
  208 + "data": {
  209 + "datasets": [
  210 + {
  211 + "label": "数据点",
  212 + "data": [
  213 + {"x": 10, "y": 20},
  214 + {"x": 15, "y": 25}
  215 + ]
  216 + }
  217 + ]
  218 + }
  219 + }
  220 +
  221 + result = self.validator.validate(widget_block)
  222 + assert result.is_valid
  223 +
  224 + def test_non_chart_widget(self):
  225 + """测试非图表类型的widget(应该跳过验证)"""
  226 + widget_block = {
  227 + "widgetType": "custom/widget",
  228 + "props": {},
  229 + "data": {}
  230 + }
  231 +
  232 + result = self.validator.validate(widget_block)
  233 + # 非chart.js类型,跳过验证,返回valid
  234 + assert result.is_valid
  235 +
  236 +
  237 +class TestChartRepairer:
  238 + """测试ChartRepairer类"""
  239 +
  240 + def setup_method(self):
  241 + """每个测试前初始化"""
  242 + self.validator = create_chart_validator()
  243 + self.repairer = create_chart_repairer(validator=self.validator)
  244 +
  245 + def test_repair_missing_props(self):
  246 + """测试修复缺少props字段"""
  247 + widget_block = {
  248 + "widgetType": "chart.js/bar",
  249 + "data": {
  250 + "labels": ["A", "B"],
  251 + "datasets": [
  252 + {
  253 + "label": "系列1",
  254 + "data": [10, 20]
  255 + }
  256 + ]
  257 + }
  258 + }
  259 +
  260 + result = self.repairer.repair(widget_block)
  261 + assert result.success
  262 + assert "props" in result.repaired_block
  263 + assert result.method == "local"
  264 +
  265 + def test_repair_missing_chart_type(self):
  266 + """测试修复缺少图表类型"""
  267 + widget_block = {
  268 + "widgetType": "chart.js/bar",
  269 + "props": {},
  270 + "data": {
  271 + "labels": ["A", "B"],
  272 + "datasets": [
  273 + {
  274 + "label": "系列1",
  275 + "data": [10, 20]
  276 + }
  277 + ]
  278 + }
  279 + }
  280 +
  281 + result = self.repairer.repair(widget_block)
  282 + assert result.success
  283 + assert result.repaired_block["props"]["type"] == "bar"
  284 + assert "图表类型" in str(result.changes)
  285 +
  286 + def test_repair_missing_datasets(self):
  287 + """测试修复缺少datasets"""
  288 + widget_block = {
  289 + "widgetType": "chart.js/bar",
  290 + "props": {"type": "bar"},
  291 + "data": {
  292 + "labels": ["A", "B"]
  293 + }
  294 + }
  295 +
  296 + result = self.repairer.repair(widget_block)
  297 + assert result.success
  298 + assert "datasets" in result.repaired_block["data"]
  299 + assert isinstance(result.repaired_block["data"]["datasets"], list)
  300 +
  301 + def test_repair_missing_labels(self):
  302 + """测试修复缺少labels"""
  303 + widget_block = {
  304 + "widgetType": "chart.js/bar",
  305 + "props": {"type": "bar"},
  306 + "data": {
  307 + "datasets": [
  308 + {
  309 + "label": "系列1",
  310 + "data": [10, 20, 30]
  311 + }
  312 + ]
  313 + }
  314 + }
  315 +
  316 + result = self.repairer.repair(widget_block)
  317 + assert result.success
  318 + assert "labels" in result.repaired_block["data"]
  319 + assert len(result.repaired_block["data"]["labels"]) == 3
  320 +
  321 + def test_repair_data_length_mismatch(self):
  322 + """测试修复数据长度不匹配"""
  323 + widget_block = {
  324 + "widgetType": "chart.js/bar",
  325 + "props": {"type": "bar"},
  326 + "data": {
  327 + "labels": ["A", "B", "C", "D"],
  328 + "datasets": [
  329 + {
  330 + "label": "系列1",
  331 + "data": [10, 20] # 长度不足
  332 + }
  333 + ]
  334 + }
  335 + }
  336 +
  337 + result = self.repairer.repair(widget_block)
  338 + assert result.success
  339 + # 应该补充到4个元素
  340 + assert len(result.repaired_block["data"]["datasets"][0]["data"]) == 4
  341 +
  342 + def test_repair_string_to_number(self):
  343 + """测试修复字符串类型的数值"""
  344 + widget_block = {
  345 + "widgetType": "chart.js/bar",
  346 + "props": {"type": "bar"},
  347 + "data": {
  348 + "labels": ["A", "B"],
  349 + "datasets": [
  350 + {
  351 + "label": "系列1",
  352 + "data": ["10", "20"] # 字符串数值
  353 + }
  354 + ]
  355 + }
  356 + }
  357 +
  358 + result = self.repairer.repair(widget_block)
  359 + assert result.success
  360 + # 应该转换为数值
  361 + assert isinstance(result.repaired_block["data"]["datasets"][0]["data"][0], float)
  362 +
  363 + def test_repair_construct_datasets_from_values(self):
  364 + """测试从values字段构造datasets"""
  365 + widget_block = {
  366 + "widgetType": "chart.js/bar",
  367 + "props": {"type": "bar"},
  368 + "data": {
  369 + "labels": ["A", "B"],
  370 + "values": [10, 20] # 使用values而不是datasets
  371 + }
  372 + }
  373 +
  374 + result = self.repairer.repair(widget_block)
  375 + assert result.success
  376 + assert "datasets" in result.repaired_block["data"]
  377 + assert len(result.repaired_block["data"]["datasets"]) > 0
  378 +
  379 + def test_no_repair_needed(self):
  380 + """测试不需要修复的情况"""
  381 + widget_block = {
  382 + "widgetType": "chart.js/bar",
  383 + "props": {"type": "bar"},
  384 + "data": {
  385 + "labels": ["A", "B"],
  386 + "datasets": [
  387 + {
  388 + "label": "系列1",
  389 + "data": [10, 20]
  390 + }
  391 + ]
  392 + }
  393 + }
  394 +
  395 + result = self.repairer.repair(widget_block)
  396 + assert result.success
  397 + assert result.method == "none"
  398 + assert len(result.changes) == 0
  399 +
  400 + def test_repair_adds_default_label(self):
  401 + """测试修复添加默认label"""
  402 + widget_block = {
  403 + "widgetType": "chart.js/bar",
  404 + "props": {"type": "bar"},
  405 + "data": {
  406 + "labels": ["A", "B"],
  407 + "datasets": [
  408 + {
  409 + # 缺少label
  410 + "data": [10, 20]
  411 + }
  412 + ]
  413 + }
  414 + }
  415 +
  416 + result = self.repairer.repair(widget_block)
  417 + assert result.success
  418 + assert "label" in result.repaired_block["data"]["datasets"][0]
  419 +
  420 +
  421 +class TestValidatorIntegration:
  422 + """集成测试"""
  423 +
  424 + def test_full_validation_and_repair_workflow(self):
  425 + """测试完整的验证和修复流程"""
  426 + validator = create_chart_validator()
  427 + repairer = create_chart_repairer(validator=validator)
  428 +
  429 + # 一个有多个问题的图表
  430 + widget_block = {
  431 + "widgetType": "chart.js/bar",
  432 + "data": {
  433 + "datasets": [
  434 + {
  435 + "data": ["10", "20", "30"] # 字符串数值
  436 + }
  437 + ]
  438 + }
  439 + }
  440 +
  441 + # 1. 验证(应该失败)
  442 + validation = validator.validate(widget_block)
  443 + assert not validation.is_valid
  444 +
  445 + # 2. 修复
  446 + repair_result = repairer.repair(widget_block, validation)
  447 + assert repair_result.success
  448 +
  449 + # 3. 再次验证(应该通过)
  450 + final_validation = validator.validate(repair_result.repaired_block)
  451 + assert final_validation.is_valid
  452 +
  453 +
  454 +if __name__ == "__main__":
  455 + # 运行测试
  456 + pytest.main([__file__, "-v", "--tb=short"])