Showing
3 changed files
with
200 additions
and
12 deletions
| @@ -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, |
-
Please register or login to post a comment