feat(#38): IMP-29 frontend zone-level evidence bridge (candidate_evidence reader + types + UI)
This commit is contained in:
@@ -82,11 +82,67 @@ export default function FramePanel({
|
|||||||
) : (
|
) : (
|
||||||
candidates.map((candidate, index) => {
|
candidates.map((candidate, index) => {
|
||||||
const isSelected = currentFrameId === candidate.id;
|
const isSelected = currentFrameId === candidate.id;
|
||||||
|
|
||||||
const isReject = candidate.label === "reject";
|
const isReject = candidate.label === "reject";
|
||||||
// catalog 미등록 = backend Step 7-A 가 override 시도해도 skip.
|
// catalog 미등록 = backend Step 7-A 가 override 시도해도 skip.
|
||||||
// catalogRegistered === false 만 체크 (undefined = 정보 없음, 일반 처리).
|
// catalogRegistered === false 만 체크 (undefined = 정보 없음, 일반 처리).
|
||||||
const isCatalogMissing = candidate.catalogRegistered === false;
|
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 (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={candidate.id}
|
key={candidate.id}
|
||||||
@@ -105,17 +161,13 @@ export default function FramePanel({
|
|||||||
? 'border-blue-500 bg-white shadow-xl shadow-blue-500/10'
|
? 'border-blue-500 bg-white shadow-xl shadow-blue-500/10'
|
||||||
: isCatalogMissing
|
: isCatalogMissing
|
||||||
? 'border-slate-100 bg-slate-50/40 opacity-60 hover:opacity-90 hover:border-amber-200'
|
? '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
|
: isReject
|
||||||
? 'border-slate-100 bg-slate-50/30 opacity-50 hover:opacity-90 hover:border-slate-200'
|
? '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'
|
: 'border-slate-100 bg-slate-50/50 hover:border-slate-200 hover:bg-white'
|
||||||
}`}
|
}`}
|
||||||
title={
|
title={composedTitle}
|
||||||
isCatalogMissing
|
|
||||||
? "⚠ catalog 미등록 — render path 에서 적용 안 됨 (선택해도 backend 가 skip)"
|
|
||||||
: isReject
|
|
||||||
? "V4 reject — render path 비추천"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{/* Rank Badge */}
|
{/* Rank Badge */}
|
||||||
<div className="absolute top-3 left-3 z-10">
|
<div className="absolute top-3 left-3 z-10">
|
||||||
@@ -199,6 +251,64 @@ export default function FramePanel({
|
|||||||
{candidate.label}
|
{candidate.label}
|
||||||
</span>
|
</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 && (
|
{isSelected && (
|
||||||
<div className="flex items-center gap-1 text-[8px] font-black text-emerald-500 uppercase">
|
<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]" />
|
<Check className="w-2.5 h-2.5 stroke-[4]" />
|
||||||
|
|||||||
@@ -503,9 +503,20 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
|
|||||||
restructure: 2,
|
restructure: 2,
|
||||||
reject: 3,
|
reject: 3,
|
||||||
};
|
};
|
||||||
const rawSource = (unit.v4_all_judgments?.length > 0)
|
// IMP-29 u2 — source priority (deterministic, no LLM):
|
||||||
? unit.v4_all_judgments
|
// 1) unit.candidate_evidence (IMP-05 L2 canonical, 14 fields per entry)
|
||||||
: (unit.v4_candidates ?? []);
|
// 2) unit.v4_all_judgments (pre-IMP-05 audit array)
|
||||||
|
// 3) unit.v4_candidates (legacy minimal)
|
||||||
|
// fallback_chain alias is intentionally NOT read (Stage 2 guardrail).
|
||||||
|
const candidateEvidence = Array.isArray(unit.candidate_evidence)
|
||||||
|
? unit.candidate_evidence
|
||||||
|
: [];
|
||||||
|
const rawSource =
|
||||||
|
candidateEvidence.length > 0
|
||||||
|
? candidateEvidence
|
||||||
|
: (unit.v4_all_judgments?.length > 0
|
||||||
|
? unit.v4_all_judgments
|
||||||
|
: (unit.v4_candidates ?? []));
|
||||||
const v4Source = [...rawSource].sort((a: any, b: any) => {
|
const v4Source = [...rawSource].sort((a: any, b: any) => {
|
||||||
const lp = (LABEL_PRIORITY[a.label] ?? 99) - (LABEL_PRIORITY[b.label] ?? 99);
|
const lp = (LABEL_PRIORITY[a.label] ?? 99) - (LABEL_PRIORITY[b.label] ?? 99);
|
||||||
if (lp !== 0) return lp;
|
if (lp !== 0) return lp;
|
||||||
@@ -525,12 +536,24 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
|
|||||||
? `/frame-preview/${String(c.frame_number).padStart(2, "0")}`
|
? `/frame-preview/${String(c.frame_number).padStart(2, "0")}`
|
||||||
: undefined,
|
: undefined,
|
||||||
// backend step09 의 catalog_registered (frame_contracts.yaml 등록 여부).
|
// backend step09 의 catalog_registered (frame_contracts.yaml 등록 여부).
|
||||||
// v4_all_judgments 에만 있음. v4_candidates fallback 시 undefined.
|
// candidate_evidence 및 v4_all_judgments 에 있음. v4_candidates fallback 시 undefined.
|
||||||
catalogRegistered: c.catalog_registered,
|
catalogRegistered: c.catalog_registered,
|
||||||
// backend step09 의 min_height_px (frame_contracts.yaml visual_hints.min_height_px).
|
// backend step09 의 min_height_px (frame_contracts.yaml visual_hints.min_height_px).
|
||||||
// logical 1280x720 px 좌표계. contract 미등록 또는 visual_hints 부재 시 undefined.
|
// logical 1280x720 px 좌표계. contract 미등록 또는 visual_hints 부재 시 undefined.
|
||||||
// v4_all_judgments 에만 있음. v4_candidates fallback 시 undefined (graceful).
|
// v4_all_judgments 에만 있음. candidate_evidence / v4_candidates fallback 시 undefined (graceful).
|
||||||
minHeightPx: c.min_height_px ?? undefined,
|
minHeightPx: c.min_height_px ?? undefined,
|
||||||
|
// ─── IMP-05 L2 candidate_evidence fields (IMP-29 u2) ─────────────────
|
||||||
|
// Populated when source = unit.candidate_evidence; otherwise silently
|
||||||
|
// undefined for legacy fixtures (pre-IMP-05 fallback path).
|
||||||
|
rank: c.rank,
|
||||||
|
frameId: c.frame_id,
|
||||||
|
v4Label: c.v4_label,
|
||||||
|
phaseZStatus: c.phase_z_status,
|
||||||
|
filteredForDirectExecution: c.filtered_for_direct_execution,
|
||||||
|
routeHint: c.route_hint,
|
||||||
|
decision: c.decision,
|
||||||
|
reason: c.reason,
|
||||||
|
capacityFit: c.capacity_fit,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const displayStrategy = (
|
const displayStrategy = (
|
||||||
|
|||||||
@@ -116,6 +116,23 @@ export interface InternalRegion {
|
|||||||
frame_candidates: FrameCandidate[];
|
frame_candidates: FrameCandidate[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** IMP-05 L2 candidate_evidence.capacity_fit — backend capacity vs. content shape audit.
|
||||||
|
* Source = src/phase_z2_pipeline.py compute_capacity_fit(). All fields optional —
|
||||||
|
* frontend tolerates absence for pre-IMP-05 fixtures and contract-less templates. */
|
||||||
|
export interface CapacityFitEvidence {
|
||||||
|
item_count?: number | null;
|
||||||
|
source_shape?: string | null;
|
||||||
|
capacity?: {
|
||||||
|
strict?: number | null;
|
||||||
|
min?: number | null;
|
||||||
|
max?: number | null;
|
||||||
|
truncate_at?: number | null;
|
||||||
|
pad_to?: number | null;
|
||||||
|
} | null;
|
||||||
|
fit_status?: string | null;
|
||||||
|
mismatch_reason?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
/** 프레임 후보 (V4 매칭 결과) */
|
/** 프레임 후보 (V4 매칭 결과) */
|
||||||
export interface FrameCandidate {
|
export interface FrameCandidate {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -131,6 +148,33 @@ export interface FrameCandidate {
|
|||||||
* Source = templates/phase_z2/catalog/frame_contracts.yaml visual_hints.min_height_px.
|
* Source = templates/phase_z2/catalog/frame_contracts.yaml visual_hints.min_height_px.
|
||||||
* Undefined when contract unregistered or visual_hints absent (frontend tolerates undefined). */
|
* Undefined when contract unregistered or visual_hints absent (frontend tolerates undefined). */
|
||||||
minHeightPx?: number;
|
minHeightPx?: number;
|
||||||
|
|
||||||
|
// ─── IMP-05 L2 candidate_evidence fields (IMP-29 u1) ───────────────────────
|
||||||
|
// Source = src/phase_z2_pipeline.py lookup_v4_match_with_fallback() candidate_trace.
|
||||||
|
// All fields optional — pre-IMP-05 fixtures fall back to v4_all_judgments/v4_candidates
|
||||||
|
// (deterministic, no LLM) and silently leave these undefined.
|
||||||
|
|
||||||
|
/** Candidate rank in V4 chain (1-based; 1 = primary). */
|
||||||
|
rank?: number;
|
||||||
|
/** Figma frame node id (backend `frame_id`). Distinct from `id` (= template_id). */
|
||||||
|
frameId?: string;
|
||||||
|
/** Alias of `label`. Kept separate for Codex IMP-05 L2 schema parity. */
|
||||||
|
v4Label?: 'use_as_is' | 'light_edit' | 'restructure' | 'reject';
|
||||||
|
/** Phase Z status enum (e.g. "auto_renderable", "fallback_candidate"). Open vocabulary. */
|
||||||
|
phaseZStatus?: string;
|
||||||
|
/** True when status is outside MVP1_ALLOWED_STATUSES (= excluded from direct render path). */
|
||||||
|
filteredForDirectExecution?: boolean;
|
||||||
|
/** Execution route mapped from `label` (direct_render / deterministic_minor_adjustment /
|
||||||
|
* ai_adaptation_required / design_reference_only). Null on unknown labels. */
|
||||||
|
routeHint?: 'direct_render' | 'deterministic_minor_adjustment' | 'ai_adaptation_required' | 'design_reference_only' | null;
|
||||||
|
/** Selection outcome ("selected" or "skipped"). */
|
||||||
|
decision?: 'selected' | 'skipped';
|
||||||
|
/** Human-readable rationale (e.g. "primary_selected", "fallback_selected",
|
||||||
|
* "duplicate_template_id", "skipped_no_contract", "capacity_mismatch:...",
|
||||||
|
* "phase_z_status_not_allowed:..."). */
|
||||||
|
reason?: string | null;
|
||||||
|
/** Capacity vs. content shape audit (compute_capacity_fit output). */
|
||||||
|
capacityFit?: CapacityFitEvidence | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user