diff --git a/backend/app/api/endpoints/analysis.py b/backend/app/api/endpoints/analysis.py index 452a7ac..2c212cd 100644 --- a/backend/app/api/endpoints/analysis.py +++ b/backend/app/api/endpoints/analysis.py @@ -318,6 +318,8 @@ async def get_analysis( hotspots=analysis.hotspots, insights=insights, ai_insights=analysis.ai_insights, + ai_score=analysis.ai_score, + ai_score_reason=analysis.ai_score_reason, ai_cost_usd=analysis.ai_cost_usd, ) @@ -413,6 +415,8 @@ async def generate_ai_insights_endpoint( # Save to DB analysis.ai_insights = result["insights"] + analysis.ai_score = result["ai_score"] + analysis.ai_score_reason = result["score_reason"] analysis.ai_cost_usd = result["cost_usd"] await db.flush() @@ -426,6 +430,8 @@ async def generate_ai_insights_endpoint( return { "insights": result["insights"], + "ai_score": result["ai_score"], + "score_reason": result["score_reason"], "cost_usd": result["cost_usd"], "input_tokens": result["input_tokens"], "output_tokens": result["output_tokens"], diff --git a/backend/app/models/analysis.py b/backend/app/models/analysis.py index 0c133aa..1bd3f6d 100644 --- a/backend/app/models/analysis.py +++ b/backend/app/models/analysis.py @@ -29,6 +29,8 @@ class Analysis(Base): hotspots: Mapped[dict | None] = mapped_column(JSON, nullable=True) overall_score: Mapped[float | None] = mapped_column(Float, nullable=True) ai_insights: Mapped[dict | None] = mapped_column(JSON, nullable=True) + ai_score: Mapped[int | None] = mapped_column(Integer, nullable=True) + ai_score_reason: Mapped[str | None] = mapped_column(String(500), nullable=True) ai_cost_usd: Mapped[float | None] = mapped_column(Float, nullable=True) created_at: Mapped[datetime] = mapped_column(server_default=func.now()) diff --git a/backend/app/schemas/analysis.py b/backend/app/schemas/analysis.py index 330c64f..cbfff67 100644 --- a/backend/app/schemas/analysis.py +++ b/backend/app/schemas/analysis.py @@ -38,6 +38,8 @@ class AnalysisDetail(AnalysisSummary): hotspots: list[dict] | None = None insights: list[Insight] | None = None ai_insights: list[Insight] | None = None + ai_score: int | None = None + ai_score_reason: str | None = None ai_cost_usd: float | None = None aoi_count: int = 0 diff --git a/backend/app/services/ai_insights.py b/backend/app/services/ai_insights.py index 315c3c3..db70dff 100644 --- a/backend/app/services/ai_insights.py +++ b/backend/app/services/ai_insights.py @@ -1,10 +1,12 @@ """Claude-powered AI insights for saliency analysis.""" import base64 +import io import json import logging import anthropic +from PIL import Image as PILImage from app.config import settings @@ -37,8 +39,18 @@ def generate_ai_insights( client = anthropic.Anthropic(api_key=settings.ANTHROPIC_API_KEY) - original_b64 = base64.standard_b64encode(original_image_bytes).decode("utf-8") - heatmap_b64 = base64.standard_b64encode(heatmap_image_bytes).decode("utf-8") + def _to_png_bytes(data: bytes) -> bytes: + """Convert any image format to PNG bytes for consistent API calls.""" + img = PILImage.open(io.BytesIO(data)) + buf = io.BytesIO() + img.save(buf, format="PNG") + return buf.getvalue() + + original_png = _to_png_bytes(original_image_bytes) + heatmap_png = _to_png_bytes(heatmap_image_bytes) + + original_b64 = base64.standard_b64encode(original_png).decode("utf-8") + heatmap_b64 = base64.standard_b64encode(heatmap_png).decode("utf-8") score = analysis_metadata.get("overall_score", 0) hotspots = analysis_metadata.get("hotspots", []) @@ -64,20 +76,28 @@ def generate_ai_insights( {metrics_text} -Based on the original image, the heatmap overlay, and the metrics, provide exactly 4 actionable design insights. Each insight should be specific to THIS image — reference actual visual elements you can see (logos, text, products, faces, backgrounds, etc.). +Based on the original image, the heatmap overlay, and the metrics, provide: + +1. An **AI Design Effectiveness Score** from 1-10 (integer) rating how well the design directs visual attention to serve its likely purpose. Consider: Does the most important element get the most attention? Is the visual hierarchy clear? Are key messages/CTAs visible? + +2. A one-sentence **score_reason** explaining the rating. + +3. Exactly 4 actionable design **insights**. Each insight should be specific to THIS image — reference actual visual elements you can see (logos, text, products, faces, backgrounds, etc.). For each insight, provide: - "type": one of "success" (something working well), "info" (neutral observation or suggestion), or "warning" (potential issue) - "title": a short heading (5-10 words) - "description": 1-2 sentences of specific, actionable advice -Respond with ONLY a JSON array of 4 insight objects. No other text, no markdown formatting, just the JSON array. - -Example format: -[ - {{"type": "success", "title": "Strong focal point on product", "description": "The product image captures the highest predicted attention, which aligns well with the likely design goal. The bright contrast against the background naturally draws the eye."}}, - {{"type": "warning", "title": "CTA button may be overlooked", "description": "The call-to-action button in the lower right receives minimal predicted attention. Consider increasing its size, contrast, or moving it closer to the primary focal area."}} -]""" +Respond with ONLY a JSON object (no markdown, no extra text) in this exact format: +{{ + "ai_score": 7, + "score_reason": "The product is the clear focal point but the CTA competes with the background.", + "insights": [ + {{"type": "success", "title": "Strong focal point on product", "description": "..."}}, + {{"type": "warning", "title": "CTA button may be overlooked", "description": "..."}} + ] +}}""" try: message = client.messages.create( @@ -129,11 +149,17 @@ Example format: if response_text.endswith("```"): response_text = response_text[:-3].strip() - insights = json.loads(response_text) + parsed = json.loads(response_text) - # Validate structure + # Extract AI score + ai_score = int(parsed.get("ai_score", 0)) + ai_score = max(1, min(10, ai_score)) # clamp 1-10 + score_reason = str(parsed.get("score_reason", "")) + + # Validate insights structure + raw_insights = parsed.get("insights", parsed if isinstance(parsed, list) else []) validated = [] - for item in insights[:5]: # cap at 5 + for item in raw_insights[:5]: # cap at 5 if isinstance(item, dict) and "type" in item and "title" in item and "description" in item: if item["type"] not in ("success", "info", "warning"): item["type"] = "info" @@ -145,6 +171,8 @@ Example format: return { "insights": validated, + "ai_score": ai_score, + "score_reason": score_reason, "cost_usd": cost_usd, "input_tokens": input_tokens, "output_tokens": output_tokens, diff --git a/frontend/src/api/analysis.ts b/frontend/src/api/analysis.ts index 0d13aab..4a1bf4a 100644 --- a/frontend/src/api/analysis.ts +++ b/frontend/src/api/analysis.ts @@ -65,6 +65,8 @@ export async function checkAIInsightsAvailable(): Promise { export interface AIInsightsResponse { insights: Insight[]; + ai_score: number; + score_reason: string; cost_usd: number; input_tokens: number; output_tokens: number; diff --git a/frontend/src/components/analysis/InsightsPanel.tsx b/frontend/src/components/analysis/InsightsPanel.tsx index 5238ebe..988c6ed 100644 --- a/frontend/src/components/analysis/InsightsPanel.tsx +++ b/frontend/src/components/analysis/InsightsPanel.tsx @@ -43,10 +43,30 @@ function InsightCard({ insight, isAI }: { insight: Insight; isAI?: boolean }) { interface InsightsPanelProps { insights: Insight[]; aiInsights?: Insight[]; + aiScore?: number | null; + aiScoreReason?: string | null; aiCostUsd?: number | null; } -export default function InsightsPanel({ insights, aiInsights, aiCostUsd }: InsightsPanelProps) { +function AIScoreBadge({ score, reason }: { score: number; reason?: string | null }) { + const color = score >= 7 ? "#16a34a" : score >= 4 ? "#d97706" : "#dc2626"; + const label = score >= 7 ? "Strong" : score >= 4 ? "Moderate" : "Needs Work"; + return ( +
+
+ {score} + /10 +
+
+ AI Design Score + {label} + {reason && {reason}} +
+
+ ); +} + +export default function InsightsPanel({ insights, aiInsights, aiScore, aiScoreReason, aiCostUsd }: InsightsPanelProps) { const hasInsights = insights && insights.length > 0; const hasAI = aiInsights && aiInsights.length > 0; @@ -77,6 +97,7 @@ export default function InsightsPanel({ insights, aiInsights, aiCostUsd }: Insig AI + {aiScore != null && }
{aiInsights!.map((insight, i) => ( diff --git a/frontend/src/pages/AnalysisView.tsx b/frontend/src/pages/AnalysisView.tsx index be1cea2..b83596a 100644 --- a/frontend/src/pages/AnalysisView.tsx +++ b/frontend/src/pages/AnalysisView.tsx @@ -5,6 +5,62 @@ import { useAnalysisStore, type AnalysisTab } from "../stores/analysisStore"; import { getAnalysisImageUrl, checkAIInsightsAvailable, generateAIInsights } from "../api/analysis"; import type { Insight } from "../types/analysis"; +function getScoreInfo(score: number) { + if (score >= 60) return { label: "High", color: "#16a34a", bg: "#f0fdf4", desc: "Strong focal point — viewers will focus on the same areas" }; + if (score >= 30) return { label: "Medium", color: "#d97706", bg: "#fffbeb", desc: "Moderate spread — several areas compete for attention" }; + return { label: "Low", color: "#dc2626", bg: "#fef2f2", desc: "Diffuse attention — no clear focal point" }; +} + +function ScoreCard({ score }: { score: number }) { + const info = getScoreInfo(score); + const [showTooltip, setShowTooltip] = useState(false); + + return ( +
+
+
setShowTooltip(true)} + onMouseLeave={() => setShowTooltip(false)} + > +
+ {score.toFixed(0)} + /100 +
+
+ Attention Focus + {info.label} +
+
+ {showTooltip && ( +
+

Attention Focus Score

+

{info.desc}

+
+
+ + 60-100: High — clear visual hierarchy, strong focal point +
+
+ + 30-59: Medium — multiple areas compete for attention +
+
+ + 0-29: Low — diffuse, no dominant element +
+
+

+ Higher isn't always better — it depends on your design goal. A product ad wants focused attention; an infographic spreads it intentionally. +

+
+ )} +
+
+ ); +} + function ZoomableImage({ src, alt }: { src: string; alt: string }) { const zoom = useAnalysisStore((s) => s.zoom); return ( @@ -78,6 +134,8 @@ export default function AnalysisView() { const [aiAvailable, setAiAvailable] = useState(false); const [aiInsights, setAiInsights] = useState([]); + const [aiScore, setAiScore] = useState(null); + const [aiScoreReason, setAiScoreReason] = useState(null); const [aiCostUsd, setAiCostUsd] = useState(null); const [aiLoading, setAiLoading] = useState(false); const [aiError, setAiError] = useState(null); @@ -90,6 +148,8 @@ export default function AnalysisView() { useEffect(() => { if (analysis?.ai_insights && analysis.ai_insights.length > 0) { setAiInsights(analysis.ai_insights); + setAiScore(analysis.ai_score ?? null); + setAiScoreReason(analysis.ai_score_reason ?? null); setAiCostUsd(analysis.ai_cost_usd ?? null); } }, [analysis]); @@ -101,6 +161,8 @@ export default function AnalysisView() { try { const result = await generateAIInsights(analysisId); setAiInsights(result.insights); + setAiScore(result.ai_score); + setAiScoreReason(result.score_reason); setAiCostUsd(result.cost_usd); } catch (err: any) { setAiError(err?.response?.data?.detail || "AI analysis failed"); @@ -157,22 +219,16 @@ export default function AnalysisView() { {analysis.name}
- Model: {analysis.model_used || analysis.model} + Model: {(analysis.model_used || analysis.model).replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase())} {new Date(analysis.created_at).toLocaleDateString()} - {analysis.overall_score !== undefined && ( - - Score: {analysis.overall_score.toFixed(1)} - - )}
+ {analysis.overall_score !== undefined && ( +
+ +
+ )}
{aiAvailable && analysis.status === "completed" && aiInsights.length === 0 && ( @@ -212,7 +268,7 @@ export default function AnalysisView() { {/* Insights */} {((analysis.insights && analysis.insights.length > 0) || aiInsights.length > 0) && ( - + )} {aiError && (
diff --git a/frontend/src/types/analysis.ts b/frontend/src/types/analysis.ts index 1b298fd..a6e5c52 100644 --- a/frontend/src/types/analysis.ts +++ b/frontend/src/types/analysis.ts @@ -48,6 +48,8 @@ export interface AnalysisDetail extends Analysis { hotspots: Hotspot[]; insights?: Insight[]; ai_insights?: Insight[]; + ai_score?: number; + ai_score_reason?: string; ai_cost_usd?: number; processing_time_ms?: number; error_message?: string;