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:
parent
3467dbcf03
commit
92062b254d
8 changed files with 146 additions and 27 deletions
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue