Spaces:
Paused
Paused
| """ | |
| AI-powered analysis pipeline for UI/UX feedback. | |
| This module orchestrates the analysis of screenshots and canvases using OpenAI vision models: | |
| Analysis types: | |
| 1. Primary analysis: Load-stage canvases → UI/UX observations | |
| - Layout, spacing, alignment, grid adherence | |
| - Typography, color balance, hierarchy | |
| - Loading states and perceived performance | |
| - Accessibility cues | |
| 2. Compatibility analysis: Cross-browser comparison | |
| - Visual differences between reference and candidate browsers | |
| - Likely engine-specific causes (flexbox, grid, font-metrics, etc.) | |
| - Minimal fix recommendations | |
| Prompt construction: | |
| - Loads prompt templates from prompts/ directory | |
| - Includes code context for developer-oriented suggestions | |
| - Adds URL-specific additional_considerations if provided | |
| - Builds multi-message conversation with proper context | |
| Output: | |
| - Structured JSON matching defined schemas | |
| - Schema validation with jsonschema | |
| - Per-URL analysis reports saved to reports/ directory | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import logging | |
| from collections import defaultdict | |
| from pathlib import Path | |
| from typing import Any, Dict, List, Optional | |
| from jsonschema import ValidationError, validate | |
| from .openai_client import analyze_images | |
| from .types import AnalysisResultRef, CanvasMeta | |
| from .utils import now_utc_iso, read_text | |
| log = logging.getLogger("frontend_support") | |
| def _load_schema(name: str) -> Dict[str, Any]: | |
| """Load JSON schema from schemas directory relative to this file.""" | |
| try: | |
| # Use __file__ to get path relative to this module | |
| schemas_dir = Path(__file__).parent / "schemas" | |
| schema_path = schemas_dir / name | |
| if not schema_path.exists(): | |
| raise FileNotFoundError(f"Schema not found: {schema_path}") | |
| with schema_path.open("r", encoding="utf-8") as f: | |
| return json.load(f) | |
| except Exception as e: | |
| raise RuntimeError(f"Failed to load schema {name}: {e}") from e | |
| def _load_prompt(name: str, default_text: Optional[str] = None) -> str: | |
| """Load prompt from prompts directory relative to this file.""" | |
| try: | |
| # Use __file__ to get path relative to this module | |
| prompts_dir = Path(__file__).parent / "prompts" | |
| prompt_path = prompts_dir / name | |
| if not prompt_path.exists(): | |
| if default_text is not None: | |
| log.warning("Prompt %s not found; using default.", name) | |
| return default_text | |
| raise FileNotFoundError(f"Prompt not found: {prompt_path}") | |
| with prompt_path.open("r", encoding="utf-8") as f: | |
| return f.read() | |
| except Exception as e: | |
| if default_text is not None: | |
| log.warning("Prompt %s not found; using default.", name) | |
| return default_text | |
| raise RuntimeError(f"Failed to load prompt {name}: {e}") from e | |
| def _group_primary_canvases_by_slug(canvases: List[CanvasMeta]) -> Dict[str, List[CanvasMeta]]: | |
| grouped: Dict[str, List[CanvasMeta]] = defaultdict(list) | |
| for c in canvases: | |
| if c.get("kind") != "load": | |
| continue | |
| slug = c.get("slug", "") | |
| grouped[slug].append(c) | |
| # Sort each group for determinism by resolution label | |
| for slug in grouped: | |
| grouped[slug].sort(key=lambda x: x.get("resolution_label", "")) | |
| return grouped | |
| def run_primary_analysis( | |
| *, | |
| project, | |
| canvases: List[CanvasMeta], | |
| captures: Optional[Dict[str, Any]] = None, | |
| code_context_path: Optional[str], | |
| openai_cfg, | |
| out_dir: Path, | |
| ) -> List[AnalysisResultRef]: | |
| """ | |
| Analyze load-stage canvases per URL (slug). Send up to max_images_per_request images | |
| per request; if more, chunk and merge observations. | |
| Optionally extracts and includes full_screen captures if available in captures data. | |
| """ | |
| schema = _load_schema("primary.schema.json") | |
| prompt_template = _load_prompt( | |
| "primary.md", | |
| default_text=( | |
| "You are an expert UI/UX reviewer. Analyze the provided canvases showing page load " | |
| "progress across device resolutions. Focus ONLY on layout, spacing, alignment, " | |
| "grid adherence, truncation, typography, color balance/contrast, hierarchy, perceived loading, " | |
| "and accessibility cues. Ignore content semantics.\n" | |
| "Return JSON strictly matching the required schema." | |
| ), | |
| ) | |
| grouped = _group_primary_canvases_by_slug(canvases) | |
| ensure_dir = out_dir.mkdir(parents=True, exist_ok=True) | |
| # Extract full_screen captures by slug if available | |
| full_screen_by_slug: Dict[str, List[Dict[str, Any]]] = {} | |
| if captures: | |
| by_url = captures.get("by_url", {}) | |
| for slug, entry in by_url.items(): | |
| res_groups = entry.get("resolutions", {}) | |
| full_screens = [] | |
| for res_label, shots in res_groups.items(): | |
| for shot in shots: | |
| if shot.get("is_full_screen", False): | |
| full_screens.append(shot) | |
| if full_screens: | |
| full_screen_by_slug[slug] = full_screens | |
| # Build mapping of URL -> additional_considerations from project config | |
| url_considerations: Dict[str, str] = {} | |
| if hasattr(project, 'urls'): | |
| for url_spec in project.urls: | |
| if url_spec.additional_considerations: | |
| url_considerations[url_spec.url] = url_spec.additional_considerations | |
| results: List[AnalysisResultRef] = [] | |
| for slug, items in grouped.items(): | |
| url = items[0].get("url", "") | |
| project_name = items[0].get("project", getattr(project, "project", "")) | |
| res_labels = [i.get("resolution_label", "") for i in items] | |
| paths = [i.get("path", "") for i in items if i.get("path")] | |
| paths = [p for p in paths if p] | |
| # Build prompt with context and expectations | |
| ctx = "" | |
| if code_context_path: | |
| ctx_text = read_text(Path(code_context_path)) | |
| if ctx_text: | |
| ctx = "\n\nDeveloper context (selected source excerpts):\n" + ctx_text[:20000] | |
| # Build base prompt | |
| base_prompt = ( | |
| f"Project: {project_name}\n" | |
| f"URL: {url}\n" | |
| f"Resolutions: {', '.join(res_labels)}\n\n" | |
| f"{prompt_template}\n" | |
| f"{ctx}" | |
| ) | |
| # Add additional considerations if specified for this URL | |
| additional_considerations_text = url_considerations.get(url) | |
| if additional_considerations_text: | |
| prompt = ( | |
| f"{base_prompt}\n\n" | |
| f"=== SPECIFIC REQUEST FROM PROJECT OWNER ===\n" | |
| f"The project owner has requested special attention to the following considerations for this URL:\n\n" | |
| f"{additional_considerations_text}\n\n" | |
| f"Please ensure your analysis and recommendations carefully address these specific concerns." | |
| ) | |
| else: | |
| prompt = base_prompt | |
| # For GPT-5: send individual canvases with metadata (max 20 images) | |
| # Build metadata for each canvas | |
| image_metadata = [] | |
| for item in items: | |
| image_metadata.append({ | |
| "browser": item.get("browser", "unknown"), | |
| "resolution": item.get("resolution_label", "unknown"), | |
| "label": item.get("resolution_label", f"canvas-{item.get('kind', 'load')}") | |
| }) | |
| # Extract full_screen images for this slug | |
| full_screen_images = [] | |
| full_screen_metadata = [] | |
| if slug in full_screen_by_slug: | |
| for fs in full_screen_by_slug[slug]: | |
| full_screen_images.append(fs.get("path", "")) | |
| full_screen_metadata.append({ | |
| "browser": fs.get("browser", "unknown"), | |
| "resolution": fs.get("resolution_label", "unknown"), | |
| "label": f"Full-page {fs.get('resolution_label', 'unknown')}" | |
| }) | |
| # GPT-5 can handle up to 20 images - use all canvases + full_screen + code context | |
| data = analyze_images( | |
| model=getattr(openai_cfg, "model", "gpt-5"), | |
| prompt_text=prompt, | |
| images=paths[:20], # Limit to 20 for GPT-5 | |
| image_metadata=image_metadata[:20], | |
| canvas_image=None, # No separate canvas in this case | |
| full_screen_images=full_screen_images if full_screen_images else None, | |
| full_screen_metadata=full_screen_metadata if full_screen_metadata else None, | |
| code_context=ctx if ctx else None, | |
| response_schema=schema, | |
| reasoning_effort=getattr(openai_cfg, "reasoning_effort", "medium"), | |
| verbosity=getattr(openai_cfg, "verbosity", "medium"), | |
| max_output_tokens=getattr(openai_cfg, "max_output_tokens", 4096), | |
| timeout_s=getattr(openai_cfg, "request_timeout_s", 120), | |
| ) | |
| # Validate response | |
| try: | |
| validate(instance=data, schema=schema) | |
| except ValidationError as ve: | |
| log.warning("Primary analysis schema validation failed for slug=%s: %s", slug, ve) | |
| merged = data | |
| # Save | |
| out_path = out_dir / f"primary_{slug}.json" | |
| try: | |
| with out_path.open("w", encoding="utf-8") as f: | |
| json.dump(merged, f, indent=2, ensure_ascii=False) | |
| results.append( | |
| AnalysisResultRef( | |
| path=str(out_path), | |
| kind="primary", | |
| url=url, | |
| project=project_name, | |
| slug=slug, | |
| model=getattr(openai_cfg, "model", "gpt-4o"), | |
| ) | |
| ) | |
| log.info("Saved primary analysis: %s", out_path) | |
| except Exception as e: | |
| log.exception("Failed to save primary analysis for slug=%s: %s", slug, e) | |
| return results | |
| def _find_reference_for_candidate(canvas: CanvasMeta) -> Optional[str]: | |
| ref = canvas.get("reference_path") | |
| if isinstance(ref, str) and ref: | |
| return ref | |
| # Fallback search: look in parent dir | |
| try: | |
| cpath = Path(canvas["path"]) | |
| for p in cpath.parent.glob("*.png"): | |
| if "__compat-ref__" in p.name and f"__{canvas.get('slug','')}__" in p.name: | |
| return str(p) | |
| except Exception: | |
| pass | |
| return None | |
| def run_compatibility_analysis( | |
| *, | |
| project, | |
| candidate_canvases: List[CanvasMeta], | |
| openai_cfg, | |
| out_dir: Path, | |
| ) -> List[AnalysisResultRef]: | |
| """ | |
| For each candidate canvas (per URL and browser), compare against the reference canvas | |
| and produce a structured diff report. | |
| """ | |
| # Try to load compatibility prompt; fallback if missing | |
| prompt_template = _load_prompt( | |
| "compatibility.md", | |
| default_text=( | |
| "Compare the two canvases (reference on Chrome/Chromium vs candidate on another browser) " | |
| "across the same set of resolutions. Identify visible differences that likely stem from " | |
| "browser/engine behavior. For each diff, provide: resolution label, concise description, " | |
| "probable cause category (e.g., flexbox, grid, font-metrics, subpixel-rounding, overflow, etc.), " | |
| "and a minimal recommendation to resolve. Return JSON per the schema." | |
| ), | |
| ) | |
| schema = _load_schema("compatibility.schema.json") | |
| out_dir.mkdir(parents=True, exist_ok=True) | |
| results: List[AnalysisResultRef] = [] | |
| by_slug_browser: Dict[tuple, List[CanvasMeta]] = {} | |
| # candidate canvases are already one per browser per slug, but guard anyway | |
| for c in candidate_canvases: | |
| key = (c.get("slug", ""), c.get("browser", "")) | |
| by_slug_browser.setdefault(key, []).append(c) | |
| for (slug, browser), lst in by_slug_browser.items(): | |
| canvas = lst[0] | |
| url = canvas.get("url", "") | |
| project_name = canvas.get("project", getattr(project, "project", "")) | |
| ref_path = _find_reference_for_candidate(canvas) | |
| if not ref_path: | |
| log.warning("No reference canvas found for compatibility analysis slug=%s", slug) | |
| continue | |
| cand_path = canvas.get("path", "") | |
| if not cand_path or not Path(cand_path).exists(): | |
| log.warning("Candidate canvas missing for slug=%s browser=%s", slug, browser) | |
| continue | |
| prompt = ( | |
| f"Project: {project_name}\n" | |
| f"URL: {url}\n" | |
| f"Reference: Chrome/Chromium (assumed from primary)\n" | |
| f"Candidate: {browser}\n\n" | |
| f"{prompt_template}" | |
| ) | |
| # Build metadata for reference and candidate | |
| image_metadata = [ | |
| {"browser": "chromium (reference)", "resolution": "multi-resolution", "label": "Reference"}, | |
| {"browser": browser, "resolution": "multi-resolution", "label": "Candidate"} | |
| ] | |
| data = analyze_images( | |
| model=getattr(openai_cfg, "model", "gpt-5"), | |
| prompt_text=prompt, | |
| images=[ref_path, cand_path], | |
| image_metadata=image_metadata, | |
| canvas_image=None, | |
| code_context=None, | |
| response_schema=schema, | |
| reasoning_effort=getattr(openai_cfg, "reasoning_effort", "medium"), | |
| verbosity=getattr(openai_cfg, "verbosity", "medium"), | |
| max_output_tokens=getattr(openai_cfg, "max_output_tokens", 4096), | |
| timeout_s=getattr(openai_cfg, "request_timeout_s", 120), | |
| ) | |
| try: | |
| validate(instance=data, schema=schema) | |
| except ValidationError as ve: | |
| log.warning("Compatibility analysis schema validation failed for slug=%s browser=%s: %s", slug, browser, ve) | |
| out_path = out_dir / f"compat_{browser}_{slug}.json" | |
| try: | |
| with out_path.open("w", encoding="utf-8") as f: | |
| json.dump(data, f, indent=2, ensure_ascii=False) | |
| results.append( | |
| AnalysisResultRef( | |
| path=str(out_path), | |
| kind="compat", | |
| url=url, | |
| project=project_name, | |
| slug=slug, | |
| browser=browser, | |
| model=getattr(openai_cfg, "model", "gpt-4o"), | |
| ) | |
| ) | |
| log.info("Saved compatibility analysis: %s", out_path) | |
| except Exception as e: | |
| log.exception("Failed to save compatibility analysis for slug=%s browser=%s: %s", slug, browser, e) | |
| return results | |