Repair and Optimize the Chart Rendering
This commit is contained in:
@@ -11,6 +11,15 @@ import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
from loguru import logger
|
||||
|
||||
from ReportEngine.utils.chart_validator import (
|
||||
ChartValidator,
|
||||
ChartRepairer,
|
||||
create_chart_validator,
|
||||
create_chart_repairer
|
||||
)
|
||||
from ReportEngine.utils.chart_repair_api import create_llm_repair_functions
|
||||
|
||||
|
||||
class HTMLRenderer:
|
||||
@@ -65,6 +74,23 @@ class HTMLRenderer:
|
||||
self.hero_kpi_signature: tuple | None = None
|
||||
self._lib_cache: Dict[str, str] = {}
|
||||
|
||||
# 初始化图表验证和修复器
|
||||
self.chart_validator = create_chart_validator()
|
||||
llm_repair_fns = create_llm_repair_functions()
|
||||
self.chart_repairer = create_chart_repairer(
|
||||
validator=self.chart_validator,
|
||||
llm_repair_fns=llm_repair_fns
|
||||
)
|
||||
|
||||
# 统计信息
|
||||
self.chart_validation_stats = {
|
||||
'total': 0,
|
||||
'valid': 0,
|
||||
'repaired_locally': 0,
|
||||
'repaired_api': 0,
|
||||
'failed': 0
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _get_lib_path() -> Path:
|
||||
"""获取第三方库文件的目录路径"""
|
||||
@@ -124,6 +150,15 @@ class HTMLRenderer:
|
||||
self.heading_label_map = self._compute_heading_labels(self.chapters)
|
||||
self.toc_entries = self._collect_toc_entries(self.chapters)
|
||||
|
||||
# 重置图表验证统计
|
||||
self.chart_validation_stats = {
|
||||
'total': 0,
|
||||
'valid': 0,
|
||||
'repaired_locally': 0,
|
||||
'repaired_api': 0,
|
||||
'failed': 0
|
||||
}
|
||||
|
||||
metadata = self.metadata
|
||||
theme_tokens = metadata.get("themeTokens") or self.document.get("themeTokens", {})
|
||||
title = metadata.get("title") or metadata.get("query") or "智能舆情报告"
|
||||
@@ -132,6 +167,10 @@ class HTMLRenderer:
|
||||
|
||||
head = self._render_head(title, theme_tokens)
|
||||
body = self._render_body()
|
||||
|
||||
# 输出图表验证统计
|
||||
self._log_chart_validation_stats()
|
||||
|
||||
return f"<!DOCTYPE html>\n<html lang=\"zh-CN\" class=\"no-js\">\n{head}\n{body}\n</html>"
|
||||
|
||||
# ====== 头部 / 正文 ======
|
||||
@@ -1150,12 +1189,66 @@ class HTMLRenderer:
|
||||
"""
|
||||
渲染Chart.js等交互组件的占位容器,并记录配置JSON。
|
||||
|
||||
在渲染前进行图表验证和修复:
|
||||
1. 验证图表数据格式
|
||||
2. 如果无效,尝试本地修复
|
||||
3. 如果本地修复失败,尝试API修复
|
||||
4. 如果所有修复都失败,使用原始数据(前端会降级处理)
|
||||
|
||||
参数:
|
||||
block: widget类型的block,包含widgetId/props/data。
|
||||
|
||||
返回:
|
||||
str: 含canvas与配置脚本的HTML。
|
||||
"""
|
||||
# 统计
|
||||
widget_type = block.get('widgetType', '')
|
||||
is_chart = isinstance(widget_type, str) and widget_type.startswith('chart.js')
|
||||
|
||||
if is_chart:
|
||||
self.chart_validation_stats['total'] += 1
|
||||
|
||||
# 验证图表数据
|
||||
validation_result = self.chart_validator.validate(block)
|
||||
|
||||
if not validation_result.is_valid:
|
||||
logger.warning(
|
||||
f"图表 {block.get('widgetId', 'unknown')} 验证失败: {validation_result.errors}"
|
||||
)
|
||||
|
||||
# 尝试修复
|
||||
repair_result = self.chart_repairer.repair(block, validation_result)
|
||||
|
||||
if repair_result.success and repair_result.repaired_block:
|
||||
# 修复成功,使用修复后的数据
|
||||
block = repair_result.repaired_block
|
||||
logger.info(
|
||||
f"图表 {block.get('widgetId', 'unknown')} 修复成功 "
|
||||
f"(方法: {repair_result.method}): {repair_result.changes}"
|
||||
)
|
||||
|
||||
# 更新统计
|
||||
if repair_result.method == 'local':
|
||||
self.chart_validation_stats['repaired_locally'] += 1
|
||||
elif repair_result.method == 'api':
|
||||
self.chart_validation_stats['repaired_api'] += 1
|
||||
else:
|
||||
# 修复失败,使用原始数据,前端会尝试降级渲染
|
||||
logger.warning(
|
||||
f"图表 {block.get('widgetId', 'unknown')} 修复失败,"
|
||||
f"将使用原始数据(前端会尝试降级渲染或显示fallback)"
|
||||
)
|
||||
self.chart_validation_stats['failed'] += 1
|
||||
else:
|
||||
# 验证通过
|
||||
self.chart_validation_stats['valid'] += 1
|
||||
if validation_result.warnings:
|
||||
logger.info(
|
||||
f"图表 {block.get('widgetId', 'unknown')} 验证通过,"
|
||||
f"但有警告: {validation_result.warnings}"
|
||||
)
|
||||
|
||||
# 渲染图表HTML
|
||||
self.chart_counter += 1
|
||||
canvas_id = f"chart-{self.chart_counter}"
|
||||
config_id = f"chart-config-{self.chart_counter}"
|
||||
@@ -1220,6 +1313,39 @@ class HTMLRenderer:
|
||||
"""
|
||||
return table_html
|
||||
|
||||
def _log_chart_validation_stats(self):
|
||||
"""输出图表验证统计信息"""
|
||||
stats = self.chart_validation_stats
|
||||
if stats['total'] == 0:
|
||||
return
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info("图表验证统计")
|
||||
logger.info("=" * 60)
|
||||
logger.info(f"总图表数量: {stats['total']}")
|
||||
logger.info(f" ✓ 验证通过: {stats['valid']} ({stats['valid']/stats['total']*100:.1f}%)")
|
||||
|
||||
if stats['repaired_locally'] > 0:
|
||||
logger.info(
|
||||
f" ⚠ 本地修复: {stats['repaired_locally']} "
|
||||
f"({stats['repaired_locally']/stats['total']*100:.1f}%)"
|
||||
)
|
||||
|
||||
if stats['repaired_api'] > 0:
|
||||
logger.info(
|
||||
f" ⚠ API修复: {stats['repaired_api']} "
|
||||
f"({stats['repaired_api']/stats['total']*100:.1f}%)"
|
||||
)
|
||||
|
||||
if stats['failed'] > 0:
|
||||
logger.warning(
|
||||
f" ✗ 修复失败: {stats['failed']} "
|
||||
f"({stats['failed']/stats['total']*100:.1f}%) - "
|
||||
f"这些图表将使用降级渲染或显示fallback表格"
|
||||
)
|
||||
|
||||
logger.info("=" * 60)
|
||||
|
||||
# ====== 前置信息防护 ======
|
||||
|
||||
def _kpi_signature_from_items(self, items: Any) -> tuple | None:
|
||||
@@ -2317,6 +2443,80 @@ function buildChartOptions(payload) {
|
||||
return mergeOptions(baseOptions, overrideOptions);
|
||||
}
|
||||
|
||||
function validateChartData(payload, type) {
|
||||
/**
|
||||
* 前端验证图表数据
|
||||
* 返回: { valid: boolean, errors: string[] }
|
||||
*/
|
||||
const errors = [];
|
||||
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
errors.push('无效的payload');
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
const data = payload.data;
|
||||
if (!data || typeof data !== 'object') {
|
||||
errors.push('缺少data字段');
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
// 特殊图表类型(scatter, bubble)
|
||||
const specialTypes = { 'scatter': true, 'bubble': true };
|
||||
if (specialTypes[type]) {
|
||||
// 这些类型需要特殊的数据格式 {x, y} 或 {x, y, r}
|
||||
// 跳过标准验证
|
||||
return { valid: true, errors };
|
||||
}
|
||||
|
||||
// 标准图表类型验证
|
||||
const datasets = data.datasets;
|
||||
if (!Array.isArray(datasets)) {
|
||||
errors.push('datasets必须是数组');
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
if (datasets.length === 0) {
|
||||
errors.push('datasets数组为空');
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
// 验证每个dataset
|
||||
for (let i = 0; i < datasets.length; i++) {
|
||||
const dataset = datasets[i];
|
||||
if (!dataset || typeof dataset !== 'object') {
|
||||
errors.push(`datasets[${i}]不是对象`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Array.isArray(dataset.data)) {
|
||||
errors.push(`datasets[${i}].data不是数组`);
|
||||
} else if (dataset.data.length === 0) {
|
||||
errors.push(`datasets[${i}].data为空`);
|
||||
}
|
||||
}
|
||||
|
||||
// 需要labels的图表类型
|
||||
const labelRequiredTypes = {
|
||||
'line': true, 'bar': true, 'radar': true,
|
||||
'polarArea': true, 'pie': true, 'doughnut': true
|
||||
};
|
||||
|
||||
if (labelRequiredTypes[type]) {
|
||||
const labels = data.labels;
|
||||
if (!Array.isArray(labels)) {
|
||||
errors.push('缺少labels数组');
|
||||
} else if (labels.length === 0) {
|
||||
errors.push('labels数组为空');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
function instantiateChart(ctx, payload, optionsTemplate, type) {
|
||||
if (!ctx) {
|
||||
return null;
|
||||
@@ -2358,9 +2558,17 @@ function hydrateCharts() {
|
||||
renderChartFallback(canvas, payload, 'Canvas 初始化失败');
|
||||
return;
|
||||
}
|
||||
|
||||
// 前端数据验证
|
||||
const desiredType = chartTypes[0];
|
||||
const validation = validateChartData(payload, desiredType);
|
||||
if (!validation.valid) {
|
||||
console.warn('图表数据验证失败:', validation.errors);
|
||||
// 验证失败但仍然尝试渲染,因为可能会降级成功
|
||||
}
|
||||
|
||||
const card = canvas.closest('.chart-card') || canvas.parentElement;
|
||||
const optionsTemplate = buildChartOptions(payload);
|
||||
const desiredType = chartTypes[0];
|
||||
let chartInstance = null;
|
||||
let selectedType = null;
|
||||
let lastError;
|
||||
|
||||
283
ReportEngine/utils/chart_repair_api.py
Normal file
283
ReportEngine/utils/chart_repair_api.py
Normal file
@@ -0,0 +1,283 @@
|
||||
"""
|
||||
图表API修复模块。
|
||||
|
||||
提供调用4个Engine(ReportEngine, ForumEngine, InsightEngine, MediaEngine)的LLM API
|
||||
来修复图表数据的功能。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
from loguru import logger
|
||||
|
||||
from ReportEngine.utils.config import settings
|
||||
|
||||
|
||||
# 图表修复提示词
|
||||
CHART_REPAIR_SYSTEM_PROMPT = """你是一个专业的图表数据修复助手。你的任务是修复Chart.js图表数据中的格式错误,确保图表能够正常渲染。
|
||||
|
||||
**Chart.js标准数据格式:**
|
||||
|
||||
1. 标准图表(line, bar, pie, doughnut, radar, polarArea):
|
||||
```json
|
||||
{
|
||||
"type": "widget",
|
||||
"widgetType": "chart.js/bar",
|
||||
"widgetId": "chart-001",
|
||||
"props": {
|
||||
"type": "bar",
|
||||
"title": "图表标题",
|
||||
"options": {
|
||||
"responsive": true,
|
||||
"plugins": {
|
||||
"legend": {
|
||||
"display": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"labels": ["A", "B", "C"],
|
||||
"datasets": [
|
||||
{
|
||||
"label": "系列1",
|
||||
"data": [10, 20, 30]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. 特殊图表(scatter, bubble):
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"datasets": [
|
||||
{
|
||||
"label": "系列1",
|
||||
"data": [
|
||||
{"x": 10, "y": 20},
|
||||
{"x": 15, "y": 25}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**修复原则:**
|
||||
1. **宁愿不改,也不要改错** - 如果不确定如何修复,保持原始数据
|
||||
2. **最小改动** - 只修复明确的错误,不要过度修改
|
||||
3. **保持数据完整性** - 不要丢失原始数据
|
||||
4. **验证修复结果** - 确保修复后符合Chart.js格式
|
||||
|
||||
**常见错误及修复方法:**
|
||||
1. 缺少labels字段 → 根据数据生成默认labels
|
||||
2. datasets不是数组 → 转换为数组格式
|
||||
3. 数据长度不匹配 → 截断或补null
|
||||
4. 非数值数据 → 尝试转换或设为null
|
||||
5. 缺少必需字段 → 添加默认值
|
||||
|
||||
请根据错误信息修复图表数据,并返回修复后的完整widget block(JSON格式)。
|
||||
"""
|
||||
|
||||
|
||||
def build_chart_repair_prompt(
|
||||
widget_block: Dict[str, Any],
|
||||
validation_errors: List[str]
|
||||
) -> str:
|
||||
"""
|
||||
构建图表修复提示词。
|
||||
|
||||
Args:
|
||||
widget_block: 原始widget block
|
||||
validation_errors: 验证错误列表
|
||||
|
||||
Returns:
|
||||
str: 提示词
|
||||
"""
|
||||
block_json = json.dumps(widget_block, ensure_ascii=False, indent=2)
|
||||
errors_text = "\n".join(f"- {error}" for error in validation_errors)
|
||||
|
||||
prompt = f"""请修复以下图表数据中的错误:
|
||||
|
||||
**原始数据:**
|
||||
```json
|
||||
{block_json}
|
||||
```
|
||||
|
||||
**检测到的错误:**
|
||||
{errors_text}
|
||||
|
||||
**要求:**
|
||||
1. 返回修复后的完整widget block(JSON格式)
|
||||
2. 只修复明确的错误,保持其他数据不变
|
||||
3. 确保修复后的数据符合Chart.js格式要求
|
||||
4. 如果无法确定如何修复,保持原始数据
|
||||
|
||||
**重要的输出格式要求:**
|
||||
1. 只返回纯JSON对象,不要添加任何说明文字
|
||||
2. 不要使用```json```标记包裹
|
||||
3. 确保JSON语法完全正确
|
||||
4. 所有字符串使用双引号
|
||||
"""
|
||||
return prompt
|
||||
|
||||
|
||||
def create_llm_repair_functions() -> List:
|
||||
"""
|
||||
创建LLM修复函数列表。
|
||||
|
||||
返回4个Engine的修复函数:
|
||||
1. ReportEngine
|
||||
2. ForumEngine (通过ForumHost)
|
||||
3. InsightEngine
|
||||
4. MediaEngine
|
||||
|
||||
Returns:
|
||||
List[Callable]: 修复函数列表
|
||||
"""
|
||||
repair_functions = []
|
||||
|
||||
# 1. ReportEngine修复函数
|
||||
if settings.REPORT_ENGINE_API_KEY and settings.REPORT_ENGINE_BASE_URL:
|
||||
def repair_with_report_engine(widget_block: Dict[str, Any], errors: List[str]) -> Optional[Dict[str, Any]]:
|
||||
"""使用ReportEngine的LLM修复图表"""
|
||||
try:
|
||||
from llm_client import LLMClient
|
||||
|
||||
client = LLMClient(
|
||||
api_key=settings.REPORT_ENGINE_API_KEY,
|
||||
base_url=settings.REPORT_ENGINE_BASE_URL,
|
||||
model_name=settings.REPORT_ENGINE_MODEL_NAME or "gpt-4",
|
||||
provider="openai"
|
||||
)
|
||||
|
||||
prompt = build_chart_repair_prompt(widget_block, errors)
|
||||
response = client.invoke(
|
||||
CHART_REPAIR_SYSTEM_PROMPT,
|
||||
prompt,
|
||||
temperature=0.0,
|
||||
top_p=0.05
|
||||
)
|
||||
|
||||
if not response:
|
||||
return None
|
||||
|
||||
# 解析响应
|
||||
repaired = json.loads(response)
|
||||
return repaired
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"ReportEngine图表修复失败: {e}")
|
||||
return None
|
||||
|
||||
repair_functions.append(repair_with_report_engine)
|
||||
|
||||
# 2. ForumEngine修复函数
|
||||
if settings.FORUM_HOST_API_KEY and settings.FORUM_HOST_BASE_URL:
|
||||
def repair_with_forum_engine(widget_block: Dict[str, Any], errors: List[str]) -> Optional[Dict[str, Any]]:
|
||||
"""使用ForumEngine的LLM修复图表"""
|
||||
try:
|
||||
from llm_client import LLMClient
|
||||
|
||||
client = LLMClient(
|
||||
api_key=settings.FORUM_HOST_API_KEY,
|
||||
base_url=settings.FORUM_HOST_BASE_URL,
|
||||
model_name=settings.FORUM_HOST_MODEL_NAME or "gpt-4",
|
||||
provider="openai"
|
||||
)
|
||||
|
||||
prompt = build_chart_repair_prompt(widget_block, errors)
|
||||
response = client.invoke(
|
||||
CHART_REPAIR_SYSTEM_PROMPT,
|
||||
prompt,
|
||||
temperature=0.0,
|
||||
top_p=0.05
|
||||
)
|
||||
|
||||
if not response:
|
||||
return None
|
||||
|
||||
repaired = json.loads(response)
|
||||
return repaired
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"ForumEngine图表修复失败: {e}")
|
||||
return None
|
||||
|
||||
repair_functions.append(repair_with_forum_engine)
|
||||
|
||||
# 3. InsightEngine修复函数
|
||||
if settings.INSIGHT_ENGINE_API_KEY and settings.INSIGHT_ENGINE_BASE_URL:
|
||||
def repair_with_insight_engine(widget_block: Dict[str, Any], errors: List[str]) -> Optional[Dict[str, Any]]:
|
||||
"""使用InsightEngine的LLM修复图表"""
|
||||
try:
|
||||
from llm_client import LLMClient
|
||||
|
||||
client = LLMClient(
|
||||
api_key=settings.INSIGHT_ENGINE_API_KEY,
|
||||
base_url=settings.INSIGHT_ENGINE_BASE_URL,
|
||||
model_name=settings.INSIGHT_ENGINE_MODEL_NAME or "gpt-4",
|
||||
provider="openai"
|
||||
)
|
||||
|
||||
prompt = build_chart_repair_prompt(widget_block, errors)
|
||||
response = client.invoke(
|
||||
CHART_REPAIR_SYSTEM_PROMPT,
|
||||
prompt,
|
||||
temperature=0.0,
|
||||
top_p=0.05
|
||||
)
|
||||
|
||||
if not response:
|
||||
return None
|
||||
|
||||
repaired = json.loads(response)
|
||||
return repaired
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"InsightEngine图表修复失败: {e}")
|
||||
return None
|
||||
|
||||
repair_functions.append(repair_with_insight_engine)
|
||||
|
||||
# 4. MediaEngine修复函数
|
||||
if settings.MEDIA_ENGINE_API_KEY and settings.MEDIA_ENGINE_BASE_URL:
|
||||
def repair_with_media_engine(widget_block: Dict[str, Any], errors: List[str]) -> Optional[Dict[str, Any]]:
|
||||
"""使用MediaEngine的LLM修复图表"""
|
||||
try:
|
||||
from llm_client import LLMClient
|
||||
|
||||
client = LLMClient(
|
||||
api_key=settings.MEDIA_ENGINE_API_KEY,
|
||||
base_url=settings.MEDIA_ENGINE_BASE_URL,
|
||||
model_name=settings.MEDIA_ENGINE_MODEL_NAME or "gpt-4",
|
||||
provider="openai"
|
||||
)
|
||||
|
||||
prompt = build_chart_repair_prompt(widget_block, errors)
|
||||
response = client.invoke(
|
||||
CHART_REPAIR_SYSTEM_PROMPT,
|
||||
prompt,
|
||||
temperature=0.0,
|
||||
top_p=0.05
|
||||
)
|
||||
|
||||
if not response:
|
||||
return None
|
||||
|
||||
repaired = json.loads(response)
|
||||
return repaired
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"MediaEngine图表修复失败: {e}")
|
||||
return None
|
||||
|
||||
repair_functions.append(repair_with_media_engine)
|
||||
|
||||
if not repair_functions:
|
||||
logger.warning("未配置任何Engine API,图表API修复功能将不可用")
|
||||
|
||||
return repair_functions
|
||||
631
ReportEngine/utils/chart_validator.py
Normal file
631
ReportEngine/utils/chart_validator.py
Normal file
@@ -0,0 +1,631 @@
|
||||
"""
|
||||
图表验证和修复工具。
|
||||
|
||||
提供对Chart.js图表数据的验证和修复能力:
|
||||
1. 验证图表数据格式是否符合Chart.js要求
|
||||
2. 本地规则修复常见问题
|
||||
3. LLM API辅助修复复杂问题
|
||||
4. 遵循"宁愿不改,也不要改错"的原则
|
||||
|
||||
支持的图表类型:
|
||||
- line (折线图)
|
||||
- bar (柱状图)
|
||||
- pie (饼图)
|
||||
- doughnut (圆环图)
|
||||
- radar (雷达图)
|
||||
- polarArea (极地区域图)
|
||||
- scatter (散点图)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional, Tuple, Callable
|
||||
from dataclasses import dataclass
|
||||
from loguru import logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
"""验证结果"""
|
||||
is_valid: bool
|
||||
errors: List[str]
|
||||
warnings: List[str]
|
||||
|
||||
def has_critical_errors(self) -> bool:
|
||||
"""是否有严重错误(会导致渲染失败)"""
|
||||
return not self.is_valid and len(self.errors) > 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class RepairResult:
|
||||
"""修复结果"""
|
||||
success: bool
|
||||
repaired_block: Optional[Dict[str, Any]]
|
||||
method: str # 'none', 'local', 'api'
|
||||
changes: List[str]
|
||||
|
||||
def has_changes(self) -> bool:
|
||||
"""是否有修改"""
|
||||
return len(self.changes) > 0
|
||||
|
||||
|
||||
class ChartValidator:
|
||||
"""
|
||||
图表验证器 - 验证Chart.js图表数据格式是否正确。
|
||||
|
||||
验证规则:
|
||||
1. 基本结构验证:widgetType, props, data字段
|
||||
2. 图表类型验证:支持的图表类型
|
||||
3. 数据格式验证:labels和datasets结构
|
||||
4. 数据一致性验证:labels和datasets长度匹配
|
||||
5. 数值类型验证:数据值类型正确
|
||||
"""
|
||||
|
||||
# 支持的图表类型
|
||||
SUPPORTED_CHART_TYPES = {
|
||||
'line', 'bar', 'pie', 'doughnut', 'radar', 'polarArea', 'scatter',
|
||||
'bubble', 'horizontalBar'
|
||||
}
|
||||
|
||||
# 需要labels的图表类型
|
||||
LABEL_REQUIRED_TYPES = {
|
||||
'line', 'bar', 'radar', 'polarArea', 'pie', 'doughnut'
|
||||
}
|
||||
|
||||
# 需要数值数据的图表类型
|
||||
NUMERIC_DATA_TYPES = {
|
||||
'line', 'bar', 'radar', 'polarArea', 'pie', 'doughnut'
|
||||
}
|
||||
|
||||
# 需要特殊数据格式的图表类型
|
||||
SPECIAL_DATA_TYPES = {
|
||||
'scatter': {'x', 'y'},
|
||||
'bubble': {'x', 'y', 'r'}
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def validate(self, widget_block: Dict[str, Any]) -> ValidationResult:
|
||||
"""
|
||||
验证图表格式。
|
||||
|
||||
Args:
|
||||
widget_block: widget类型的block,包含widgetId/widgetType/props/data
|
||||
|
||||
Returns:
|
||||
ValidationResult: 验证结果
|
||||
"""
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
# 1. 基本结构验证
|
||||
if not isinstance(widget_block, dict):
|
||||
errors.append("widget_block必须是字典类型")
|
||||
return ValidationResult(False, errors, warnings)
|
||||
|
||||
# 2. 检查widgetType
|
||||
widget_type = widget_block.get('widgetType', '')
|
||||
if not widget_type or not isinstance(widget_type, str):
|
||||
errors.append("缺少widgetType字段或类型不正确")
|
||||
return ValidationResult(False, errors, warnings)
|
||||
|
||||
# 检查是否是chart.js类型
|
||||
if not widget_type.startswith('chart.js'):
|
||||
# 不是图表类型,跳过验证
|
||||
return ValidationResult(True, errors, warnings)
|
||||
|
||||
# 3. 提取图表类型
|
||||
chart_type = self._extract_chart_type(widget_block)
|
||||
if not chart_type:
|
||||
errors.append("无法确定图表类型")
|
||||
return ValidationResult(False, errors, warnings)
|
||||
|
||||
# 4. 检查是否支持该图表类型
|
||||
if chart_type not in self.SUPPORTED_CHART_TYPES:
|
||||
warnings.append(f"图表类型 '{chart_type}' 可能不被支持,将尝试降级渲染")
|
||||
|
||||
# 5. 验证数据结构
|
||||
data = widget_block.get('data')
|
||||
if not isinstance(data, dict):
|
||||
errors.append("data字段必须是字典类型")
|
||||
return ValidationResult(False, errors, warnings)
|
||||
|
||||
# 6. 根据图表类型验证数据
|
||||
if chart_type in self.SPECIAL_DATA_TYPES:
|
||||
# 特殊数据格式(scatter, bubble)
|
||||
self._validate_special_data(data, chart_type, errors, warnings)
|
||||
else:
|
||||
# 标准数据格式(labels + datasets)
|
||||
self._validate_standard_data(data, chart_type, errors, warnings)
|
||||
|
||||
# 7. 验证props
|
||||
props = widget_block.get('props')
|
||||
if props is not None and not isinstance(props, dict):
|
||||
warnings.append("props字段应该是字典类型")
|
||||
|
||||
is_valid = len(errors) == 0
|
||||
return ValidationResult(is_valid, errors, warnings)
|
||||
|
||||
def _extract_chart_type(self, widget_block: Dict[str, Any]) -> Optional[str]:
|
||||
"""
|
||||
提取图表类型。
|
||||
|
||||
优先级:
|
||||
1. props.type
|
||||
2. widgetType中的类型(chart.js/bar -> bar)
|
||||
3. data.type
|
||||
"""
|
||||
# 1. 从props中获取
|
||||
props = widget_block.get('props') or {}
|
||||
if isinstance(props, dict):
|
||||
chart_type = props.get('type')
|
||||
if chart_type and isinstance(chart_type, str):
|
||||
return chart_type.lower()
|
||||
|
||||
# 2. 从widgetType中提取
|
||||
widget_type = widget_block.get('widgetType', '')
|
||||
if '/' in widget_type:
|
||||
chart_type = widget_type.split('/')[-1]
|
||||
if chart_type:
|
||||
return chart_type.lower()
|
||||
|
||||
# 3. 从data中获取
|
||||
data = widget_block.get('data') or {}
|
||||
if isinstance(data, dict):
|
||||
chart_type = data.get('type')
|
||||
if chart_type and isinstance(chart_type, str):
|
||||
return chart_type.lower()
|
||||
|
||||
return None
|
||||
|
||||
def _validate_standard_data(
|
||||
self,
|
||||
data: Dict[str, Any],
|
||||
chart_type: str,
|
||||
errors: List[str],
|
||||
warnings: List[str]
|
||||
):
|
||||
"""验证标准数据格式(labels + datasets)"""
|
||||
labels = data.get('labels')
|
||||
datasets = data.get('datasets')
|
||||
|
||||
# 验证labels
|
||||
if chart_type in self.LABEL_REQUIRED_TYPES:
|
||||
if not labels:
|
||||
errors.append(f"{chart_type}类型图表必须包含labels字段")
|
||||
elif not isinstance(labels, list):
|
||||
errors.append("labels必须是数组类型")
|
||||
elif len(labels) == 0:
|
||||
warnings.append("labels数组为空,图表可能无法正常显示")
|
||||
|
||||
# 验证datasets
|
||||
if datasets is None:
|
||||
errors.append("缺少datasets字段")
|
||||
return
|
||||
|
||||
if not isinstance(datasets, list):
|
||||
errors.append("datasets必须是数组类型")
|
||||
return
|
||||
|
||||
if len(datasets) == 0:
|
||||
errors.append("datasets数组为空")
|
||||
return
|
||||
|
||||
# 验证每个dataset
|
||||
for idx, dataset in enumerate(datasets):
|
||||
if not isinstance(dataset, dict):
|
||||
errors.append(f"datasets[{idx}]必须是对象类型")
|
||||
continue
|
||||
|
||||
# 验证data字段
|
||||
ds_data = dataset.get('data')
|
||||
if ds_data is None:
|
||||
errors.append(f"datasets[{idx}]缺少data字段")
|
||||
continue
|
||||
|
||||
if not isinstance(ds_data, list):
|
||||
errors.append(f"datasets[{idx}].data必须是数组类型")
|
||||
continue
|
||||
|
||||
if len(ds_data) == 0:
|
||||
warnings.append(f"datasets[{idx}].data数组为空")
|
||||
continue
|
||||
|
||||
# 验证数据长度一致性
|
||||
if labels and isinstance(labels, list):
|
||||
if len(ds_data) != len(labels):
|
||||
warnings.append(
|
||||
f"datasets[{idx}].data长度({len(ds_data)})与labels长度({len(labels)})不匹配"
|
||||
)
|
||||
|
||||
# 验证数值类型
|
||||
if chart_type in self.NUMERIC_DATA_TYPES:
|
||||
for data_idx, value in enumerate(ds_data):
|
||||
if value is not None and not isinstance(value, (int, float)):
|
||||
errors.append(
|
||||
f"datasets[{idx}].data[{data_idx}]的值'{value}'不是有效的数值类型"
|
||||
)
|
||||
break # 只报告第一个错误
|
||||
|
||||
def _validate_special_data(
|
||||
self,
|
||||
data: Dict[str, Any],
|
||||
chart_type: str,
|
||||
errors: List[str],
|
||||
warnings: List[str]
|
||||
):
|
||||
"""验证特殊数据格式(scatter, bubble)"""
|
||||
datasets = data.get('datasets')
|
||||
|
||||
if not datasets:
|
||||
errors.append("缺少datasets字段")
|
||||
return
|
||||
|
||||
if not isinstance(datasets, list):
|
||||
errors.append("datasets必须是数组类型")
|
||||
return
|
||||
|
||||
if len(datasets) == 0:
|
||||
errors.append("datasets数组为空")
|
||||
return
|
||||
|
||||
required_keys = self.SPECIAL_DATA_TYPES.get(chart_type, set())
|
||||
|
||||
# 验证每个dataset
|
||||
for idx, dataset in enumerate(datasets):
|
||||
if not isinstance(dataset, dict):
|
||||
errors.append(f"datasets[{idx}]必须是对象类型")
|
||||
continue
|
||||
|
||||
ds_data = dataset.get('data')
|
||||
if ds_data is None:
|
||||
errors.append(f"datasets[{idx}]缺少data字段")
|
||||
continue
|
||||
|
||||
if not isinstance(ds_data, list):
|
||||
errors.append(f"datasets[{idx}].data必须是数组类型")
|
||||
continue
|
||||
|
||||
if len(ds_data) == 0:
|
||||
warnings.append(f"datasets[{idx}].data数组为空")
|
||||
continue
|
||||
|
||||
# 验证数据点格式
|
||||
for data_idx, point in enumerate(ds_data):
|
||||
if not isinstance(point, dict):
|
||||
errors.append(
|
||||
f"datasets[{idx}].data[{data_idx}]必须是对象类型(包含{required_keys}字段)"
|
||||
)
|
||||
break
|
||||
|
||||
# 检查必需的键
|
||||
missing_keys = required_keys - set(point.keys())
|
||||
if missing_keys:
|
||||
errors.append(
|
||||
f"datasets[{idx}].data[{data_idx}]缺少必需字段: {missing_keys}"
|
||||
)
|
||||
break
|
||||
|
||||
# 验证数值类型
|
||||
for key in required_keys:
|
||||
value = point.get(key)
|
||||
if value is not None and not isinstance(value, (int, float)):
|
||||
errors.append(
|
||||
f"datasets[{idx}].data[{data_idx}].{key}的值'{value}'不是有效的数值类型"
|
||||
)
|
||||
break
|
||||
|
||||
def can_render(self, widget_block: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
判断图表是否能正常渲染(快速检查)。
|
||||
|
||||
Args:
|
||||
widget_block: widget类型的block
|
||||
|
||||
Returns:
|
||||
bool: 是否能正常渲染
|
||||
"""
|
||||
result = self.validate(widget_block)
|
||||
return result.is_valid
|
||||
|
||||
|
||||
class ChartRepairer:
|
||||
"""
|
||||
图表修复器 - 尝试修复图表数据。
|
||||
|
||||
修复策略:
|
||||
1. 本地规则修复:修复常见问题
|
||||
2. API修复:使用LLM修复复杂问题
|
||||
3. 验证修复结果:确保修复后能正常渲染
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
validator: ChartValidator,
|
||||
llm_repair_fns: Optional[List[Callable]] = None
|
||||
):
|
||||
"""
|
||||
初始化修复器。
|
||||
|
||||
Args:
|
||||
validator: 图表验证器实例
|
||||
llm_repair_fns: LLM修复函数列表(对应4个Engine)
|
||||
"""
|
||||
self.validator = validator
|
||||
self.llm_repair_fns = llm_repair_fns or []
|
||||
|
||||
def repair(
|
||||
self,
|
||||
widget_block: Dict[str, Any],
|
||||
validation_result: Optional[ValidationResult] = None
|
||||
) -> RepairResult:
|
||||
"""
|
||||
尝试修复图表数据。
|
||||
|
||||
Args:
|
||||
widget_block: widget类型的block
|
||||
validation_result: 验证结果(可选,如果没有会先进行验证)
|
||||
|
||||
Returns:
|
||||
RepairResult: 修复结果
|
||||
"""
|
||||
# 1. 如果没有验证结果,先验证
|
||||
if validation_result is None:
|
||||
validation_result = self.validator.validate(widget_block)
|
||||
|
||||
# 2. 尝试本地修复(即使验证通过也尝试,因为可能有警告)
|
||||
logger.info(f"尝试本地修复图表")
|
||||
local_result = self.repair_locally(widget_block, validation_result)
|
||||
|
||||
# 3. 验证修复结果
|
||||
if local_result.has_changes():
|
||||
repaired_validation = self.validator.validate(local_result.repaired_block)
|
||||
if repaired_validation.is_valid:
|
||||
logger.info(f"本地修复成功: {local_result.changes}")
|
||||
return RepairResult(True, local_result.repaired_block, 'local', local_result.changes)
|
||||
else:
|
||||
logger.warning(f"本地修复后仍然无效: {repaired_validation.errors}")
|
||||
|
||||
# 4. 如果本地修复失败且有严重错误,尝试API修复
|
||||
if validation_result.has_critical_errors() and len(self.llm_repair_fns) > 0:
|
||||
logger.info("本地修复失败,尝试API修复")
|
||||
api_result = self.repair_with_api(widget_block, validation_result)
|
||||
|
||||
if api_result.success:
|
||||
# 验证修复结果
|
||||
repaired_validation = self.validator.validate(api_result.repaired_block)
|
||||
if repaired_validation.is_valid:
|
||||
logger.info(f"API修复成功: {api_result.changes}")
|
||||
return api_result
|
||||
else:
|
||||
logger.warning(f"API修复后仍然无效: {repaired_validation.errors}")
|
||||
|
||||
# 5. 如果验证通过,返回原始或修复后的数据
|
||||
if validation_result.is_valid:
|
||||
if local_result.has_changes():
|
||||
return RepairResult(True, local_result.repaired_block, 'local', local_result.changes)
|
||||
else:
|
||||
return RepairResult(True, widget_block, 'none', [])
|
||||
|
||||
# 6. 所有修复都失败,返回原始数据
|
||||
logger.warning("所有修复尝试失败,保持原始数据")
|
||||
return RepairResult(False, widget_block, 'none', [])
|
||||
|
||||
def repair_locally(
|
||||
self,
|
||||
widget_block: Dict[str, Any],
|
||||
validation_result: ValidationResult
|
||||
) -> RepairResult:
|
||||
"""
|
||||
使用本地规则修复。
|
||||
|
||||
修复规则:
|
||||
1. 补全缺失的基本字段
|
||||
2. 修复数据类型错误
|
||||
3. 修复数据长度不匹配
|
||||
4. 清理无效数据
|
||||
5. 添加默认值
|
||||
"""
|
||||
repaired = copy.deepcopy(widget_block)
|
||||
changes = []
|
||||
|
||||
# 1. 确保基本结构存在
|
||||
if 'props' not in repaired or not isinstance(repaired.get('props'), dict):
|
||||
repaired['props'] = {}
|
||||
changes.append("添加缺失的props字段")
|
||||
|
||||
if 'data' not in repaired or not isinstance(repaired.get('data'), dict):
|
||||
repaired['data'] = {}
|
||||
changes.append("添加缺失的data字段")
|
||||
|
||||
# 2. 确保图表类型存在
|
||||
chart_type = self.validator._extract_chart_type(repaired)
|
||||
props = repaired['props']
|
||||
|
||||
if not chart_type:
|
||||
# 尝试从widgetType推断
|
||||
widget_type = repaired.get('widgetType', '')
|
||||
if '/' in widget_type:
|
||||
chart_type = widget_type.split('/')[-1].lower()
|
||||
props['type'] = chart_type
|
||||
changes.append(f"从widgetType推断图表类型: {chart_type}")
|
||||
else:
|
||||
# 默认使用bar类型
|
||||
chart_type = 'bar'
|
||||
props['type'] = chart_type
|
||||
changes.append("设置默认图表类型: bar")
|
||||
elif 'type' not in props or not props['type']:
|
||||
# chart_type存在但props中没有type字段,需要添加
|
||||
props['type'] = chart_type
|
||||
changes.append(f"将推断的图表类型添加到props: {chart_type}")
|
||||
|
||||
# 3. 修复数据结构
|
||||
data = repaired['data']
|
||||
|
||||
# 确保datasets存在
|
||||
if 'datasets' not in data or not isinstance(data.get('datasets'), list):
|
||||
data['datasets'] = []
|
||||
changes.append("添加缺失的datasets字段")
|
||||
|
||||
# 如果datasets为空但data中有其他数据,尝试构造datasets
|
||||
if len(data['datasets']) == 0:
|
||||
constructed = self._try_construct_datasets(data, chart_type)
|
||||
if constructed:
|
||||
data['datasets'] = constructed
|
||||
changes.append("从data中构造datasets")
|
||||
elif 'labels' in data and isinstance(data.get('labels'), list) and len(data['labels']) > 0:
|
||||
# 如果有labels但没有数据,创建一个空dataset
|
||||
data['datasets'] = [{
|
||||
'label': '数据',
|
||||
'data': [0] * len(data['labels'])
|
||||
}]
|
||||
changes.append("根据labels创建默认dataset(使用零值)")
|
||||
|
||||
# 确保labels存在(如果需要)
|
||||
if chart_type in ChartValidator.LABEL_REQUIRED_TYPES:
|
||||
if 'labels' not in data or not isinstance(data.get('labels'), list):
|
||||
# 尝试根据datasets长度生成labels
|
||||
if data['datasets'] and len(data['datasets']) > 0:
|
||||
first_ds = data['datasets'][0]
|
||||
if isinstance(first_ds, dict) and isinstance(first_ds.get('data'), list):
|
||||
data_len = len(first_ds['data'])
|
||||
data['labels'] = [f"项目 {i+1}" for i in range(data_len)]
|
||||
changes.append(f"生成{data_len}个默认labels")
|
||||
|
||||
# 4. 修复datasets中的数据
|
||||
for idx, dataset in enumerate(data.get('datasets', [])):
|
||||
if not isinstance(dataset, dict):
|
||||
continue
|
||||
|
||||
# 确保有data字段
|
||||
if 'data' not in dataset or not isinstance(dataset.get('data'), list):
|
||||
dataset['data'] = []
|
||||
changes.append(f"为datasets[{idx}]添加空data数组")
|
||||
|
||||
# 确保有label
|
||||
if 'label' not in dataset:
|
||||
dataset['label'] = f"系列 {idx + 1}"
|
||||
changes.append(f"为datasets[{idx}]添加默认label")
|
||||
|
||||
# 修复数据长度不匹配
|
||||
labels = data.get('labels', [])
|
||||
ds_data = dataset.get('data', [])
|
||||
if isinstance(labels, list) and isinstance(ds_data, list):
|
||||
if len(ds_data) < len(labels):
|
||||
# 数据不够,补null
|
||||
dataset['data'] = ds_data + [None] * (len(labels) - len(ds_data))
|
||||
changes.append(f"datasets[{idx}]数据长度不足,补充null")
|
||||
elif len(ds_data) > len(labels):
|
||||
# 数据过多,截断
|
||||
dataset['data'] = ds_data[:len(labels)]
|
||||
changes.append(f"datasets[{idx}]数据长度过长,截断")
|
||||
|
||||
# 转换非数值数据为数值(如果可能)
|
||||
if chart_type in ChartValidator.NUMERIC_DATA_TYPES:
|
||||
ds_data = dataset.get('data', [])
|
||||
converted = False
|
||||
for i, value in enumerate(ds_data):
|
||||
if value is None:
|
||||
continue
|
||||
if not isinstance(value, (int, float)):
|
||||
# 尝试转换
|
||||
try:
|
||||
if isinstance(value, str):
|
||||
# 尝试转换字符串
|
||||
ds_data[i] = float(value)
|
||||
converted = True
|
||||
except (ValueError, TypeError):
|
||||
# 转换失败,设为null
|
||||
ds_data[i] = None
|
||||
converted = True
|
||||
if converted:
|
||||
changes.append(f"datasets[{idx}]包含非数值数据,已尝试转换")
|
||||
|
||||
# 5. 验证修复结果
|
||||
success = len(changes) > 0
|
||||
|
||||
return RepairResult(success, repaired, 'local', changes)
|
||||
|
||||
def _try_construct_datasets(
|
||||
self,
|
||||
data: Dict[str, Any],
|
||||
chart_type: str
|
||||
) -> Optional[List[Dict[str, Any]]]:
|
||||
"""尝试从data中构造datasets"""
|
||||
# 如果data直接包含数据数组,尝试构造
|
||||
if 'values' in data and isinstance(data['values'], list):
|
||||
return [{
|
||||
'label': '数据',
|
||||
'data': data['values']
|
||||
}]
|
||||
|
||||
# 如果data包含series字段
|
||||
if 'series' in data and isinstance(data['series'], list):
|
||||
datasets = []
|
||||
for idx, series in enumerate(data['series']):
|
||||
if isinstance(series, dict):
|
||||
datasets.append({
|
||||
'label': series.get('name', f'系列 {idx + 1}'),
|
||||
'data': series.get('data', [])
|
||||
})
|
||||
elif isinstance(series, list):
|
||||
datasets.append({
|
||||
'label': f'系列 {idx + 1}',
|
||||
'data': series
|
||||
})
|
||||
if datasets:
|
||||
return datasets
|
||||
|
||||
return None
|
||||
|
||||
def repair_with_api(
|
||||
self,
|
||||
widget_block: Dict[str, Any],
|
||||
validation_result: ValidationResult
|
||||
) -> RepairResult:
|
||||
"""
|
||||
使用API修复(调用4个Engine的LLM)。
|
||||
|
||||
策略:按顺序尝试不同的Engine,直到修复成功
|
||||
"""
|
||||
if not self.llm_repair_fns:
|
||||
return RepairResult(False, None, 'api', [])
|
||||
|
||||
for idx, repair_fn in enumerate(self.llm_repair_fns):
|
||||
try:
|
||||
logger.info(f"尝试使用Engine {idx + 1}修复图表")
|
||||
repaired = repair_fn(widget_block, validation_result.errors)
|
||||
|
||||
if repaired and isinstance(repaired, dict):
|
||||
# 验证修复结果
|
||||
repaired_validation = self.validator.validate(repaired)
|
||||
if repaired_validation.is_valid:
|
||||
return RepairResult(
|
||||
True,
|
||||
repaired,
|
||||
'api',
|
||||
[f"使用Engine {idx + 1}修复成功"]
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Engine {idx + 1}修复失败: {e}")
|
||||
continue
|
||||
|
||||
return RepairResult(False, None, 'api', [])
|
||||
|
||||
|
||||
def create_chart_validator() -> ChartValidator:
|
||||
"""创建图表验证器实例"""
|
||||
return ChartValidator()
|
||||
|
||||
|
||||
def create_chart_repairer(
|
||||
validator: Optional[ChartValidator] = None,
|
||||
llm_repair_fns: Optional[List[Callable]] = None
|
||||
) -> ChartRepairer:
|
||||
"""创建图表修复器实例"""
|
||||
if validator is None:
|
||||
validator = create_chart_validator()
|
||||
return ChartRepairer(validator, llm_repair_fns)
|
||||
456
ReportEngine/utils/test_chart_validator.py
Normal file
456
ReportEngine/utils/test_chart_validator.py
Normal file
@@ -0,0 +1,456 @@
|
||||
"""
|
||||
图表验证器和修复器的测试用例。
|
||||
|
||||
运行测试:
|
||||
python -m pytest ReportEngine/utils/test_chart_validator.py -v
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from ReportEngine.utils.chart_validator import (
|
||||
ChartValidator,
|
||||
ChartRepairer,
|
||||
ValidationResult,
|
||||
RepairResult,
|
||||
create_chart_validator,
|
||||
create_chart_repairer
|
||||
)
|
||||
|
||||
|
||||
class TestChartValidator:
|
||||
"""测试ChartValidator类"""
|
||||
|
||||
def setup_method(self):
|
||||
"""每个测试前初始化"""
|
||||
self.validator = create_chart_validator()
|
||||
|
||||
def test_valid_bar_chart(self):
|
||||
"""测试有效的柱状图"""
|
||||
widget_block = {
|
||||
"type": "widget",
|
||||
"widgetType": "chart.js/bar",
|
||||
"widgetId": "chart-001",
|
||||
"props": {
|
||||
"type": "bar",
|
||||
"title": "销售数据"
|
||||
},
|
||||
"data": {
|
||||
"labels": ["一月", "二月", "三月"],
|
||||
"datasets": [
|
||||
{
|
||||
"label": "销售额",
|
||||
"data": [100, 200, 150]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
result = self.validator.validate(widget_block)
|
||||
assert result.is_valid
|
||||
assert len(result.errors) == 0
|
||||
|
||||
def test_valid_line_chart(self):
|
||||
"""测试有效的折线图"""
|
||||
widget_block = {
|
||||
"type": "widget",
|
||||
"widgetType": "chart.js/line",
|
||||
"widgetId": "chart-002",
|
||||
"props": {
|
||||
"type": "line"
|
||||
},
|
||||
"data": {
|
||||
"labels": ["周一", "周二", "周三"],
|
||||
"datasets": [
|
||||
{
|
||||
"label": "访问量",
|
||||
"data": [50, 75, 60]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
result = self.validator.validate(widget_block)
|
||||
assert result.is_valid
|
||||
|
||||
def test_valid_pie_chart(self):
|
||||
"""测试有效的饼图"""
|
||||
widget_block = {
|
||||
"widgetType": "chart.js/pie",
|
||||
"props": {"type": "pie"},
|
||||
"data": {
|
||||
"labels": ["A", "B", "C"],
|
||||
"datasets": [
|
||||
{
|
||||
"data": [30, 40, 30]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
result = self.validator.validate(widget_block)
|
||||
assert result.is_valid
|
||||
|
||||
def test_missing_widgetType(self):
|
||||
"""测试缺少widgetType"""
|
||||
widget_block = {
|
||||
"props": {},
|
||||
"data": {}
|
||||
}
|
||||
|
||||
result = self.validator.validate(widget_block)
|
||||
assert not result.is_valid
|
||||
assert "widgetType" in result.errors[0]
|
||||
|
||||
def test_missing_data_field(self):
|
||||
"""测试缺少data字段"""
|
||||
widget_block = {
|
||||
"widgetType": "chart.js/bar",
|
||||
"props": {"type": "bar"}
|
||||
}
|
||||
|
||||
result = self.validator.validate(widget_block)
|
||||
assert not result.is_valid
|
||||
assert "data" in result.errors[0]
|
||||
|
||||
def test_missing_datasets(self):
|
||||
"""测试缺少datasets"""
|
||||
widget_block = {
|
||||
"widgetType": "chart.js/bar",
|
||||
"props": {"type": "bar"},
|
||||
"data": {
|
||||
"labels": ["A", "B"]
|
||||
}
|
||||
}
|
||||
|
||||
result = self.validator.validate(widget_block)
|
||||
assert not result.is_valid
|
||||
assert "datasets" in result.errors[0]
|
||||
|
||||
def test_empty_datasets(self):
|
||||
"""测试空datasets"""
|
||||
widget_block = {
|
||||
"widgetType": "chart.js/bar",
|
||||
"props": {"type": "bar"},
|
||||
"data": {
|
||||
"labels": ["A", "B"],
|
||||
"datasets": []
|
||||
}
|
||||
}
|
||||
|
||||
result = self.validator.validate(widget_block)
|
||||
assert not result.is_valid
|
||||
assert "空" in result.errors[0]
|
||||
|
||||
def test_missing_labels_for_bar_chart(self):
|
||||
"""测试柱状图缺少labels"""
|
||||
widget_block = {
|
||||
"widgetType": "chart.js/bar",
|
||||
"props": {"type": "bar"},
|
||||
"data": {
|
||||
"datasets": [
|
||||
{
|
||||
"label": "系列1",
|
||||
"data": [10, 20, 30]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
result = self.validator.validate(widget_block)
|
||||
assert not result.is_valid
|
||||
assert "labels" in result.errors[0]
|
||||
|
||||
def test_invalid_data_type(self):
|
||||
"""测试数据类型错误"""
|
||||
widget_block = {
|
||||
"widgetType": "chart.js/bar",
|
||||
"props": {"type": "bar"},
|
||||
"data": {
|
||||
"labels": ["A", "B"],
|
||||
"datasets": [
|
||||
{
|
||||
"label": "系列1",
|
||||
"data": ["abc", "def"] # 应该是数值
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
result = self.validator.validate(widget_block)
|
||||
assert not result.is_valid
|
||||
assert "数值类型" in result.errors[0]
|
||||
|
||||
def test_data_length_mismatch_warning(self):
|
||||
"""测试数据长度不匹配(警告)"""
|
||||
widget_block = {
|
||||
"widgetType": "chart.js/bar",
|
||||
"props": {"type": "bar"},
|
||||
"data": {
|
||||
"labels": ["A", "B", "C"],
|
||||
"datasets": [
|
||||
{
|
||||
"label": "系列1",
|
||||
"data": [10, 20] # 长度不匹配
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
result = self.validator.validate(widget_block)
|
||||
# 长度不匹配是警告,不是错误
|
||||
assert len(result.warnings) > 0
|
||||
assert "不匹配" in result.warnings[0]
|
||||
|
||||
def test_scatter_chart(self):
|
||||
"""测试散点图(特殊数据格式)"""
|
||||
widget_block = {
|
||||
"widgetType": "chart.js/scatter",
|
||||
"props": {"type": "scatter"},
|
||||
"data": {
|
||||
"datasets": [
|
||||
{
|
||||
"label": "数据点",
|
||||
"data": [
|
||||
{"x": 10, "y": 20},
|
||||
{"x": 15, "y": 25}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
result = self.validator.validate(widget_block)
|
||||
assert result.is_valid
|
||||
|
||||
def test_non_chart_widget(self):
|
||||
"""测试非图表类型的widget(应该跳过验证)"""
|
||||
widget_block = {
|
||||
"widgetType": "custom/widget",
|
||||
"props": {},
|
||||
"data": {}
|
||||
}
|
||||
|
||||
result = self.validator.validate(widget_block)
|
||||
# 非chart.js类型,跳过验证,返回valid
|
||||
assert result.is_valid
|
||||
|
||||
|
||||
class TestChartRepairer:
|
||||
"""测试ChartRepairer类"""
|
||||
|
||||
def setup_method(self):
|
||||
"""每个测试前初始化"""
|
||||
self.validator = create_chart_validator()
|
||||
self.repairer = create_chart_repairer(validator=self.validator)
|
||||
|
||||
def test_repair_missing_props(self):
|
||||
"""测试修复缺少props字段"""
|
||||
widget_block = {
|
||||
"widgetType": "chart.js/bar",
|
||||
"data": {
|
||||
"labels": ["A", "B"],
|
||||
"datasets": [
|
||||
{
|
||||
"label": "系列1",
|
||||
"data": [10, 20]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
result = self.repairer.repair(widget_block)
|
||||
assert result.success
|
||||
assert "props" in result.repaired_block
|
||||
assert result.method == "local"
|
||||
|
||||
def test_repair_missing_chart_type(self):
|
||||
"""测试修复缺少图表类型"""
|
||||
widget_block = {
|
||||
"widgetType": "chart.js/bar",
|
||||
"props": {},
|
||||
"data": {
|
||||
"labels": ["A", "B"],
|
||||
"datasets": [
|
||||
{
|
||||
"label": "系列1",
|
||||
"data": [10, 20]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
result = self.repairer.repair(widget_block)
|
||||
assert result.success
|
||||
assert result.repaired_block["props"]["type"] == "bar"
|
||||
assert "图表类型" in str(result.changes)
|
||||
|
||||
def test_repair_missing_datasets(self):
|
||||
"""测试修复缺少datasets"""
|
||||
widget_block = {
|
||||
"widgetType": "chart.js/bar",
|
||||
"props": {"type": "bar"},
|
||||
"data": {
|
||||
"labels": ["A", "B"]
|
||||
}
|
||||
}
|
||||
|
||||
result = self.repairer.repair(widget_block)
|
||||
assert result.success
|
||||
assert "datasets" in result.repaired_block["data"]
|
||||
assert isinstance(result.repaired_block["data"]["datasets"], list)
|
||||
|
||||
def test_repair_missing_labels(self):
|
||||
"""测试修复缺少labels"""
|
||||
widget_block = {
|
||||
"widgetType": "chart.js/bar",
|
||||
"props": {"type": "bar"},
|
||||
"data": {
|
||||
"datasets": [
|
||||
{
|
||||
"label": "系列1",
|
||||
"data": [10, 20, 30]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
result = self.repairer.repair(widget_block)
|
||||
assert result.success
|
||||
assert "labels" in result.repaired_block["data"]
|
||||
assert len(result.repaired_block["data"]["labels"]) == 3
|
||||
|
||||
def test_repair_data_length_mismatch(self):
|
||||
"""测试修复数据长度不匹配"""
|
||||
widget_block = {
|
||||
"widgetType": "chart.js/bar",
|
||||
"props": {"type": "bar"},
|
||||
"data": {
|
||||
"labels": ["A", "B", "C", "D"],
|
||||
"datasets": [
|
||||
{
|
||||
"label": "系列1",
|
||||
"data": [10, 20] # 长度不足
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
result = self.repairer.repair(widget_block)
|
||||
assert result.success
|
||||
# 应该补充到4个元素
|
||||
assert len(result.repaired_block["data"]["datasets"][0]["data"]) == 4
|
||||
|
||||
def test_repair_string_to_number(self):
|
||||
"""测试修复字符串类型的数值"""
|
||||
widget_block = {
|
||||
"widgetType": "chart.js/bar",
|
||||
"props": {"type": "bar"},
|
||||
"data": {
|
||||
"labels": ["A", "B"],
|
||||
"datasets": [
|
||||
{
|
||||
"label": "系列1",
|
||||
"data": ["10", "20"] # 字符串数值
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
result = self.repairer.repair(widget_block)
|
||||
assert result.success
|
||||
# 应该转换为数值
|
||||
assert isinstance(result.repaired_block["data"]["datasets"][0]["data"][0], float)
|
||||
|
||||
def test_repair_construct_datasets_from_values(self):
|
||||
"""测试从values字段构造datasets"""
|
||||
widget_block = {
|
||||
"widgetType": "chart.js/bar",
|
||||
"props": {"type": "bar"},
|
||||
"data": {
|
||||
"labels": ["A", "B"],
|
||||
"values": [10, 20] # 使用values而不是datasets
|
||||
}
|
||||
}
|
||||
|
||||
result = self.repairer.repair(widget_block)
|
||||
assert result.success
|
||||
assert "datasets" in result.repaired_block["data"]
|
||||
assert len(result.repaired_block["data"]["datasets"]) > 0
|
||||
|
||||
def test_no_repair_needed(self):
|
||||
"""测试不需要修复的情况"""
|
||||
widget_block = {
|
||||
"widgetType": "chart.js/bar",
|
||||
"props": {"type": "bar"},
|
||||
"data": {
|
||||
"labels": ["A", "B"],
|
||||
"datasets": [
|
||||
{
|
||||
"label": "系列1",
|
||||
"data": [10, 20]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
result = self.repairer.repair(widget_block)
|
||||
assert result.success
|
||||
assert result.method == "none"
|
||||
assert len(result.changes) == 0
|
||||
|
||||
def test_repair_adds_default_label(self):
|
||||
"""测试修复添加默认label"""
|
||||
widget_block = {
|
||||
"widgetType": "chart.js/bar",
|
||||
"props": {"type": "bar"},
|
||||
"data": {
|
||||
"labels": ["A", "B"],
|
||||
"datasets": [
|
||||
{
|
||||
# 缺少label
|
||||
"data": [10, 20]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
result = self.repairer.repair(widget_block)
|
||||
assert result.success
|
||||
assert "label" in result.repaired_block["data"]["datasets"][0]
|
||||
|
||||
|
||||
class TestValidatorIntegration:
|
||||
"""集成测试"""
|
||||
|
||||
def test_full_validation_and_repair_workflow(self):
|
||||
"""测试完整的验证和修复流程"""
|
||||
validator = create_chart_validator()
|
||||
repairer = create_chart_repairer(validator=validator)
|
||||
|
||||
# 一个有多个问题的图表
|
||||
widget_block = {
|
||||
"widgetType": "chart.js/bar",
|
||||
"data": {
|
||||
"datasets": [
|
||||
{
|
||||
"data": ["10", "20", "30"] # 字符串数值
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
# 1. 验证(应该失败)
|
||||
validation = validator.validate(widget_block)
|
||||
assert not validation.is_valid
|
||||
|
||||
# 2. 修复
|
||||
repair_result = repairer.repair(widget_block, validation)
|
||||
assert repair_result.success
|
||||
|
||||
# 3. 再次验证(应该通过)
|
||||
final_validation = validator.validate(repair_result.repaired_block)
|
||||
assert final_validation.is_valid
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 运行测试
|
||||
pytest.main([__file__, "-v", "--tb=short"])
|
||||
Reference in New Issue
Block a user