File size: 25,557 Bytes
0db40c8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
"""
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