Spaces:
Sleeping
Sleeping
| """ | |
| 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 | |