马一丁

Fixed the Issue of Charts being Repeatedly Repaired

@@ -90,6 +90,9 @@ class HTMLRenderer: @@ -90,6 +90,9 @@ class HTMLRenderer:
90 validator=self.chart_validator, 90 validator=self.chart_validator,
91 llm_repair_fns=llm_repair_fns 91 llm_repair_fns=llm_repair_fns
92 ) 92 )
  93 + # 记录修复失败的图表,避免多次触发LLM循环修复
  94 + self._chart_failure_notes: Dict[str, str] = {}
  95 + self._chart_failure_recorded: set[str] = set()
93 96
94 # 统计信息 97 # 统计信息
95 self.chart_validation_stats = { 98 self.chart_validation_stats = {
@@ -260,6 +263,8 @@ class HTMLRenderer: @@ -260,6 +263,8 @@ class HTMLRenderer:
260 'repaired_api': 0, 263 'repaired_api': 0,
261 'failed': 0 264 'failed': 0
262 } 265 }
  266 + # 每次渲染重新统计失败计数,但保留失败原因,避免重复LLM调用
  267 + self._chart_failure_recorded = set()
263 268
264 metadata = self.metadata 269 metadata = self.metadata
265 theme_tokens = metadata.get("themeTokens") or self.document.get("themeTokens", {}) 270 theme_tokens = metadata.get("themeTokens") or self.document.get("themeTokens", {})
@@ -1441,6 +1446,82 @@ class HTMLRenderer: @@ -1441,6 +1446,82 @@ class HTMLRenderer:
1441 1446
1442 return True 1447 return True
1443 1448
  1449 + def _chart_cache_key(self, block: Dict[str, Any]) -> str:
  1450 + """使用修复器的缓存算法生成稳定的key,便于跨阶段共享结果"""
  1451 + if hasattr(self, "chart_repairer") and block:
  1452 + try:
  1453 + return self.chart_repairer.build_cache_key(block)
  1454 + except Exception:
  1455 + pass
  1456 + return str(id(block))
  1457 +
  1458 + def _note_chart_failure(self, cache_key: str, reason: str) -> None:
  1459 + """记录修复失败原因,后续渲染直接使用占位提示"""
  1460 + if not cache_key:
  1461 + return
  1462 + if not reason:
  1463 + reason = "LLM返回的图表信息格式有误,无法正常显示"
  1464 + self._chart_failure_notes[cache_key] = reason
  1465 +
  1466 + def _record_chart_failure_stat(self, cache_key: str | None = None) -> None:
  1467 + """确保失败计数只统计一次"""
  1468 + if cache_key and cache_key in self._chart_failure_recorded:
  1469 + return
  1470 + self.chart_validation_stats['failed'] += 1
  1471 + if cache_key:
  1472 + self._chart_failure_recorded.add(cache_key)
  1473 +
  1474 + def _format_chart_error_reason(
  1475 + self,
  1476 + validation_result: ValidationResult | None = None,
  1477 + fallback_reason: str | None = None
  1478 + ) -> str:
  1479 + """拼接友好的失败提示"""
  1480 + base = "LLM返回的图表信息格式有误,已尝试本地与多模型修复但仍无法正常显示。"
  1481 + detail = None
  1482 + if validation_result:
  1483 + if validation_result.errors:
  1484 + detail = validation_result.errors[0]
  1485 + elif validation_result.warnings:
  1486 + detail = validation_result.warnings[0]
  1487 + if not detail and fallback_reason:
  1488 + detail = fallback_reason
  1489 + if detail:
  1490 + text = f"{base} 提示:{detail}"
  1491 + return text[:180] + ("..." if len(text) > 180 else "")
  1492 + return base
  1493 +
  1494 + def _render_chart_error_placeholder(
  1495 + self,
  1496 + title: str | None,
  1497 + reason: str,
  1498 + widget_id: str | None = None
  1499 + ) -> str:
  1500 + """输出图表失败时的简洁占位提示,避免破坏HTML/PDF布局"""
  1501 + safe_title = self._escape_html(title or "图表未能展示")
  1502 + safe_reason = self._escape_html(reason)
  1503 + widget_attr = f' data-widget-id="{self._escape_attr(widget_id)}"' if widget_id else ""
  1504 + return f"""
  1505 + <div class="chart-card chart-card--error"{widget_attr}>
  1506 + <div class="chart-error">
  1507 + <div class="chart-error__icon">!</div>
  1508 + <div class="chart-error__body">
  1509 + <div class="chart-error__title">{safe_title}</div>
  1510 + <p class="chart-error__desc">{safe_reason}</p>
  1511 + </div>
  1512 + </div>
  1513 + </div>
  1514 + """
  1515 +
  1516 + def _has_chart_failure(self, block: Dict[str, Any]) -> tuple[bool, str | None]:
  1517 + """检查是否已有修复失败记录"""
  1518 + cache_key = self._chart_cache_key(block)
  1519 + if block.get("_chart_renderable") is False:
  1520 + return True, block.get("_chart_error_reason")
  1521 + if cache_key in self._chart_failure_notes:
  1522 + return True, self._chart_failure_notes.get(cache_key)
  1523 + return False, None
  1524 +
