Skill Tracer
Skills › Scientific Agent Skills › infographics › generate_infographic_ai.py

generate_infographic_ai.py

1364 lines · skills/infographics/scripts/generate_infographic_ai.py at 5f2bc03

1#!/usr/bin/env python3
2"""
3AI-powered infographic generation using Nano Banana 2.
4
5This script uses a smart iterative refinement approach:
61. (Optional) Research phase - gather facts and data using Perplexity Sonar
72. Generate initial infographic with Nano Banana 2
83. AI quality review using Gemini 3.7 Flash for infographic critique
94. Only regenerate if quality is below threshold for document type
105. Repeat until quality meets standards (max iterations)
12Requirements:
13 - OPENROUTER_API_KEY environment variable
14 - requests library
16Usage:
17 python generate_infographic_ai.py "5 benefits of exercise" -o benefits.png --type list
18 python generate_infographic_ai.py "Global AI market size" -o ai_market.png --type statistical --research
19 python generate_infographic_ai.py "Company history 2010-2025" -o timeline.png --type timeline --style corporate
20"""
22import argparse
23import base64
24import json
25import os
26import re
27import sys
28import time
29from pathlib import Path
30from datetime import date
31from typing import Optional, Dict, Any, List, NamedTuple
33try:
34 import requests
35except ImportError:
36 print("Error: requests library not found. Install with: uv pip install requests")
37 sys.exit(1)
40class ReviewResult(NamedTuple):
41 """The outcome of one quality review.
43 A named tuple rather than a bare tuple because the review has several
44 failure modes -- the model returns no choices, the request raises, the
45 answer arrives in an unexpected shape -- and each of them used to be a
46 chance to return the wrong number of values to the caller.
48 `score` is None whenever no usable number was parsed, and `reviewed` is
49 False whenever the review never produced an answer at all. Neither case
50 invents a score: an infographic whose quality was not measured is reported
51 as unmeasured, not as passing.
52 """
54 critique: str
55 score: Optional[float]
56 needs_improvement: bool
57 reviewed: bool
58 error: Optional[str] = None
61# Markdown decoration the reviewer routinely wraps its answer in. Gemini writes
62# "**SCORE:** 8.5" far more often than the bare "SCORE: 8.5" the rubric asks
63# for, and a pattern that does not allow for it silently scores every image
64# at whatever the default happens to be.
65_MARKUP = re.compile(r"[*_`#]")
67_SCORE_PATTERN = re.compile(r"\bSCORE\s*:?\s*(\d+(?:\.\d+)?)", re.IGNORECASE)
68_LOOSE_SCORE_PATTERN = re.compile(
69 r"(?:score|rating|quality)\s*[:\s]\s*(\d+(?:\.\d+)?)\s*(?:/\s*10)?", re.IGNORECASE
71# The underscore is optional because normalization strips it out of
72# NEEDS_IMPROVEMENT along with the surrounding markdown.
73_VERDICT_PATTERN = re.compile(
74 r"^\s*VERDICT\s*:?\s*(ACCEPTABLE|NEEDS[_ ]?IMPROVEMENT)", re.IGNORECASE | re.MULTILINE
78def _normalize_review_text(text: str) -> str:
79 """Strip markdown decoration so the rubric's fields can be found."""
80 return _MARKUP.sub("", text or "")
83def _parse_score(text: str) -> Optional[float]:
84 """Return the reviewer's 0-10 score, or None when there isn't a usable one.
86 None rather than a default: a fabricated score is indistinguishable from a
87 real one once it reaches the review log, and a default near the middle of
88 the range quietly passes lenient document types while failing strict ones.
89 """
90 normalized = _normalize_review_text(text)
91 match = _SCORE_PATTERN.search(normalized) or _LOOSE_SCORE_PATTERN.search(normalized)
92 if not match:
93 return None
94 score = float(match.group(1))
95 # A number outside the rubric's range means something other than the score
96 # was matched -- a figure count, a percentage, an iteration number.
97 return score if 0.0 <= score <= 10.0 else None
100def _parse_verdict(text: str) -> Optional[str]:
101 """Return ACCEPTABLE / NEEDS_IMPROVEMENT from the verdict line, or None.
103 Anchored to the start of a line. The rubric prompt spells out both tokens
104 when it explains the response format, so a reviewer that echoes those
105 instructions back would fail a perfectly good image under a plain
106 substring search.
107 """
108 match = _VERDICT_PATTERN.search(_normalize_review_text(text))
109 if not match:
110 return None
111 verdict = match.group(1).upper().replace(" ", "").replace("_", "")
112 return "NEEDS_IMPROVEMENT" if verdict == "NEEDSIMPROVEMENT" else "ACCEPTABLE"
115def _resolve_api_key(explicit: Optional[str] = None) -> Optional[str]:
116 """Resolve the OpenRouter key from --api-key, the environment, then any .env file.
118 The .env scan walks up from the working directory and finally checks the
119 script's own directory, so running from anywhere inside a project picks up
120 the key at its root. Only the standard library is used: python-dotenv is a
121 common omission, and a missing optional dependency should not read as a
122 missing credential.
123 """
124 if explicit:
125 return explicit
127 from_env = os.environ.get("OPENROUTER_API_KEY", "").strip()
128 if from_env:
129 return from_env
131 cwd = Path.cwd()
132 for directory in [cwd, *cwd.parents, Path(__file__).resolve().parent]:
133 env_file = directory / ".env"
134 if not env_file.is_file():
135 continue
136 try:
137 content = env_file.read_text(encoding="utf-8", errors="replace")
138 except OSError:
139 continue
140 for raw in content.splitlines():
141 line = raw.strip()
142 if line.startswith("#") or "=" not in line:
143 continue
144 name, _, value = line.partition("=")
145 if name.strip() == "OPENROUTER_API_KEY":
146 value = value.strip().strip('"').strip("'")
147 if value:
148 return value
150 return None
153# Infographic type configurations with detailed prompting
154INFOGRAPHIC_TYPES = {
155 "statistical": {
156 "name": "Statistical/Data-Driven",
157 "guidelines": """
158STATISTICAL INFOGRAPHIC REQUIREMENTS:
159- Large, bold numbers that are immediately readable
160- Clear data visualization (bar charts, pie charts, or donut charts)
161- Data callouts with context (e.g., "15% increase")
162- Trend indicators (arrows, growth symbols)
163- Legend if multiple data series
164- Source attribution area at bottom
165- Clean grid or alignment for data elements
166"""
167 },
168 "timeline": {
169 "name": "Timeline/Chronological",
170 "guidelines": """
171TIMELINE INFOGRAPHIC REQUIREMENTS:
172- Clear chronological flow (horizontal or vertical)
173- Prominent date/year markers
174- Connecting line or path between events
175- Event nodes (circles, icons, or markers)
176- Brief event descriptions
177- Consistent spacing between events
178- Visual progression indicating time direction
179- Start and end points clearly marked
180"""
181 },
182 "process": {
183 "name": "Process/How-To",
184 "guidelines": """
185PROCESS INFOGRAPHIC REQUIREMENTS:
186- Numbered steps (1, 2, 3, etc.) clearly visible
187- Directional arrows between steps
188- Action-oriented icons for each step
189- Brief action text for each step
190- Clear start and end indicators
191- Logical flow direction (top-to-bottom or left-to-right)
192- Visual connection between sequential steps
193"""
194 },
195 "comparison": {
196 "name": "Comparison",
197 "guidelines": """
198COMPARISON INFOGRAPHIC REQUIREMENTS:
199- Symmetrical side-by-side layout
200- Clear headers for each option being compared
201- Matching rows/categories for fair comparison
202- Visual indicators (checkmarks, X marks, ratings)
203- Balanced visual weight on both sides
204- Color differentiation between options
205- Summary or verdict section if applicable
206"""
207 },
208 "list": {
209 "name": "List/Informational",
210 "guidelines": """
211LIST INFOGRAPHIC REQUIREMENTS:
212- Large, clear numbers or bullet points
213- Icons representing each list item
214- Brief, scannable text descriptions
215- Consistent visual treatment for all items
216- Clear hierarchy (title, items, details)
217- Adequate spacing between items
218- Visual variety to prevent monotony
219"""
220 },
221 "geographic": {
222 "name": "Geographic/Map-Based",
223 "guidelines": """
224GEOGRAPHIC INFOGRAPHIC REQUIREMENTS:
225- Accurate map representation
226- Color-coded regions based on data
227- Clear legend explaining color scale
228- Data callouts for key regions
229- Region labels where space permits
230- Scale or context indicator
231- Clean cartographic style
232"""
233 },
234 "hierarchical": {
235 "name": "Hierarchical/Pyramid",
236 "guidelines": """
237HIERARCHICAL INFOGRAPHIC REQUIREMENTS:
238- Clear pyramid or tree structure
239- Distinct levels with visual separation
240- Size progression (larger at base, smaller at top)
241- Labels for each tier
242- Color gradient or distinct colors per level
243- Clear top-to-bottom or bottom-to-top hierarchy
244- Balanced, centered composition
245"""
246 },
247 "anatomical": {
248 "name": "Anatomical/Visual Metaphor",
249 "guidelines": """
250ANATOMICAL INFOGRAPHIC REQUIREMENTS:
251- Central metaphor image (body, tree, machine, etc.)
252- Labeled components with callout lines
253- Clear connection between labels and parts
254- Explanatory text for each labeled part
255- Consistent callout style throughout
256- Educational, diagram-like appearance
257"""
258 },
259 "resume": {
260 "name": "Resume/Professional",
261 "guidelines": """
262RESUME INFOGRAPHIC REQUIREMENTS:
263- Professional photo/avatar placeholder area
264- Skill visualization (bars, charts, ratings)
265- Experience timeline or list
266- Contact information section with icons
267- Achievement or certification badges
268- Clean, professional layout
269- Personal branding colors
270"""
271 },
272 "social": {
273 "name": "Social Media",
274 "guidelines": """
275SOCIAL MEDIA INFOGRAPHIC REQUIREMENTS:
276- Bold, attention-grabbing headline
277- Large, impactful central statistic or visual
278- Minimal text, maximum visual impact
279- Platform-appropriate format (square for Instagram)
280- Vibrant, eye-catching colors
281- Call-to-action element
282- Brand/logo placement area
283"""
284 },
287# Industry style configurations
288STYLE_PRESETS = {
289 "corporate": {
290 "name": "Corporate/Business",
291 "colors": "navy blue (#1E3A5F), steel blue (#4A90A4), gold (#F5A623) accents",
292 "description": "Clean, professional, minimal design with structured layout",
293 },
294 "healthcare": {
295 "name": "Healthcare/Medical",
296 "colors": "medical blue (#0077B6), cyan (#00B4D8), light cyan (#90E0EF)",
297 "description": "Trust-inducing, clinical, clean design",
298 },
299 "technology": {
300 "name": "Technology/Data",
301 "colors": "tech blue (#2563EB), slate gray (#475569), violet (#7C3AED) accents",
302 "description": "Modern, innovative, futuristic design",
303 },
304 "nature": {
305 "name": "Nature/Environmental",
306 "colors": "forest green (#2D6A4F), mint (#95D5B2), earth brown (#8B4513)",
307 "description": "Organic, natural, earth-toned design",
308 },
309 "education": {
310 "name": "Education/Academic",
311 "colors": "academic blue (#3D5A80), light blue (#98C1D9), coral (#EE6C4D) accents",
312 "description": "Friendly, approachable, educational design",
313 },
314 "marketing": {
315 "name": "Marketing/Creative",
316 "colors": "coral (#FF6B6B), teal (#4ECDC4), yellow (#FFE66D)",
317 "description": "Bold, vibrant, eye-catching design",
318 },
319 "finance": {
320 "name": "Finance/Investment",
321 "colors": "navy (#14213D), gold (#FCA311), green (#2ECC71) for positive",
322 "description": "Conservative, trustworthy, professional design",
323 },
324 "nonprofit": {
325 "name": "Nonprofit/Cause",
326 "colors": "warm orange (#E07A5F), sage green (#81B29A), sand (#F2CC8F)",
327 "description": "Warm, human-centered, impactful design",
328 },
331# Colorblind-safe palette options
332PALETTE_PRESETS = {
333 "wong": {
334 "name": "Wong's Palette",
335 "colors": "orange (#E69F00), sky blue (#56B4E9), bluish green (#009E73), yellow (#F0E442), blue (#0072B2), vermillion (#D55E00), reddish purple (#CC79A7), black (#000000)",
336 },
337 "ibm": {
338 "name": "IBM Colorblind-Safe",
339 "colors": "ultramarine (#648FFF), indigo (#785EF0), magenta (#DC267F), orange (#FE6100), gold (#FFB000)",
340 },
341 "tol": {
342 "name": "Tol's Muted Qualitative",
343 "colors": "rose (#CC6677), indigo (#332288), sand (#DDCC77), green (#117733), cyan (#88CCEE), wine (#882255), teal (#44AA99), olive (#999933), purple (#AA4499); pale gray (#DDDDDD) only for missing data",
344 },
348class InfographicGenerator:
349 """Generate infographics using AI with smart iterative refinement.
351 Uses Gemini 3.7 Flash for quality review to determine if regeneration is needed.
352 Multiple passes only occur if the generated infographic doesn't meet the
353 quality threshold for the target document type.
354 """
356 # Quality thresholds by document type (score out of 10)
357 QUALITY_THRESHOLDS = {
358 "marketing": 8.5, # Marketing materials - must be compelling
359 "report": 8.0, # Business reports - professional quality
360 "presentation": 7.5, # Slides/talks - clear and engaging
361 "social": 7.0, # Social media - eye-catching
362 "internal": 7.0, # Internal use - good quality
363 "draft": 6.5, # Draft/working - acceptable
364 "default": 7.5, # Default threshold
365 }
367 # Base infographic design guidelines
368 INFOGRAPHIC_GUIDELINES = """
369Create a high-quality professional infographic with these requirements:
371VISUAL QUALITY:
372- Clean white or light solid color background (no busy textures)
373- High contrast for readability
374- Professional, polished appearance
375- Sharp, clear graphics and text
376- Adequate spacing to prevent crowding
377- Balanced composition
379TYPOGRAPHY:
380- Bold, attention-grabbing headline
381- Clear, readable sans-serif fonts
382- Minimum 12pt font size for body text
383- Larger text for key statistics and headlines
384- Consistent font hierarchy throughout
385- No overlapping text
387LAYOUT:
388- Clear visual hierarchy (most important info largest/first)
389- Logical reading flow (top-to-bottom or left-to-right)
390- 60% visual elements, 40% text (approximately)
391- Adequate white space between sections
392- Balanced left-right composition
393- Clear section divisions
395DATA VISUALIZATION:
396- Large, bold numbers for key statistics
397- Clear, accurate charts and graphs
398- Labeled axes and data points
399- Legend where needed
400- Icons that clearly represent concepts
402ACCESSIBILITY:
403- Colorblind-friendly color choices
404- High contrast between text and background
405- Shapes and labels, not just colors, to convey meaning
406- Works in grayscale
408IMPORTANT - NO META CONTENT:
409- Do NOT include the prompt, instructions, or metadata in the image
410- Do NOT include layout descriptions like "left panel", "right panel"
411- Do NOT include font or color specifications
412- Only include the actual infographic content
413"""
415 def __init__(self, api_key: Optional[str] = None, verbose: bool = False):
416 """Initialize the generator."""
417 self.api_key = _resolve_api_key(api_key)
419 if not self.api_key:
420 raise ValueError(
421 "OPENROUTER_API_KEY not found. Please either:\n"
422 " 1. Set the OPENROUTER_API_KEY environment variable\n"
423 " 2. Add OPENROUTER_API_KEY to your .env file\n"
424 " 3. Pass api_key parameter to the constructor\n"
425 "Get your API key from: https://openrouter.ai/keys"
426 )
428 self.verbose = verbose
429 self._last_error = None
430 self.base_url = "https://openrouter.ai/api/v1"
431 # Nano Banana 2 for image generation. The slug must be an image-output
432 # model; a text-only chat model is rejected with "No endpoints found that
433 # support the requested output modalities".
434 # https://openrouter.ai/google/gemini-3.1-flash-image
435 self.image_model = "google/gemini-3.1-flash-image"
436 # Gemini 3.7 Flash for quality review - reads the image, answers in text
437 self.review_model = "google/gemini-3.7-flash"
438 self.research_model = "perplexity/sonar-pro"
440 def _log(self, message: str):
441 """Log message if verbose mode is enabled."""
442 if self.verbose:
443 print(f"[{time.strftime('%H:%M:%S')}] {message}")
445 # ========== RESEARCH METHODS ==========
447 def research_topic(self, topic: str, infographic_type: Optional[str] = None) -> Dict[str, Any]:
448 """
449 Research a topic using Perplexity Sonar Pro to gather facts and data.
451 Args:
452 topic: The topic to research
453 infographic_type: Type of infographic to tailor the research
455 Returns:
456 Dictionary with research results including facts, statistics, and sources
457 """
458 self._log(f"Researching topic: {topic}")
460 # Build research query based on infographic type
461 type_context = ""
462 if infographic_type:
463 if infographic_type == "statistical":
464 type_context = "Focus on statistics, numbers, percentages, and quantitative data."
465 elif infographic_type == "timeline":
466 type_context = "Focus on key dates, milestones, and chronological events."
467 elif infographic_type == "process":
468 type_context = "Focus on steps, procedures, and sequential information."
469 elif infographic_type == "comparison":
470 type_context = "Focus on comparing different options, pros/cons, and differences."
471 elif infographic_type == "list":
472 type_context = "Focus on key points, tips, facts, and organized information."
473 elif infographic_type == "geographic":
474 type_context = "Focus on regional data, location-based statistics, and geographic distribution."
475 elif infographic_type == "hierarchical":
476 type_context = "Focus on levels, rankings, and hierarchical relationships."
478 research_prompt = f"""You are a research assistant gathering information for an infographic.
480TOPIC: {topic}
482{type_context}
484Please provide:
4851. KEY FACTS: 5-8 key facts or statistics about this topic (with specific numbers where possible)
4862. CONTEXT: Brief background context (2-3 sentences)
4873. SOURCES: Provide primary-source URLs, publication dates, and which claims they support
4884. DATA POINTS: Any specific data points that would make good visualizations
490Format your response as structured data that can be easily incorporated into an infographic.
491Be specific with numbers, percentages, and dates.
492As of {date.today().isoformat()}, prioritize relevant recent evidence; retain original dates for historical events.
493Do not invent missing numbers. Include units, populations, denominators, and source URLs."""
495 messages = [
496 {
497 "role": "system",
498 "content": "You are an expert research assistant. Provide accurate, well-sourced information formatted for infographic creation. Include numbers, dates, and statistics only when supported by cited evidence."
499 },
500 {"role": "user", "content": research_prompt}
501 ]
503 return self._research_request(messages, max_tokens=2000, search_context="high")
505 @staticmethod
506 def _text_content(message: Dict[str, Any]) -> str:
507 """Read returned answer text without treating internal reasoning as an answer."""
508 content = message.get("content")
509 if isinstance(content, str):
510 return content
511 if isinstance(content, list):
512 return "\n".join(
513 block["text"] for block in content
514 if isinstance(block, dict) and block.get("type") == "text"
515 and isinstance(block.get("text"), str)
516 )
517 return ""
519 def _research_request(self, messages: List[Dict[str, Any]], max_tokens: int,
520 search_context: str = "medium") -> Dict[str, Any]:
521 """Preserve normalized citations from OpenRouter's Sonar response."""
522 try:
523 result = self._post_request("chat/completions", {
524 "model": self.research_model,
525 "messages": messages,
526 "max_tokens": max_tokens,
527 "temperature": 0.1,
528 "web_search_options": {"search_context_size": search_context},
529 })
530 choices = result.get("choices", [])
531 if not choices:
532 raise RuntimeError("No response from research model")
533 message = choices[0].get("message", {})
534 content = self._text_content(message)
535 if not content.strip():
536 raise RuntimeError("Research response contained no answer text")
537 annotations = message.get("annotations") or []
538 sources = [
539 item["url_citation"] for item in annotations
540 if isinstance(item, dict) and item.get("type") == "url_citation"
541 and isinstance(item.get("url_citation"), dict)
542 ]
543 return {
544 "success": True, "content": content, "sources": sources,
545 "annotations": annotations,
546 # Preserve provider fields if present; normalized citations above
547 # are the documented OpenRouter response contract.
548 "search_results": result.get("search_results", []),
549 "citations": result.get("citations", []),
550 "model": self.research_model,
551 "retrieved_on": date.today().isoformat(),
552 }
553 except Exception as exc:
554 detail = str(exc).replace(self.api_key, "[redacted]")
555 self._log(f"Research failed: {detail}")
556 return {"success": False, "error": detail, "model": self.research_model}
558 def web_search(self, query: str) -> Dict[str, Any]:
559 """
560 Perform a quick web search for current information.
562 Args:
563 query: Search query
565 Returns:
566 Dictionary with search results
567 """
568 self._log(f"Web search: {query}")
570 search_prompt = f"""Search for current information about: {query}
572Provide:
5731. The most relevant and recent facts
5742. Any statistics or numbers
5753. Key dates if applicable
5764. Brief source attribution
578Be concise and factual. Focus on information useful for an infographic."""
580 messages = [
581 {
582 "role": "system",
583 "content": "You are a web search assistant. Provide accurate, current information with sources."
584 },
585 {"role": "user", "content": search_prompt}
586 ]
588 return self._research_request(messages, max_tokens=1000)
590 def _enhance_prompt_with_research(self, user_prompt: str, research_data: Dict[str, Any]) -> str:
591 """
592 Enhance the user prompt with researched information.
594 Args:
595 user_prompt: Original user prompt
596 research_data: Research results dictionary
598 Returns:
599 Enhanced prompt with research data
600 """
601 if not research_data.get("success") or not research_data.get("content"):
602 return user_prompt
604 enhanced = f"""{user_prompt}
606RESEARCH CANDIDATES (check cited evidence before publication; do not invent additional claims):
607{research_data['content']}
609SOURCE RECORDS:
610{json.dumps(research_data.get('sources', []), ensure_ascii=False)}
612Use the above researched facts, statistics, and data points to create an accurate, informative infographic.
613Incorporate specific numbers, percentages, and dates from the research."""
615 return enhanced
617 # ========== END RESEARCH METHODS ==========
619 def _post_request(self, endpoint: str, payload: Dict[str, Any]) -> Dict[str, Any]:
620 """Send one non-streaming request; never automatically replay a paid call."""
621 headers = {
622 "Authorization": f"Bearer {self.api_key}",
623 "Content-Type": "application/json",
624 "X-OpenRouter-Title": "Infographic Generator",
625 }
626 self._log(f"POST {endpoint} using {payload['model']}")
627 try:
628 response = requests.post(
629 f"{self.base_url}/{endpoint}", headers=headers,
630 json=payload, timeout=120,
631 )
632 try:
633 response_json = response.json()
634 except ValueError:
635 raise RuntimeError(
636 f"API returned non-JSON data (HTTP {response.status_code})"
637 ) from None
638 if not isinstance(response_json, dict):
639 raise RuntimeError("API response must be a JSON object")
640 if response.status_code != 200 or "error" in response_json:
641 error = response_json.get("error", {})
642 detail = error.get("message", "request rejected") if isinstance(error, dict) else str(error)
643 # Providers occasionally echo input; do not dump whole bodies or credentials.
644 detail = str(detail).replace(self.api_key, "[redacted]")[:500]
645 raise RuntimeError(f"API request failed (HTTP {response.status_code}): {detail}")
646 return response_json
647 except requests.exceptions.Timeout:
648 raise RuntimeError("API request timed out after 120 seconds") from None
649 except requests.exceptions.RequestException as exc:
650 detail = str(exc).replace(self.api_key, "[redacted]")
651 raise RuntimeError(f"API request failed: {detail}") from None
653 def _make_request(self, model: str, messages: List[Dict[str, Any]],
654 modalities: Optional[List[str]] = None) -> Dict[str, Any]:
655 """Use chat completions for the text-and-image vision review."""
656 payload = {"model": model, "messages": messages}
657 if modalities:
658 payload["modalities"] = modalities
659 return self._post_request("chat/completions", payload)
661 def _extract_image_from_response(self, response: Dict[str, Any]) -> bytes:
662 """Decode the dedicated Image API response and enforce this helper's PNG contract.
664 The provider may omit media_type; the PNG signature still has to match.
665 This is a format check, not a full image-decoder or scientific-content check.
666 """
667 data = response.get("data")
668 if not isinstance(data, list) or not data or not isinstance(data[0], dict):
669 raise RuntimeError("Image API returned no data[0] image")
670 item = data[0]
671 if item.get("media_type") not in (None, "image/png"):
672 raise RuntimeError(f"Expected PNG output; received {item['media_type']}")
673 encoded = item.get("b64_json")
674 if not isinstance(encoded, str) or not encoded:
675 raise RuntimeError("Image API returned no b64_json image")
676 try:
677 image_data = base64.b64decode(encoded, validate=True)
678 except ValueError:
679 raise RuntimeError("Image API returned invalid base64 image data") from None
680 if not image_data.startswith(b"\x89PNG\r\n\x1a\n"):
681 raise RuntimeError("Image API output does not have a PNG signature")
682 return image_data
684 def _image_to_base64(self, image_path: str) -> str:
685 """Convert image file to base64 data URL."""
686 ext = Path(image_path).suffix.lower()
687 if ext not in {".png", ".jpg", ".jpeg", ".gif", ".webp"}:
688 raise ValueError("Reference images must be PNG, JPEG, GIF, or WebP")
689 with open(image_path, "rb") as f:
690 image_data = f.read()
691 mime_type = {
692 ".png": "image/png",
693 ".jpg": "image/jpeg",
694 ".jpeg": "image/jpeg",
695 ".gif": "image/gif",
696 ".webp": "image/webp"
697 }.get(ext, "image/png")
699 base64_data = base64.b64encode(image_data).decode("utf-8")
700 return f"data:{mime_type};base64,{base64_data}"
702 def _build_generation_prompt(self, user_prompt: str,
703 infographic_type: Optional[str] = None,
704 style: Optional[str] = None,
705 palette: Optional[str] = None,
706 background: str = "white",
707 context_image_count: int = 0) -> str:
708 """Build the full generation prompt with all enhancements."""
709 parts = [self.INFOGRAPHIC_GUIDELINES]
711 # Add type-specific guidelines
712 if infographic_type and infographic_type in INFOGRAPHIC_TYPES:
713 type_config = INFOGRAPHIC_TYPES[infographic_type]
714 parts.append(f"\nINFOGRAPHIC TYPE: {type_config['name']}")
715 parts.append(type_config['guidelines'])
717 # Add style preset
718 if style and style in STYLE_PRESETS:
719 style_config = STYLE_PRESETS[style]
720 parts.append(f"\nSTYLE: {style_config['name']}")
721 parts.append(f"Colors: {style_config['colors']}")
722 parts.append(f"Design: {style_config['description']}")
724 # Add colorblind-safe palette
725 if palette and palette in PALETTE_PRESETS:
726 palette_config = PALETTE_PRESETS[palette]
727 parts.append(f"\nCOLORBLIND-SAFE PALETTE: {palette_config['name']}")
728 parts.append(f"Use these colors: {palette_config['colors']}")
730 # Add user request
731 parts.append(f"\nUSER REQUEST: {user_prompt}")
733 if context_image_count:
734 plural = "image" if context_image_count == 1 else "images"
735 parts.append(
736 f"\nREFERENCE CONTEXT: {context_image_count} reference {plural} attached. "
737 "Use them as visual/content context for the infographic while following the user request."
738 )
740 # Add background
741 parts.append(f"\nBackground: {background} background")
743 # Final instruction
744 parts.append("\nGenerate a professional, publication-quality infographic that meets all the guidelines above.")
746 return "\n".join(parts)
748 def generate_image(self, prompt: str, context_images: Optional[List[str]] = None) -> Optional[bytes]:
749 """Generate a PNG with the dedicated Image API and optional references."""
750 self._last_error = None
751 context_images = context_images or []
752 if len(context_images) > 14:
753 raise ValueError("Gemini 3.1 Flash Image accepts at most 14 reference images")
754 payload = {"model": self.image_model, "prompt": prompt, "n": 1}
755 if context_images:
756 payload["input_references"] = [
757 {"type": "image_url", "image_url": {"url": self._image_to_base64(path)}}
758 for path in context_images
759 ]
760 try:
761 # Current Gemini endpoints do not advertise output_format. Accept
762 # their default only after checking the returned PNG MIME/signature.
763 response = self._post_request("images", payload)
764 image_data = self._extract_image_from_response(response)
765 self._log(f"[OK] Generated PNG ({len(image_data)} bytes)")
766 return image_data
767 except RuntimeError as exc:
768 self._last_error = str(exc)
769 self._log(f"[FAIL] Generation failed: {self._last_error}")
770 return None
772 def review_image(self, image_path: str, original_prompt: str,
773 infographic_type: Optional[str],
774 iteration: int, doc_type: str = "default",
775 max_iterations: int = 3) -> ReviewResult:
776 """
777 Review generated infographic using Gemini 3.7 Flash for quality analysis.
779 Evaluates the infographic on multiple criteria specific to good
780 infographic design and determines if regeneration is needed.
781 """
782 image_data_url = self._image_to_base64(image_path)
784 threshold = self.QUALITY_THRESHOLDS.get(doc_type.lower(),
785 self.QUALITY_THRESHOLDS["default"])
787 type_name = "general"
788 if infographic_type and infographic_type in INFOGRAPHIC_TYPES:
789 type_name = INFOGRAPHIC_TYPES[infographic_type]["name"]
791 review_prompt = f"""You are an expert infographic designer reviewing a generated infographic for quality.
793ORIGINAL REQUEST: {original_prompt}
795INFOGRAPHIC TYPE: {type_name}
796QUALITY THRESHOLD: {threshold}/10
797ITERATION: {iteration}/{max_iterations}
799Carefully evaluate this infographic on these criteria:
8011. **Visual Hierarchy & Layout** (0-2 points)
802 - Clear visual hierarchy (most important elements prominent)
803 - Logical reading flow
804 - Balanced composition
805 - Adequate white space
8072. **Typography & Readability** (0-2 points)
808 - Text is readable and appropriately sized
809 - Headlines are bold and attention-grabbing
810 - No overlapping or cramped text
811 - Consistent font usage
8133. **Data Visualization** (0-2 points)
814 - Numbers and statistics are prominent
815 - Charts/icons are clear and accurate
816 - Data is easy to understand at a glance
817 - Labels are present where needed
8194. **Color & Accessibility** (0-2 points)
820 - Colors are harmonious and professional
821 - Sufficient contrast for readability
822 - Works for colorblind viewers
823 - Colors support the content hierarchy
8255. **Overall Impact & Professionalism** (0-2 points)
826 - Looks professional and polished
827 - Engaging and visually appealing
828 - Free of visual bugs or artifacts
829 - Achieves its communication goal
831RESPOND IN THIS EXACT FORMAT:
832SCORE: [total score 0-10]
834STRENGTHS:
835- [strength 1]
836- [strength 2]
838ISSUES:
839- [issue 1 if any]
840- [issue 2 if any]
842SPECIFIC_IMPROVEMENTS:
843- [specific improvement 1]
844- [specific improvement 2]
846VERDICT: [ACCEPTABLE or NEEDS_IMPROVEMENT]
848If score >= {threshold}, the infographic is ACCEPTABLE.
849If score < {threshold}, mark as NEEDS_IMPROVEMENT with specific suggestions."""
851 messages = [
852 {
853 "role": "user",
854 "content": [
855 {
856 "type": "text",
857 "text": review_prompt
858 },
859 {
860 "type": "image_url",
861 "image_url": {
862 "url": image_data_url
863 }
864 }
865 ]
866 }
867 ]
869 try:
870 response = self._make_request(
871 model=self.review_model,
872 messages=messages
873 )
875 choices = response.get("choices", [])
876 if not choices:
877 # A normal enough outcome -- a rate limit, a content filter, a
878 # provider hiccup. The image itself is fine; only its review is
879 # missing, and saying so beats inventing a score.
880 reason = "the review model returned no choices"
881 self._log(f"[WARN] Review unavailable: {reason}")
882 return ReviewResult(
883 f"Review unavailable: {reason}.",
884 None, False, reviewed=False, error=reason,
885 )
887 message = choices[0].get("message", {})
888 content = self._text_content(message)
890 score = _parse_score(content)
891 verdict = _parse_verdict(content)
893 if score is None and verdict is None:
894 reason = "no score or verdict found in the review"
895 self._log(f"[WARN] Review unusable: {reason}")
896 return ReviewResult(
897 content if content else f"Review unusable: {reason}.",
898 None, False, reviewed=True, error=reason,
899 )
901 needs_improvement = verdict == "NEEDS_IMPROVEMENT" or (
902 score is not None and score < threshold
903 )
905 shown = f"{score}/10" if score is not None else "not stated"
906 self._log(f"[OK] Review complete (Score: {shown}, Threshold: {threshold}/10)")
907 self._log(f" Verdict: {'Needs improvement' if needs_improvement else 'Acceptable'}")
909 return ReviewResult(
910 content if content else "Review returned no text",
911 score,
912 needs_improvement,
913 reviewed=True,
914 error=None if score is not None else "no score found in the review",
915 )
916 except Exception as e:
917 # A failed review must not fail the run -- the image is already
918 # generated and saved -- but it must not read as a pass either.
919 self._log(f"[WARN] Review failed: {str(e)}")
920 return ReviewResult(
921 f"Review failed: {str(e)}",
922 None, False, reviewed=False, error=str(e),
923 )
925 def improve_prompt(self, original_prompt: str, critique: str,
926 infographic_type: Optional[str],
927 style: Optional[str],
928 palette: Optional[str],
929 background: str,
930 iteration: int,
931 context_image_count: int = 0) -> str:
932 """Improve the generation prompt based on critique."""
934 parts = [self.INFOGRAPHIC_GUIDELINES]
936 # Add type-specific guidelines
937 if infographic_type and infographic_type in INFOGRAPHIC_TYPES:
938 type_config = INFOGRAPHIC_TYPES[infographic_type]
939 parts.append(f"\nINFOGRAPHIC TYPE: {type_config['name']}")
940 parts.append(type_config['guidelines'])
942 # Add style preset
943 if style and style in STYLE_PRESETS:
944 style_config = STYLE_PRESETS[style]
945 parts.append(f"\nSTYLE: {style_config['name']}")
946 parts.append(f"Colors: {style_config['colors']}")
947 parts.append(f"Design: {style_config['description']}")
949 # Add palette
950 if palette and palette in PALETTE_PRESETS:
951 palette_config = PALETTE_PRESETS[palette]
952 parts.append(f"\nCOLORBLIND-SAFE PALETTE: {palette_config['name']}")
953 parts.append(f"Use these colors: {palette_config['colors']}")
955 # Add original request
956 parts.append(f"\nUSER REQUEST: {original_prompt}")
958 if context_image_count:
959 plural = "image" if context_image_count == 1 else "images"
960 parts.append(
961 f"\nREFERENCE CONTEXT: {context_image_count} reference {plural} attached. "
962 "Continue using them as visual/content context while addressing the review feedback."
963 )
965 parts.append(f"\nBackground: {background} background")
967 # Add improvement instructions
968 parts.append(f"""
969ITERATION {iteration}: Based on previous review, address these specific improvements:
970{critique}
972Generate an improved version that:
9731. Fixes ALL the issues mentioned in the review
9742. Maintains the requested infographic type and style
9753. Ensures professional, publication-ready quality
9764. Has no visual bugs, overlapping elements, or readability issues
977""")
979 return "\n".join(parts)
981 def generate_iterative(self, user_prompt: str, output_path: str,
982 infographic_type: Optional[str] = None,
983 style: Optional[str] = None,
984 palette: Optional[str] = None,
985 background: str = "white",
986 iterations: int = 3,
987 doc_type: str = "default",
988 research: bool = False,
989 context_images: Optional[List[str]] = None) -> Dict[str, Any]:
990 """
991 Generate infographic with smart iterative refinement.
993 Only regenerates if the quality score is below the threshold.
995 Args:
996 user_prompt: Description of the infographic content
997 output_path: Path to save final image
998 infographic_type: Type preset (statistical, timeline, etc.)
999 style: Industry style preset
1000 palette: Colorblind-safe palette
1001 background: Background color
1002 iterations: Maximum refinement iterations
1003 doc_type: Document type for quality threshold
1004 research: If True, research the topic first for better data
1005 context_images: Optional image paths to attach as generation context
1006 """
1007 if isinstance(iterations, bool) or not isinstance(iterations, int) or iterations < 1:
1008 raise ValueError("iterations must be a positive integer")
1009 if doc_type not in self.QUALITY_THRESHOLDS:
1010 raise ValueError(f"Unknown document type: {doc_type}")
1011 for name, value, choices in (("type", infographic_type, INFOGRAPHIC_TYPES),
1012 ("style", style, STYLE_PRESETS),
1013 ("palette", palette, PALETTE_PRESETS)):
1014 if value is not None and value not in choices:
1015 raise ValueError(f"Unknown {name}: {value}")
1016 output_path = Path(output_path)
1017 if output_path.suffix.lower() != ".png":
1018 raise ValueError("Output path must end in .png")
1019 if len(context_images or []) > 14:
1020 raise ValueError("Gemini 3.1 Flash Image accepts at most 14 reference images")
1021 output_dir = output_path.parent
1022 output_dir.mkdir(parents=True, exist_ok=True)
1024 context_images = [str(Path(image_path)) for image_path in (context_images or [])]
1025 for image_path in context_images:
1026 if not Path(image_path).is_file():
1027 raise FileNotFoundError(f"Context image not found: {image_path}")
1028 if Path(image_path).suffix.lower() not in {".png", ".jpg", ".jpeg", ".gif", ".webp"}:
1029 raise ValueError("Reference images must be PNG, JPEG, GIF, or WebP")
1031 base_name = output_path.stem
1032 extension = output_path.suffix or ".png"
1034 threshold = self.QUALITY_THRESHOLDS.get(doc_type.lower(),
1035 self.QUALITY_THRESHOLDS["default"])
1037 type_name = infographic_type if infographic_type else "general"
1038 style_name = style if style else "default"
1040 results = {
1041 "user_prompt": user_prompt,
1042 "infographic_type": infographic_type,
1043 "style": style,
1044 "palette": palette,
1045 "doc_type": doc_type,
1046 "quality_threshold": threshold,
1047 "research_enabled": research,
1048 "image_model": self.image_model,
1049 "review_model": self.review_model,
1050 "research_model": self.research_model if research else None,
1051 "quality_met": False,
1052 "termination_reason": None,
1053 "research_data": None,
1054 "context_images": context_images,
1055 "iterations": [],
1056 "final_image": None,
1057 # None, not 0.0 -- a run whose review never completed has no score,
1058 # and a number here would be read as one the reviewer gave.
1059 "final_score": None,
1060 "final_reviewed": False,
1061 "success": False,
1062 "early_stop": False,
1063 "early_stop_reason": None
1066 print(f"\n{'='*60}")
1067 print(f"Generating Infographic with Nano Banana 2")
1068 print(f"{'='*60}")
1069 print(f"Content: {user_prompt}")
1070 print(f"Type: {type_name}")
1071 print(f"Style: {style_name}")
1072 print(f"Research: {'Enabled' if research else 'Disabled'}")
1073 print(f"Context Images: {len(context_images)}")
1074 print(f"Quality Threshold: {threshold}/10")
1075 print(f"Max Iterations: {iterations}")
1076 print(f"Output: {output_path}")
1077 print(f"{'='*60}\n")
1079 # ===== RESEARCH PHASE =====
1080 enhanced_prompt = user_prompt
1081 if research:
1082 print(f"\n[Research Phase]")
1083 print("-" * 40)
1084 print(f"Researching topic for accurate data...")
1086 research_result = self.research_topic(user_prompt, infographic_type)
1087 results["research_data"] = research_result
1089 if research_result.get("success"):
1090 print(f"[OK] Research complete - gathered facts and statistics")
1092 # Enhance the prompt with researched data
1093 enhanced_prompt = self._enhance_prompt_with_research(user_prompt, research_result)
1095 # Save research data to file
1096 research_path = output_dir / f"{base_name}_research.json"
1097 with open(research_path, "w", encoding="utf-8") as f:
1098 json.dump(research_result, f, indent=2)
1099 print(f"[OK] Research saved: {research_path}")
1100 else:
1101 print(f"[WARN] Research failed: {research_result.get('error', 'Unknown error')}")
1102 print(f" Proceeding with original prompt...")
1104 # Build initial prompt (using enhanced prompt if research was done)
1105 current_prompt = self._build_generation_prompt(
1106 enhanced_prompt, infographic_type, style, palette, background, len(context_images)
1109 for i in range(1, iterations + 1):
1110 print(f"\n[Iteration {i}/{iterations}]")
1111 print("-" * 40)
1113 # Generate image
1114 print(f"Generating infographic with Nano Banana 2...")
1115 image_data = self.generate_image(current_prompt, context_images)
1117 if not image_data:
1118 error_msg = getattr(self, '_last_error', 'Generation failed')
1119 print(f"[FAIL] Generation failed: {error_msg}")
1120 results["iterations"].append({
1121 "iteration": i,
1122 "success": False,
1123 "error": error_msg
1124 })
1125 results["termination_reason"] = "generation_failed"
1126 break
1128 # Save iteration image
1129 iter_path = output_dir / f"{base_name}_v{i}{extension}"
1130 with open(iter_path, "wb") as f:
1131 f.write(image_data)
1132 print(f"[OK] Saved: {iter_path}")
1134 # Review the image with the vision review model
1135 print(f"Reviewing with {self.review_model}...")
1136 review = self.review_image(
1137 str(iter_path), enhanced_prompt, infographic_type, i, doc_type, iterations
1139 if review.score is not None:
1140 print(f"[OK] Score: {review.score}/10 (threshold: {threshold}/10)")
1141 else:
1142 print(f"[WARN] Review unavailable - image kept, quality not verified")
1143 print(f" Reason: {review.error}")
1145 # Save iteration results
1146 iteration_result = {
1147 "iteration": i,
1148 "image_path": str(iter_path),
1149 "prompt_length": len(current_prompt),
1150 "critique": review.critique,
1151 "score": review.score,
1152 "reviewed": review.reviewed,
1153 "review_error": review.error,
1154 "needs_improvement": review.needs_improvement,
1155 "success": True
1157 results["iterations"].append(iteration_result)
1158 # Retain each saved draft if a later generation fails.
1159 results["final_image"] = str(iter_path)
1160 results["final_score"] = review.score
1161 results["final_reviewed"] = review.reviewed and review.score is not None
1162 results["success"] = True
1163 results["quality_met"] = (
1164 results["final_reviewed"] and review.score >= threshold
1165 and not review.needs_improvement
1168 # Check if quality is acceptable
1169 if not review.needs_improvement:
1170 if results["quality_met"]:
1171 print(f"\n[OK] Quality meets threshold ({review.score} >= {threshold})")
1172 print(f" No further iterations needed!")
1173 reason = f"Quality score {review.score} meets threshold {threshold}"
1174 else:
1175 # Regenerating cannot fix a reviewer that did not answer.
1176 print(f"\n[WARN] Stopping without a verified score - review the image yourself")
1177 reason = f"Review did not produce a score: {review.error}"
1178 results["final_image"] = str(iter_path)
1179 results["final_score"] = review.score
1180 results["final_reviewed"] = review.reviewed and review.score is not None
1181 results["success"] = True
1182 results["early_stop"] = True
1183 results["early_stop_reason"] = reason
1184 results["termination_reason"] = "quality_met" if results["quality_met"] else "review_unavailable"
1185 break
1187 # If this is the last iteration, keep the draft without claiming a pass.
1188 if i == iterations:
1189 results["termination_reason"] = "max_iterations"
1190 print(f"\n[WARN] Maximum iterations reached")
1191 results["final_image"] = str(iter_path)
1192 results["final_score"] = review.score
1193 results["final_reviewed"] = review.reviewed and review.score is not None
1194 results["success"] = True
1195 break
1197 # Quality below threshold - improve prompt
1198 print("\n[WARN] Reviewer requested improvements")
1199 print(f"Improving prompt based on feedback...")
1200 current_prompt = self.improve_prompt(
1201 enhanced_prompt, review.critique, infographic_type, style, palette, background, i + 1,
1202 len(context_images)
1205 # Copy final version to output path
1206 if results["success"] and results["final_image"]:
1207 final_iter_path = Path(results["final_image"])
1208 if final_iter_path != output_path:
1209 import shutil
1210 shutil.copy(final_iter_path, output_path)
1211 print(f"\n[OK] Final image: {output_path}")
1213 # Save review log
1214 log_path = output_dir / f"{base_name}_review_log.json"
1215 with open(log_path, "w", encoding="utf-8") as f:
1216 json.dump(results, f, indent=2)
1217 print(f"[OK] Review log: {log_path}")
1219 print(f"\n{'='*60}")
1220 print(f"Generation Complete!")
1221 if results["final_score"] is not None:
1222 print(f"Final Score: {results['final_score']}/10")
1223 else:
1224 print(f"Final Score: unavailable (the review did not produce one)")
1225 if results["early_stop"]:
1226 iterations_used = len([r for r in results['iterations'] if r.get('success')])
1227 print(f"Iterations Used: {iterations_used}/{iterations} (early stop)")
1228 print(f"{'='*60}\n")
1230 return results
1233def main():
1234 """Command-line interface."""
1235 parser = argparse.ArgumentParser(
1236 description="Generate infographics using Nano Banana 2 with smart iterative refinement",
1237 formatter_class=argparse.RawDescriptionHelpFormatter,
1238 epilog="""
1239Examples:
1240 # Generate a list infographic
1241 python generate_infographic_ai.py "5 benefits of meditation" -o benefits.png --type list
1243 # Generate a timeline with corporate style
1244 python generate_infographic_ai.py "Company history 2010-2025" -o timeline.png --type timeline --style corporate
1246 # Generate with colorblind-safe palette
1247 python generate_infographic_ai.py "Heart disease stats" -o stats.png --type statistical --palette wong
1249 # Generate with RESEARCH for accurate data (uses Perplexity Sonar)
1250 python generate_infographic_ai.py "Global AI market 2025" -o ai_market.png --type statistical --research
1252 # Generate using reference images as visual/content context
1253 python generate_infographic_ai.py "Create a branded hiring infographic" -o hiring.png --context-image logo.png --context-image brand_chart.png
1255 # Verbose output
1256 python generate_infographic_ai.py "Process diagram" -o process.png --type process -v
1258Infographic Types:
1259 statistical - Data-driven with charts and numbers
1260 timeline - Chronological events
1261 process - Step-by-step instructions
1262 comparison - Side-by-side comparison
1263 list - Tips, facts, key points
1264 geographic - Map-based visualization
1265 hierarchical - Pyramid or tree structure
1266 anatomical - Visual metaphor
1267 resume - Professional/CV style
1268 social - Social media optimized
1270Style Presets:
1271 corporate, healthcare, technology, nature, education, marketing, finance, nonprofit
1273Colorblind-Safe Palettes:
1274 wong, ibm, tol
1276Document Types (quality thresholds):
1277 marketing 8.5/10 - Marketing materials
1278 report 8.0/10 - Business reports
1279 presentation 7.5/10 - Slides/talks
1280 social 7.0/10 - Social media
1281 internal 7.0/10 - Internal use
1282 draft 6.5/10 - Working drafts
1283 default 7.5/10 - General purpose
1285Environment:
1286 OPENROUTER_API_KEY OpenRouter API key (required)
1287 """
1290 parser.add_argument("prompt", help="Description of the infographic content")
1291 parser.add_argument("-o", "--output", required=True,
1292 help="Output image path (e.g., infographic.png)")
1293 parser.add_argument("--type", "-t", choices=list(INFOGRAPHIC_TYPES.keys()),
1294 help="Infographic type preset")
1295 parser.add_argument("--style", "-s", choices=list(STYLE_PRESETS.keys()),
1296 help="Industry style preset")
1297 parser.add_argument("--palette", "-p", choices=list(PALETTE_PRESETS.keys()),
1298 help="Colorblind-safe palette")
1299 parser.add_argument("--background", "-b", default="white",
1300 help="Background color (default: white)")
1301 parser.add_argument("--iterations", type=int, default=3,
1302 help="Maximum refinement iterations (default: 3)")
1303 parser.add_argument("--doc-type", default="default",
1304 choices=["marketing", "report", "presentation", "social",
1305 "internal", "draft", "default"],
1306 help="Document type for quality threshold (default: default)")
1307 parser.add_argument("--api-key", help="OpenRouter API key (or set OPENROUTER_API_KEY)")
1308 parser.add_argument("-v", "--verbose", action="store_true",
1309 help="Verbose output")
1310 parser.add_argument("--research", "-r", action="store_true",
1311 help="Research the topic first using Perplexity Sonar for accurate data")
1312 parser.add_argument("--context-image", action="append", default=[],
1313 help="Reference image path to include as Nano Banana 2 context; repeat for multiple images")
1315 args = parser.parse_args()
1317 if args.iterations < 1:
1318 parser.error("--iterations must be at least 1")
1319 if Path(args.output).suffix.lower() != ".png":
1320 parser.error("--output must end in .png")
1322 # Check for API key - resolves --api-key, the environment, then any .env file
1323 api_key = _resolve_api_key(args.api_key)
1324 if not api_key:
1325 print("Error: OPENROUTER_API_KEY not found")
1326 print("\nSet it with:")
1327 print(" export OPENROUTER_API_KEY='your_api_key'")
1328 print("\nOr add OPENROUTER_API_KEY=your_api_key to a .env file")
1329 print("Or provide via --api-key flag")
1330 sys.exit(1)
1332 try:
1333 generator = InfographicGenerator(api_key=api_key, verbose=args.verbose)
1334 results = generator.generate_iterative(
1335 user_prompt=args.prompt,
1336 output_path=args.output,
1337 infographic_type=args.type,
1338 style=args.style,
1339 palette=args.palette,
1340 background=args.background,
1341 iterations=args.iterations,
1342 doc_type=args.doc_type,
1343 research=args.research,
1344 context_images=args.context_image
1347 if results["success"]:
1348 print(f"\n[OK] Success! Infographic saved to: {args.output}")
1349 if results.get("quality_met"):
1350 iterations_used = len([r for r in results['iterations'] if r.get('success')])
1351 print(f" (Completed in {iterations_used} iteration(s) - quality threshold met)")
1352 else:
1353 print(f" [WARN] Draft retained; quality not verified as passing ({results['termination_reason']})")
1354 sys.exit(0)
1355 else:
1356 print(f"\n[FAIL] Generation failed. Check review log for details.")
1357 sys.exit(1)
1358 except Exception as e:
1359 print(f"\n[FAIL] Error: {str(e)}")
1360 sys.exit(1)
1363if __name__ == "__main__":
1364 main()