Add score clarity, AI design score, image format fix, cost tracking

- Replace bare score badge with rich ScoreCard component showing
  color-coded score (green/amber/red), label, and hover tooltip
  explaining what the 0-100 Attention Focus score means
- Add AI Design Effectiveness Score (1-10) from Claude alongside
  qualitative insights, with score_reason explanation
- Fix image/png media type error by converting all images to PNG
  before sending to Claude API
- Save ai_score and ai_score_reason to DB
- Display AI score badge in InsightsPanel with color coding

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
DJP 2026-02-23 20:37:03 -05:00
parent 3467dbcf03
commit 92062b254d
8 changed files with 146 additions and 27 deletions

View file

@ -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"],

View file

@ -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())

View file

@ -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

View file

@ -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,

View file

@ -65,6 +65,8 @@ export async function checkAIInsightsAvailable(): Promise<boolean> {
export interface AIInsightsResponse {
insights: Insight[];
ai_score: number;
score_reason: string;
cost_usd: number;
input_tokens: number;
output_tokens: number;

View file

@ -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 (
<div className="flex items-center gap-3 mb-3 p-3 rounded-lg" style={{ backgroundColor: `${color}08`, border: `1px solid ${color}20` }}>
<div className="flex items-baseline gap-0.5">
<span className="text-3xl font-bold" style={{ color }}>{score}</span>
<span className="text-sm text-gray-400">/10</span>
</div>
<div className="flex flex-col">
<span className="text-[10px] font-semibold uppercase tracking-wider text-gray-500">AI Design Score</span>
<span className="text-xs font-semibold" style={{ color }}>{label}</span>
{reason && <span className="text-xs text-gray-500 mt-0.5">{reason}</span>}
</div>
</div>
);
}
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
</span>
</div>
{aiScore != null && <AIScoreBadge score={aiScore} reason={aiScoreReason} />}
<div className="space-y-2">
{aiInsights!.map((insight, i) => (
<InsightCard key={i} insight={insight} isAI />

View file

@ -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 (
<div className="flex items-center gap-4">
<div className="relative">
<div
className="flex items-center gap-2 px-3 py-1.5 rounded-lg cursor-help"
style={{ backgroundColor: info.bg, border: `1px solid ${info.color}30` }}
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
>
<div className="flex items-baseline gap-1">
<span className="text-2xl font-bold" style={{ color: info.color }}>{score.toFixed(0)}</span>
<span className="text-xs text-gray-400">/100</span>
</div>
<div className="flex flex-col">
<span className="text-[10px] font-semibold uppercase tracking-wider text-gray-500">Attention Focus</span>
<span className="text-xs font-semibold" style={{ color: info.color }}>{info.label}</span>
</div>
</div>
{showTooltip && (
<div className="absolute top-full left-0 mt-2 z-50 w-72 bg-white rounded-lg shadow-lg border border-gray-200 p-3 text-xs">
<p className="font-semibold text-gray-800 mb-1">Attention Focus Score</p>
<p className="text-gray-600 mb-2">{info.desc}</p>
<div className="space-y-1 text-gray-500">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-500" />
<span><strong>60-100:</strong> High clear visual hierarchy, strong focal point</span>
</div>
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-amber-500" />
<span><strong>30-59:</strong> Medium multiple areas compete for attention</span>
</div>
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-red-500" />
<span><strong>0-29:</strong> Low diffuse, no dominant element</span>
</div>
</div>
<p className="text-gray-400 mt-2 border-t border-gray-100 pt-2">
Higher isn't always better it depends on your design goal. A product ad wants focused attention; an infographic spreads it intentionally.
</p>
</div>
)}
</div>
</div>
);
}
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<Insight[]>([]);
const [aiScore, setAiScore] = useState<number | null>(null);
const [aiScoreReason, setAiScoreReason] = useState<string | null>(null);
const [aiCostUsd, setAiCostUsd] = useState<number | null>(null);
const [aiLoading, setAiLoading] = useState(false);
const [aiError, setAiError] = useState<string | null>(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}
</h1>
<div className="flex items-center gap-4 mt-1 text-sm text-gray-500">
<span>Model: {analysis.model_used || analysis.model}</span>
<span>Model: {(analysis.model_used || analysis.model).replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase())}</span>
<span>
{new Date(analysis.created_at).toLocaleDateString()}
</span>
{analysis.overall_score !== undefined && (
<span
className="font-semibold px-2 py-0.5 rounded"
style={{
backgroundColor: "#ffc40720",
color: "#ffc407",
}}
>
Score: {analysis.overall_score.toFixed(1)}
</span>
)}
</div>
{analysis.overall_score !== undefined && (
<div className="mt-2">
<ScoreCard score={analysis.overall_score} />
</div>
)}
</div>
<div className="flex items-center gap-3">
{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) && (
<InsightsPanel insights={analysis.insights || []} aiInsights={aiInsights} aiCostUsd={aiCostUsd} />
<InsightsPanel insights={analysis.insights || []} aiInsights={aiInsights} aiScore={aiScore} aiScoreReason={aiScoreReason} aiCostUsd={aiCostUsd} />
)}
{aiError && (
<div className="text-sm text-red-500 bg-red-50 border border-red-200 rounded-lg px-4 py-2">

View file

@ -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;