1444 def _normalize_chart_block( 1525 def _normalize_chart_block(
1445 self, 1526 self,
1446 block: Dict[str, Any], 1527 block: Dict[str, Any],
@@ -1522,7 +1603,7 @@ class HTMLRenderer: @@ -1522,7 +1603,7 @@ class HTMLRenderer:
1522 1. 验证图表数据格式 1603 1. 验证图表数据格式
1523 2. 如果无效,尝试本地修复 1604 2. 如果无效,尝试本地修复
1524 3. 如果本地修复失败,尝试API修复 1605 3. 如果本地修复失败,尝试API修复
1525 - 4. 如果所有修复都失败,使用原始数据(前端会降级处理) 1606 + 4. 如果所有修复都失败,输出提示占位并跳过再次修复
1526 1607
1527 参数: 1608 参数:
1528 block: widget类型的block,包含widgetId/props/data。 1609 block: widget类型的block,包含widgetId/props/data。
@@ -1537,10 +1618,21 @@ class HTMLRenderer: @@ -1537,10 +1618,21 @@ class HTMLRenderer:
1537 widget_type = block.get('widgetType', '') 1618 widget_type = block.get('widgetType', '')
1538 is_chart = isinstance(widget_type, str) and widget_type.startswith('chart.js') 1619 is_chart = isinstance(widget_type, str) and widget_type.startswith('chart.js')
1539 is_wordcloud = isinstance(widget_type, str) and 'wordcloud' in widget_type.lower() 1620 is_wordcloud = isinstance(widget_type, str) and 'wordcloud' in widget_type.lower()
  1621 + widget_id = block.get('widgetId')
  1622 + cache_key = self._chart_cache_key(block) if is_chart else ""
  1623 + props_snapshot = block.get("props") if isinstance(block.get("props"), dict) else {}
  1624 + display_title = props_snapshot.get("title") or block.get("title") or widget_id or "图表"
1540 1625
1541 if is_chart: 1626 if is_chart:
1542 self.chart_validation_stats['total'] += 1 1627 self.chart_validation_stats['total'] += 1
1543 1628
  1629 + # 如果此前已记录失败,直接使用占位提示,避免重复修复
  1630 + has_failed, cached_reason = self._has_chart_failure(block)
  1631 + if has_failed:
  1632 + self._record_chart_failure_stat(cache_key)
  1633 + reason = cached_reason or "LLM返回的图表信息格式有误,无法正常显示"
  1634 + return self._render_chart_error_placeholder(display_title, reason, widget_id)
  1635 +
1544 # 验证图表数据 1636 # 验证图表数据
1545 validation_result = self.chart_validator.validate(block) 1637 validation_result = self.chart_validator.validate(block)
1546 1638
@@ -1566,12 +1658,16 @@ class HTMLRenderer: @@ -1566,12 +1658,16 @@ class HTMLRenderer:
1566 elif repair_result.method == 'api': 1658 elif repair_result.method == 'api':
1567 self.chart_validation_stats['repaired_api'] += 1 1659 self.chart_validation_stats['repaired_api'] += 1
1568 else: 1660 else:
1569 - # 修复失败,使用原始数据,前端会尝试降级渲染 1661 + # 修复失败,记录失败并输出占位提示
  1662 + fail_reason = self._format_chart_error_reason(validation_result)
  1663 + block["_chart_renderable"] = False
  1664 + block["_chart_error_reason"] = fail_reason
  1665 + self._note_chart_failure(cache_key, fail_reason)
  1666 + self._record_chart_failure_stat(cache_key)
1570 logger.warning( 1667 logger.warning(
1571 - f"图表 {block.get('widgetId', 'unknown')} 修复失败,"  
1572 - f"将使用原始数据(前端会尝试降级渲染或显示fallback)" 1668 + f"图表 {block.get('widgetId', 'unknown')} 修复失败,已跳过渲染: {fail_reason}"
1573 ) 1669 )
1574 - self.chart_validation_stats['failed'] += 1 1670 + return self._render_chart_error_placeholder(display_title, fail_reason, widget_id)
1575 else: 1671 else:
1576 # 验证通过 1672 # 验证通过
1577 self.chart_validation_stats['valid'] += 1 1673 self.chart_validation_stats['valid'] += 1
@@ -1725,7 +1821,7 @@ class HTMLRenderer: @@ -1725,7 +1821,7 @@ class HTMLRenderer:
1725 logger.warning( 1821 logger.warning(
1726 f" ✗ 修复失败: {stats['failed']} " 1822 f" ✗ 修复失败: {stats['failed']} "
1727 f"({stats['failed']/stats['total']*100:.1f}%) - " 1823 f"({stats['failed']/stats['total']*100:.1f}%) - "
1728 - f"这些图表将使用降级渲染或显示fallback表格" 1824 + f"这些图表将展示简洁占位提示"
1729 ) 1825 )
1730 1826
1731 logger.info("=" * 60) 1827 logger.info("=" * 60)
@@ -2558,6 +2654,41 @@ table th {{ @@ -2558,6 +2654,41 @@ table th {{
2558 border-radius: 12px; 2654 border-radius: 12px;
2559 background: rgba(0,0,0,0.01); 2655 background: rgba(0,0,0,0.01);
2560 }} 2656 }}
  2657 +.chart-card.chart-card--error {{
  2658 + border-style: dashed;
  2659 + background: linear-gradient(135deg, rgba(0,0,0,0.015), rgba(0,0,0,0.04));
  2660 +}}
  2661 +.chart-error {{
  2662 + display: flex;
  2663 + gap: 12px;
  2664 + padding: 14px 12px;
  2665 + border-radius: 10px;
  2666 + align-items: flex-start;
  2667 + background: rgba(0,0,0,0.03);
  2668 + color: var(--secondary-color);
  2669 +}}
  2670 +.chart-error__icon {{
  2671 + width: 28px;
  2672 + height: 28px;
  2673 + flex-shrink: 0;
  2674 + border-radius: 50%;
  2675 + display: inline-flex;
  2676 + align-items: center;
  2677 + justify-content: center;
  2678 + font-weight: 700;
  2679 + color: var(--secondary-color-dark);
  2680 + background: rgba(0,0,0,0.06);
  2681 + font-size: 0.9rem;
  2682 +}}
  2683 +.chart-error__title {{
  2684 + font-weight: 600;
  2685 + color: var(--text-color);
  2686 +}}
  2687 +.chart-error__desc {{
  2688 + margin: 4px 0 0;
  2689 + color: var(--secondary-color);
  2690 + line-height: 1.6;
  2691 +}}
2561 .chart-card.wordcloud-card .chart-container {{ 2692 .chart-card.wordcloud-card .chart-container {{
2562 min-height: 260px; 2693 min-height: 260px;
2563 }} 2694 }}
@@ -211,8 +211,15 @@ class PDFRenderer: @@ -211,8 +211,15 @@ class PDFRenderer:
211 ) 211 )
212 else: 212 else:
213 repair_stats['failed'] += 1 213 repair_stats['failed'] += 1
  214 + reason = self.html_renderer._format_chart_error_reason(validation)
  215 + block["_chart_renderable"] = False
  216 + block["_chart_error_reason"] = reason
  217 + self.html_renderer._note_chart_failure(
  218 + self.html_renderer._chart_cache_key(block),
  219 + reason
  220 + )
