ChartPipeline / modules /title_styler /llm_analyzer.py
Ray1ee01's picture
Upload folder using huggingface_hub
0db40c8 verified
Raw
History Blame Contribute Delete
25.6 kB
"""
Infographic Title Generator - LLM Analyzer
Use LLM to analyze title structure and recommend suitable templates
"""
import json
import os
import random
import re
import requests
from typing import Dict, List, Optional
def _split_title_evenly(title: str, n_segments: int) -> List[str]:
"""按词把 title 大致均分成 n 段,作为最后兜底(语义切分也失败时使用)。"""
if n_segments <= 0:
n_segments = 1
text = (title or "").strip()
if not text:
return [""] * n_segments
words = re.split(r"\s+", text)
if len(words) <= n_segments:
out = list(words)
while len(out) < n_segments:
out.append("")
return out
base = len(words) // n_segments
extra = len(words) % n_segments
parts: List[str] = []
idx = 0
for i in range(n_segments):
take = base + (1 if i < extra else 0)
parts.append(" ".join(words[idx:idx + take]))
idx += take
return parts
# === Heuristic semantic splitter ===================================
# When LLM is disabled (default in the prod pipeline) we still want the
# title to break at natural language boundaries — colons, commas, the
# word "vs.", before prepositions / conjunctions — instead of a blind
# even-by-words split. This module-local DP picks N-1 break points to
# maximise a token-gap score table.
_PREPOSITIONS = frozenset({
"of", "in", "by", "with", "for", "from", "on", "over", "per",
"to", "at", "into", "across", "between", "after", "before",
"during", "since", "through", "via", "under", "above",
})
_CONJUNCTIONS = frozenset({"and", "or", "but", "vs", "vs.", "versus", "&"})
def _gap_score(left_word: str, right_word: str) -> float:
"""Score the desirability of breaking after ``left_word``.
Higher = more natural break point. Negative numbers mean "fairly
arbitrary place to break" (we still let length-balance decide there).
"""
lw = (left_word or "").rstrip()
rw_lower = (right_word or "").lower().lstrip()
if not lw or not rw_lower:
return 0.0
# Hard punctuation at the end of left word — strongest preference.
last = lw[-1]
if last == ":":
return 1000.0
if last == ";":
return 900.0
if last in {"—", "–"}:
return 850.0
if last == "?" or last == "!":
return 800.0
if last == "." and len(lw) > 2 and not lw.endswith(("Mr.", "Mrs.", "Dr.", "St.", "vs.")):
# Likely sentence-ending period (skip common abbreviations and "vs.").
return 700.0
if last == ",":
return 500.0
# Soft preference: break BEFORE conjunctions / prepositions.
rw_clean = rw_lower.rstrip(",.;:")
if rw_clean in _CONJUNCTIONS:
return 250.0
if rw_clean in _PREPOSITIONS:
return 150.0
# Fallback: arbitrary mid-phrase break, no bonus, length balance wins.
return 0.0
def _split_title_semantic(title: str, n_segments: int) -> List[str]:
"""Split ``title`` into ``n_segments`` segments by maximising a
semantic-break score subject to length balance.
Algorithm:
1) Tokenise on whitespace; collect word widths in characters.
2) For every gap i (between word[i] and word[i+1]) compute a
positive bonus from punctuation/POS, then add a length-balance
term that penalises uneven segment widths.
3) Greedy DP: dp[k][i] = best score for splitting the first i words
into k segments. Pick the top N-1 gaps that maximise the sum.
Falls back to even split when title is too short for ``n_segments``.
"""
if n_segments <= 0:
n_segments = 1
text = (title or "").strip()
if not text:
return [""] * n_segments
words = re.split(r"\s+", text)
W = len(words)
if n_segments == 1:
return [" ".join(words)]
if W <= n_segments:
out = list(words)
while len(out) < n_segments:
out.append("")
return out
# Per-word "width" in characters (proxy for visual width). Spaces between
# words add 1 char.
char_lens = [len(w) for w in words]
# cumulative chars (each word + 1 space sep, except first), len = W+1
cum = [0] * (W + 1)
for i, c in enumerate(char_lens):
cum[i + 1] = cum[i] + c + (1 if i > 0 else 0)
total_chars = cum[W]
target_seg = total_chars / n_segments
def seg_chars(s: int, e: int) -> int:
"""Chars in words[s..e-1] joined by spaces."""
if e <= s:
return 0
return cum[e] - cum[s] - (1 if s > 0 else 0)
# Pre-compute gap bonuses for every internal gap i (break AFTER word i).
gap_bonus = [0.0] * (W - 1)
for i in range(W - 1):
gap_bonus[i] = _gap_score(words[i], words[i + 1])
def seg_score(s: int, e: int) -> float:
"""Score of segment words[s..e-1] viewed as one row."""
if e <= s:
return -1e9
c = seg_chars(s, e)
# Penalise distance from ideal segment length (per char).
# Coefficient 5 keeps it on the same order as gap bonuses for typical
# text (10-50 char titles), so a "+150 break before preposition"
# never beats a 30-char balance issue.
return -5.0 * abs(c - target_seg)
# dp[k][i] = best total score using first i words in exactly k segments.
# Track parent pointers to reconstruct the breaks.
NEG_INF = float("-inf")
dp = [[NEG_INF] * (W + 1) for _ in range(n_segments + 1)]
parent = [[-1] * (W + 1) for _ in range(n_segments + 1)]
dp[0][0] = 0.0
for k in range(1, n_segments + 1):
# Each segment must have at least 1 word; previous segments need
# at least k-1 words; final segment must leave at least 1 word.
for i in range(k, W + 1):
best = NEG_INF
best_j = -1
for j in range(k - 1, i):
if dp[k - 1][j] == NEG_INF:
continue
# Break after word j (i.e. between word j-1 and word j when
# j > 0); add the gap bonus for that break.
gap = gap_bonus[j - 1] if j > 0 else 0.0
cand = dp[k - 1][j] + seg_score(j, i) + gap
if cand > best:
best = cand
best_j = j
dp[k][i] = best
parent[k][i] = best_j
# Reconstruct boundaries.
boundaries = []
i = W
for k in range(n_segments, 0, -1):
j = parent[k][i]
boundaries.append((j, i))
i = j
boundaries.reverse()
parts = [" ".join(words[s:e]) for s, e in boundaries]
# Safety net — should never trigger, but keep length contract.
while len(parts) < n_segments:
parts.append("")
return parts[:n_segments]
class LLMAnalyzer:
"""LLM Title Analyzer"""
def __init__(self, api_key=None, base_url=None, model=None):
"""
Initialize LLM analyzer
Args:
api_key: API key (default uses built-in key)
base_url: API base URL (default uses built-in URL)
model: Model name (default uses gpt-5-mini)
"""
# Use provided API configuration(环境变量优先,缺省禁用 LLM 走 fallback)
self.api_key = api_key or os.environ.get('CHARTPIPELINE_LLM_API_KEY') or ''
self.base_url = base_url or os.environ.get('CHARTPIPELINE_LLM_BASE_URL') or 'https://aihubmix.com/v1'
self.model = model or os.environ.get('CHARTPIPELINE_LLM_MODEL') or 'deepseek-v3.2'
self.llm_disabled = os.environ.get('CHARTPIPELINE_DISABLE_LLM', '1') == '1' or not self.api_key
# 仅第一次失败时打印 fallback 提示,避免刷屏
self._fallback_logged = False
def _log_fallback_once(self, reason: str):
if not self._fallback_logged:
print(f" ⚠️ LLM unavailable ({reason}), using local heuristic fallback for title styling")
self._fallback_logged = True
def _fallback_single(self, title: str, templates_info: Optional[List[Dict]]) -> Dict:
"""LLM 失败时的单模板 fallback。"""
self._log_fallback_once("single-template")
if templates_info:
chosen = random.choice(templates_info)
name = chosen['name']
seg = int(chosen.get('segment_count') or len(chosen.get('segments', [])) or 1)
else:
name = 'infographic_standard'
seg = 1
return {
'recommended_template': name,
'title_split': _split_title_semantic(title, seg),
'split_method': 'semantic_dp',
'reasoning': 'fallback: LLM disabled, picked template by random sampling and split title via semantic DP',
}
def _fallback_top_k(self, title: str, all_templates_info: list, top_k: int) -> Dict:
"""LLM 失败时的 top-k fallback。"""
self._log_fallback_once("top-k")
if not all_templates_info:
return {'recommendations': []}
pool = list(all_templates_info)
random.shuffle(pool)
recs = []
for info in pool[:max(1, top_k)]:
seg = int(info.get('segment_count') or 1)
recs.append({
'template_name': info['name'],
'title_split': _split_title_semantic(title, seg),
'split_method': 'semantic_dp',
'paraphrased_title': None,
'confidence': 0.5,
'reasoning': 'fallback: LLM disabled, random template + semantic DP split',
})
return {'recommendations': recs}
def recommend_template(self, title: str, has_description: bool = True, templates_info: Optional[List[Dict]] = None) -> Dict:
"""
Analyze title and recommend suitable template with splitting scheme
Args:
title: Main title text
has_description: Whether description is provided
templates_info: List of template info dicts (optional, for filtering by style)
Returns:
Dictionary containing:
{
'recommended_template': 'template_name',
'title_split': ['line1', 'line2', ...],
'reasoning': 'explanation'
}
"""
return self._analyze_with_llm(title, has_description, templates_info)
def recommend_top_k_templates(self, title: str, all_templates_info: list, top_k: int = 3) -> Dict:
"""
Analyze title and recommend top-k suitable templates with splitting schemes
Args:
title: Main title text
all_templates_info: List of template info dicts with name, description, segment_roles, segments
top_k: Number of templates to recommend (default 3)
Returns:
Dictionary containing:
{
'recommendations': [
{
'template_name': 'template1',
'title_split': ['line1', 'line2', ...],
'paraphrased_title': 'Restructured title or null',
'confidence': 0.95,
'reasoning': 'explanation'
},
...
]
}
"""
return self._analyze_with_llm_top_k(title, all_templates_info, top_k)
def _analyze_with_llm(self, title: str, has_description: bool, templates_info: Optional[List[Dict]] = None) -> Dict:
"""
Use LLM API to analyze and recommend template
"""
if self.llm_disabled:
return self._fallback_single(title, templates_info)
# Build prompt
prompt = self._build_recommendation_prompt(title, has_description, templates_info)
# Call LLM API
try:
response = self._query_llm(prompt)
if response:
# Parse JSON response
try:
# Try to clean possible markdown code blocks
cleaned_response = response.strip()
if cleaned_response.startswith('```'):
lines = cleaned_response.split('\n')
cleaned_response = '\n'.join(lines[1:-1] if lines[-1].strip() == '```' else lines[1:])
cleaned_response = cleaned_response.replace('```json', '').replace('```', '').strip()
result = json.loads(cleaned_response)
# Validate return format
if 'recommended_template' in result and 'title_split' in result:
# Validate template name if templates_info is provided
if templates_info:
available_names = [t['name'] for t in templates_info]
if result['recommended_template'] not in available_names:
print(f"❌ Unknown template '{result['recommended_template']}'")
return None
return result
else:
print("❌ LLM response missing required fields")
return self._fallback_single(title, templates_info)
except json.JSONDecodeError as e:
print(f"❌ LLM response is not valid JSON")
print(f"Response: {response[:200]}...")
return self._fallback_single(title, templates_info)
else:
return self._fallback_single(title, templates_info)
except Exception as e:
print(f"❌ LLM analysis error: {e}")
return self._fallback_single(title, templates_info)
def _query_llm(self, prompt: str) -> Optional[str]:
"""
Query LLM API
Args:
prompt: Prompt to send to LLM
Returns:
str: LLM response content
"""
headers = {
'Authorization': f'Bearer {self.api_key}',
'Content-Type': 'application/json'
}
data = {
'model': self.model,
'messages': [
{
'role': 'system',
'content': 'You are a professional infographic design assistant, skilled at analyzing title structure and visual hierarchy. Always return valid JSON format only, without any markdown formatting or extra text.'
},
{
'role': 'user',
'content': prompt
}
],
'temperature': 0.3
}
try:
response = requests.post(
f'{self.base_url}/chat/completions',
headers=headers,
json=data,
timeout=30
)
response.raise_for_status()
result = response.json()
return result['choices'][0]['message']['content'].strip()
except requests.exceptions.Timeout:
print("❌ LLM API timeout")
return None
except requests.exceptions.HTTPError as e:
print(f"❌ LLM API HTTP error: {e}")
if hasattr(e.response, 'text'):
print(f" Response: {e.response.text[:200]}")
return None
except requests.exceptions.RequestException as e:
print(f"❌ LLM API request error: {e}")
return None
except KeyError as e:
print(f"❌ LLM API response format error: {e}")
return None
def _analyze_with_llm_top_k(self, title: str, all_templates_info: list, top_k: int = 3) -> Dict:
"""
Use LLM API to recommend top-k templates
Args:
title: Main title text
all_templates_info: List of template info dicts
top_k: Number of templates to recommend
Returns:
Dictionary with recommendations list
"""
if self.llm_disabled:
return self._fallback_top_k(title, all_templates_info, top_k)
# Build prompt for top-k recommendation
prompt = self._build_top_k_prompt(title, all_templates_info, top_k)
# Call LLM API
try:
response = self._query_llm(prompt)
if response:
# Parse JSON response
try:
cleaned_response = response.strip()
if cleaned_response.startswith('```'):
lines = cleaned_response.split('\n')
cleaned_response = '\n'.join(lines[1:-1] if lines[-1].strip() == '```' else lines[1:])
cleaned_response = cleaned_response.replace('```json', '').replace('```', '').strip()
result = json.loads(cleaned_response)
# Validate return format
if 'recommendations' in result and isinstance(result['recommendations'], list):
return result
else:
print("❌ LLM response missing required 'recommendations' field")
return self._fallback_top_k(title, all_templates_info, top_k)
except json.JSONDecodeError as e:
print(f"❌ LLM response is not valid JSON")
return self._fallback_top_k(title, all_templates_info, top_k)
else:
return self._fallback_top_k(title, all_templates_info, top_k)
except Exception as e:
print(f"❌ LLM analysis error: {e}")
return self._fallback_top_k(title, all_templates_info, top_k)
def _build_recommendation_prompt(self, title: str, has_description: bool, templates_info: Optional[List[Dict]] = None) -> str:
"""Build LLM recommendation prompt"""
# Build default templates info if not provided
if not templates_info:
# Use a basic set of templates
templates_list = [
{"name": "infographic_standard", "description": "Single line bold title", "segments": 1},
{"name": "two_line_hierarchy", "description": "Two-line: small prefix + large main title", "segments": 2},
{"name": "three_line_emphasis", "description": "Three-line: prefix + emphasized middle + suffix", "segments": 3}
]
else:
templates_list = templates_info
templates_desc = "\n".join([
f"- **{t['name']}**: {t.get('description', 'No description')}, segments: {t.get('segment_count', len(t.get('segments', [])))}"
for t in templates_list
])
prompt = f"""
Analyze this infographic title to recommend the layout template and split strategy.
Input Data:
- Title: "{title}"
- Has Subtitle/Description: {has_description}
### Task
1. Identify the **PRIMARY** focus (The "Hero" of the title) and **SECONDARY** context.
2. Recommend the best template (`infographic_standard`, `two_line_hierarchy`, or `three_line_emphasis`).
3. Split the title text strictly according to the template rules.
### 1. Semantic Analysis Rules
**PRIMARY Segment (The "Hero"):**
- The core metric, main topic, or "What" the chart shows.
- *Examples:* "GDP Growth", "Carbon Emissions", "Mobile Phones", "Annual Report".
- **Constraint:** NEVER split a noun phrase inside the Primary segment.
**SECONDARY Segment (The "Context"):**
- Modifiers, Prepositions, Locations, Time, or "Which/Where/When".
- *Examples:* "The United States of...", "Trends in...", "...of All Time", "Global...", "Top 10...".
- *Note:* If a title contains both a Metric (e.g., Inflation) and a Location (e.g., in Europe), treat the Metric as PRIMARY and Location as SECONDARY.
### 2. Template Selection Guidelines
**Option A: infographic_standard**
- *Criteria:* Short titles (1-5 words) OR titles that are a single cohesive phrase.
- *Split Strategy:* Return as a single string in the array.
- *Example:* ["Annual Report 2023"]
**Option B: two_line_hierarchy**
- *Criteria:* Strong prefix (Context) + Main Topic (Hero). Good for "The X of Y" structures.
- *Split Strategy:* - Line 1: Secondary Context (Prefix)
- Line 2: Primary Topic (Hero)
- *Example:* ["The United States of", "Food Inflation"]
**Option C: three_line_emphasis**
- *Criteria:* Long titles (6+ words) with a "Sandwich" structure: Context + Hero + Context.
- *Split Strategy:*
- Line 1: Secondary Context (Prefix)
- Line 2: Primary Topic (Hero - Centerpiece)
- Line 3: Secondary Context (Suffix)
- *Example:* ["The Best Selling", "Mobile Phones", "of All Time"]
### Output Format
Return ONLY valid JSON:
```json
{{
"recommended_template": "template_name",
"title_split": ["string1", "string2", ...],
"reasoning": "Explain why this template was chosen based on the Primary/Secondary analysis."
}}
"""
return prompt
def _build_top_k_prompt(self, title: str, all_templates_info: list, top_k: int) -> str:
"""
Build prompt for top-k template recommendation
Args:
title: Main title text
all_templates_info: List of template info with segment_roles
top_k: Number of recommendations
Returns:
Prompt string
"""
# Build templates list for prompt
templates_desc = []
for info in all_templates_info:
name = info['name']
segment_roles = info.get('segment_roles', 'No role description available')
segment_count = info.get('segment_count', 1)
templates_desc.append(f"- **{name}** ({segment_count} segments): {segment_roles}")
templates_text = "\n".join(templates_desc)
prompt = f"""You are an expert in infographic title design and linguistic analysis.
**Task**: Analyze the given title and recommend the top {top_k} most suitable templates based on grammatical structure and semantic roles.
**Input Title**: "{title}"
**Available Templates** (with segment role descriptions):
{templates_text}
**Your Responsibilities**:
1. **Grammatical Analysis**: Parse the title to identify its grammatical components (subject, modifier, qualifier, possessive, prepositional phrase, etc.)
2. **Structural Matching**: Match the title's structure against each template's segment_roles based on:
- Number of natural semantic units in the title
- Grammatical roles of each unit (modifier, subject, qualifier, etc.)
- Visual hierarchy implied by the content
3. **Title Adaptation**: If necessary, **paraphrase or restructure** the title to better fit the template:
- Preserve the core meaning
- Adjust phrasing to match segment roles
- Optimize for visual impact
4. **Segmentation**: For each recommended template, split the title (original or paraphrased) into segments that match the template's segment_roles.
5. **Ranking**: Order recommendations by confidence, considering both grammatical fit and visual effectiveness.
**Output Format** (return ONLY valid JSON, no markdown):
{{
"recommendations": [
{{
"template_name": "template_name",
"title_split": ["segment1", "segment2", ...],
"paraphrased_title": "Restructured title if modified, or null if unchanged",
"confidence": 0.95,
"reasoning": "Brief explanation of grammatical structure match and any paraphrasing done"
}}
]
}}
**Critical Guidelines on Segment Importance**:
- **PRIMARY importance**: Core subject nouns, key entities, or concrete data/metrics that form the main topic
* Examples: "Life Expectancy", "Mobile Phones", "Carbon Emissions", "Europe", "GDP Growth"
* These are the focal subjects - the "what" the infographic is about
* Will be rendered with larger, bolder, more prominent styling
* **KEEP PRIMARY INTACT**: Do not split core noun phrases (e.g., keep "Life Expectancy" together)
- **SECONDARY importance**: Modifiers, time references, actions, prepositions, and contextual phrases
* Examples:
- Time references: "Since 1950", "in 2023", "by 2030"
- Actions/verbs: "Gains", "Changes", "Growth"
- Prepositions: "by Country", "via Technology", "of All Time"
- Quantifiers/modifiers: "Top 10", "Best-Selling", "Average"
* These provide context but are NOT the main subject
* Will be rendered with smaller, less prominent styling
**Key Principle**: Identify the SUBJECT first (PRIMARY), then treat everything else as decoration (SECONDARY).
- Example: "Average Life Expectancy Gains Since 1950" → PRIMARY: "Life Expectancy", SECONDARY: "Average...Gains Since 1950"
- Example: "Europe's Best-Selling Car Brands" → PRIMARY: "Car Brands" (or "Europe"), SECONDARY: "Best-Selling"
**Other Guidelines**:
- Ensure title_split length matches the template's segment count
- Consider grammatical roles (possessive, modifier, subject, etc.)
- Paraphrase when it improves fit without changing meaning
- Prioritize templates with natural grammatical alignment
- Provide diverse options (avoid similar templates)
- Order by confidence (highest first)
- **Identify topic keywords and metrics for PRIMARY segments, context for SECONDARY**
"""
return prompt
# Convenience function