feat(#38): IMP-29 frontend zone-level evidence bridge (candidate_evidence reader + types + UI)

This commit is contained in:
2026-05-20 21:53:47 +09:00
parent 265d70ed91
commit b4872ba6ce
3 changed files with 190 additions and 13 deletions

View File

@@ -82,11 +82,67 @@ export default function FramePanel({
) : (
candidates.map((candidate, index) => {
const isSelected = currentFrameId === candidate.id;
const isReject = candidate.label === "reject";
// catalog 미등록 = backend Step 7-A 가 override 시도해도 skip.
// catalogRegistered === false 만 체크 (undefined = 정보 없음, 일반 처리).
const isCatalogMissing = candidate.catalogRegistered === false;
// ─── IMP-29 u3 — IMP-05 L2 candidate_evidence surface ───────────
// All evidence fields optional; silent degradation when undefined
// (pre-IMP-05 fixtures fall back to label/catalogRegistered only).
const isFilteredDirect = candidate.filteredForDirectExecution === true;
const hasDecision = candidate.decision === "selected" || candidate.decision === "skipped";
const isSkipped = candidate.decision === "skipped";
const isSelectedDecision = candidate.decision === "selected";
const showRouteChip =
candidate.routeHint && candidate.routeHint !== "direct_render";
const showStatusChip =
candidate.phaseZStatus && candidate.phaseZStatus !== "auto_renderable";
const hasCapacityFit =
candidate.capacityFit && candidate.capacityFit.fit_status;
const capacityMismatch =
hasCapacityFit && candidate.capacityFit!.fit_status !== "ok";
// Compose evidence tooltip lines (only when at least one signal present).
const evidenceLines: string[] = [];
if (candidate.decision) evidenceLines.push(`decision: ${candidate.decision}`);
if (candidate.reason) evidenceLines.push(`reason: ${candidate.reason}`);
if (candidate.routeHint) evidenceLines.push(`route: ${candidate.routeHint}`);
if (candidate.phaseZStatus)
evidenceLines.push(`phase_z_status: ${candidate.phaseZStatus}`);
if (hasCapacityFit) {
const cf = candidate.capacityFit!;
const capacityLine =
cf.fit_status === "ok"
? `capacity: ok${
typeof cf.item_count === "number"
? ` (items=${cf.item_count})`
: ""
}`
: `capacity: ${cf.fit_status}${
cf.mismatch_reason ? `${cf.mismatch_reason}` : ""
}`;
evidenceLines.push(capacityLine);
}
const evidenceTooltip =
evidenceLines.length > 0 ? evidenceLines.join("\n") : undefined;
// Compose final tooltip: existing catalog/reject reasons first, then
// evidence detail (preserves Phase Q tooltip semantics).
const tooltipParts = [
isCatalogMissing
? "⚠ catalog 미등록 — render path 에서 적용 안 됨 (선택해도 backend 가 skip)"
: null,
isFilteredDirect
? "⚠ filtered_for_direct_execution — MVP1 직접 렌더 경로 제외"
: null,
isReject ? "V4 reject — render path 비추천" : null,
evidenceTooltip,
].filter((s): s is string => Boolean(s));
const composedTitle =
tooltipParts.length > 0 ? tooltipParts.join("\n\n") : undefined;
return (
<motion.div
key={candidate.id}
@@ -105,17 +161,13 @@ export default function FramePanel({
? 'border-blue-500 bg-white shadow-xl shadow-blue-500/10'
: isCatalogMissing
? 'border-slate-100 bg-slate-50/40 opacity-60 hover:opacity-90 hover:border-amber-200'
: isFilteredDirect
? 'border-slate-100 bg-slate-50/30 opacity-50 hover:opacity-90 hover:border-amber-200'
: isReject
? 'border-slate-100 bg-slate-50/30 opacity-50 hover:opacity-90 hover:border-slate-200'
: 'border-slate-100 bg-slate-50/50 hover:border-slate-200 hover:bg-white'
}`}
title={
isCatalogMissing
? "⚠ catalog 미등록 — render path 에서 적용 안 됨 (선택해도 backend 가 skip)"
: isReject
? "V4 reject — render path 비추천"
: undefined
}
title={composedTitle}
>
{/* Rank Badge */}
<div className="absolute top-3 left-3 z-10">
@@ -199,6 +251,64 @@ export default function FramePanel({
{candidate.label}
</span>
)}
{/* IMP-29 u3 — route hint chip (skip when direct_render = default). */}
{showRouteChip && (
<span
className="text-[8px] font-black uppercase tracking-tight px-1.5 py-0.5 rounded bg-slate-100 text-slate-600"
title={`route_hint: ${candidate.routeHint}`}
>
{candidate.routeHint === "deterministic_minor_adjustment"
? "adapt"
: candidate.routeHint === "ai_adaptation_required"
? "ai req"
: candidate.routeHint === "design_reference_only"
? "ref"
: candidate.routeHint}
</span>
)}
{/* IMP-29 u3 — phase_z status warning chip (skip when auto_renderable). */}
{showStatusChip && (
<span
className="text-[8px] font-black uppercase tracking-tight px-1.5 py-0.5 rounded bg-amber-50 text-amber-700"
title={`phase_z_status: ${candidate.phaseZStatus}`}
>
{candidate.phaseZStatus!.replace(/_/g, " ")}
</span>
)}
{/* IMP-29 u3 — capacity_fit indicator (ok = subtle, mismatch = warning). */}
{hasCapacityFit && (
<span
className={`text-[8px] font-black uppercase tracking-tight px-1.5 py-0.5 rounded ${
capacityMismatch
? "bg-amber-100 text-amber-700"
: "bg-slate-100 text-slate-500"
}`}
title={`capacity_fit: ${candidate.capacityFit!.fit_status}${
candidate.capacityFit!.mismatch_reason
? `${candidate.capacityFit!.mismatch_reason}`
: ""
}`}
>
{capacityMismatch
? `fit: ${candidate.capacityFit!.fit_status}`
: "fit ok"}
</span>
)}
{/* IMP-29 u3 — decision badge (Stage 2 contract: surface both selected & skipped). */}
{hasDecision && (
<span
className={`text-[8px] font-black uppercase tracking-tight px-1.5 py-0.5 rounded ${
isSelectedDecision
? "bg-emerald-50 text-emerald-700"
: "bg-red-50 text-red-600"
}`}
title={`decision: ${candidate.decision}${
candidate.reason ? `${candidate.reason}` : ""
}`}
>
{isSkipped ? "skip" : "sel"}
</span>
)}
{isSelected && (
<div className="flex items-center gap-1 text-[8px] font-black text-emerald-500 uppercase">
<Check className="w-2.5 h-2.5 stroke-[4]" />