214 logger.warning( 221 logger.warning(
215 - f"图表 {block.get('widgetId')} 修复失败,将使用原始数据" 222 + f"图表 {block.get('widgetId')} 修复失败,将使用占位提示: {reason}"
216 ) 223 )
217 224
218 # 递归处理嵌套的blocks 225 # 递归处理嵌套的blocks
@@ -324,6 +331,13 @@ class PDFRenderer: @@ -324,6 +331,13 @@ class PDFRenderer:
324 331
325 # 只处理chart.js类型的widget 332 # 只处理chart.js类型的widget
326 if widget_id and widget_type.startswith('chart.js'): 333 if widget_id and widget_type.startswith('chart.js'):
  334 + failed, fail_reason = self.html_renderer._has_chart_failure(block)
  335 + if block.get("_chart_renderable") is False or failed:
  336 + logger.debug(
  337 + f"跳过转换失败的图表 {widget_id}"
  338 + f"{f',原因: {fail_reason}' if fail_reason else ''}"
  339 + )
  340 + continue
327 try: 341 try:
328 svg_content = self.chart_converter.convert_widget_to_svg( 342 svg_content = self.chart_converter.convert_widget_to_svg(
329 block, 343 block,
@@ -21,6 +21,7 @@ from __future__ import annotations @@ -21,6 +21,7 @@ from __future__ import annotations
21 21
22 import copy 22 import copy
23 import json 23 import json
  24 +import hashlib
24 from typing import Any, Dict, List, Optional, Tuple, Callable 25 from typing import Any, Dict, List, Optional, Tuple, Callable
25 from dataclasses import dataclass 26 from dataclasses import dataclass
26 from loguru import logger 27 from loguru import logger
@@ -383,6 +384,30 @@ class ChartRepairer: @@ -383,6 +384,30 @@ class ChartRepairer:
383 """ 384 """
384 self.validator = validator 385 self.validator = validator
385 self.llm_repair_fns = llm_repair_fns or [] 386 self.llm_repair_fns = llm_repair_fns or []
  387 + # 缓存修复结果,避免同一个图表在多处被重复调用LLM
  388 + self._result_cache: Dict[str, RepairResult] = {}
  389 +
  390 + def build_cache_key(self, widget_block: Dict[str, Any]) -> str:
  391 + """
  392 + 为图表生成稳定的缓存key,保证同样的数据不会重复触发修复。
  393 +
  394 + - 优先使用widgetId;
  395 + - 结合数据内容的哈希,避免同ID但内容变化时误用旧结果。
  396 + """
  397 + widget_id = ""
  398 + if isinstance(widget_block, dict):
  399 + widget_id = widget_block.get('widgetId') or widget_block.get('id') or ""
  400 + try:
  401 + serialized = json.dumps(
  402 + widget_block,
  403 + ensure_ascii=False,
  404 + sort_keys=True,
  405 + default=str
  406 + )
  407 + except Exception:
  408 + serialized = repr(widget_block)
  409 + digest = hashlib.md5(serialized.encode('utf-8', errors='ignore')).hexdigest()
  410 + return f"{widget_id}:{digest}"
386 411
387 def repair( 412 def repair(
388 self, 413 self,
@@ -399,6 +424,20 @@ class ChartRepairer: @@ -399,6 +424,20 @@ class ChartRepairer:
399 Returns: 424 Returns:
400 RepairResult: 修复结果 425 RepairResult: 修复结果
401 """ 426 """
  427 + cache_key = self.build_cache_key(widget_block)
  428 +
  429 + cached = self._result_cache.get(cache_key)
  430 + if cached:
  431 + # 返回缓存的深拷贝,避免外部修改影响缓存
  432 + return copy.deepcopy(cached)
  433 +
  434 + def _cache_and_return(res: RepairResult) -> RepairResult:
  435 + try:
  436 + self._result_cache[cache_key] = copy.deepcopy(res)
  437 + except Exception:
  438 + self._result_cache[cache_key] = res
  439 + return res
  440 +
402 # 1. 如果没有验证结果,先验证 441 # 1. 如果没有验证结果,先验证
403 if validation_result is None: 442 if validation_result is None:
404 validation_result = self.validator.validate(widget_block) 443 validation_result = self.validator.validate(widget_block)
@@ -412,7 +451,9 @@ class ChartRepairer: @@ -412,7 +451,9 @@ class ChartRepairer:
412 repaired_validation = self.validator.validate(local_result.repaired_block) 451 repaired_validation = self.validator.validate(local_result.repaired_block)
413 if repaired_validation.is_valid: 452 if repaired_validation.is_valid:
414 logger.info(f"本地修复成功: {local_result.changes}") 453 logger.info(f"本地修复成功: {local_result.changes}")
415 - return RepairResult(True, local_result.repaired_block, 'local', local_result.changes) 454 + return _cache_and_return(
  455 + RepairResult(True, local_result.repaired_block, 'local', local_result.changes)
  456 + )
416 else: 457 else:
417 logger.warning(f"本地修复后仍然无效: {repaired_validation.errors}") 458 logger.warning(f"本地修复后仍然无效: {repaired_validation.errors}")
418 459
@@ -426,20 +467,22 @@ class ChartRepairer: @@ -426,20 +467,22 @@ class ChartRepairer:
426 repaired_validation = self.validator.validate(api_result.repaired_block) 467 repaired_validation = self.validator.validate(api_result.repaired_block)
427 if repaired_validation.is_valid: 468 if repaired_validation.is_valid:
428 logger.info(f"API修复成功: {api_result.changes}") 469 logger.info(f"API修复成功: {api_result.changes}")
429 - return api_result 470 + return _cache_and_return(api_result)
430 else: 471 else:
431 logger.warning(f"API修复后仍然无效: {repaired_validation.errors}") 472 logger.warning(f"API修复后仍然无效: {repaired_validation.errors}")
432 473
433 # 5. 如果验证通过,返回原始或修复后的数据 474 # 5. 如果验证通过,返回原始或修复后的数据
434 if validation_result.is_valid: 475 if validation_result.is_valid:
435 if local_result.has_changes(): 476 if local_result.has_changes():
436 - return RepairResult(True, local_result.repaired_block, 'local', local_result.changes) 477 + return _cache_and_return(
  478 + RepairResult(True, local_result.repaired_block, 'local', local_result.changes)
  479 + )
437 else: 480 else:
438 - return RepairResult(True, widget_block, 'none', []) 481 + return _cache_and_return(RepairResult(True, widget_block, 'none', []))
439 482
440 # 6. 所有修复都失败,返回原始数据 483 # 6. 所有修复都失败,返回原始数据
441 logger.warning("所有修复尝试失败,保持原始数据") 484 logger.warning("所有修复尝试失败,保持原始数据")
442 - return RepairResult(False, widget_block, 'none', []) 485 + return _cache_and_return(RepairResult(False, widget_block, 'none', []))
443 486
444 def repair_locally( 487 def repair_locally(
445 self, 488 self,