马一丁

Incomplete chart normalization in "ChartReviewService" causes missing critical fields

@@ -297,30 +297,58 @@ class ChartReviewService: @@ -297,30 +297,58 @@ class ChartReviewService:
297 chapter_context: Dict[str, Any] | None = None 297 chapter_context: Dict[str, Any] | None = None
298 ) -> None: 298 ) -> None:
299 """ 299 """
300 - 规范化图表数据,从章节上下文补充缺失数据。 300 + 规范化图表数据,补全缺失字段(如props、scales、datasets),提升容错性。
  301 +
  302 + 与 HTMLRenderer._normalize_chart_block() 保持一致:
  303 + - 确保 props 存在
  304 + - 将顶层 scales 合并进 props.options
  305 + - 确保 data 存在
  306 + - 尝试使用章节级 data 作为兜底
  307 + - 自动生成 labels
301 """ 308 """
302 if not isinstance(block, dict): 309 if not isinstance(block, dict):
303 return 310 return
304 311
  312 + if block.get("type") != "widget":
  313 + return
  314 +
  315 + widget_type = block.get("widgetType", "")
  316 + if not (isinstance(widget_type, str) and widget_type.startswith("chart.js")):
  317 + return
  318 +
  319 + # 确保 props 存在
  320 + props = block.get("props")
  321 + if not isinstance(props, dict):
  322 + block["props"] = {}
  323 + props = block["props"]
  324 +
  325 + # 将顶层 scales 合并进 options,避免配置丢失
  326 + scales = block.get("scales")
  327 + if isinstance(scales, dict):
  328 + options = props.get("options") if isinstance(props.get("options"), dict) else {}
  329 + props["options"] = self._merge_dicts(options, {"scales": scales})
  330 +
  331 + # 确保 data 存在
305 data = block.get("data") 332 data = block.get("data")
306 if not isinstance(data, dict): 333 if not isinstance(data, dict):
307 - return 334 + data = {}
  335 + block["data"] = data
308 336
309 - # 尝试从章节上下文补充 datasets  
310 - datasets = data.get("datasets")  
311 - if not datasets or (isinstance(datasets, list) and len(datasets) == 0):  
312 - if isinstance(chapter_context, dict):  
313 - chapter_data = chapter_context.get("data")  
314 - if isinstance(chapter_data, dict):  
315 - fallback_ds = chapter_data.get("datasets")  
316 - if isinstance(fallback_ds, list) and len(fallback_ds) > 0:  
317 - merged_data = copy.deepcopy(data)  
318 - merged_data["datasets"] = copy.deepcopy(fallback_ds)  
319 - if not merged_data.get("labels") and isinstance(chapter_data.get("labels"), list):  
320 - merged_data["labels"] = copy.deepcopy(chapter_data["labels"])  
321 - block["data"] = merged_data  
322 -  
323 - # 如果缺少 labels 且数据点包含 x 值,自动生成 337 + # 如果 datasets 为空,尝试使用章节级 data 填充
  338 + if chapter_context and self._is_chart_data_empty(data):
  339 + chapter_data = chapter_context.get("data") if isinstance(chapter_context, dict) else None
  340 + if isinstance(chapter_data, dict):
  341 + fallback_ds = chapter_data.get("datasets")
  342 + if isinstance(fallback_ds, list) and len(fallback_ds) > 0:
  343 + merged_data = copy.deepcopy(data)
  344 + merged_data["datasets"] = copy.deepcopy(fallback_ds)
  345 +
  346 + if not merged_data.get("labels") and isinstance(chapter_data.get("labels"), list):
  347 + merged_data["labels"] = copy.deepcopy(chapter_data["labels"])
  348 +
  349 + block["data"] = merged_data
  350 +
  351 + # 若仍缺少 labels 且数据点包含 x 值,自动生成便于 fallback 和坐标刻度
324 data_ref = block.get("data") 352 data_ref = block.get("data")
325 if isinstance(data_ref, dict) and not data_ref.get("labels"): 353 if isinstance(data_ref, dict) and not data_ref.get("labels"):
326 datasets_ref = data_ref.get("datasets") 354 datasets_ref = data_ref.get("datasets")
@@ -335,9 +363,46 @@ class ChartReviewService: @@ -335,9 +363,46 @@ class ChartReviewService:
335 else: 363 else:
336 label_text = f"点{idx + 1}" 364 label_text = f"点{idx + 1}"
337 labels_from_data.append(str(label_text)) 365 labels_from_data.append(str(label_text))
  366 +
338 if labels_from_data: 367 if labels_from_data:
339 data_ref["labels"] = labels_from_data 368 data_ref["labels"] = labels_from_data
340 369
  370 + @staticmethod
  371 + def _is_chart_data_empty(data: Dict[str, Any] | None) -> bool:
  372 + """检查图表数据是否为空或缺少有效 datasets"""
  373 + if not isinstance(data, dict):
  374 + return True
  375 +
  376 + datasets = data.get("datasets")
  377 + if not isinstance(datasets, list) or len(datasets) == 0:
  378 + return True
  379 +
  380 + for ds in datasets:
  381 + if not isinstance(ds, dict):
  382 + continue
  383 + series = ds.get("data")
  384 + if isinstance(series, list) and len(series) > 0:
  385 + return False
  386 +
  387 + return True
  388 +
  389 + @staticmethod
  390 + def _merge_dicts(
  391 + base: Dict[str, Any] | None, override: Dict[str, Any] | None
  392 + ) -> Dict[str, Any]:
  393 + """
  394 + 递归合并两个字典,override 覆盖 base,均为新副本,避免副作用。
  395 + """
  396 + result = copy.deepcopy(base) if isinstance(base, dict) else {}
  397 + if not isinstance(override, dict):
  398 + return result
  399 + for key, value in override.items():
  400 + if isinstance(value, dict) and isinstance(result.get(key), dict):
  401 + result[key] = ChartReviewService._merge_dicts(result[key], value)
  402 + else:
  403 + result[key] = copy.deepcopy(value)
  404 + return result
  405 +
341 def _format_error_reason(self, validation_result: ValidationResult | None) -> str: 406 def _format_error_reason(self, validation_result: ValidationResult | None) -> str:
342 """格式化错误原因""" 407 """格式化错误原因"""
343 if not validation_result: 408 if not validation_result: