From 57e37019af61cf8873755d0a8545abe10dd7a047 Mon Sep 17 00:00:00 2001 From: shield <1637502839@qq.com> Date: Mon, 27 Jul 2026 15:18:41 +0800 Subject: [PATCH 01/13] fix: improve glm mermaid chart generation reliability --- .../prompts/chart_compliance_validate.md | 91 +- .../prompts/sub_report_brief_markdown.md | 4 + .../algorithm/prompts/sub_report_markdown.md | 5 + .../sub_section_visualization_content.md | 126 +- .../algorithm/report/report.py | 1627 ++++++++++++++++- .../algorithm/report/report_utils.py | 7 +- .../report_export/test_mermaid_renderer.py | 6 +- tests/report/test_sub_report.py | 1072 ++++++++++- tests/report/test_tools_in_report.py | 59 + 9 files changed, 2867 insertions(+), 130 deletions(-) diff --git a/openjiuwen_deepsearch/algorithm/prompts/chart_compliance_validate.md b/openjiuwen_deepsearch/algorithm/prompts/chart_compliance_validate.md index f960e6fd..388fd056 100644 --- a/openjiuwen_deepsearch/algorithm/prompts/chart_compliance_validate.md +++ b/openjiuwen_deepsearch/algorithm/prompts/chart_compliance_validate.md @@ -1,66 +1,63 @@ # Role -You are a professional visualization data compliance validator. Perform a **comprehensive check** to verify two independent requirements simultaneously: 1) the chart’s data has **semantic relevance** to the chapter outline (only absolutely no relevance = invalid); 2) the chart data meets the core specifications of its chart type (including single dimension/metric check). Output only a fixed validation JSON with no extra text, formatting or comments. +You are a professional visualization data compliance validator. Check two independent requirements at the same time: 1) whether the chart data has semantic relevance to the chapter outline; 2) whether the chart data meets the core specification of its chart type. Output only the fixed validation JSON. # Input Specification - Input 1: extracted_chart_json: {{extracted_chart_json}} - The input JSON strictly follows this fixed schema: + The input JSON follows this schema: { - "image_title": "string", // Main basis for relevance judgment - "image_type": "string", // Exact value: bar/line/pie/timeline - "records": [[]] // List of 3-element arrays: [x_or_category, value_string, unit_string] + "image_title": "string", + "image_type": "bar|line|pie|timeline", + "records": [[]] } - Input 2: section_outline: {{section_outline}} - A hierarchical outline of the entire chapter, representing the **topic scope and core logic** of the chapter. + A hierarchical outline of the chapter, representing the topic scope and core logic. # Core Task -Conduct a validation of both rules (outline relevance AND chart type compliance) at the same time, do not terminate validation at the first identified error. -Output a **single combined result** in the fixed JSON schema below. The `error_msg` must be a **concise summary of ALL identified issues** (relevance and/or compliance). +Validate outline relevance and chart type compliance together. Do not stop at the first issue. + +Output exactly: { "valid": true/false, "error_msg": "string" } # Mandatory Validation Rules -## 1. Critical Rule: Chapter Outline Relevance -- **Core Requirement**: The chart’s full data (prioritize `image_title`, supplemented by text in `records` including `x_or_category`, `value_string`, `unit_string`) must have **at least basic semantic relevance** to the `section_outline`. -- **Invalid If**: No semantic overlap, implication, or connection exists between any part of the chart data (title or records text) and any heading/subheading in the `section_outline`. -- **Error Requirement**: If invalid due to absolute irrelevance, clearly summarize the **specific reason** for the lack of connection in `error_msg`. - -## 2. Chart Type Specific Compliance Rules -### 2.1 Bar Chart (Categorical Comparison) -- **Core Rule**: "Single metric + discrete categories" with **identical units (same dimension)**, valid information density and comparative value. -- **Invalid If**: Mixed dimensions/metrics; inconsistent units; X-axis is continuous; trivial comparison (conveyed by a single sentence). - -### 2.2 Line Chart (Trend/Change Analysis) -- **Core Rule**: "Single metric + continuous dimension" with **identical units (same dimension)**, valid information density and trend value. -- **Invalid If**: Mixed dimensions/metrics; inconsistent units; X-axis is not continuous/unequal granularity; trivial trend (conveyed by a single sentence). - -### 2.3 Pie Chart (Parts of a Whole) -- **Core Rule**: "Single metric + whole-part proportion" with **identical units (same dimension)** and valid information density. -- **Invalid If**: Mixed dimensions/metrics; inconsistent units; pure ranking data (no proportion). - -### 2.4 Timeline (Event Milestone) -- **Core Rule**: "Non-pure-numeric event text + empty unit string" (no numeric comparison/composition, no dimension requirement). -- **Invalid If**: `value_string` is a pure numeric string; `unit_string` is non-empty; contains valid numeric comparison/composition data. +## 1. Chapter Outline Relevance +- The chart data must have at least basic semantic relevance to `section_outline`. Judge by `image_title` first, then by text in `records`. +- Invalid only if no semantic overlap, implication, or connection exists between the chart data and any heading/subheading in `section_outline`. +- Do not require full scope coverage. A chart does not need to cover every subheading, every year, or every detail in `section_outline`. Partial but clear semantic relevance is valid. +- If invalid due to absolute irrelevance, summarize the specific reason in `error_msg`. + +## 2. Unit Consistency +- Simple scale variants of the same base unit/dimension are valid. Do not treat them as inconsistent units, because the normalization step will unify them later. +- Examples of valid scale variants: "vehicle" vs "10k vehicles", "yuan" vs "10k yuan", "USD" vs "million USD". +- Mark units invalid only when the base unit/dimension is incompatible, or when records mix different metrics/statistical calibers. + +## 3. Chart Type Rules +### 3.1 Bar Chart +- Core rule: one metric, discrete categories, compatible base units, at least 3 comparable records. +- Invalid if records mix dimensions/metrics, incompatible base units, continuous X-axis values, or fewer than 3 comparable records. + +### 3.2 Line Chart +- Core rule: one metric, continuous/equal-granularity X-axis, compatible base units, at least 3 comparable records. +- Invalid if records mix dimensions/metrics, incompatible base units, non-continuous or unequal-granularity X-axis values, or fewer than 3 comparable records. + +### 3.3 Pie Chart +- Core rule: one whole-part/proportion metric, compatible base units, at least 3 comparable records. +- Invalid if records mix dimensions/metrics, incompatible base units, or represent pure ranking/comparison without proportion semantics. + +### 3.4 Timeline +- Core rule: event/milestone text with an empty unit string. +- Invalid if `value_string` is a pure numeric string, `unit_string` is non-empty, or the records are better represented as numeric comparison/composition data. # Output Constraints -- **Output ONLY**: A valid JSON object with exactly two keys: `valid` (boolean), `error_msg` (string). -- **valid**: `true` if all rules (outline relevance + chart type compliance) are satisfied; `false` if any rule is violated. -- **error_msg**: - - A combined, specific summary of ALL validation issues in English only. Include problematic details (e.g., specific reason for absolute irrelevance, inconsistent units). - - Max Length: ≤ 200 words. - - Valid Case: Empty string (`""`). -- **Format**: Standard JSON only. No extra characters, line breaks, or markdown. +- Output only a valid JSON object with exactly two keys: `valid` (boolean), `error_msg` (string). +- `valid`: true only if relevance and chart type rules are satisfied. +- `error_msg`: English only, max 200 words, concise and specific. Use "" for valid results. +- No markdown, comments, code fences, extra characters, or line breaks. # Output Examples -## Invalid (Combined Issues: Absolute Irrelevance + Inconsistent Units/Dimensions) -{"valid":false,"error_msg":"1. Chart data has no relevance to chapter outline (Chart focuses on '2023 employee training' while outline covers '2024 sales performance' with no overlapping topics); 2. Bar chart has inconsistent units (same dimension violated): '亿元' and '万套'."} - -## Invalid (Only Absolute Irrelevance) -{"valid":false,"error_msg":"Chart data has no relevance to chapter outline (chart is about 'international market expansion' while the outline’s core theme is 'domestic market operations' with no connected topics)."} - -## Invalid (Only Chart Type Issue: Same Dimension Violation) -{"valid":false,"error_msg":"Line chart has mixed dimensions/metrics (same dimension required): both 'revenue' and 'user count' are included with inconsistent units 'million yuan' and 'persons'."} - -## Valid (Any Level of Relevance is Acceptable) -{"valid":true,"error_msg":""} \ No newline at end of file +{"valid":false,"error_msg":"1. Chart data has no relevance to chapter outline (chart focuses on 2023 employee training while outline covers 2024 sales performance); 2. Bar chart mixes incompatible base units/metrics: million yuan and employees."} +{"valid":false,"error_msg":"Chart data has no relevance to chapter outline (chart is about international market expansion while the outline covers domestic market operations)."} +{"valid":false,"error_msg":"Line chart mixes dimensions/metrics: revenue and user count are included with incompatible base units million yuan and persons."} +{"valid":true,"error_msg":""} diff --git a/openjiuwen_deepsearch/algorithm/prompts/sub_report_brief_markdown.md b/openjiuwen_deepsearch/algorithm/prompts/sub_report_brief_markdown.md index 5e44b1c2..9a3f2bc7 100644 --- a/openjiuwen_deepsearch/algorithm/prompts/sub_report_brief_markdown.md +++ b/openjiuwen_deepsearch/algorithm/prompts/sub_report_brief_markdown.md @@ -96,6 +96,10 @@ format_requirements: {{ current_section_format_requirements }} - For optional tables that are not explicitly required by the user, `format_requirements`, or the current chapter outline, prefer at most **1 table** for the whole chapter and skip them when they do not improve clarity. - Required tables are exempt from the one-table preference: if the user, `format_requirements`, or the current chapter outline requires multiple tables, exact columns, or specific row objects, preserve those requirements and keep each table concise. - If a table is used, write one intro sentence above it and exactly one concise plain-text caption below it; keep the caption to the table's subject/scope only. Do not manually number the table or add extra table notes/blockquotes such as "表格说明", "表说明", "Table note", or "Note". +{% if visualization_enable | default(false) %} +- Do NOT output Mermaid code fences, chart code, or hand-written chart blocks in this brief chapter body. +- If the user asks for charts or Mermaid diagrams, satisfy the request with source-backed prose/tables only here; validated Mermaid charts are generated, checked, inserted, and captioned by the visualization pipeline after this draft. +{% endif %} - Avoid long historical background, repeated context, and generic transition language. ## 4) Content Prioritization diff --git a/openjiuwen_deepsearch/algorithm/prompts/sub_report_markdown.md b/openjiuwen_deepsearch/algorithm/prompts/sub_report_markdown.md index 8f0ad25b..3e19284e 100644 --- a/openjiuwen_deepsearch/algorithm/prompts/sub_report_markdown.md +++ b/openjiuwen_deepsearch/algorithm/prompts/sub_report_markdown.md @@ -140,6 +140,11 @@ format_requirements: {{ current_section_format_requirements }} - **Specifics**: When mentioning data, cite the source authority (e.g., "According to data from China Education Online..."). - Every number, date, amount, percentage, ranking, company name, policy name, and table cell must be traceable to the provided Collected Information. - Do not calculate derived metrics, comparisons, trends, or rankings unless the required source values are present and cited. +{% if visualization_enable | default(false) %} +- **Visualization Boundary**: + - Do NOT output Mermaid code fences, chart code, or hand-written chart blocks in this chapter body. + - If the user asks for charts or Mermaid diagrams, satisfy the request with source-backed prose/tables only here; validated Mermaid charts are generated, checked, inserted, and captioned by the visualization pipeline after this draft. +{% endif %} - **Language**: The output language must be **{{language}}**. # Writing Strategy diff --git a/openjiuwen_deepsearch/algorithm/prompts/sub_section_visualization_content.md b/openjiuwen_deepsearch/algorithm/prompts/sub_section_visualization_content.md index 41a385b0..f4caa879 100644 --- a/openjiuwen_deepsearch/algorithm/prompts/sub_section_visualization_content.md +++ b/openjiuwen_deepsearch/algorithm/prompts/sub_section_visualization_content.md @@ -1,65 +1,79 @@ # Role -You are a professional data analyst for chartable data extraction and visualization schema generation, adhering to strict traceability, format specs and single-metric consistency for valid, chart-type-compliant visualizations. +You are a professional data analyst for chartable data extraction and visualization schema generation. Your job is to extract one valid, traceable chart dataset from the provided source text. # Input Specification -- Input: section_outline: {{section_outline}}, origin_content: {{origin_content}}; -- All params are non-empty strings; extractable data is only from `origin_content`; -- `section_outline` defines the **scope of the chapter content** (including chapter title and all subheadings) to ensure extracted data is relevant; -- Output language: {{language}} (If `Output language` is "zh", convert all Traditional Chinese characters to Simplified Chinese). +- Input: section_outline: {{section_outline}}, origin_content: {{origin_content}} +- Optional input: desired_chart_type: {{desired_chart_type}} +- Optional input: avoid_chart_data: {{avoid_chart_data}} +- All extractable data must come only from `origin_content`. +- `section_outline` defines the chapter scope and helps judge relevance. +- If `desired_chart_type` is one of `line`, `bar`, `pie`, or `timeline`, prefer that chart type when it is compatible with the traceable data in `origin_content`. If it is incompatible, choose the best valid chart type instead of fabricating data. +- If `avoid_chart_data` is not empty, it lists chart datasets that have already been generated for this chapter. Extract a different coherent metric, dimension, or record set. Do not re-express the same records with another chart type. Return `{}` if no distinct valid dataset remains. +- Output language: {{language}}. If output language is Chinese, convert Traditional Chinese characters to Simplified Chinese. # Core Task -**Critical Priority**: Extract valid chartable data from `origin_content` and output ONLY a single valid JSON following the fixed global schema (below); return an empty JSON object `{}` if no valid data exists or any mandatory rule/chart type specification is violated. Never return invalid or non-compliant visualization data, and output pure JSON only with no markdown, code fences, extra text, characters or line breaks. +Extract valid chartable data from `origin_content` and output only one JSON object following the fixed schema below. -# Global Output Schema Definition (Mandatory) -Only valid output structure; no extra/missing fields/nested objects/arrays (violation → output {}); +Return `{}` only when no valid chartable dataset exists. If `origin_content` contains 3 or more traceable records for one coherent metric, prefer producing the best valid chart JSON instead of being over-conservative. + +Never fabricate data. Never infer missing records. Never output markdown, code fences, explanations, comments, or extra characters. + +# Output Schema { - "image_title": "non-empty string", // Follow subsequent field constraints; - "image_type": "fixed string", // Only allow specified chart types; - "records": [[]] // Follow 3-element array specs. + "image_title": "non-empty string", + "image_type": "pie|line|timeline|bar", + "records": [ + ["x_or_category", "value_string", "unit_string"] + ] } -# Mandatory Core Rules (Violate Any → Output {}) -## 1. Single-Metric Consistency (Fundamental) -A single visualization must represent one coherent metric with 3 strict conditions (non-timeline only): - 1. Same semantic dimension (no cross-dimension mixing, e.g., performance vs honor); - 2. Identical statistical caliber (same cycle/standard, e.g., all monthly sales); - 3. Exact same unit (no mixed units; timeline uses empty unit string ""). -- Extract only the most prominent dimension from multi-dimension content (`section_outline` emphasis/largest record count); output {} if no dominant dimension; -- Forbid mixing dimensions/metrics/units in one visualization. - -## 2. Records Fixed Specification -- `records` = list of 3-element arrays (fixed order): [x_or_category, value_string, unit_string]; - 1. x_or_category: Non-empty, Maximum 15 characters (Chinese) or 15 words (English), original label; preserve suffixes (year/month/%); shorten slightly if over 15 chars (keep core meaning); - 2. value_string: Non-empty, Maximum 20 characters (Chinese) or 20 words (English), original numeric/text; reserve digits/decimals/commas; no conversion/rescaling/calculation; - 3. unit_string: Maximum 15 characters (Chinese) or 15 words (English), original unit; ONLY timeline = ""; no 或, /, |, ,, ;, and (case-insensitive). -- All content in records must be explicitly traceable to origin_content; x_or_category, value_string, unit_string shall use the original text verbatim (only whitespace trimming, case insensitivity and unambiguous punctuation differences are allowed). No guessing, extrapolation, fabrication or arbitrary modification is permitted. - -## 3. Schema Field Strict Constraints -- image_title: Non-empty, Maximum 50 characters (Chinese) or 50 words (English), punctuation and whitespace are not counted; must clearly describe the chart's core content with core metric + dimension/scope + time/object, concise and consistent with input `section_outline` and data theme; -- image_type: Must be [pie, line, timeline, bar]; no other values/abbreviations; -- records: Follow above specs; keep original extraction order. - -# Chart Type Selection & Compliance Rules -Select the best chart type by content data pattern/semantics (strict priority for ambiguity); preserve original data order (line = sequential order of continuous X-axis). Each type has mandatory compliance rules (violation → output {}). -1. **Line Chart (Trend/Change Analysis)** - - Applicable: Continuous, equal-granularity quantifiable sequences (time, temperature, price, etc.) with the same metric across ≥3 data points; - - Compliance: Forbid single/non-continuous/unequal-granularity X-axis; X-axis must be a continuous quantifiable indicator; no mixed metrics. -2. **Pie Chart (Parts of a Whole)** - - Applicable: "Parts of a whole" data (keywords: 占比/比例/份额/构成/分布/总计/100%); no percentage calculation/fabrication; - - Compliance: Forbid pure ranking/comparison data; identical units for all records. -3. **Bar Chart (Categorical Comparison)** - - Applicable: Pure ranking/comparison of the same metric across different discrete non-continuous categories at the same time point; default for other valid numeric data; - - Compliance: Forbid mixed continuous/discrete X-axis categories; no mixed metrics. -4. **Timeline (Event Milestone)** - - Applicable: Milestones/events/policies with explicit dates/years (no valid numeric comparison/composition data); - - Compliance: records[1] = original event text (may contain numbers, **forbid pure numeric strings**); records[2] = "". - -# Standard Examples (Match All Rules & Schema) -## Line Chart -{"image_title":"Product Defect Rate Trend Analysis at Different Temperatures","image_type":"line","records":[["20°C","1.2","%"],["25°C","1.8","%"],["30°C","2.5","%"]]} -## Pie Chart -{"image_title":"Regional Distribution of Professional League Match Win Rates","image_type":"pie","records":[["North","35","%"],["South","25","%"],["East","20","%"]]} -## Bar Chart -{"image_title":"2024 LCK Season Player Total Kill Count Comparison","image_type":"bar","records":[["Faker","2450","kills"],["Deft","1890","kills"],["Chovy","1760","kills"]]} -## Timeline -{"image_title":"T1 Team LCK Championship Milestone History","image_type":"timeline","records":[["2013","SKT T1 First LCK Title",""],["2015","SKT T1 Second LCK Title",""],["2023","T1 Fourth LCK Title",""]]} \ No newline at end of file +# Mandatory Core Rules +## 1. Single-Metric Consistency +For non-timeline charts, one visualization must represent one coherent metric: +1. Same semantic dimension. +2. Same statistical caliber. +3. Same base unit/dimension. + +Simple scale variants of the same base unit are allowed when copied verbatim from `origin_content`, for example "vehicle" vs "10k vehicles", "yuan" vs "10k yuan", "USD" vs "million USD". The later normalization step will unify scales. + +Do not mix incompatible dimensions, metrics, statistical calibers, or base units. + +If the source text contains multiple metrics, choose the most prominent metric by chapter emphasis and record count. Return `{}` only if no dominant chartable metric exists. + +## 2. Record Format +- `records` must be a list of 3-element arrays in this order: `[x_or_category, value_string, unit_string]`. +- `x_or_category`: non-empty original label. Preserve year/month/% suffixes. Shorten only if clearly too long, while keeping the core meaning. +- `value_string`: non-empty original numeric/text value. Preserve digits, decimals, commas, fractions, and ratios. Do not convert, rescale, or calculate. +- `unit_string`: original unit string. Use `""` only for timeline records. +- Every field must be explicitly traceable to `origin_content`. Only trimming whitespace, case normalization, and unambiguous punctuation cleanup are allowed. + +## 3. Field Constraints +- `image_title`: non-empty, concise, and consistent with the metric, dimension/scope, time/object, and `section_outline`. +- `image_type`: exactly one of `pie`, `line`, `timeline`, `bar`. +- `records`: preserve original extraction order. Non-timeline charts require at least 3 records. + +# Chart Type Selection +1. Line Chart + - Use for continuous, equal-granularity quantitative sequences with the same metric across at least 3 points. + - Examples: yearly trend, monthly trend, price series, temperature sequence. + - Do not use for non-continuous categories or mixed metrics. + +2. Pie Chart + - Use only for explicit whole-part/proportion data. + - Valid clues include share, percentage, proportion, composition, distribution, total, or 100%. + - Do not calculate missing percentages or use pie for pure ranking/comparison data. + +3. Bar Chart + - Use for categorical comparison/ranking of the same metric across discrete categories at the same time point. + - This is the default for valid numeric comparison data that is not a trend or whole-part proportion. + +4. Timeline + - Use for milestones, events, or policies with explicit dates/years when there is no valid numeric comparison/composition data. + - Timeline record format still uses 3 fields: `[time, event_text, ""]`. + - `event_text` must not be a pure numeric string. + +# Standard Examples +{"image_title":"Product Defect Rate Trend by Temperature","image_type":"line","records":[["20C","1.2","%"],["25C","1.8","%"],["30C","2.5","%"]]} +{"image_title":"Regional Match Win Rate Distribution","image_type":"pie","records":[["North","35","%"],["South","25","%"],["East","20","%"]]} +{"image_title":"2024 Player Kill Count Comparison","image_type":"bar","records":[["Faker","2450","kills"],["Deft","1890","kills"],["Chovy","1760","kills"]]} +{"image_title":"Team Championship Milestones","image_type":"timeline","records":[["2013","First league title",""],["2015","Second league title",""],["2023","Fourth league title",""]]} diff --git a/openjiuwen_deepsearch/algorithm/report/report.py b/openjiuwen_deepsearch/algorithm/report/report.py index 4efcfaec..14d2d4ee 100644 --- a/openjiuwen_deepsearch/algorithm/report/report.py +++ b/openjiuwen_deepsearch/algorithm/report/report.py @@ -3,6 +3,7 @@ import asyncio import html from datetime import datetime, timezone +from decimal import Decimal, InvalidOperation from copy import deepcopy import json import logging @@ -76,6 +77,29 @@ def _format_sub_report_error(detail: str | BaseException) -> str: EFFECT_SUB_REPORT_TAG = "### sub_report_tag ###" BATCH_SIZE = 15 MAX_CONCURRENT_BATCHES = 5 +REPORT_CONTENT_VISUALIZATION_MAX_CANDIDATES = 6 +REPORT_CONTENT_VISUALIZATION_MAX_CHARTS_PER_CANDIDATE = 3 +REPORT_CONTENT_VISUALIZATION_MAX_TOTAL_CHARTS = 8 +REPORT_CONTENT_LOCAL_VISUALIZATION_MAX_RECORDS = 12 +REPORT_CONTENT_LOCAL_VISUALIZATION_UNIT_PATTERN = ( + r"%|百分点|个百分点|" + r"(?:百万|千万|十亿|万|千|百|十|亿|兆)?" + r"(?:元|美元|人民币|港元|欧元|日元|英镑|人|户|家|个|件|台|辆|吨|千克|公斤|克|" + r"平方米|平方公里|公里|米|千瓦时|度|瓦|千瓦|兆瓦|吉瓦|次|页|篇|份)|" + r"(?i:(?:million|billion|thousand|mn|bn|k)?\s*" + r"(?:users?|people|customers?|visits?|downloads?|orders?|units?|vehicles?|tons?|" + r"usd|dollars?|rmb|yuan|eur|euros?|gbp|hours?|minutes?|seconds?|pages?|items?))" +) +LOCAL_CHART_LABEL_METRIC_SUFFIX_PATTERN = re.compile( + r"(?:销售额|销量|收入|营收|利润|亏损|规模|产量|产能|装机量|出货量|订单量|用户数|客户数|" + r"访问量|下载量|价格|成本|费用|支出|投资额|融资额|市值|份额|占比|比重|市占率|" + r"增长率|增速|增幅|增长|下降|减少|提升|上升|增加|提高|降低|数量|金额|指数|面积|" + r"人口|排放量|能耗|用电量|发电量|客流量|吞吐量|货运量|周转量|里程|时长|" + r"revenue|sales|profit|loss|users?|customers?|visits?|downloads?|orders?|volume|" + r"output|capacity|price|cost|expenses?|investment|funding|market\s*share|share|" + r"ratio|rate|growth|increase|decrease|decline|index|emissions?|energy\s*use|traffic).*$", + re.IGNORECASE, +) LEADING_TITLE_NUMBER_PATTERN = re.compile( r"^(?:" r"[\(][一二三四五六七八九十\d]{1,2}[\)]\s*|" @@ -96,6 +120,15 @@ def _format_sub_report_error(detail: str | BaseException) -> str: ) +MERMAID_CODE_FENCE_PATTERN = re.compile( + r"(?ms)^```mermaid\r?\n(.*?)^```[ \t]*(?:\r?\n|$)" +) +MANAGED_MERMAID_CAPTION_PATTERN = re.compile( + r'(?s)^\s*
\s*\*\*.+?\*\*\s*
' +) +MERMAID_TITLE_PATTERN = re.compile(r'(?m)^\s*title\s+"([^"]+)"\s*$') + + @dataclass class VisualizationInsertPlanContext: messages: list @@ -260,6 +293,209 @@ def _precheck_value_variation( ) return True + @staticmethod + def _infer_desired_chart_type(*texts: str) -> str: + """ + Infer a preferred chart type from section-level instructions. + + The visualization extractor still decides from traceable source data, but + explicit report requirements such as "use a line chart" should not be + lost between outline generation, data extraction, and Mermaid rendering. + """ + context = " ".join(str(text or "") for text in texts).lower() + if not context: + return "" + + explicit_patterns = ( + ("line", (r"折线图", r"折线", r"走势图", r"line\s+chart", r"line\s+graph")), + ("bar", (r"柱状图", r"柱形图", r"条形图", r"柱状", r"bar\s+chart")), + ("pie", (r"饼图", r"环形图", r"pie\s+chart")), + ("timeline", (r"时间线", r"timeline")), + ) + for chart_type, patterns in explicit_patterns: + if any(re.search(pattern, context) for pattern in patterns): + return chart_type + + # Common implicit section intents. These are intentionally conservative + # and describe chartable data shapes rather than a specific domain. + trend_keywords = ( + "趋势", + "走势", + "逐年", + "历年", + "年度", + "月度", + "季度", + "时间序列", + "同比", + "环比", + "增速", + "增长率", + "变化", + "演变", + "trend", + "over time", + "time series", + "annual", + "monthly", + "quarterly", + "year-over-year", + "yoy", + "growth", + ) + comparison_keywords = ( + "对比", + "比较", + "排名", + "排行", + "分布", + "结构", + "占比", + "份额", + "市占率", + "comparison", + "ranking", + "distribution", + "breakdown", + "share", + "market share", + ) + category_keywords = ( + "厂商", + "品牌", + "企业", + "公司", + "地区", + "区域", + "城市", + "国家", + "产品", + "车型", + "品类", + "部门", + "行业", + "vendor", + "manufacturer", + "brand", + "company", + "region", + "country", + "city", + "product", + "segment", + "category", + ) + has_trend = any(keyword in context for keyword in trend_keywords) + has_comparison = any(keyword in context for keyword in comparison_keywords) + has_category = any(keyword in context for keyword in category_keywords) + has_year_range = bool( + re.search(r"(?:19|20)\d{2}\s*(?:至|到|[-—–~~])\s*(?:19|20)\d{2}", context) + or re.search(r"(?:19|20)\d{2}\s*[,,、/]\s*(?:19|20)\d{2}", context) + ) + + # A section may say "compare 2022-2024 trend"; the comparison verb is + # about years, not categories. Prefer line charts for temporal records. + if has_trend and ( + has_year_range + or not has_comparison + or "趋势" in context + or "走势" in context + or "trend" in context + or "time series" in context + or ("年度" in context and not has_category) + or ("annual" in context and not has_category) + ): + return "line" + if has_comparison: + return "bar" + if has_trend: + return "line" + return "" + + @staticmethod + def _visualization_label_is_temporal(label: str) -> bool: + label = str(label or "").strip() + if not label: + return False + temporal_patterns = ( + r"^(?:19|20)\d{2}\s*年?$", + r"^(?:19|20)\d{2}\s*[-/]\s*\d{1,2}\s*月?$", + r"^(?:19|20)\d{2}\s*[Qq][1-4]$", + r"^(?:[1-4]|一|二|三|四)\s*季度$", + r"^第\s*(?:[1-4]|一|二|三|四)\s*季度$", + r"^(?:[1-9]|1[0-2])\s*月$", + ) + return any(re.search(pattern, label) for pattern in temporal_patterns) + + @classmethod + def _records_look_like_time_series(cls, records: list) -> bool: + if not isinstance(records, list) or len(records) < 3: + return False + labels = [] + for row in records: + if not isinstance(row, list) or len(row) < 2: + return False + labels.append(str(row[0] or "").strip()) + if not labels: + return False + temporal_count = sum(1 for label in labels if cls._visualization_label_is_temporal(label)) + return temporal_count >= max(3, int(len(labels) * 0.75)) + + @classmethod + def _coerce_visualization_chart_type( + cls, + extracted_obj: dict, + visualization_dict: dict, + ) -> dict: + """ + Correct obvious chart-type drift while preserving the extracted data. + + GLM can correctly extract yearly records but label them as a bar chart. + When the section intent and/or record labels clearly indicate a time + series, render it as a line chart. Conversely, explicit comparison + sections should remain bar charts when the records are category values. + """ + if not isinstance(extracted_obj, dict): + return extracted_obj + + current_type = str(extracted_obj.get("image_type", "") or "").strip() + records = extracted_obj.get("records", []) + desired_type = str(visualization_dict.get("desired_chart_type", "") or "").strip() + if not desired_type: + desired_type = cls._infer_desired_chart_type( + visualization_dict.get("section_title", ""), + visualization_dict.get("section_outline", ""), + ) + + coerced_type = "" + looks_time_series = cls._records_look_like_time_series(records) + if looks_time_series and current_type in ("bar", "line"): + coerced_type = "line" + elif desired_type == "line" and looks_time_series: + coerced_type = "line" + elif ( + desired_type == "bar" + and not looks_time_series + and isinstance(records, list) + and len(records) >= 3 + ): + coerced_type = "bar" + + if not coerced_type or coerced_type == current_type: + return extracted_obj + + corrected = deepcopy(extracted_obj) + corrected["image_type"] = coerced_type + logger.info( + "%s [process_visualization_task] section_idx: [%s], " + "coerce visualization chart type from %s to %s", + EFFECT_SUB_REPORT_TAG, + visualization_dict.get("section_idx", 1), + current_type, + coerced_type, + ) + return corrected + @staticmethod def _generate_mermaid_code(visualization_content: dict, section_idx: int) -> dict: # Generate Mermaid code from data and chart type @@ -2211,6 +2447,8 @@ async def _extract_data_from_text( tmp_context = { "language": visualization_dict.get("language", "zh-CN"), "section_outline": visualization_dict.get("section_outline", ""), + "desired_chart_type": visualization_dict.get("desired_chart_type", ""), + "avoid_chart_data": visualization_dict.get("avoid_chart_data", ""), "origin_content": visualization_dict.get("origin_content", ""), } validation_error = (validation_error or "").strip() @@ -2310,7 +2548,7 @@ async def _validate_chart_compliance( ) continue raw = (llm_output.get("content") or "").strip() - result = json.loads(raw) + result = json.loads(normalize_json_output(raw)) if not isinstance(result, dict): logger.warning( "%s [validate_chart_compliance] section_idx: [%s] " @@ -2385,7 +2623,7 @@ async def _validate_chart_traceability( ) continue raw = (llm_output.get("content") or "").strip() - result = json.loads(raw) + result = json.loads(normalize_json_output(raw)) if not isinstance(result, dict): logger.warning( "%s [validate_chart_traceability] section_idx: [%s] " @@ -2445,7 +2683,29 @@ async def _extract_visualization_data( raw_payload = ( visualization_content.get("sub_section_visualization_content") or "" ).strip() + if raw_payload: + raw_payload = normalize_json_output(raw_payload).strip() + visualization_content[ + "sub_section_visualization_content" + ] = raw_payload if raw_payload == "{}": + validation_error = ( + "Previous output was empty JSON. If origin_content contains at " + "least three traceable records for one metric, extract the best " + "valid chart JSON instead of returning {}. Return {} only when " + "no valid chartable dataset exists." + ) + previous_records = raw_payload + if i < max_attempt_num - 1: + logger.warning( + "%s [process_visualization_task] section_idx: [%s], " + "empty visualization JSON on attempt %s/%s, retry ...", + EFFECT_SUB_REPORT_TAG, + section_idx, + i + 1, + max_attempt_num, + ) + continue visualization_content["rs_success"] = False visualization_content["error_msg"] = "no_chart_data" return False, visualization_content, None @@ -2453,10 +2713,23 @@ async def _extract_visualization_data( extracted_obj = json.loads(raw_payload) except Exception: extracted_obj = None + validation_error = ( + "Previous output was not valid JSON. Output only one JSON object " + "matching the required visualization schema, with no markdown or " + "extra text." + ) extract_ok = isinstance( extracted_obj, dict ) and validate_visualization_extraction_schema(extracted_obj) if extract_ok: + extracted_obj = self._coerce_visualization_chart_type( + extracted_obj, + visualization_dict, + ) + raw_payload = json.dumps(extracted_obj, ensure_ascii=False) + visualization_content[ + "sub_section_visualization_content" + ] = raw_payload traceability = await self._validate_chart_traceability( raw_payload, visualization_dict.get("origin_content", ""), @@ -2516,6 +2789,12 @@ async def _extract_visualization_data( ) extract_ok = False continue + if not extract_ok and not validation_error: + validation_error = ( + "Previous output did not match the required visualization schema. " + "Keep only traceable records from origin_content and output a " + "single valid chart JSON, or {} if no valid chartable dataset exists." + ) logger.warning( f"{EFFECT_SUB_REPORT_TAG} [process_visualization_task] section_idx: [{section_idx}], " f"Warning: Extract data from text on attempt {i + 1}/{max_attempt_num}. retry ..." @@ -2553,6 +2832,59 @@ async def _build_visualization_mermaid( return visualization_content return self._generate_mermaid_code(visualization_content, section_idx) + @staticmethod + def _parse_visualization_number(value: str) -> int | float | None: + normalized_value = value.strip().replace(",", "").replace(",", "") + try: + numeric_value = Decimal(normalized_value) + except (InvalidOperation, ValueError): + return None + if not numeric_value.is_finite(): + return None + if numeric_value == numeric_value.to_integral_value(): + return int(numeric_value) + return float(numeric_value) + + @classmethod + def _normalize_same_unit_records_locally( + cls, + records: list, + image_type: str, + ) -> dict | None: + if image_type not in ("bar", "line", "pie"): + return None + + normalized_records = [] + normalized_unit = None + for row in records: + if not isinstance(row, list) or len(row) != 3: + return None + x_value, numeric_text, unit_text = row + if not ( + isinstance(x_value, str) + and isinstance(numeric_text, str) + and isinstance(unit_text, str) + ): + return None + x_value = x_value.strip() + unit_text = unit_text.strip() + if not x_value or not unit_text: + return None + if normalized_unit is None: + normalized_unit = unit_text + if unit_text != normalized_unit: + return None + + parsed_value = cls._parse_visualization_number(numeric_text) + if parsed_value is None: + return None + normalized_records.append([x_value, parsed_value]) + + if normalized_unit is None: + return None + + return {"unit": normalized_unit, "records": normalized_records} + async def _normalize_visualization_content( self, visualization_content: dict, @@ -2591,6 +2923,26 @@ async def _normalize_visualization_content( return True final_obj = None + locally_normalized = self._normalize_same_unit_records_locally( + extracted_records, + image_type, + ) + if locally_normalized and validate_visualization_normalization_schema( + locally_normalized, image_type + ): + final_obj = { + "image_title": image_title, + "image_type": image_type, + "unit": locally_normalized.get("unit", ""), + "records": locally_normalized.get("records", []), + } + + if final_obj: + visualization_content["sub_section_visualization_content"] = json.dumps( + final_obj, ensure_ascii=False + ) + return True + records_json = json.dumps({"records": extracted_records}, ensure_ascii=False) normalize_context = { "language": visualization_dict.get("language", "zh-CN"), @@ -2607,7 +2959,9 @@ async def _normalize_visualization_content( ) if not normalize_output or not normalize_output.get("content"): continue - normalized_payload = (normalize_output.get("content") or "").strip() + normalized_payload = normalize_json_output( + (normalize_output.get("content") or "").strip() + ).strip() if normalized_payload == "{}": continue try: @@ -2700,6 +3054,7 @@ async def _generate_content_for_visualization(self, current_inputs: dict) -> dic EFFECT_SUB_REPORT_TAG, section_idx, ) + desired_chart_type = self._infer_desired_chart_type(section_task, section_outline) classified_content_for_visualization = deepcopy( current_inputs.get("classified_content", []) @@ -2731,6 +3086,7 @@ async def _generate_content_for_visualization(self, current_inputs: dict) -> dic "language": current_inputs.get("language", "zh-CN"), "section_title": section_task, "section_outline": section_outline, + "desired_chart_type": desired_chart_type, "max_attempt_num": current_inputs.get("max_generate_retry_num", 3), } task = self._process_visualization_task(visualization_dict) @@ -2769,6 +3125,1074 @@ async def _generate_content_for_visualization(self, current_inputs: dict) -> dic ) return dict(rs_success=True, visualization_content=visualization_content) + @staticmethod + def _has_visualization_mermaid(visualization_result: object) -> bool: + return isinstance(visualization_result, list) and any( + isinstance(item, dict) and bool(item.get("mermaid_content")) + for item in visualization_result + ) + + @staticmethod + def _visualization_payload_from_item(item: object) -> dict | None: + if not isinstance(item, dict): + return None + payload = (item.get("sub_section_visualization_content") or "").strip() + if not payload: + return None + try: + parsed = json.loads(payload) + except Exception: + return None + return parsed if isinstance(parsed, dict) else None + + @staticmethod + def _normalize_visualization_signature_value(value: object) -> str: + if isinstance(value, (int, float)): + return f"{float(value):.8g}" + normalized = str(value or "").strip().lower() + normalized = normalized.replace(",", "") + normalized = re.sub(r"\s+", "", normalized) + return normalized + + @classmethod + def _visualization_data_signature(cls, chart_obj: dict | None) -> tuple | None: + if not isinstance(chart_obj, dict): + return None + records = chart_obj.get("records", []) + if not isinstance(records, list) or not records: + return None + normalized_records = [] + for row in records: + if not isinstance(row, list) or len(row) < 2: + return None + label = str(row[0] or "").strip().lower() + label = re.sub(r"\s+", "", label) + value = cls._normalize_visualization_signature_value(row[1]) + normalized_records.append((label, value)) + unit = str(chart_obj.get("unit", "") or "").strip().lower() + unit = re.sub(r"\s+", "", unit) + return (unit, tuple(sorted(normalized_records))) + + @classmethod + def _visualization_data_is_redundant( + cls, + chart_obj: dict | None, + existing_charts: list[dict], + ) -> bool: + if not isinstance(chart_obj, dict) or not existing_charts: + return False + signature = cls._visualization_data_signature(chart_obj) + if not signature: + return False + unit, records = signature + record_map = { + cls._normalize_visualization_overlap_label(label): value + for label, value in records + } + if len(record_map) < 3: + return False + + for existing_chart in existing_charts: + existing_signature = cls._visualization_data_signature(existing_chart) + if not existing_signature: + continue + existing_unit, existing_records = existing_signature + if existing_unit != unit: + continue + existing_map = { + cls._normalize_visualization_overlap_label(label): value + for label, value in existing_records + } + overlap = [ + label + for label, value in record_map.items() + if label in existing_map and existing_map[label] == value + ] + if len(overlap) >= 3 and len(overlap) >= min(len(record_map), len(existing_map)) * 0.8: + return True + fuzzy_overlap = 0 + unmatched_existing_records = list(existing_records) + for label, value in records: + for idx, (existing_label, existing_value) in enumerate(unmatched_existing_records): + if existing_value == value and cls._visualization_labels_overlap( + label, + existing_label, + ): + fuzzy_overlap += 1 + unmatched_existing_records.pop(idx) + break + if ( + fuzzy_overlap >= 3 + and fuzzy_overlap >= min(len(records), len(existing_records)) * 0.8 + ): + return True + return False + + @classmethod + def _collect_existing_visualization_data(cls, visualization_result: object) -> tuple[set[tuple], list[dict]]: + signatures: set[tuple] = set() + avoid_chart_data = [] + if not isinstance(visualization_result, list): + return signatures, avoid_chart_data + for item in visualization_result: + if not isinstance(item, dict) or not item.get("mermaid_content"): + continue + chart_obj = cls._visualization_payload_from_item(item) + signature = cls._visualization_data_signature(chart_obj) + if signature: + signatures.add(signature) + if chart_obj: + avoid_chart_data.append(chart_obj) + return signatures, avoid_chart_data + + @staticmethod + def _visualization_relevance_terms(text: str) -> set[str]: + normalized = str(text or "").lower() + terms = set(re.findall(r"[a-z][a-z0-9_-]{2,}", normalized)) + cjk_chunks = re.findall(r"[\u4e00-\u9fff]{2,}", normalized) + for chunk in cjk_chunks: + if len(chunk) <= 4: + terms.add(chunk) + continue + for size in (2, 3, 4): + terms.update( + chunk[index: index + size] + for index in range(0, len(chunk) - size + 1) + ) + return terms + + @classmethod + def _visualization_relevance_overlap(cls, left: str, right: str) -> int: + return len( + cls._visualization_relevance_terms(left) + & cls._visualization_relevance_terms(right) + ) + + @classmethod + def _visualization_item_score( + cls, + item: dict, + chart_obj: dict, + current_inputs: dict, + order: int, + ) -> tuple[int, int]: + section_task = cls.strip_leading_number(current_inputs.get("section_task", "")) + section_outline = current_inputs.get("sub_section_outline", "") or "" + section_context = f"{section_task}\n{section_outline}" + chart_text = " ".join( + str(value or "") + for value in ( + item.get("title", ""), + chart_obj.get("image_title", ""), + chart_obj.get("image_type", ""), + json.dumps(chart_obj.get("records", []), ensure_ascii=False), + ) + ) + desired_type = cls._infer_desired_chart_type(section_task, section_outline) + chart_type = str(chart_obj.get("image_type", "") or "").strip() + records = chart_obj.get("records", []) + + score = min( + cls._visualization_relevance_overlap(chart_text, section_context), + 80, + ) + if desired_type and chart_type == desired_type: + score += 25 + if chart_type == "line" and cls._records_look_like_time_series(records): + score += 12 + if isinstance(records, list): + score += min(len(records), 12) + if item.get("index"): + score += 2 + if str(item.get("url", "")).startswith("generated://section/"): + # Final-section fallback is grounded in the actual written report, + # so it is often more section-local than broad pre-write passages. + score += 4 + return score, -order + + @classmethod + def _limit_visualization_result_for_section( + cls, + current_inputs: dict, + max_chart_count: int, + ) -> list: + existing = current_inputs.get("visualization_result", []) + if not isinstance(existing, list) or max_chart_count <= 0: + return [] + + scored_items = [] + seen_signatures: set[tuple] = set() + for order, item in enumerate(existing): + if not isinstance(item, dict) or not item.get("mermaid_content"): + continue + chart_obj = cls._visualization_payload_from_item(item) + signature = cls._visualization_data_signature(chart_obj) + if not chart_obj or not signature or signature in seen_signatures: + continue + seen_signatures.add(signature) + scored_items.append( + { + "item": item, + "chart_obj": chart_obj, + "signature": signature, + "score": cls._visualization_item_score( + item, + chart_obj, + current_inputs, + order, + ), + "order": order, + } + ) + + if len(scored_items) <= max_chart_count: + return [entry["item"] for entry in scored_items] + + selected: list[dict] = [] + selected_signatures: set[tuple] = set() + desired_type = cls._infer_desired_chart_type( + current_inputs.get("section_task", ""), + current_inputs.get("sub_section_outline", ""), + ) + + def choose_best(predicate) -> None: + if len(selected) >= max_chart_count: + return + candidates = [ + entry + for entry in scored_items + if entry["signature"] not in selected_signatures + and predicate(entry) + ] + if not candidates: + return + best = max(candidates, key=lambda entry: entry["score"]) + selected.append(best) + selected_signatures.add(best["signature"]) + + if desired_type: + choose_best( + lambda entry: entry["chart_obj"].get("image_type") == desired_type + ) + for chart_type in ("line", "bar", "pie", "timeline"): + choose_best(lambda entry, chart_type=chart_type: entry["chart_obj"].get("image_type") == chart_type) + + for entry in sorted(scored_items, key=lambda entry: entry["score"], reverse=True): + if len(selected) >= max_chart_count: + break + if entry["signature"] in selected_signatures: + continue + selected.append(entry) + selected_signatures.add(entry["signature"]) + + return [ + entry["item"] + for entry in sorted(selected, key=lambda entry: entry["order"]) + ] + + @staticmethod + def _strip_mermaid_blocks(text: str) -> str: + return re.sub( + r"```mermaid\s*[\s\S]*?```", + "", + text or "", + flags=re.IGNORECASE, + ) + + @staticmethod + def _strip_markdown_noise_for_numeric_density(text: str) -> str: + cleaned = "\n".join( + line + for line in (text or "").splitlines() + if not re.match(r"^\s*#+\s+", line) + ) + cleaned = re.sub(r"\[[^\]]+\]\([^)]+\)|\[(?:checked_)?citation:\d+\]|https?://\S+", "", cleaned) + return cleaned + + @classmethod + def _chartable_numeric_count(cls, text: str) -> int: + cleaned = cls._strip_markdown_noise_for_numeric_density(text) + return len(re.findall(r"(? int: + cleaned = cls._strip_markdown_noise_for_numeric_density( + cls._strip_mermaid_blocks(text or "") + ) + cleaned = MANAGED_MERMAID_CAPTION_PATTERN.sub("", cleaned) + cjk_count = len(re.findall(r"[\u4e00-\u9fff]", cleaned)) + latin_word_count = len(re.findall(r"\b[A-Za-z][A-Za-z0-9_-]*\b", cleaned)) + return cjk_count + latin_word_count + + @staticmethod + def _extract_first_citation_index(text: str) -> int: + match = re.search(r"\[(?:checked_)?citation:(\d+)\]", text or "") + return int(match.group(1)) if match else 0 + + @classmethod + def _report_content_visualization_candidates( + cls, + current_inputs: dict, + ) -> list[dict]: + report_markdown = cls._strip_mermaid_blocks( + current_inputs.get("sub_report_content") or "" + ).strip() + if not report_markdown: + return [] + + section_outline = (current_inputs.get("sub_section_outline", "") or "").strip() + section_task = cls.strip_leading_number(current_inputs.get("section_task", "")) + blocks: list[tuple[str, str]] = [] + current_title = "" + current_lines: list[str] = [] + + def flush_block() -> None: + nonlocal current_title, current_lines + block_text = "\n".join(current_lines).strip() + if block_text: + blocks.append((current_title, block_text)) + current_title = "" + current_lines = [] + + for line in report_markdown.splitlines(): + if re.match(r"^\s*##\s+", line): + flush_block() + current_title = re.sub(r"^\s*##\s+", "", line).strip() + current_lines = [line] + continue + if re.match(r"^\s*#\s+", line): + continue + current_lines.append(line) + flush_block() + + if not blocks: + blocks = [(section_task, report_markdown)] + + candidates = [] + for idx, (title, block_text) in enumerate(blocks, 1): + numeric_count = cls._chartable_numeric_count(block_text) + if numeric_count < 3: + continue + candidates.append( + { + "candidate_idx": idx, + "title": title or f"section content {idx}", + "origin_content": block_text, + "numeric_count": numeric_count, + "citation_index": cls._extract_first_citation_index(block_text), + "desired_chart_type": ( + cls._infer_desired_chart_type(title, block_text) + or cls._infer_desired_chart_type( + section_outline, + section_task, + ) + ), + } + ) + if not candidates and cls._chartable_numeric_count(report_markdown) >= 3: + candidates.append( + { + "candidate_idx": 1, + "title": section_task or "section content", + "origin_content": report_markdown, + "numeric_count": cls._chartable_numeric_count(report_markdown), + "citation_index": cls._extract_first_citation_index(report_markdown), + "desired_chart_type": cls._infer_desired_chart_type( + report_markdown, + section_outline, + section_task, + ), + } + ) + return candidates[:REPORT_CONTENT_VISUALIZATION_MAX_CANDIDATES] + + @classmethod + def _adaptive_report_content_visualization_limit( + cls, + current_inputs: dict, + candidates: list[dict], + ) -> int: + report_markdown = current_inputs.get("sub_report_content") or "" + numeric_count = cls._chartable_numeric_count(report_markdown) + if numeric_count < 3: + return 0 + + subsection_count = len( + re.findall( + r"(?m)^\s*##\s+", + cls._strip_mermaid_blocks(report_markdown), + ) + ) + subsection_count = max(1, subsection_count) + local_payload_count = sum( + min( + len(cls._local_report_content_chart_payloads(candidate)), + REPORT_CONTENT_VISUALIZATION_MAX_CHARTS_PER_CANDIDATE, + ) + for candidate in candidates + ) + potential_count = max(len(candidates), local_payload_count, 1) + cleaned_report = cls._clean_local_visualization_text(report_markdown) + has_percent_metric = bool(re.search(r"[-+]?\d[\d,]*(?:\.\d+)?\s*%", cleaned_report)) + has_non_percent_unit_metric = bool( + re.search( + rf"[-+]?\d[\d,]*(?:\.\d+)?\s*(?!%)({REPORT_CONTENT_LOCAL_VISUALIZATION_UNIT_PATTERN})", + cleaned_report, + ) + ) + if numeric_count >= 6 and has_percent_metric and has_non_percent_unit_metric: + potential_count = max(potential_count, 2) + + # Allow more than one chart where the content actually exposes distinct + # dimensions, but keep brief chapters from turning into chart catalogs. + limit = min(potential_count, subsection_count + 1) + text_units = cls._report_content_text_units(report_markdown) + if text_units >= 900 and numeric_count >= 18: + limit += 1 + if text_units >= 1500 and numeric_count >= 30: + limit += 1 + if text_units >= 2500 and numeric_count >= 45: + limit += 1 + return max(1, min(limit, REPORT_CONTENT_VISUALIZATION_MAX_TOTAL_CHARTS)) + + @staticmethod + def _format_avoid_chart_data(avoid_chart_data: list[dict]) -> str: + return json.dumps(avoid_chart_data[-REPORT_CONTENT_VISUALIZATION_MAX_TOTAL_CHARTS:], ensure_ascii=False) if avoid_chart_data else "" + + @classmethod + def _clean_local_visualization_text(cls, text: str) -> str: + cleaned = cls._strip_mermaid_blocks(text or "") + cleaned = re.sub( + r'
[\s\S]*?
', + "", + cleaned, + flags=re.IGNORECASE, + ) + cleaned = re.sub(r"\[(?:checked_)?citation:\d+\]|\[\[\d+\]\]\([^)]+\)", "", cleaned) + cleaned = re.sub(r"\[[^\]]+\]\([^)]+\)|https?://\S+", "", cleaned) + cleaned = cleaned.replace("−", "-").replace("–", "-") + return cleaned + + @staticmethod + def _clean_local_chart_label(label: str) -> str: + label = re.sub(r"[*_`#|<>]", "", str(label or "")).strip() + label = re.split(r"[\r\n]+", label)[-1].strip() + label = re.sub(r"^\s*(?:\d+(?:\.\d+)*\s*)", "", label) + label = re.sub( + r"^\s*(?:而|但|然而|其中|同时|此外|则|为|是|和|与|and|but|while|whereas|meanwhile|also|with)\s*", + "", + label, + flags=re.IGNORECASE, + ) + label = re.sub(r"(?:的|则|为|是|以|约为|达到|达)$", "", label).strip() + label = re.sub( + r"(?:19|20)\d{2}年.*$", + "", + label, + ).strip(" ,,、::;;。()()") + label = LOCAL_CHART_LABEL_METRIC_SUFFIX_PATTERN.sub("", label).strip(" ,,、::;;。()()") + if len(label) > 24: + candidates = [ + item.strip(" ,,、::;;。()()") + for item in re.split(r"[\s,,、::;;|/]+", label) + if item.strip(" ,,、::;;。()()") and not item.strip().isdigit() + ] + if candidates: + label = candidates[-1] + return label.strip(" ,,、::;;。()()")[:24] + + @staticmethod + def _normalize_visualization_overlap_label(label: str) -> str: + normalized = str(label or "").strip().lower() + normalized = re.sub(r"\s+", "", normalized) + normalized = re.sub( + r"(?:集团|股份|有限|公司|co\.?|company|inc\.?|ltd\.?|llc|corp\.?|corporation|group)$", + "", + normalized, + ) + return normalized + + @classmethod + def _visualization_labels_overlap(cls, left: str, right: str) -> bool: + left_normalized = cls._normalize_visualization_overlap_label(left) + right_normalized = cls._normalize_visualization_overlap_label(right) + if not left_normalized or not right_normalized: + return False + if left_normalized == right_normalized: + return True + if min(len(left_normalized), len(right_normalized)) < 2: + return False + return ( + left_normalized in right_normalized + or right_normalized in left_normalized + ) + + @staticmethod + def _local_chart_payload( + image_title: str, + image_type: str, + unit: str, + records: list[list], + ) -> dict | None: + if image_type not in ("bar", "line") or not unit: + return None + if not (3 <= len(records) <= REPORT_CONTENT_LOCAL_VISUALIZATION_MAX_RECORDS): + return None + cleaned_records = [] + seen_labels = set() + for row in records: + if not isinstance(row, list) or len(row) != 2: + return None + label = str(row[0] or "").strip() + value = row[1] + if not label or label in seen_labels: + continue + if not isinstance(value, (int, float)): + return None + cleaned_records.append([label, value]) + seen_labels.add(label) + if len(cleaned_records) < 3: + return None + return { + "image_title": (image_title or "Chart").strip()[:80], + "image_type": image_type, + "unit": unit.strip(), + "records": cleaned_records, + } + + @classmethod + def _extract_local_year_range_payloads( + cls, + candidate: dict, + text: str, + ) -> list[dict]: + payloads = [] + range_pattern = re.compile( + r"(?P(?:19|20)\d{2})\s*(?:至|到|[-—–~~])\s*" + r"(?P(?:19|20)\d{2})\s*年?" + r"(?P[^。;;\n]{0,120}?分别(?:为|是)?[^。;;\n]{0,180})" + ) + value_pattern = re.compile( + rf"([-+]?\d[\d,]*(?:\.\d+)?)\s*({REPORT_CONTENT_LOCAL_VISUALIZATION_UNIT_PATTERN})" + ) + for match in range_pattern.finditer(text): + start_year = int(match.group("start")) + end_year = int(match.group("end")) + if end_year < start_year or end_year - start_year + 1 > REPORT_CONTENT_LOCAL_VISUALIZATION_MAX_RECORDS: + continue + years = [f"{year}年" for year in range(start_year, end_year + 1)] + values = value_pattern.findall(match.group("context")) + if len(values) < len(years): + continue + unit = values[0][1] + if any(unit_item != unit for _, unit_item in values[: len(years)]): + continue + records = [] + for year_label, (value_text, _) in zip(years, values): + parsed = cls._parse_visualization_number(value_text) + if parsed is None: + records = [] + break + records.append([year_label, parsed]) + payload = cls._local_chart_payload( + f"{candidate.get('title', '')}趋势", + "line", + unit, + records, + ) + if payload: + payloads.append(payload) + return payloads + + @staticmethod + def _local_numeric_context_is_approximate( + text: str, + value_start: int, + value_end: int, + ) -> bool: + before = text[max(0, value_start - 12): value_start] + after = text[value_end: min(len(text), value_end + 12)] + return bool( + re.search(r"(?:约|约为|近|逾|超过|超|突破|不低于|不少于)\s*$", before) + or re.search(r"^\s*(?:左右|以上|大关|附近)", after) + ) + + @classmethod + def _local_year_value_score( + cls, + text: str, + value_start: int, + value_end: int, + ) -> int: + return 0 if cls._local_numeric_context_is_approximate(text, value_start, value_end) else 1 + + @classmethod + def _extract_local_year_value_payloads( + cls, + candidate: dict, + text: str, + ) -> list[dict]: + payloads = [] + emitted_signatures: set[tuple] = set() + value_pattern = re.compile( + r"((?:19|20)\d{2})(?:\s*年)?[^。;;\n.!?]{0,60}?" + rf"([-+]?\d[\d,]*(?:\.\d+)?)\s*({REPORT_CONTENT_LOCAL_VISUALIZATION_UNIT_PATTERN})" + ) + def append_payload(unit: str, records: list[list]) -> None: + payload = cls._local_chart_payload( + f"{candidate.get('title', '')}趋势", + "line", + unit, + records, + ) + signature = cls._visualization_data_signature(payload) + if payload and signature and signature not in emitted_signatures: + payloads.append(payload) + emitted_signatures.add(signature) + + chunks = re.split(r"[。;;\n.!?]+", text) + for chunk in chunks: + matches = list(value_pattern.finditer(chunk)) + if len(matches) < 3: + continue + by_unit: dict[str, dict[str, tuple[int, int | float]]] = {} + for match in matches: + year, value_text, unit = match.groups() + parsed = cls._parse_visualization_number(value_text) + if parsed is None: + continue + score = cls._local_year_value_score( + chunk, + match.start(2), + match.end(2), + ) + by_unit.setdefault(unit, {}) + existing = by_unit[unit].get(year) + if existing is None or score > existing[0]: + by_unit[unit][year] = (score, parsed) + for unit, values_by_year in by_unit.items(): + records = [ + [f"{year}年", values_by_year[year][1]] + for year in sorted(values_by_year) + ] + append_payload(unit, records) + cross_sentence_by_unit: dict[str, dict[str, tuple[int, int | float]]] = {} + for match in value_pattern.finditer(text): + year, value_text, unit = match.groups() + parsed = cls._parse_visualization_number(value_text) + if parsed is None: + continue + cross_sentence_by_unit.setdefault(unit, {}) + score = cls._local_year_value_score( + text, + match.start(2), + match.end(2), + ) + existing = cross_sentence_by_unit[unit].get(year) + if existing is None or score > existing[0]: + cross_sentence_by_unit[unit][year] = (score, parsed) + for unit, values_by_year in cross_sentence_by_unit.items(): + records = [ + [f"{year}年", values_by_year[year][1]] + for year in sorted(values_by_year) + ] + append_payload(unit, records) + return payloads + + @classmethod + def _extract_local_markdown_table_payloads( + cls, + candidate: dict, + text: str, + ) -> list[dict]: + payloads = [] + lines = [line.strip() for line in text.splitlines()] + i = 0 + while i < len(lines) - 2: + if not (lines[i].startswith("|") and lines[i + 1].startswith("|")): + i += 1 + continue + header = [cell.strip() for cell in lines[i].strip("|").split("|")] + separator = [cell.strip() for cell in lines[i + 1].strip("|").split("|")] + if not all(re.match(r"^:?-{3,}:?$", cell) for cell in separator): + i += 1 + continue + rows = [] + j = i + 2 + while j < len(lines) and lines[j].startswith("|"): + cells = [cell.strip() for cell in lines[j].strip("|").split("|")] + if len(cells) >= len(header): + rows.append(cells) + j += 1 + numeric_columns: list[tuple[int, int]] = [] + for col_idx in range(1, len(header)): + header_text = header[col_idx] + if re.search(r"排名|序号|rank", header_text, flags=re.IGNORECASE): + continue + numeric_count = sum( + cls._parse_visualization_number(row[col_idx]) is not None + for row in rows + ) + if numeric_count >= 3: + numeric_columns.append((col_idx, numeric_count)) + if numeric_columns: + col_idx = sorted(numeric_columns, key=lambda item: item[1], reverse=True)[0][0] + unit_match = re.search(r"[((]([^))]+)[))]", header[col_idx]) + unit = unit_match.group(1).strip() if unit_match else header[col_idx].strip() + records = [] + for row in rows: + label = cls._clean_local_chart_label(row[0]) + parsed = cls._parse_visualization_number(row[col_idx]) + if label and parsed is not None: + records.append([label, parsed]) + payload = cls._local_chart_payload( + f"{candidate.get('title', '')}{header[col_idx]}对比", + "bar", + unit, + records[:REPORT_CONTENT_LOCAL_VISUALIZATION_MAX_RECORDS], + ) + if payload: + payloads.append(payload) + i = max(j, i + 1) + return payloads + + @classmethod + def _extract_local_percent_comparison_payloads( + cls, + candidate: dict, + text: str, + ) -> list[dict]: + records = [] + seen_labels = set() + generic_labels = { + "总计", + "合计", + "总体", + "整体", + "平均", + "市场", + "行业", + "板块", + "领域", + "类别", + "项目", + "指标", + "样本", + "其他", + "总", + "total", + "overall", + "average", + "market", + "industry", + "others", + } + share_or_rate_context = ( + r"同比|环比|增长率|增幅|增速|增长|下降|下跌|减少|提升|上升|增加|提高|降低|" + r"市场份额|市占率|份额|占比|比重|渗透率|转化率|留存率|毛利率|利润率|" + r"growth|grew|increase|increased|decrease|decreased|decline|declined|drop|dropped|" + r"share|market\s+share|rate|ratio" + ) + + def add_percent_record( + raw_label: str, + value_text: str, + metric_text: str, + context_text: str, + ) -> None: + label = cls._clean_local_chart_label( + re.split(r"[,,、]", raw_label)[-1] + ) + if ( + not label + or label in seen_labels + or any(generic in label.lower() for generic in generic_labels) + ): + return + parsed = cls._parse_visualization_number(value_text) + if parsed is None: + return + value_start = context_text.find(value_text) + if value_start > 0 and cls._local_numeric_context_is_approximate( + context_text, + value_start, + value_start + len(value_text), + ): + return + if re.search( + r"下跌|下降|大跌|负增长|减少|降低|decrease|decline|drop|down|negative|fell|fall", + f"{metric_text} {context_text}", + flags=re.IGNORECASE, + ): + parsed = -abs(parsed) + records.append([label, parsed]) + seen_labels.add(label) + + parenthesized_percent_pattern = re.compile( + r"(?P