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