Showing
4 changed files
with
1579 additions
and
1 deletions
| @@ -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; |
ReportEngine/utils/chart_repair_api.py
0 → 100644
| 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 |
ReportEngine/utils/chart_validator.py
0 → 100644
| 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) |
ReportEngine/utils/test_chart_validator.py
0 → 100644
| 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"]) |
-
Please register or login to post a comment