Spaces:
Paused
Paused
File size: 14,680 Bytes
71d0e7d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 | """
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
|