generate_schematic_ai.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817
  1. #!/usr/bin/env python3
  2. """
  3. AI-powered scientific schematic generation using Nano Banana 2.
  4. This script uses a smart iterative refinement approach:
  5. 1. Generate initial image with Nano Banana 2
  6. 2. AI quality review using Gemini 3.1 Pro Preview for scientific critique
  7. 3. Only regenerate if quality is below threshold for document type
  8. 4. Repeat until quality meets standards (max iterations)
  9. Requirements:
  10. - OPENROUTER_API_KEY environment variable
  11. - requests library
  12. Usage:
  13. python generate_schematic_ai.py "Create a flowchart showing CONSORT participant flow" -o flowchart.png
  14. python generate_schematic_ai.py "Neural network architecture diagram" -o architecture.png --iterations 2
  15. python generate_schematic_ai.py "Simple block diagram" -o diagram.png --doc-type poster
  16. """
  17. import argparse
  18. import base64
  19. import json
  20. import os
  21. import sys
  22. import time
  23. from pathlib import Path
  24. from typing import Optional, Dict, Any, List, Tuple
  25. try:
  26. import requests
  27. except ImportError:
  28. print("Error: requests library not found. Install with: pip install requests")
  29. sys.exit(1)
  30. # Try to load .env file from multiple potential locations
  31. def _load_env_file():
  32. """Load .env file from current directory or script directory only."""
  33. try:
  34. from dotenv import load_dotenv
  35. except ImportError:
  36. return False
  37. for candidate in [Path.cwd() / ".env", Path(__file__).resolve().parent / ".env"]:
  38. if candidate.exists():
  39. load_dotenv(dotenv_path=candidate, override=False)
  40. return True
  41. return False
  42. class ScientificSchematicGenerator:
  43. """Generate scientific schematics using AI with smart iterative refinement.
  44. Uses Gemini 3.1 Pro Preview for quality review to determine if regeneration is needed.
  45. Multiple passes only occur if the generated schematic doesn't meet the
  46. quality threshold for the target document type.
  47. """
  48. # Quality thresholds by document type (score out of 10)
  49. # Higher thresholds for more formal publications
  50. QUALITY_THRESHOLDS = {
  51. "journal": 8.5, # Nature, Science, etc. - highest standards
  52. "conference": 8.0, # Conference papers - high standards
  53. "poster": 7.0, # Academic posters - good quality
  54. "presentation": 6.5, # Slides/talks - clear but less formal
  55. "report": 7.5, # Technical reports - professional
  56. "grant": 8.0, # Grant proposals - must be compelling
  57. "thesis": 8.0, # Dissertations - formal academic
  58. "preprint": 7.5, # arXiv, etc. - good quality
  59. "default": 7.5, # Default threshold
  60. }
  61. # Scientific diagram best practices prompt template
  62. SCIENTIFIC_DIAGRAM_GUIDELINES = """
  63. Create a high-quality scientific diagram with these requirements:
  64. VISUAL QUALITY:
  65. - Clean white or light background (no textures or gradients)
  66. - High contrast for readability and printing
  67. - Professional, publication-ready appearance
  68. - Sharp, clear lines and text
  69. - Adequate spacing between elements to prevent crowding
  70. TYPOGRAPHY:
  71. - Clear, readable sans-serif fonts (Arial, Helvetica style)
  72. - Minimum 10pt font size for all labels
  73. - Consistent font sizes throughout
  74. - All text horizontal or clearly readable
  75. - No overlapping text
  76. SCIENTIFIC STANDARDS:
  77. - Accurate representation of concepts
  78. - Clear labels for all components
  79. - Include scale bars, legends, or axes where appropriate
  80. - Use standard scientific notation and symbols
  81. - Include units where applicable
  82. ACCESSIBILITY:
  83. - Colorblind-friendly color palette (use Okabe-Ito colors if using color)
  84. - High contrast between elements
  85. - Redundant encoding (shapes + colors, not just colors)
  86. - Works well in grayscale
  87. LAYOUT:
  88. - Logical flow (left-to-right or top-to-bottom)
  89. - Clear visual hierarchy
  90. - Balanced composition
  91. - Appropriate use of whitespace
  92. - No clutter or unnecessary decorative elements
  93. IMPORTANT - NO FIGURE NUMBERS:
  94. - Do NOT include "Figure 1:", "Fig. 1", or any figure numbering in the image
  95. - Do NOT add captions or titles like "Figure: ..." at the top or bottom
  96. - Figure numbers and captions are added separately in the document/LaTeX
  97. - The diagram should contain only the visual content itself
  98. """
  99. def __init__(self, api_key: Optional[str] = None, verbose: bool = False):
  100. """
  101. Initialize the generator.
  102. Args:
  103. api_key: OpenRouter API key (or use OPENROUTER_API_KEY env var)
  104. verbose: Print detailed progress information
  105. """
  106. # Priority: 1) explicit api_key param, 2) environment variable, 3) .env file
  107. self.api_key = api_key or os.getenv("OPENROUTER_API_KEY")
  108. # If not found in environment, try loading from .env file
  109. if not self.api_key:
  110. _load_env_file()
  111. self.api_key = os.getenv("OPENROUTER_API_KEY")
  112. if not self.api_key:
  113. raise ValueError(
  114. "OPENROUTER_API_KEY not found. Please either:\n"
  115. " 1. Set the OPENROUTER_API_KEY environment variable\n"
  116. " 2. Add OPENROUTER_API_KEY to your .env file\n"
  117. " 3. Pass api_key parameter to the constructor\n"
  118. "Get your API key from: https://openrouter.ai/keys"
  119. )
  120. self.verbose = verbose
  121. self._last_error = None # Track last error for better reporting
  122. self.base_url = "https://openrouter.ai/api/v1"
  123. # Nano Banana 2 - Google's advanced image generation model
  124. # https://openrouter.ai/google/gemini-3-pro-image-preview
  125. self.image_model = "google/gemini-3.1-flash-image-preview"
  126. # Gemini 3.1 Pro Preview for quality review - excellent vision and reasoning
  127. self.review_model = "google/gemini-3.1-pro-preview"
  128. def _log(self, message: str):
  129. """Log message if verbose mode is enabled."""
  130. if self.verbose:
  131. print(f"[{time.strftime('%H:%M:%S')}] {message}")
  132. def _make_request(self, model: str, messages: List[Dict[str, Any]],
  133. modalities: Optional[List[str]] = None) -> Dict[str, Any]:
  134. """
  135. Make a request to OpenRouter API.
  136. Args:
  137. model: Model identifier
  138. messages: List of message dictionaries
  139. modalities: Optional list of modalities (e.g., ["image", "text"])
  140. Returns:
  141. API response as dictionary
  142. """
  143. headers = {
  144. "Authorization": f"Bearer {self.api_key}",
  145. "Content-Type": "application/json",
  146. "HTTP-Referer": "https://github.com/scientific-writer",
  147. "X-Title": "Scientific Schematic Generator"
  148. }
  149. payload = {
  150. "model": model,
  151. "messages": messages
  152. }
  153. if modalities:
  154. payload["modalities"] = modalities
  155. self._log(f"Making request to {model}...")
  156. try:
  157. response = requests.post(
  158. f"{self.base_url}/chat/completions",
  159. headers=headers,
  160. json=payload,
  161. timeout=120
  162. )
  163. # Try to get response body even on error
  164. try:
  165. response_json = response.json()
  166. except json.JSONDecodeError:
  167. response_json = {"raw_text": response.text[:500]}
  168. # Check for HTTP errors but include response body in error message
  169. if response.status_code != 200:
  170. error_detail = response_json.get("error", response_json)
  171. self._log(f"HTTP {response.status_code}: {error_detail}")
  172. raise RuntimeError(f"API request failed (HTTP {response.status_code}): {error_detail}")
  173. return response_json
  174. except requests.exceptions.Timeout:
  175. raise RuntimeError("API request timed out after 120 seconds")
  176. except requests.exceptions.RequestException as e:
  177. raise RuntimeError(f"API request failed: {str(e)}")
  178. def _extract_image_from_response(self, response: Dict[str, Any]) -> Optional[bytes]:
  179. """
  180. Extract base64-encoded image from API response.
  181. For Nano Banana 2, images are returned in the 'images' field of the message,
  182. not in the 'content' field.
  183. Args:
  184. response: API response dictionary
  185. Returns:
  186. Image bytes or None if not found
  187. """
  188. try:
  189. choices = response.get("choices", [])
  190. if not choices:
  191. self._log("No choices in response")
  192. return None
  193. message = choices[0].get("message", {})
  194. # IMPORTANT: Nano Banana 2 returns images in the 'images' field
  195. images = message.get("images", [])
  196. if images and len(images) > 0:
  197. self._log(f"Found {len(images)} image(s) in 'images' field")
  198. # Get first image
  199. first_image = images[0]
  200. if isinstance(first_image, dict):
  201. # Extract image_url
  202. if first_image.get("type") == "image_url":
  203. url = first_image.get("image_url", {})
  204. if isinstance(url, dict):
  205. url = url.get("url", "")
  206. if url and url.startswith("data:image"):
  207. # Extract base64 data after comma
  208. if "," in url:
  209. base64_str = url.split(",", 1)[1]
  210. # Clean whitespace
  211. base64_str = base64_str.replace('\n', '').replace('\r', '').replace(' ', '')
  212. self._log(f"Extracted base64 data (length: {len(base64_str)})")
  213. return base64.b64decode(base64_str)
  214. # Fallback: check content field (for other models or future changes)
  215. content = message.get("content", "")
  216. if self.verbose:
  217. self._log(f"Content type: {type(content)}, length: {len(str(content))}")
  218. # Handle string content
  219. if isinstance(content, str) and "data:image" in content:
  220. import re
  221. match = re.search(r'data:image/[^;]+;base64,([A-Za-z0-9+/=\n\r]+)', content, re.DOTALL)
  222. if match:
  223. base64_str = match.group(1).replace('\n', '').replace('\r', '').replace(' ', '')
  224. self._log(f"Found image in content field (length: {len(base64_str)})")
  225. return base64.b64decode(base64_str)
  226. # Handle list content
  227. if isinstance(content, list):
  228. for i, block in enumerate(content):
  229. if isinstance(block, dict) and block.get("type") == "image_url":
  230. url = block.get("image_url", {})
  231. if isinstance(url, dict):
  232. url = url.get("url", "")
  233. if url and url.startswith("data:image") and "," in url:
  234. base64_str = url.split(",", 1)[1].replace('\n', '').replace('\r', '').replace(' ', '')
  235. self._log(f"Found image in content block {i}")
  236. return base64.b64decode(base64_str)
  237. self._log("No image data found in response")
  238. return None
  239. except Exception as e:
  240. self._log(f"Error extracting image: {str(e)}")
  241. import traceback
  242. if self.verbose:
  243. traceback.print_exc()
  244. return None
  245. def _image_to_base64(self, image_path: str) -> str:
  246. """
  247. Convert image file to base64 data URL.
  248. Args:
  249. image_path: Path to image file
  250. Returns:
  251. Base64 data URL string
  252. """
  253. with open(image_path, "rb") as f:
  254. image_data = f.read()
  255. # Determine image type from extension
  256. ext = Path(image_path).suffix.lower()
  257. mime_type = {
  258. ".png": "image/png",
  259. ".jpg": "image/jpeg",
  260. ".jpeg": "image/jpeg",
  261. ".gif": "image/gif",
  262. ".webp": "image/webp"
  263. }.get(ext, "image/png")
  264. base64_data = base64.b64encode(image_data).decode("utf-8")
  265. return f"data:{mime_type};base64,{base64_data}"
  266. def generate_image(self, prompt: str) -> Optional[bytes]:
  267. """
  268. Generate an image using Nano Banana 2.
  269. Args:
  270. prompt: Description of the diagram to generate
  271. Returns:
  272. Image bytes or None if generation failed
  273. """
  274. self._last_error = None # Reset error
  275. messages = [
  276. {
  277. "role": "user",
  278. "content": prompt
  279. }
  280. ]
  281. try:
  282. response = self._make_request(
  283. model=self.image_model,
  284. messages=messages,
  285. modalities=["image", "text"]
  286. )
  287. # Debug: print response structure if verbose
  288. if self.verbose:
  289. self._log(f"Response keys: {response.keys()}")
  290. if "error" in response:
  291. self._log(f"API Error: {response['error']}")
  292. if "choices" in response and response["choices"]:
  293. msg = response["choices"][0].get("message", {})
  294. self._log(f"Message keys: {msg.keys()}")
  295. # Show content preview without printing huge base64 data
  296. content = msg.get("content", "")
  297. if isinstance(content, str):
  298. preview = content[:200] + "..." if len(content) > 200 else content
  299. self._log(f"Content preview: {preview}")
  300. elif isinstance(content, list):
  301. self._log(f"Content is list with {len(content)} items")
  302. for i, item in enumerate(content[:3]):
  303. if isinstance(item, dict):
  304. self._log(f" Item {i}: type={item.get('type')}")
  305. # Check for API errors in response
  306. if "error" in response:
  307. error_msg = response["error"]
  308. if isinstance(error_msg, dict):
  309. error_msg = error_msg.get("message", str(error_msg))
  310. self._last_error = f"API Error: {error_msg}"
  311. print(f"✗ {self._last_error}")
  312. return None
  313. image_data = self._extract_image_from_response(response)
  314. if image_data:
  315. self._log(f"✓ Generated image ({len(image_data)} bytes)")
  316. else:
  317. self._last_error = "No image data in API response - model may not support image generation"
  318. self._log(f"✗ {self._last_error}")
  319. # Additional debug info when image extraction fails
  320. if self.verbose and "choices" in response:
  321. msg = response["choices"][0].get("message", {})
  322. self._log(f"Full message structure: {json.dumps({k: type(v).__name__ for k, v in msg.items()})}")
  323. return image_data
  324. except RuntimeError as e:
  325. self._last_error = str(e)
  326. self._log(f"✗ Generation failed: {self._last_error}")
  327. return None
  328. except Exception as e:
  329. self._last_error = f"Unexpected error: {str(e)}"
  330. self._log(f"✗ Generation failed: {self._last_error}")
  331. import traceback
  332. if self.verbose:
  333. traceback.print_exc()
  334. return None
  335. def review_image(self, image_path: str, original_prompt: str,
  336. iteration: int, doc_type: str = "default",
  337. max_iterations: int = 2) -> Tuple[str, float, bool]:
  338. """
  339. Review generated image using Gemini 3.1 Pro Preview for quality analysis.
  340. Uses Gemini 3.1 Pro Preview's superior vision and reasoning capabilities to
  341. evaluate the schematic quality and determine if regeneration is needed.
  342. Args:
  343. image_path: Path to the generated image
  344. original_prompt: Original user prompt
  345. iteration: Current iteration number
  346. doc_type: Document type (journal, poster, presentation, etc.)
  347. max_iterations: Maximum iterations allowed
  348. Returns:
  349. Tuple of (critique text, quality score 0-10, needs_improvement bool)
  350. """
  351. # Use Gemini 3.1 Pro Preview for review - excellent vision and analysis
  352. image_data_url = self._image_to_base64(image_path)
  353. # Get quality threshold for this document type
  354. threshold = self.QUALITY_THRESHOLDS.get(doc_type.lower(),
  355. self.QUALITY_THRESHOLDS["default"])
  356. review_prompt = f"""You are an expert reviewer evaluating a scientific diagram for publication quality.
  357. ORIGINAL REQUEST: {original_prompt}
  358. DOCUMENT TYPE: {doc_type} (quality threshold: {threshold}/10)
  359. ITERATION: {iteration}/{max_iterations}
  360. Carefully evaluate this diagram on these criteria:
  361. 1. **Scientific Accuracy** (0-2 points)
  362. - Correct representation of concepts
  363. - Proper notation and symbols
  364. - Accurate relationships shown
  365. 2. **Clarity and Readability** (0-2 points)
  366. - Easy to understand at a glance
  367. - Clear visual hierarchy
  368. - No ambiguous elements
  369. 3. **Label Quality** (0-2 points)
  370. - All important elements labeled
  371. - Labels are readable (appropriate font size)
  372. - Consistent labeling style
  373. 4. **Layout and Composition** (0-2 points)
  374. - Logical flow (top-to-bottom or left-to-right)
  375. - Balanced use of space
  376. - No overlapping elements
  377. 5. **Professional Appearance** (0-2 points)
  378. - Publication-ready quality
  379. - Clean, crisp lines and shapes
  380. - Appropriate colors/contrast
  381. RESPOND IN THIS EXACT FORMAT:
  382. SCORE: [total score 0-10]
  383. STRENGTHS:
  384. - [strength 1]
  385. - [strength 2]
  386. ISSUES:
  387. - [issue 1 if any]
  388. - [issue 2 if any]
  389. VERDICT: [ACCEPTABLE or NEEDS_IMPROVEMENT]
  390. If score >= {threshold}, the diagram is ACCEPTABLE for {doc_type} publication.
  391. If score < {threshold}, mark as NEEDS_IMPROVEMENT with specific suggestions."""
  392. messages = [
  393. {
  394. "role": "user",
  395. "content": [
  396. {
  397. "type": "text",
  398. "text": review_prompt
  399. },
  400. {
  401. "type": "image_url",
  402. "image_url": {
  403. "url": image_data_url
  404. }
  405. }
  406. ]
  407. }
  408. ]
  409. try:
  410. # Use Gemini 3.1 Pro Preview for high-quality review
  411. response = self._make_request(
  412. model=self.review_model,
  413. messages=messages
  414. )
  415. # Extract text response
  416. choices = response.get("choices", [])
  417. if not choices:
  418. return "Image generated successfully", 8.0
  419. message = choices[0].get("message", {})
  420. content = message.get("content", "")
  421. # Check reasoning field (Nano Banana 2 puts analysis here)
  422. reasoning = message.get("reasoning", "")
  423. if reasoning and not content:
  424. content = reasoning
  425. if isinstance(content, list):
  426. # Extract text from content blocks
  427. text_parts = []
  428. for block in content:
  429. if isinstance(block, dict) and block.get("type") == "text":
  430. text_parts.append(block.get("text", ""))
  431. content = "\n".join(text_parts)
  432. # Try to extract score
  433. score = 7.5 # Default score if extraction fails
  434. import re
  435. # Look for SCORE: X or SCORE: X/10 format
  436. score_match = re.search(r'SCORE:\s*(\d+(?:\.\d+)?)', content, re.IGNORECASE)
  437. if score_match:
  438. score = float(score_match.group(1))
  439. else:
  440. # Fallback: look for any score pattern
  441. score_match = re.search(r'(?:score|rating|quality)[:\s]+(\d+(?:\.\d+)?)\s*(?:/\s*10)?', content, re.IGNORECASE)
  442. if score_match:
  443. score = float(score_match.group(1))
  444. # Determine if improvement is needed based on verdict or score
  445. needs_improvement = False
  446. if "NEEDS_IMPROVEMENT" in content.upper():
  447. needs_improvement = True
  448. elif score < threshold:
  449. needs_improvement = True
  450. self._log(f"✓ Review complete (Score: {score}/10, Threshold: {threshold}/10)")
  451. self._log(f" Verdict: {'Needs improvement' if needs_improvement else 'Acceptable'}")
  452. return (content if content else "Image generated successfully",
  453. score,
  454. needs_improvement)
  455. except Exception as e:
  456. self._log(f"Review skipped: {str(e)}")
  457. # Don't fail the whole process if review fails - assume acceptable
  458. return "Image generated successfully (review skipped)", 7.5, False
  459. def improve_prompt(self, original_prompt: str, critique: str,
  460. iteration: int) -> str:
  461. """
  462. Improve the generation prompt based on critique.
  463. Args:
  464. original_prompt: Original user prompt
  465. critique: Review critique from previous iteration
  466. iteration: Current iteration number
  467. Returns:
  468. Improved prompt for next generation
  469. """
  470. improved_prompt = f"""{self.SCIENTIFIC_DIAGRAM_GUIDELINES}
  471. USER REQUEST: {original_prompt}
  472. ITERATION {iteration}: Based on previous feedback, address these specific improvements:
  473. {critique}
  474. Generate an improved version that addresses all the critique points while maintaining scientific accuracy and professional quality."""
  475. return improved_prompt
  476. def generate_iterative(self, user_prompt: str, output_path: str,
  477. iterations: int = 2,
  478. doc_type: str = "default") -> Dict[str, Any]:
  479. """
  480. Generate scientific schematic with smart iterative refinement.
  481. Only regenerates if the quality score is below the threshold for the
  482. specified document type. This saves API calls and time when the first
  483. generation is already good enough.
  484. Args:
  485. user_prompt: User's description of desired diagram
  486. output_path: Path to save final image
  487. iterations: Maximum refinement iterations (default: 2, max: 2)
  488. doc_type: Document type for quality threshold (journal, poster, etc.)
  489. Returns:
  490. Dictionary with generation results and metadata
  491. """
  492. output_path = Path(output_path)
  493. output_dir = output_path.parent
  494. output_dir.mkdir(parents=True, exist_ok=True)
  495. base_name = output_path.stem
  496. extension = output_path.suffix or ".png"
  497. # Get quality threshold for this document type
  498. threshold = self.QUALITY_THRESHOLDS.get(doc_type.lower(),
  499. self.QUALITY_THRESHOLDS["default"])
  500. results = {
  501. "user_prompt": user_prompt,
  502. "doc_type": doc_type,
  503. "quality_threshold": threshold,
  504. "iterations": [],
  505. "final_image": None,
  506. "final_score": 0.0,
  507. "success": False,
  508. "early_stop": False,
  509. "early_stop_reason": None
  510. }
  511. current_prompt = f"""{self.SCIENTIFIC_DIAGRAM_GUIDELINES}
  512. USER REQUEST: {user_prompt}
  513. Generate a publication-quality scientific diagram that meets all the guidelines above."""
  514. print(f"\n{'='*60}")
  515. print(f"Generating Scientific Schematic")
  516. print(f"{'='*60}")
  517. print(f"Description: {user_prompt}")
  518. print(f"Document Type: {doc_type}")
  519. print(f"Quality Threshold: {threshold}/10")
  520. print(f"Max Iterations: {iterations}")
  521. print(f"Output: {output_path}")
  522. print(f"{'='*60}\n")
  523. for i in range(1, iterations + 1):
  524. print(f"\n[Iteration {i}/{iterations}]")
  525. print("-" * 40)
  526. # Generate image
  527. print(f"Generating image...")
  528. image_data = self.generate_image(current_prompt)
  529. if not image_data:
  530. error_msg = getattr(self, '_last_error', 'Image generation failed - no image data returned')
  531. print(f"✗ Generation failed: {error_msg}")
  532. results["iterations"].append({
  533. "iteration": i,
  534. "success": False,
  535. "error": error_msg
  536. })
  537. continue
  538. # Save iteration image
  539. iter_path = output_dir / f"{base_name}_v{i}{extension}"
  540. with open(iter_path, "wb") as f:
  541. f.write(image_data)
  542. print(f"✓ Saved: {iter_path}")
  543. # Review image using Gemini 3.1 Pro Preview
  544. print(f"Reviewing image with Gemini 3.1 Pro Preview...")
  545. critique, score, needs_improvement = self.review_image(
  546. str(iter_path), user_prompt, i, doc_type, iterations
  547. )
  548. print(f"✓ Score: {score}/10 (threshold: {threshold}/10)")
  549. # Save iteration results
  550. iteration_result = {
  551. "iteration": i,
  552. "image_path": str(iter_path),
  553. "prompt": current_prompt,
  554. "critique": critique,
  555. "score": score,
  556. "needs_improvement": needs_improvement,
  557. "success": True
  558. }
  559. results["iterations"].append(iteration_result)
  560. # Check if quality is acceptable - STOP EARLY if so
  561. if not needs_improvement:
  562. print(f"\n✓ Quality meets {doc_type} threshold ({score} >= {threshold})")
  563. print(f" No further iterations needed!")
  564. results["final_image"] = str(iter_path)
  565. results["final_score"] = score
  566. results["success"] = True
  567. results["early_stop"] = True
  568. results["early_stop_reason"] = f"Quality score {score} meets threshold {threshold} for {doc_type}"
  569. break
  570. # If this is the last iteration, we're done regardless
  571. if i == iterations:
  572. print(f"\n⚠ Maximum iterations reached")
  573. results["final_image"] = str(iter_path)
  574. results["final_score"] = score
  575. results["success"] = True
  576. break
  577. # Quality below threshold - improve prompt for next iteration
  578. print(f"\n⚠ Quality below threshold ({score} < {threshold})")
  579. print(f"Improving prompt based on feedback...")
  580. current_prompt = self.improve_prompt(user_prompt, critique, i + 1)
  581. # Copy final version to output path
  582. if results["success"] and results["final_image"]:
  583. final_iter_path = Path(results["final_image"])
  584. if final_iter_path != output_path:
  585. import shutil
  586. shutil.copy(final_iter_path, output_path)
  587. print(f"\n✓ Final image: {output_path}")
  588. # Save review log
  589. log_path = output_dir / f"{base_name}_review_log.json"
  590. with open(log_path, "w") as f:
  591. json.dump(results, f, indent=2)
  592. print(f"✓ Review log: {log_path}")
  593. print(f"\n{'='*60}")
  594. print(f"Generation Complete!")
  595. print(f"Final Score: {results['final_score']}/10")
  596. if results["early_stop"]:
  597. print(f"Iterations Used: {len([r for r in results['iterations'] if r.get('success')])}/{iterations} (early stop)")
  598. print(f"{'='*60}\n")
  599. return results
  600. def main():
  601. """Command-line interface."""
  602. parser = argparse.ArgumentParser(
  603. description="Generate scientific schematics using AI with smart iterative refinement",
  604. formatter_class=argparse.RawDescriptionHelpFormatter,
  605. epilog="""
  606. Examples:
  607. # Generate a flowchart for a journal paper
  608. python generate_schematic_ai.py "CONSORT participant flow diagram" -o flowchart.png --doc-type journal
  609. # Generate neural network architecture for presentation (lower threshold)
  610. python generate_schematic_ai.py "Transformer encoder-decoder architecture" -o transformer.png --doc-type presentation
  611. # Generate with custom max iterations for poster
  612. python generate_schematic_ai.py "Biological signaling pathway" -o pathway.png --iterations 2 --doc-type poster
  613. # Verbose output
  614. python generate_schematic_ai.py "Circuit diagram" -o circuit.png -v
  615. Document Types (quality thresholds):
  616. journal 8.5/10 - Nature, Science, peer-reviewed journals
  617. conference 8.0/10 - Conference papers
  618. thesis 8.0/10 - Dissertations, theses
  619. grant 8.0/10 - Grant proposals
  620. preprint 7.5/10 - arXiv, bioRxiv, etc.
  621. report 7.5/10 - Technical reports
  622. poster 7.0/10 - Academic posters
  623. presentation 6.5/10 - Slides, talks
  624. default 7.5/10 - General purpose
  625. Note: Multiple iterations only occur if quality is BELOW the threshold.
  626. If the first generation meets the threshold, no extra API calls are made.
  627. Environment:
  628. OPENROUTER_API_KEY OpenRouter API key (required)
  629. """
  630. )
  631. parser.add_argument("prompt", help="Description of the diagram to generate")
  632. parser.add_argument("-o", "--output", required=True,
  633. help="Output image path (e.g., diagram.png)")
  634. parser.add_argument("--iterations", type=int, default=2,
  635. help="Maximum refinement iterations (default: 2, max: 2)")
  636. parser.add_argument("--doc-type", default="default",
  637. choices=["journal", "conference", "poster", "presentation",
  638. "report", "grant", "thesis", "preprint", "default"],
  639. help="Document type for quality threshold (default: default)")
  640. parser.add_argument("--api-key", help="OpenRouter API key (or set OPENROUTER_API_KEY)")
  641. parser.add_argument("-v", "--verbose", action="store_true",
  642. help="Verbose output")
  643. args = parser.parse_args()
  644. # Check for API key
  645. api_key = args.api_key or os.getenv("OPENROUTER_API_KEY")
  646. if not api_key:
  647. print("Error: OPENROUTER_API_KEY environment variable not set")
  648. print("\nSet it with:")
  649. print(" export OPENROUTER_API_KEY='your_api_key'")
  650. print("\nOr provide via --api-key flag")
  651. sys.exit(1)
  652. # Validate iterations - enforce max of 2
  653. if args.iterations < 1 or args.iterations > 2:
  654. print("Error: Iterations must be between 1 and 2")
  655. sys.exit(1)
  656. try:
  657. generator = ScientificSchematicGenerator(api_key=api_key, verbose=args.verbose)
  658. results = generator.generate_iterative(
  659. user_prompt=args.prompt,
  660. output_path=args.output,
  661. iterations=args.iterations,
  662. doc_type=args.doc_type
  663. )
  664. if results["success"]:
  665. print(f"\n✓ Success! Image saved to: {args.output}")
  666. if results.get("early_stop"):
  667. print(f" (Completed in {len([r for r in results['iterations'] if r.get('success')])} iteration(s) - quality threshold met)")
  668. sys.exit(0)
  669. else:
  670. print(f"\n✗ Generation failed. Check review log for details.")
  671. sys.exit(1)
  672. except Exception as e:
  673. print(f"\n✗ Error: {str(e)}")
  674. sys.exit(1)
  675. if __name__ == "__main__":
  676. main()