wip: phase_z2 evidence 파이프라인 + matching 실험(phase2~26) + 프론트 trace 패널 진행분 스냅샷

- src: phase_z2 composition/mapper/pipeline/placement_planner/retry, ai_fallback(prompts/schema/validate), mdx_text_atoms 신규
- Front: PipelineTracePanel 신규, FramePanel/SlideCanvas/Home/designAgentApi 등 갱신 + 테스트 4종 추가
- templates/phase_z2: catalog(component_expansion_registry, node_slot_mapping 신규), frames, families, slide_base 갱신
- tests/matching: phase2~26 매칭 실험 스크립트·리포트·온톨로지 전체 (미커밋 진행분)
- tests: b4_v4 evidence, task5~28.5 시리즈, regression(imp95 baseline) 등 신규 테스트 대량 추가
- docs/reference: MDX 구조 인벤토리, MDX→Frame 구조 계약 문서
- scripts: mdx 계약/parity/coverage/viewport 체크, gitea comment, run sync 유틸
- .gitignore: tmp*.json, chromedriver, .orchestrator, *.pkl, Front_test* 등 임시/스냅샷 제외

미완성 작업의 보존용 스냅샷 커밋 (2026-07-02)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 17:03:42 +09:00
co-authored by Claude Opus 4.8
parent 97b7833a1b
commit b836e79ee1
527 changed files with 673036 additions and 717 deletions
@@ -99,6 +99,9 @@ export default function FramePanel({
{candidates.length === 0 ? (
<div className="py-20 text-center border-2 border-dashed border-slate-50 rounded-2xl">
<p className="text-[10px] font-bold text-slate-300 uppercase">No Candidates Available</p>
<p className="mt-2 px-4 text-[10px] leading-relaxed text-slate-400">
backend candidate pool is empty for {assignedSectionIds.join(", ") || selectedZone.zone_id}.
</p>
</div>
) : (
candidates.map((candidate, index) => {
@@ -120,6 +123,7 @@ export default function FramePanel({
candidate.routeHint && candidate.routeHint !== "direct_render";
const showStatusChip =
candidate.phaseZStatus && candidate.phaseZStatus !== "auto_renderable";
const showCoverageChip = Boolean(candidate.coverageState);
const hasCapacityFit =
candidate.capacityFit && candidate.capacityFit.fit_status;
const capacityMismatch =
@@ -132,6 +136,10 @@ export default function FramePanel({
if (candidate.routeHint) evidenceLines.push(`route: ${candidate.routeHint}`);
if (candidate.phaseZStatus)
evidenceLines.push(`phase_z_status: ${candidate.phaseZStatus}`);
if (candidate.candidateStatus)
evidenceLines.push(`candidate_status: ${candidate.candidateStatus}`);
if (candidate.coverageState)
evidenceLines.push(`coverage_state: ${candidate.coverageState}`);
if (hasCapacityFit) {
const cf = candidate.capacityFit!;
const capacityLine =
@@ -279,6 +287,28 @@ export default function FramePanel({
{candidate.label}
</span>
)}
{showCoverageChip && (
<span
className={`text-[8px] font-black uppercase tracking-tight px-1.5 py-0.5 rounded ${
candidate.coverageState === "covered_native"
? "bg-emerald-50 text-emerald-700"
: candidate.coverageState === "covered_via_expand"
? "bg-cyan-50 text-cyan-700"
: candidate.coverageState === "requires_adaptation"
? "bg-amber-50 text-amber-700"
: "bg-slate-100 text-slate-600"
}`}
title={`coverage_state: ${candidate.coverageState}`}
>
{candidate.coverageState === "covered_native"
? "native"
: candidate.coverageState === "covered_via_expand"
? "expand"
: candidate.coverageState === "requires_adaptation"
? "adapt"
: "unsup"}
</span>
)}
{/* IMP-29 u3 — route hint chip (skip when direct_render = default). */}
{showRouteChip && (
<span
+7 -5
View File
@@ -34,12 +34,14 @@ interface LeftMdxPanelProps {
onFileUpload: (file: File) => void;
onGenerate: () => void;
onSectionClick: (sectionId: string) => void;
/** 사용자 lock 2026-05-14 — 좌측 패널에 03/04/05 fix 고정 list. 클릭 시 callback. */
onSelectSample?: (which: "03" | "04" | "05") => void;
selectedSample?: "03" | "04" | "05" | null;
/** 사용자 lock 2026-05-14 — 좌측 패널에 01~05 fix 고정 list. 클릭 시 callback. */
onSelectSample?: (which: "01" | "02" | "03" | "04" | "05") => void;
selectedSample?: "01" | "02" | "03" | "04" | "05" | null;
}
const SAMPLE_MDX_LIST: { key: "03" | "04" | "05"; label: string; subtitle: string }[] = [
const SAMPLE_MDX_LIST: { key: "01" | "02" | "03" | "04" | "05"; label: string; subtitle: string }[] = [
{ key: "01", label: "01. 건설산업 DX의 올바른 이해", subtitle: "DX 개념 + 산업 전환 관점" },
{ key: "02", label: "02. DX의 시행 목표 및 기대효과", subtitle: "시행 목표 + 기대효과" },
{ key: "03", label: "03. DX 시행을 위한 필수 요건", subtitle: "필수 요건 + Process/Product 혁신" },
{ key: "04", label: "04. DX 지연 요인", subtitle: "DX 인식 + 정책/조직 실태" },
{ key: "05", label: "05. 설계 방식의 왜곡", subtitle: "설계 자동화 오용 + S/W 한계" },
@@ -74,7 +76,7 @@ export default function LeftMdxPanel({
MDX Source
</h2>
{/* 2026-05-14 — 03/04/05 fix 고정 list. 클릭 시 해당 mdx 자동 fetch + 분석.
{/* 2026-05-14 — 01~05 fix 고정 list. 클릭 시 해당 mdx 자동 fetch + 분석.
frame/layout override 는 분석 후 우측 패널에서 가능. */}
{onSelectSample && (
<div className="space-y-1 mb-3">
@@ -0,0 +1,216 @@
import { AlertTriangle, CheckCircle2, GitBranch, Layers3 } from "lucide-react";
import type React from "react";
import type { AiTraceSummary, PipelineTraceSummary } from "../services/designAgentApi";
interface PipelineTracePanelProps {
trace: PipelineTraceSummary | null | undefined;
aiTrace?: AiTraceSummary | null;
}
const stateClass: Record<string, string> = {
rendered: "bg-emerald-50 text-emerald-700 border-emerald-200",
render_blocked: "bg-red-50 text-red-700 border-red-200",
filtered: "bg-amber-50 text-amber-700 border-amber-200",
covered_not_rendered: "bg-orange-50 text-orange-700 border-orange-200",
uncovered: "bg-slate-50 text-slate-500 border-slate-200",
};
function Chip({ children, tone = "slate" }: { children: React.ReactNode; tone?: "slate" | "red" | "green" | "amber" }) {
const tones = {
slate: "bg-slate-100 text-slate-600",
red: "bg-red-100 text-red-700",
green: "bg-emerald-100 text-emerald-700",
amber: "bg-amber-100 text-amber-700",
};
return (
<span className={`inline-flex items-center rounded px-1.5 py-0.5 text-[9px] font-black uppercase ${tones[tone]}`}>
{children}
</span>
);
}
export default function PipelineTracePanel({ trace, aiTrace }: PipelineTracePanelProps) {
if (!trace) {
return (
<div className="h-full flex items-center justify-center bg-slate-50 p-8 text-center text-slate-400">
<div>
<GitBranch className="mx-auto mb-3 h-10 w-10 opacity-20" />
<p className="text-[10px] font-black uppercase tracking-[0.18em]">No Trace Loaded</p>
</div>
</div>
);
}
return (
<div className="h-full overflow-y-auto bg-white p-4 text-slate-700">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 text-blue-500" />
<h3 className="text-[10px] font-black uppercase tracking-widest text-slate-500">Pipeline Trace</h3>
</div>
<Chip tone={trace.warnings.length > 0 ? "amber" : "green"}>{trace.status}</Chip>
</div>
{trace.warnings.length > 0 && (
<div className="mb-4 rounded border border-amber-200 bg-amber-50 p-3">
<div className="mb-1 flex items-center gap-1.5 text-[10px] font-black uppercase text-amber-700">
<AlertTriangle className="h-3.5 w-3.5" />
Warnings
</div>
<div className="flex flex-wrap gap-1">
{trace.warnings.map((warning) => (
<Chip key={warning} tone="amber">{warning}</Chip>
))}
</div>
</div>
)}
<section className="mb-5">
<div className="mb-2 flex items-center gap-1.5 text-[10px] font-black uppercase tracking-widest text-slate-400">
<Layers3 className="h-3.5 w-3.5" />
Sections
</div>
<div className="space-y-2">
{trace.sections.map((section) => (
<div key={section.id} className={`rounded border p-2 ${stateClass[section.state] ?? stateClass.uncovered}`}>
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-[11px] font-bold">{section.id}</span>
<span className="text-[9px] font-black uppercase">{section.state}</span>
</div>
{section.title && <div className="mt-1 text-[11px] leading-snug">{section.title}</div>}
{section.child_ids.length > 0 && (
<div className="mt-1 text-[10px] text-slate-500">children: {section.child_ids.join(", ")}</div>
)}
{section.reasons.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{section.reasons.map((reason) => (
<Chip key={reason} tone={section.state === "render_blocked" ? "red" : "amber"}>
{reason}
</Chip>
))}
</div>
)}
</div>
))}
</div>
</section>
<section className="mb-5">
<div className="mb-2 text-[10px] font-black uppercase tracking-widest text-slate-400">Units</div>
<div className="space-y-2">
{trace.units.map((unit) => (
<div key={unit.unit_id} className="rounded border border-slate-200 bg-slate-50 p-2">
<div className="font-mono text-[11px] font-bold text-slate-700">{unit.unit_id || "(empty unit)"}</div>
<div className="mt-1 grid grid-cols-2 gap-x-2 gap-y-1 text-[10px]">
<span>frame</span><span className="font-mono">{unit.selected_frame ?? "-"}</span>
<span>candidates</span><span className="font-mono">{unit.candidate_count}</span>
<span>path</span><span className="font-mono">{unit.selection_path ?? "-"}</span>
<span>label</span><span className="font-mono">{unit.label ?? "-"}</span>
</div>
{unit.warnings.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{unit.warnings.map((warning) => <Chip key={warning} tone="amber">{warning}</Chip>)}
</div>
)}
</div>
))}
</div>
</section>
<section>
<div className="mb-2 text-[10px] font-black uppercase tracking-widest text-slate-400">Zones / Render</div>
<div className="space-y-2">
{trace.zones.map((zone) => {
const filled = zone.slot_status === "filled";
return (
<div key={zone.position} className="rounded border border-slate-200 bg-white p-2">
<div className="flex items-center justify-between">
<span className="font-mono text-[11px] font-bold">{zone.position}</span>
<Chip tone={filled ? "green" : "red"}>{zone.slot_status}</Chip>
</div>
<div className="mt-1 text-[10px] text-slate-500">sections: {zone.source_section_ids.join(", ") || "-"}</div>
<div className="mt-1 text-[10px] text-slate-500">template: <span className="font-mono">{zone.template_id ?? "-"}</span></div>
<div className="mt-1 text-[10px] text-slate-500">slot keys: {zone.slot_key_count}</div>
{zone.warnings.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1">
{zone.warnings.map((warning) => <Chip key={warning} tone="red">{warning}</Chip>)}
</div>
) : (
<div className="mt-2 flex items-center gap-1 text-[10px] text-emerald-600">
<CheckCircle2 className="h-3 w-3" />
render payload ready
</div>
)}
</div>
);
})}
</div>
</section>
<section className="mt-5">
<div className="mb-2 text-[10px] font-black uppercase tracking-widest text-slate-400">AI Status</div>
{!aiTrace ? (
<div className="rounded border border-slate-200 bg-slate-50 p-3 text-[11px] text-slate-500">
AI trace not loaded.
</div>
) : (
<div className="rounded border border-slate-200 bg-white p-3">
<div className="mb-2 flex flex-wrap gap-1">
<Chip tone={aiTrace.artifact_present ? "green" : "red"}>
{aiTrace.artifact_present ? "artifact" : "missing"}
</Chip>
<Chip tone={aiTrace.ai_enabled === false ? "amber" : aiTrace.ai_enabled ? "green" : "slate"}>
enabled {aiTrace.ai_enabled === null ? "unknown" : String(aiTrace.ai_enabled)}
</Chip>
<Chip tone={aiTrace.ai_called_count > 0 ? "green" : "slate"}>
called {aiTrace.ai_called_count}
</Chip>
<Chip tone={aiTrace.eligible_count > 0 ? "amber" : "slate"}>
eligible {aiTrace.eligible_count}
</Chip>
<Chip tone={aiTrace.error_count > 0 ? "red" : "slate"}>
errors {aiTrace.error_count}
</Chip>
</div>
<div className="grid grid-cols-2 gap-x-2 gap-y-1 text-[10px] text-slate-500">
<span>status</span><span className="font-mono">{aiTrace.status}</span>
<span>coverage</span><span className="font-mono">{aiTrace.coverage_status ?? "-"}</span>
<span>provider</span><span className="font-mono">{aiTrace.provider ?? "-"}</span>
<span>human review</span><span className="font-mono">{String(aiTrace.human_review_required)}</span>
</div>
{Object.keys(aiTrace.skip_reasons).length > 0 && (
<div className="mt-3">
<div className="mb-1 text-[10px] font-black uppercase text-slate-400">Skip Reasons</div>
<div className="flex flex-wrap gap-1">
{Object.entries(aiTrace.skip_reasons).map(([reason, count]) => (
<Chip key={reason} tone={reason === "called" ? "green" : "slate"}>
{reason}: {count}
</Chip>
))}
</div>
</div>
)}
{aiTrace.units.length > 0 && (
<div className="mt-3 space-y-2">
{aiTrace.units.map((unit) => (
<div key={`${unit.unit_index}-${unit.source_section_ids.join("+")}`} className="rounded bg-slate-50 p-2 text-[10px]">
<div className="flex items-center justify-between">
<span className="font-mono font-bold">{unit.source_section_ids.join("+") || `unit-${unit.unit_index}`}</span>
<Chip tone={unit.ai_called ? "green" : "slate"}>{unit.ai_called ? "called" : "not called"}</Chip>
</div>
<div className="mt-1 grid grid-cols-2 gap-x-2 gap-y-1 text-slate-500">
<span>route</span><span className="font-mono">{unit.route_hint ?? "-"}</span>
<span>skip</span><span className="font-mono">{unit.skip_reason ?? "-"}</span>
<span>apply</span><span className="font-mono">{unit.apply_status ?? "-"}</span>
<span>error</span><span className="font-mono">{unit.error ?? "-"}</span>
</div>
</div>
))}
</div>
)}
</div>
)}
</section>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,181 @@
/**
* Design Agent - Mock Data
*
* 이 파일은 백엔드 파이프라인이 연결되기 전까지 사용하는 mock 데이터입니다.
* Phase Z 설계 규격을 따릅니다.
*/
import type {
NormalizedContent,
SlidePlan,
LayoutCandidate,
FrameCandidate,
} from "../types/designAgent";
// ─────────────────────────────────────────────────────────────────────────────
// Layout Candidates
// ─────────────────────────────────────────────────────────────────────────────
// 8 preset = backend templates/phase_z2/layouts/layouts.yaml 1:1 매칭.
// id / 순서 / 의미 모두 backend 와 동일해야 함.
export const MOCK_LAYOUT_CANDIDATES: LayoutCandidate[] = [
{ id: "single", name: "단일 본문", type: "full", description: "전체 영역 1 zone (primary)" },
{ id: "horizontal-2", name: "상/하 2단", type: "top-bottom", description: "위/아래 2 zone (top, bottom) — rows topology" },
{ id: "vertical-2", name: "좌/우 2단", type: "left-right", description: "좌/우 2 zone (left, right) — cols topology" },
{ id: "top-1-bottom-2", name: "상 1 : 하 2", type: "asymmetric", description: "위 1 zone + 아래 좌/우 2 zone — T topology" },
{ id: "top-2-bottom-1", name: "상 2 : 하 1", type: "asymmetric", description: "위 좌/우 2 zone + 아래 1 zone — inverted-T topology" },
{ id: "left-1-right-2", name: "좌 1 : 우 2", type: "asymmetric", description: "좌 1 zone + 우 상/하 2 zone — side-T-left topology" },
{ id: "left-2-right-1", name: "좌 2 : 우 1", type: "asymmetric", description: "좌 상/하 2 zone + 우 1 zone — side-T-right topology" },
{ id: "grid-2x2", name: "2x2 그리드", type: "grid", description: "4 zone 균등 배치 — 2x2 topology" },
];
// ─────────────────────────────────────────────────────────────────────────────
// Frame Candidates (샘플 이미지 포함 - 8종 세트)
// ─────────────────────────────────────────────────────────────────────────────
const SAMPLE_FRAMES: FrameCandidate[] = [
{
id: "frame-001",
name: "3-Card 프로세스",
score: 0.98,
confidence: "high",
label: "use_as_is",
thumbnailUrl: "https://images.unsplash.com/photo-1614850523296-d8c1af93d400?w=300&h=200&fit=crop",
},
{
id: "frame-002",
name: "비교 테이블 (Light)",
score: 0.85,
confidence: "medium",
label: "light_edit",
thumbnailUrl: "https://images.unsplash.com/photo-1508615039623-a25651266b91?w=300&h=200&fit=crop",
},
{
id: "frame-003",
name: "계층형 트리",
score: 0.72,
confidence: "medium",
label: "restructure",
thumbnailUrl: "https://images.unsplash.com/photo-1551288049-bbda03a24d5d?w=300&h=200&fit=crop",
},
{
id: "frame-004",
name: "4-Grid 핵심 요약",
score: 0.65,
confidence: "low",
label: "reject",
thumbnailUrl: "https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=300&h=200&fit=crop",
},
{
id: "frame-005",
name: "타임라인 로드맵",
score: 0.91,
confidence: "high",
label: "use_as_is",
thumbnailUrl: "https://images.unsplash.com/photo-1531403009284-440f080d1e12?w=300&h=200&fit=crop",
},
{
id: "frame-006",
name: "순환형 다이어그램",
score: 0.78,
confidence: "medium",
label: "light_edit",
thumbnailUrl: "https://images.unsplash.com/photo-1557804506-669a67965ba0?w=300&h=200&fit=crop",
},
{
id: "frame-007",
name: "수치 강조 카드",
score: 0.88,
confidence: "high",
label: "use_as_is",
thumbnailUrl: "https://images.unsplash.com/photo-1551288049-bbda03a24d5d?w=300&h=200&fit=crop",
},
{
id: "frame-008",
name: "좌우 대비 분석",
score: 0.62,
confidence: "low",
label: "restructure",
thumbnailUrl: "https://images.unsplash.com/photo-1454165833767-02a6ed8a687a?w=300&h=200&fit=crop",
},
];
export const MOCK_FRAME_CANDIDATES_SECTION1 = SAMPLE_FRAMES;
// ─────────────────────────────────────────────────────────────────────────────
// Normalized Content
// ─────────────────────────────────────────────────────────────────────────────
export const MOCK_NORMALIZED_CONTENT: NormalizedContent = {
title: "DX 실행 체계 구축 방안",
sections: [
{
id: "section-1",
index: 1,
level: 2,
title: "DX 실행 필수 요건",
content_objects: [
{ id: "co-1", type: "text_block", role: "summary", raw_payload: "조직 전반의 DX 역량 강화 필요", size_estimate: { line_count: 2 } },
{ id: "co-2", type: "text_block", role: "detail", raw_payload: "전담 조직 구성 및 KPI 설정", size_estimate: { line_count: 3 } }
]
}
]
};
// ─────────────────────────────────────────────────────────────────────────────
// Slide Plan (Phase Z 구조)
// ─────────────────────────────────────────────────────────────────────────────
export const MOCK_SLIDE_PLAN: SlidePlan = {
id: "slide-001",
title: "DX 실행 체계 구축 방안",
layout_preset: "horizontal-2",
zones: [
{
id: "zone-left",
zone_id: "left",
section_ids: ["section-1"],
position: { x: 0, y: 0, width: 0.5, height: 1 },
internal_regions: [
{
id: "region-l1",
region_id: "content-1",
role: "primary",
content_type: "mixed",
ratio_estimate: 1,
content_unit_ids: ["co-1", "co-2"],
frame_match_strategy: {
kind: "frame_match",
frame_id: "frame-001",
display_strategy: "inline_full"
},
frame_candidates: SAMPLE_FRAMES
}
]
},
{
id: "zone-right",
zone_id: "right",
section_ids: ["section-1"],
position: { x: 0.5, y: 0, width: 0.5, height: 1 },
internal_regions: [
{
id: "region-r1",
region_id: "content-2",
role: "secondary",
content_type: "text_block",
ratio_estimate: 1,
content_unit_ids: [],
frame_match_strategy: {
kind: "frame_match",
frame_id: "frame-002",
display_strategy: "inline_full"
},
frame_candidates: SAMPLE_FRAMES
}
]
}
],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
+65 -21
View File
@@ -20,6 +20,7 @@ import {
saveStructureOverride,
deriveUserOverridesKey,
applyPersistedNonFrameOverrides,
mergeSubmittedPipelineOverridesForRestore,
remapPersistedFramesToZoneFrames,
validateZoneGeometriesAgainstLayout,
} from "../utils/slidePlanUtils";
@@ -43,6 +44,7 @@ import LeftMdxPanel from "../components/LeftMdxPanel";
import SlideCanvas from "../components/SlideCanvas";
import LayoutPanel from "../components/LayoutPanel";
import FramePanel from "../components/FramePanel";
import PipelineTracePanel from "../components/PipelineTracePanel";
import BottomActions from "../components/BottomActions";
import {
Sparkles, Loader2,
@@ -60,7 +62,7 @@ const INITIAL_STATE: DesignAgentState = {
error: null,
};
type RightPanelTab = "layout" | "frame";
type RightPanelTab = "layout" | "frame" | "trace";
export default function Home() {
const [state, setState] = useState<DesignAgentState>(INITIAL_STATE);
@@ -266,7 +268,10 @@ export default function Home() {
setState((p) => ({
...p,
normalizedContent: content,
userSelection: applyPersistedNonFrameOverrides(p.userSelection, persisted),
// #98 Task 1 — selecting/uploading a MDX starts from a clean analysis
// state. Persisted overrides stay cached for explicit edit/regenerate
// paths, but they must not pre-seed the first "Generate" run.
userSelection: createInitialUserSelection(),
isLoading: false,
}));
toast.success(`"${file.name}" 분석 완료 — 하단 버튼으로 슬라이드 생성하세요.`);
@@ -279,11 +284,11 @@ export default function Home() {
}
}, []);
// 2026-05-14 — 좌측 패널의 03/04/05 fix list 클릭 또는 URL `?mdx=04` 변경 시
// 2026-05-14 — 좌측 패널의 01~05 fix list 클릭 또는 URL `?mdx=04` 변경 시
// 호출되는 단일 callback. handleFileUpload 가 자동 분석 trigger.
const [selectedSample, setSelectedSample] = useState<"03" | "04" | "05" | null>(null);
const [selectedSample, setSelectedSample] = useState<"01" | "02" | "03" | "04" | "05" | null>(null);
const handleSelectSample = useCallback(async (which: "03" | "04" | "05") => {
const handleSelectSample = useCallback(async (which: "01" | "02" | "03" | "04" | "05") => {
try {
const res = await fetch(`/api/sample-mdx?mdx=${encodeURIComponent(which)}`);
if (!res.ok) return;
@@ -304,11 +309,11 @@ export default function Home() {
}, []);
// 페이지 첫 로드 시 데모용 mdx 자동 로드 — 상대방에게 mdx 파일 공유 안 해도 되게.
// URL query `?mdx=04` / `?mdx=05` 로 다른 sample 선택 가능. default = 03.
// URL query `?mdx=01`~`?mdx=05` 로 다른 sample 선택 가능. default = 03.
// 사용자가 다른 파일을 직접 업로드하면 그것이 override 됨.
useEffect(() => {
const which = (new URLSearchParams(window.location.search).get("mdx") as "03" | "04" | "05" | null) || "03";
if (!["03", "04", "05"].includes(which)) return;
const which = (new URLSearchParams(window.location.search).get("mdx") as "01" | "02" | "03" | "04" | "05" | null) || "03";
if (!["01", "02", "03", "04", "05"].includes(which)) return;
handleSelectSample(which);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -326,9 +331,10 @@ export default function Home() {
// - layout : userSelection.overrides.layout_preset (default 와 다를 때만)
// - frames : zone.section_ids → unit_id ("+".join). region.id 별 zone_frames lookup.
// pendingZones 의 region 도 동일 — 그 region 의 zone 의 sections 가 unit_id 결정.
const shouldUseUserOverrides = hasPendingChanges;
const overrides: PipelineOverrides = {};
const sourcePlan = effectiveSlidePlan;
if (sourcePlan && state.slidePlan) {
if (shouldUseUserOverrides && sourcePlan && state.slidePlan) {
// 2026-05-22 demo hot-fix — 이전 비교 가드 (default !== override) 제거.
// restore loop 이 default = override 로 sync 시 override 안 보내고 backend
// default fallback 발생. user 가 명시한 layout 이 있으면 무조건 보냄.
@@ -438,6 +444,11 @@ export default function Home() {
}
}
// #98 Task 4 — stale render guard. Once a new generate/regenerate starts,
// the previous final.html must no longer be treated as the current result.
// If the backend run later fails, keeping the old runMeta would resurrect
// the old iframe and make it look as if the new selection rendered.
setRunMeta(null);
setState((p) => ({ ...p, isLoading: true }));
setHasPendingChanges(false); // 재생성 트리거 시 override pending flag reset
setPendingLayout(null); // pending layout 모드 종료
@@ -461,6 +472,16 @@ export default function Home() {
// clicks Generate would race the PUT against /api/run; the u2
// fallback could then load a stale persisted document.
await flushUserOverrides();
const restoreOverrides = shouldUseUserOverrides
? mergeSubmittedPipelineOverridesForRestore(
await getUserOverrides(deriveUserOverridesKey(state.uploadedFile.name)),
overrides,
state.userSelection.overrides.manual_section_assignment,
)
: {};
if (shouldUseUserOverrides) {
persistedOverridesRef.current = restoreOverrides;
}
// IMP-42 u4 — unconditional DIAG console.log on the handleGenerate
// entry-to-backend boundary. Surfaces the override payload + uploaded
// file name so the user can see exactly what crossed the wire when
@@ -469,7 +490,12 @@ export default function Home() {
file: state.uploadedFile.name,
overrides,
});
const result = await runPipeline(state.uploadedFile, overrides);
const result = await runPipeline(
state.uploadedFile,
shouldUseUserOverrides ? overrides : undefined,
undefined,
{ ignoreUserOverrides: !shouldUseUserOverrides },
);
if (!result.success || !result.final_html_exists) {
const detail =
@@ -488,10 +514,12 @@ export default function Home() {
// zone_frames is keyed by region.id. Remap against the new slidePlan
// zones so SlideCanvas's override-vs-default preview indicator shows
// the user's persisted choice without forcing them to re-click.
const restoredZoneFrames = remapPersistedFramesToZoneFrames(
slidePlan,
persistedOverridesRef.current.frames as Record<string, string> | undefined,
);
const restoredZoneFrames = shouldUseUserOverrides
? remapPersistedFramesToZoneFrames(
slidePlan,
restoreOverrides.frames as Record<string, string> | undefined,
)
: {};
setState((p) => {
// IMP-52 u6 — restore-on-reopen: re-layer the persisted non-frame
// axes (layout / zone_geometries / zone_sections) onto the post-load
@@ -500,10 +528,10 @@ export default function Home() {
// a CLI arg — `zone_geometries` in particular has no slidePlan
// representation, so without this merge the user would see their
// resized zones revert on every Generate.
const base = applyPersistedNonFrameOverrides(
createInitialUserSelection(slidePlan),
persistedOverridesRef.current,
);
const cleanBase = createInitialUserSelection(slidePlan);
const base = shouldUseUserOverrides
? applyPersistedNonFrameOverrides(cleanBase, restoreOverrides)
: cleanBase;
return {
...p,
normalizedContent,
@@ -519,6 +547,9 @@ export default function Home() {
};
});
setRunMeta(runMeta);
if (runMeta.status !== "PASS" || (runMeta.pipeline_trace?.warnings.length ?? 0) > 0) {
setRightTab("trace");
}
toast.success(`run "${result.run_id}" 완료 — ${runMeta.status}`);
const aiReviewMsg = formatAiRepairHumanReviewMessage(runMeta.ai_repair_status);
if (aiReviewMsg) toast.error(aiReviewMsg);
@@ -529,7 +560,7 @@ export default function Home() {
);
setState((p) => ({ ...p, isLoading: false }));
}
}, [state.uploadedFile, state.slidePlan, state.userSelection, pendingZones, pendingLayout]);
}, [state.uploadedFile, state.slidePlan, state.userSelection, pendingZones, pendingLayout, hasPendingChanges]);
// ── 섹션 드래그 앤 드롭 (Zone으로 재배치) ──
const handleSectionDrop = useCallback((sectionId: string, zoneId: string) => {
@@ -848,6 +879,16 @@ export default function Home() {
</div>
</details>
)}
{(runMeta.pipeline_trace?.warnings.length ?? 0) > 0 && (
<button
type="button"
className="text-[10px] font-bold px-1.5 py-0.5 bg-red-100 text-red-700 rounded uppercase tracking-wider"
onClick={() => setRightTab("trace")}
title={runMeta.pipeline_trace?.warnings.join(" / ")}
>
Trace: {runMeta.pipeline_trace?.warnings.length}
</button>
)}
</>
)}
</div>
@@ -898,7 +939,7 @@ export default function Home() {
slidePlan={effectiveSlidePlan}
normalizedContent={state.normalizedContent}
userSelection={state.userSelection}
finalHtmlUrl={runMeta?.final_html_url}
finalHtmlUrl={state.isLoading ? undefined : runMeta?.final_html_url}
isPipelineRunning={state.isLoading}
isPendingLayout={!!pendingLayout}
pendingLayoutId={pendingLayout}
@@ -927,6 +968,7 @@ export default function Home() {
<div className="flex-shrink-0 flex p-1 bg-slate-50 border-b border-slate-200 m-2 rounded-lg">
<button className={`flex-1 py-1.5 text-[10px] font-black uppercase tracking-widest rounded-md transition-all ${rightTab === "frame" ? "bg-white text-blue-600 shadow-sm" : "text-slate-400"}`} onClick={() => setRightTab("frame")}>Frame</button>
<button className={`flex-1 py-1.5 text-[10px] font-black uppercase tracking-widest rounded-md transition-all ${rightTab === "layout" ? "bg-white text-blue-600 shadow-sm" : "text-slate-400"}`} onClick={() => setRightTab("layout")}>Layout</button>
<button className={`flex-1 py-1.5 text-[10px] font-black uppercase tracking-widest rounded-md transition-all ${rightTab === "trace" ? "bg-white text-blue-600 shadow-sm" : "text-slate-400"}`} onClick={() => setRightTab("trace")}>Trace</button>
</div>
<div className="flex-1 overflow-hidden">
{rightTab === "frame" ? (
@@ -938,7 +980,7 @@ export default function Home() {
onFrameSelect={handleFrameSelect}
onNoDesignToggle={() => {}}
/>
) : (
) : rightTab === "layout" ? (
<LayoutPanel
selectedZone={selectedZone}
userSelection={state.userSelection}
@@ -948,6 +990,8 @@ export default function Home() {
pipelineSelectedLayoutId={state.slidePlan?.layout_preset}
pendingLayoutId={pendingLayout}
/>
) : (
<PipelineTracePanel trace={runMeta?.pipeline_trace} aiTrace={runMeta?.ai_trace} />
)}
</div>
</aside>
+529 -3
View File
@@ -276,6 +276,74 @@ export interface RunMeta {
region_layout_candidates_by_zone: Record<string, string[]>; // step08 placeholder
display_strategy_candidates_by_zone: Record<string, string[]>; // step08 placeholder
ai_repair_status: AiRepairStatus | null;
pipeline_trace: PipelineTraceSummary | null;
ai_trace: AiTraceSummary | null;
}
export interface PipelineTraceSection {
id: string;
title: string;
child_ids: string[];
state: "rendered" | "render_blocked" | "filtered" | "covered_not_rendered" | "uncovered";
reasons: string[];
}
export interface PipelineTraceUnit {
unit_id: string;
source_section_ids: string[];
merge_type: string | null;
selected_frame: string | null;
label: string | null;
candidate_count: number;
selection_path: string | null;
warnings: string[];
}
export interface PipelineTraceZone {
position: string;
source_section_ids: string[];
template_id: string | null;
slot_status: "filled" | "empty";
slot_key_count: number;
warnings: string[];
}
export interface PipelineTraceSummary {
sections: PipelineTraceSection[];
units: PipelineTraceUnit[];
zones: PipelineTraceZone[];
status: string;
warnings: string[];
}
export interface AiTraceUnit {
unit_index: number | null;
source_section_ids: string[];
frame_template_id: string | null;
route_hint: string | null;
provisional: boolean;
ai_called: boolean;
skip_reason: string | null;
apply_status: string | null;
api_error_kind: string | null;
error: string | null;
}
export interface AiTraceSummary {
artifact_present: boolean;
ai_enabled: boolean | null;
status: string;
ai_called_count: number;
eligible_count: number;
skipped_count: number;
error_count: number;
skip_reasons: Record<string, number>;
provider: string | null;
model: string | null;
coverage_status: string | null;
human_review_required: boolean;
units: AiTraceUnit[];
warnings: string[];
}
// IMP-92 u5 — Operational-only AI repair message formatter.
@@ -352,6 +420,7 @@ export async function runPipeline(
// from the prior run). When omitted / empty, the POST body is
// byte-identical to pre-u6 (no reuseFromRunId key → no flag forwarded).
reuseFromRunId?: string,
options?: { ignoreUserOverrides?: boolean },
): Promise<RunPipelineResult> {
const content = await file.text();
const body: Record<string, unknown> = {
@@ -360,6 +429,7 @@ export async function runPipeline(
overrides,
};
if (reuseFromRunId) body.reuseFromRunId = reuseFromRunId;
if (options?.ignoreUserOverrides) body.ignoreUserOverrides = true;
const res = await fetch("/api/run", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -439,12 +509,417 @@ function classifyConfidence(score: number): "high" | "medium" | "low" {
return "low";
}
const TOP_N_FRAMES = 6;
function getCandidateTemplateId(c: any): string | null {
const id = c?.template_id ?? c?.frame_template_id ?? c?.id;
return typeof id === "string" && id.length > 0 ? id : null;
}
function normalizeRawCandidate(c: any): any | null {
const templateId = getCandidateTemplateId(c);
if (!templateId) return null;
return {
...c,
template_id: templateId,
label: c.label ?? c.v4_label,
confidence: c.confidence ?? c.score,
rank: c.rank ?? c.v4_rank,
};
}
function mergeMissingCandidateFields(existing: any, incoming: any): any {
const merged = { ...existing };
for (const [key, value] of Object.entries(incoming)) {
if (value === undefined || value === null || value === "") continue;
if (merged[key] === undefined || merged[key] === null || merged[key] === "") {
merged[key] = value;
}
}
return merged;
}
function pushCandidate(candidateMap: Map<string, any>, c: any): void {
const normalized = normalizeRawCandidate(c);
if (!normalized) return;
const key = normalized.template_id;
const prior = candidateMap.get(key);
candidateMap.set(key, prior ? mergeMissingCandidateFields(prior, normalized) : normalized);
}
function candidatesMatchingTemplate(unit: any, templateId: string | null): any[] {
if (!templateId) return [];
const sources = [
unit?.sorted_candidate_evidence,
unit?.candidate_evidence,
unit?.v4_candidates,
unit?.v4_all_judgments,
unit?.application_candidates,
];
return sources
.flatMap((source) => (Array.isArray(source) ? source : []))
.filter((c) => getCandidateTemplateId(c) === templateId);
}
function compositionUnitToCandidate(compositionUnit: any | null | undefined): any | null {
if (!compositionUnit) return null;
const templateId = getCandidateTemplateId(compositionUnit);
if (!templateId) return null;
return {
template_id: templateId,
frame_id: compositionUnit.frame_id,
frame_number: compositionUnit.frame_number,
confidence: compositionUnit.confidence ?? compositionUnit.score ?? 0,
label: compositionUnit.label ?? "reject",
v4_label: compositionUnit.label,
phase_z_status: compositionUnit.phase_z_status,
candidate_status: compositionUnit.candidate_status,
coverage_state:
compositionUnit.coverage_state ??
(compositionUnit.rationale?.ai_adaptation_required === true
? "requires_adaptation"
: undefined),
route_hint:
compositionUnit.rationale?.ai_adaptation_required === true
? "ai_adaptation_required"
: undefined,
decision: "selected",
reason: compositionUnit.selection_path ?? compositionUnit.fallback_reason,
capacity_fit: compositionUnit.rationale?.capacity_fit,
};
}
export function buildFrameCandidatesForUnit(
unit: any,
compositionUnit?: any | null,
): FrameCandidate[] {
const candidateMap = new Map<string, any>();
const currentDefault =
typeof unit?.current_default_candidate === "string"
? unit.current_default_candidate
: null;
candidatesMatchingTemplate(unit, currentDefault).forEach((c) => pushCandidate(candidateMap, c));
pushCandidate(candidateMap, compositionUnitToCandidate(compositionUnit));
const sortedCandidateEvidence = Array.isArray(unit?.sorted_candidate_evidence)
? unit.sorted_candidate_evidence
: [];
const candidateEvidence = Array.isArray(unit?.candidate_evidence)
? unit.candidate_evidence
: [];
const primaryEvidence =
sortedCandidateEvidence.length > 0 ? sortedCandidateEvidence : candidateEvidence;
primaryEvidence.forEach((c: any) => pushCandidate(candidateMap, c));
(Array.isArray(unit?.v4_candidates) ? unit.v4_candidates : []).forEach((c: any) => pushCandidate(candidateMap, c));
(Array.isArray(unit?.v4_all_judgments) ? unit.v4_all_judgments : []).forEach((c: any) => pushCandidate(candidateMap, c));
(Array.isArray(unit?.application_candidates) ? unit.application_candidates : []).forEach((c: any) => pushCandidate(candidateMap, c));
const applicationModeMap = mergeApplicationCandidates(unit?.application_candidates);
return Array.from(candidateMap.values())
.slice(0, TOP_N_FRAMES)
.map((c: any) => {
const appMatch = applicationModeMap.get(c.template_id);
const numericScore = Number(c.confidence ?? c.score ?? 0);
const frameNumber = Number(c.frame_number);
return {
id: c.template_id,
name: c.template_id,
score: Number.isFinite(numericScore) ? numericScore : 0,
confidence: classifyConfidence(Number.isFinite(numericScore) ? numericScore : 0),
label: (c.label ?? c.v4_label ?? "reject") as FrameCandidate["label"],
thumbnailUrl:
Number.isFinite(frameNumber) && frameNumber > 0
? `/frame-preview/${String(frameNumber).padStart(2, "0")}`
: undefined,
catalogRegistered: c.catalog_registered,
minHeightPx: c.min_height_px ?? undefined,
rank: c.rank,
frameId: c.frame_id,
v4Label: c.v4_label,
phaseZStatus: c.phase_z_status,
coverageState: c.coverage_state ?? c.coverageState,
candidateStatus: c.candidate_status ?? c.candidateStatus,
filteredForDirectExecution: c.filtered_for_direct_execution,
routeHint: c.route_hint,
decision: c.decision,
reason: c.reason,
capacityFit: c.capacity_fit,
applicationMode: appMatch?.application_mode ?? c.application_mode,
autoApplicable: appMatch?.auto_applicable ?? c.auto_applicable,
delegatedTo: appMatch?.delegated_to ?? c.delegated_to ?? null,
};
});
}
/**
* 실제 Phase Z run 산출물을 로드하여 frontend type 으로 변환.
*
* @example
* const { normalizedContent, slidePlan, runMeta } = await loadRun("mdx03_f29_fix_check");
*/
function arrayOfStrings(value: unknown): string[] {
return Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : [];
}
function slotPayloadStatus(zone: any): { status: "filled" | "empty"; keyCount: number } {
const payload = zone?.slot_payload;
const keys = payload && typeof payload === "object" && !Array.isArray(payload)
? Object.keys(payload).filter((key) => key !== "_truncated_count")
: [];
const templateId = zone?.template_id ?? zone?.v4_template_id ?? null;
return {
status: templateId === "__empty__" || keys.length === 0 ? "empty" : "filled",
keyCount: keys.length,
};
}
function pushReason(map: Map<string, string[]>, id: string, reason: string): void {
if (!id || !reason) return;
const reasons = map.get(id) ?? [];
if (!reasons.includes(reason)) reasons.push(reason);
map.set(id, reasons);
}
function formatFilteredReason(reason: any): string {
const parts = [
reason?.selection_state,
reason?.merge_type,
reason?.template_id,
reason?.v4_label,
reason?.phase_z_status,
...(Array.isArray(reason?.filter_reasons) ? reason.filter_reasons : []),
].filter((value): value is string => typeof value === "string" && value.length > 0);
return parts.join(" / ") || "filtered";
}
export function buildPipelineTraceSummary(args: {
normalized: any;
compositionPlan: any;
applicationPlan: any;
slotPayload: any;
slideStatus: any;
}): PipelineTraceSummary {
const slideData = args.slideStatus?.data ?? {};
const rendered = new Set(arrayOfStrings(slideData.content_rendered_section_ids));
const blocked = new Set(arrayOfStrings(slideData.render_blocked_section_ids));
const filtered = new Set(arrayOfStrings(slideData.filtered_section_ids));
const covered = new Set(arrayOfStrings(slideData.covered_section_ids));
const sectionReasons = new Map<string, string[]>();
(Array.isArray(slideData.filtered_section_reasons) ? slideData.filtered_section_reasons : [])
.forEach((reason: any) => {
arrayOfStrings(reason?.section_ids).forEach((id) => {
pushReason(sectionReasons, id, formatFilteredReason(reason));
});
});
(Array.isArray(slideData.adapter_needed_units) ? slideData.adapter_needed_units : [])
.forEach((unit: any) => {
const reason = unit?.reason ?? "adapter_needed";
arrayOfStrings(unit?.source_section_ids).forEach((id) => {
pushReason(sectionReasons, id, String(reason));
});
});
filtered.forEach((id) => pushReason(sectionReasons, id, "filtered"));
const sections = (Array.isArray(args.normalized?.data?.sections) ? args.normalized.data.sections : [])
.map((section: any, index: number): PipelineTraceSection => {
const id = section.section_id ?? `section-${index + 1}`;
const childIds = Array.isArray(section.sub_sections)
? section.sub_sections
.map((sub: any, subIndex: number) => sub.section_id ?? `${id}-sub-${subIndex + 1}`)
.filter(Boolean)
: [];
let state: PipelineTraceSection["state"] = "uncovered";
if (rendered.has(id)) state = "rendered";
else if (blocked.has(id)) state = "render_blocked";
else if (filtered.has(id)) state = "filtered";
else if (covered.has(id)) state = "covered_not_rendered";
return {
id,
title: section.title ?? "",
child_ids: childIds,
state,
reasons: sectionReasons.get(id) ?? [],
};
});
const compositionByUnit = new Map<string, any>();
const selectedUnits = Array.isArray(args.compositionPlan?.data?.selected_units)
? args.compositionPlan.data.selected_units
: [];
selectedUnits.forEach((unit: any) => {
const unitId = arrayOfStrings(unit.source_section_ids).join("+");
if (unitId) compositionByUnit.set(unitId, unit);
});
const appUnits = Array.isArray(args.applicationPlan?.data?.units)
? args.applicationPlan.data.units
: [];
const units = appUnits.map((unit: any): PipelineTraceUnit => {
const sectionIds = typeof unit.unit_id === "string"
? unit.unit_id.split("+").filter(Boolean)
: arrayOfStrings(unit.source_section_ids);
const compositionUnit = compositionByUnit.get(sectionIds.join("+"));
const warnings: string[] = [];
if (sectionIds.length > 1) warnings.push("merged_sections");
const selectionPath = unit.selection_path ?? compositionUnit?.selection_path ?? null;
if (typeof selectionPath === "string" && selectionPath.includes("fallback")) warnings.push(selectionPath);
const candidateCount = buildFrameCandidatesForUnit(unit, compositionUnit).length;
if (candidateCount === 0) warnings.push("no_frame_candidates");
return {
unit_id: sectionIds.join("+") || unit.unit_id || "",
source_section_ids: sectionIds,
merge_type: unit.merge_type ?? compositionUnit?.merge_type ?? null,
selected_frame:
unit.current_default_candidate ??
unit.template_id ??
compositionUnit?.frame_template_id ??
null,
label: unit.label ?? compositionUnit?.label ?? null,
candidate_count: candidateCount,
selection_path: selectionPath,
warnings,
};
});
const adapterByPosition = new Map<string, any>();
(Array.isArray(slideData.adapter_needed_units) ? slideData.adapter_needed_units : [])
.forEach((unit: any) => {
if (unit.position) adapterByPosition.set(unit.position, unit);
});
const zones = (Array.isArray(args.slotPayload?.data?.per_zone) ? args.slotPayload.data.per_zone : [])
.map((zone: any, index: number): PipelineTraceZone => {
const { status, keyCount } = slotPayloadStatus(zone);
const adapterNeeded = adapterByPosition.get(zone.position);
const appUnit = appUnits[index] ?? {};
const sectionIds = arrayOfStrings(adapterNeeded?.source_section_ids).length > 0
? arrayOfStrings(adapterNeeded.source_section_ids)
: typeof appUnit.unit_id === "string"
? appUnit.unit_id.split("+").filter(Boolean)
: [];
const warnings: string[] = [];
if (zone.template_id === "__empty__") warnings.push("__empty__");
if (status === "empty") warnings.push("empty_slot_payload");
if (adapterNeeded) warnings.push(adapterNeeded.reason ?? "adapter_needed");
return {
position: zone.position ?? `zone-${index}`,
source_section_ids: sectionIds,
template_id: zone.template_id ?? null,
slot_status: status,
slot_key_count: keyCount,
warnings,
};
});
const warnings = [
...arrayOfStrings(slideData.filtered_section_ids).map((id) => `filtered:${id}`),
...arrayOfStrings(slideData.render_blocked_section_ids).map((id) => `render_blocked:${id}`),
];
if ((slideData.adapter_needed_count ?? 0) > 0) {
warnings.push(`adapter_needed:${slideData.adapter_needed_count}`);
}
return {
sections,
units,
zones,
status: slideData.overall ?? "UNKNOWN",
warnings,
};
}
export function buildAiTraceSummary(
aiRepairArtifact: any | null,
aiRepairStatus: AiRepairStatus | null | undefined,
): AiTraceSummary {
if (!aiRepairArtifact?.data) {
return {
artifact_present: false,
ai_enabled: null,
status: aiRepairStatus?.status ?? "missing_artifact",
ai_called_count: 0,
eligible_count: 0,
skipped_count: 0,
error_count: 0,
skip_reasons: {},
provider: null,
model: null,
coverage_status: aiRepairStatus?.coverage_status ?? null,
human_review_required: aiRepairStatus?.human_review_required ?? false,
units: [],
warnings: ["step12_ai_repair.json missing"],
};
}
const rawUnits = Array.isArray(aiRepairArtifact.data.per_unit)
? aiRepairArtifact.data.per_unit
: [];
const units: AiTraceUnit[] = rawUnits.map((unit: any) => ({
unit_index: typeof unit.unit_index === "number" ? unit.unit_index : null,
source_section_ids: arrayOfStrings(unit.source_section_ids),
frame_template_id: unit.frame_template_id ?? null,
route_hint: unit.route_hint ?? null,
provisional: unit.provisional === true,
ai_called: unit.ai_called === true,
skip_reason: unit.skip_reason ?? null,
apply_status: unit.apply_status ?? null,
api_error_kind: unit.api_error_kind ?? null,
error: unit.error ?? null,
}));
const skipReasons: Record<string, number> = {};
units.forEach((unit) => {
const key = unit.skip_reason ?? (unit.ai_called ? "called" : "unknown");
skipReasons[key] = (skipReasons[key] ?? 0) + 1;
});
const warnings: string[] = [];
units
.filter((unit) => unit.error)
.forEach((unit) => warnings.push(`error:${unit.unit_index ?? "?"}`));
units
.filter((unit) => unit.api_error_kind)
.forEach((unit) => warnings.push(`api_${unit.api_error_kind}:${unit.unit_index ?? "?"}`));
if (units.some((unit) => unit.skip_reason === "router_short_circuit")) {
warnings.push("router_short_circuit");
}
const eligibleUnits = units.filter(
(unit) => unit.provisional && unit.route_hint === "ai_adaptation_required",
);
const aiEnabled =
units.some((unit) => unit.ai_called)
? true
: eligibleUnits.length > 0 && eligibleUnits.every((unit) => unit.skip_reason === "router_short_circuit")
? false
: null;
return {
artifact_present: true,
ai_enabled: aiEnabled,
status: aiRepairStatus?.status ?? aiRepairArtifact.step_status ?? "unknown",
ai_called_count: units.filter((unit) => unit.ai_called).length,
eligible_count: units.filter((unit) => unit.provisional && unit.route_hint === "ai_adaptation_required").length,
skipped_count: units.filter((unit) => !unit.ai_called).length,
error_count: units.filter((unit) => !!unit.error).length,
skip_reasons: skipReasons,
provider: "anthropic",
model: null,
coverage_status:
aiRepairStatus?.coverage_status ??
aiRepairArtifact.data.coverage_invariant?.status ??
null,
human_review_required: aiRepairStatus?.human_review_required ?? false,
units,
warnings,
};
}
export async function loadRun(runId: string): Promise<LoadRunResult> {
console.log(`[Phase Z] loadRun: ${runId}`);
const base = `/data/runs/${runId}`;
@@ -456,6 +931,14 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
}
return res.json();
};
const fetchJsonOptional = async (path: string) => {
const res = await fetch(`${base}/${path}`);
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(`Failed to fetch ${path}: ${res.status} ${res.statusText}`);
}
return res.json();
};
const fetchText = async (path: string) => {
const res = await fetch(`${base}/${path}`);
if (!res.ok) {
@@ -468,21 +951,28 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
mdxSource,
upload,
normalized,
compositionPlan,
layout,
zoneRegion,
applicationPlan,
slotPayload,
slideStatus,
aiRepair,
] = await Promise.all([
fetchText("steps/step01_mdx_source.md"),
fetchJson("steps/step01_mdx_upload.json"),
fetchJson("steps/step02_normalized.json"),
fetchJson("steps/step06_composition_plan.json"),
fetchJson("steps/step07_layout.json"),
fetchJson("steps/step08_zone_region_ratios.json"),
fetchJson("steps/step09_application_plan.json"),
fetchJson("steps/step12_slot_payload.json"),
fetchJson("steps/step20_slide_status.json"),
fetchJsonOptional("steps/step12_ai_repair.json"),
]);
// ── RunMeta ──
const aiRepairStatus = (slideStatus.data?.ai_repair_status ?? null) as AiRepairStatus | null;
const runMeta: RunMeta = {
run_id: upload.data?.run_id ?? runId,
mdx_path: upload.data?.mdx_path ?? "",
@@ -507,7 +997,15 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
z.display_strategy_candidates ?? [],
])
),
ai_repair_status: (slideStatus.data?.ai_repair_status ?? null) as AiRepairStatus | null,
ai_repair_status: aiRepairStatus,
pipeline_trace: buildPipelineTraceSummary({
normalized,
compositionPlan,
applicationPlan,
slotPayload,
slideStatus,
}),
ai_trace: buildAiTraceSummary(aiRepair, aiRepairStatus),
};
// ── NormalizedContent ──
@@ -562,6 +1060,16 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
const layoutPreset = (layout.data?.layout_preset ?? "single") as LayoutPresetId;
const positions = computeZonePositions(layoutPreset);
const units: any[] = applicationPlan.data?.units ?? [];
const compositionUnitsById = new Map<string, any>();
const selectedUnits = compositionPlan.data?.selected_units;
if (Array.isArray(selectedUnits)) {
selectedUnits.forEach((unit: any) => {
const unitId = Array.isArray(unit.source_section_ids)
? unit.source_section_ids.join("+")
: "";
if (unitId) compositionUnitsById.set(unitId, unit);
});
}
const zones = units.map((unit: any, idx: number) => {
const posEntry = positions[idx] ?? {
@@ -631,7 +1139,14 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
let v4Source: any[];
if (backendPolicyPayloadPresent) {
// Emergency fix (2026-05-26) — sorted_candidate_evidence 는 primary (rank order)
// 로 먼저 push (first-wins dedup map 이 순서 보존). 그 후 v4_all_judgments +
// v4_candidates 도 push 해서 후보 pool 채움 (reject 포함, max TOP_N_FRAMES).
// 이전 버그: sorted_candidate_evidence 만 사용 → "선택된 후보 1개" 만 frontend panel
// 에 surface, 나머지 reject/light_edit/restructure 후보 사라짐.
sortedCandidateEvidence!.forEach(pushCandidate);
(unit.v4_all_judgments ?? []).forEach(pushCandidate);
(unit.v4_candidates ?? []).forEach(pushCandidate);
v4Source = Array.from(candidateMap.values());
} else {
// IMP-39 u4 — warn-fallback path. Legacy fixtures predating u3 (or
@@ -698,6 +1213,8 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
frameId: c.frame_id,
v4Label: c.v4_label,
phaseZStatus: c.phase_z_status,
coverageState: c.coverage_state ?? c.coverageState,
candidateStatus: c.candidate_status ?? c.candidateStatus,
filteredForDirectExecution: c.filtered_for_direct_execution,
routeHint: c.route_hint,
decision: c.decision,
@@ -713,6 +1230,15 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
});
});
const mergedFrameCandidates = buildFrameCandidatesForUnit(
unit,
compositionUnitsById.get(unit.unit_id),
);
const effectiveFrameCandidates =
mergedFrameCandidates.length > 0 ? mergedFrameCandidates : frameCandidates;
const defaultFrameId =
unit.current_default_candidate ?? effectiveFrameCandidates[0]?.id ?? null;
const displayStrategy = (
runMeta.display_strategy_candidates_by_zone[posEntry.name]?.[0] ??
"inline_full"
@@ -737,10 +1263,10 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
content_unit_ids: [],
frame_match_strategy: {
kind: "frame_match" as const,
frame_id: unit.current_default_candidate ?? null,
frame_id: defaultFrameId,
display_strategy: displayStrategy,
},
frame_candidates: frameCandidates,
frame_candidates: effectiveFrameCandidates,
},
],
region_layout_type: regionId,
+4
View File
@@ -162,6 +162,10 @@ export interface FrameCandidate {
v4Label?: 'use_as_is' | 'light_edit' | 'restructure' | 'reject';
/** Phase Z status enum (e.g. "auto_renderable", "fallback_candidate"). Open vocabulary. */
phaseZStatus?: string;
/** T21.6 coverage-state contract for candidate UX. */
coverageState?: 'covered_native' | 'covered_via_expand' | 'requires_adaptation' | 'unsupported' | string;
/** Backend 3-status pool classification (auto/adaptation/blocked). */
candidateStatus?: 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 /
+140 -46
View File
@@ -8,20 +8,20 @@ import type {
} from "../services/userOverridesApi";
import { computeZonePositions } from "../services/designAgentApi";
// ─── IMP-52 u6 restore-on-reopen helpers (pure, exported for testing) ────
// ?€?€?€ IMP-52 u6 ??restore-on-reopen helpers (pure, exported for testing) ?€?€?€?€
// These helpers compose persisted `user_overrides.json` payloads (typed by
// the u5 service) onto the in-memory `UserSelection`. They live here rather
// than inline in Home.tsx so vitest can drive them in a node environment
// without booting React or pulling in the radix-ui / lucide UI deps that
// Home.tsx requires. Home.tsx wires these into:
// handleFileUpload (pre-Generate layout / zone_geometries / zone_sections
// ??handleFileUpload (pre-Generate layout / zone_geometries / zone_sections
// seed so handleGenerate's CLI-args build picks them up)
// handleGenerate post-loadRun (frame remap unit_id region.id over the
// ??handleGenerate post-loadRun (frame remap unit_id ??region.id over the
// freshly built slidePlan)
// The on-disk schema and clear-sentinel semantics are owned by:
// src/user_overrides_io.py (KNOWN_AXES, u1)
// Front/vite.config.ts mergeUserOverrides (u4)
// Front/client/src/services/userOverridesApi.ts (UserOverrides type, u5)
// ??src/user_overrides_io.py (KNOWN_AXES, u1)
// ??Front/vite.config.ts mergeUserOverrides (u4)
// ??Front/client/src/services/userOverridesApi.ts (UserOverrides type, u5)
// Any KNOWN_AXES drift must land in those files first.
/**
@@ -31,7 +31,101 @@ import { computeZonePositions } from "../services/designAgentApi";
* so the same persisted file is read from both ends without translation.
*/
export function deriveUserOverridesKey(filename: string): string {
return filename.replace(/\.mdx$/i, "");
if (filename === "") return "";
const basename = filename.split(/[\\/]/).pop() ?? filename;
const noExt = basename.replace(/\.mdx$/i, "");
return noExt.replace(/[^A-Za-z0-9_.-]/g, "_");
}
export interface SubmittedPipelineOverridesForRestore {
layout?: string;
frames?: Record<string, string>;
zoneGeometries?: Record<string, ZoneGeometryValue>;
zoneSections?: Record<string, string[]>;
}
function _copyStringMap(raw: unknown): Record<string, string> | undefined {
if (!_isPlainObject(raw)) return undefined;
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(raw)) {
if (k && typeof v === "string" && v.length > 0) out[k] = v;
}
return Object.keys(out).length > 0 ? out : undefined;
}
function _copyZoneSections(raw: unknown): Record<string, string[]> | undefined {
if (!_isPlainObject(raw)) return undefined;
const out: Record<string, string[]> = {};
for (const [zoneId, sids] of Object.entries(raw)) {
if (!zoneId || !Array.isArray(sids)) continue;
const cleaned = sids.filter((sid) => typeof sid === "string" && sid.trim());
if (cleaned.length > 0) out[zoneId] = cleaned;
}
return Object.keys(out).length > 0 ? out : undefined;
}
function _copyZoneGeometries(raw: unknown): Record<string, ZoneGeometryValue> | undefined {
if (!_isPlainObject(raw)) return undefined;
const out: Record<string, ZoneGeometryValue> = {};
for (const [zoneId, geom] of Object.entries(raw)) {
if (!zoneId || !_isPlainObject(geom)) continue;
const { x, y, w, h } = geom;
if (
typeof x === "number" &&
typeof y === "number" &&
typeof w === "number" &&
typeof h === "number"
) {
out[zoneId] = { x, y, w, h };
}
}
return Object.keys(out).length > 0 ? out : undefined;
}
/**
* Build the post-run restore document from the latest persisted payload plus
* the exact override payload submitted to `/api/run`.
*
* The generated HTML is driven by the submitted override payload, so the
* frontend state rebuilt after `loadRun()` must prefer that same payload over
* any stale layout/frame/zone axes that were read at file-upload time. Other
* persisted axes (text/image/structure edits) are preserved.
*/
export function mergeSubmittedPipelineOverridesForRestore(
persisted: Partial<UserOverrides> | null | undefined,
submitted: SubmittedPipelineOverridesForRestore | null | undefined,
manualSectionAssignment: boolean,
): Partial<UserOverrides> {
const next: Partial<UserOverrides> = { ...(persisted ?? {}) };
// These axes are owned by the current Generate payload. Clear stale values
// first so an older user_overrides file cannot reappear in the UI after a
// successful render with a different explicit override.
delete next.layout;
delete next.frames;
delete next.zone_geometries;
delete next.zone_sections;
next.manual_section_assignment = false;
if (!submitted || typeof submitted !== "object") return next;
if (typeof submitted.layout === "string" && submitted.layout.length > 0) {
next.layout = submitted.layout;
}
const frames = _copyStringMap(submitted.frames);
if (frames) next.frames = frames;
const zoneGeometries = _copyZoneGeometries(submitted.zoneGeometries);
if (zoneGeometries) next.zone_geometries = zoneGeometries;
if (manualSectionAssignment === true) {
const zoneSections = _copyZoneSections(submitted.zoneSections);
next.zone_sections = zoneSections ?? {};
next.manual_section_assignment = true;
}
return next;
}
const LAYOUT_PRESET_IDS = new Set<string>([
@@ -48,7 +142,7 @@ const LAYOUT_PRESET_IDS = new Set<string>([
/**
* Layer the three non-frame axes from a persisted `user_overrides.json`
* payload onto an existing `UserSelection`. Foreign / unrecognized payload
* shapes are silently ignored the u5 GET path already returns `{}` on
* shapes are silently ignored ??the u5 GET path already returns `{}` on
* corrupt files, but we revalidate here so hand-edited files or future
* forward-compat axes cannot poison the in-memory state.
*
@@ -79,11 +173,11 @@ export function applyPersistedNonFrameOverrides(
) {
next.zone_sections = { ...persisted.zone_sections };
}
// IMP-51 (#79) u11 layer the 5th persisted axis (`image_overrides`) by
// IMP-51 (#79) u11 ??layer the 5th persisted axis (`image_overrides`) by
// the same array / non-object guard the zone_geometries branch uses. The
// u3 typed client (services/userOverridesApi.ts) shape and the on-disk
// KNOWN_AXES entry (src/user_overrides_io.py u1) are both flat dicts
// (image_id {x,y,w,h} percent-of-slide), so a shallow copy is enough.
// (image_id ??{x,y,w,h} percent-of-slide), so a shallow copy is enough.
if (
persisted.image_overrides &&
typeof persisted.image_overrides === "object" &&
@@ -91,7 +185,7 @@ export function applyPersistedNonFrameOverrides(
) {
next.image_overrides = { ...persisted.image_overrides };
}
// IMP-55 (#93) u3 restore the bool intent marker only when the persisted
// IMP-55 (#93) u3 ??restore the bool intent marker only when the persisted
// value is a real `boolean`. A missing axis, `null` (the u4 clear sentinel
// observed post-flush), or any non-boolean shape (string "true", 1, {})
// intentionally falls through to the `createInitialUserSelection` seed of
@@ -101,11 +195,11 @@ export function applyPersistedNonFrameOverrides(
// `true` MUST end up as `false` in memory to avoid resurrecting stale
// auto-carry assignments as user intent. Both `true` and `false` are
// restored verbatim (the explicit `false` from u12's apply/cancel write
// is meaningful it pins the marker off across reopens).
// is meaningful ??it pins the marker off across reopens).
if (typeof persisted.manual_section_assignment === "boolean") {
next.manual_section_assignment = persisted.manual_section_assignment;
}
// IMP-56 (#90) u15 layer the two Step-22 persist axes through the
// IMP-56 (#90) u15 ??layer the two Step-22 persist axes through the
// u10 extract helpers; their `_isPlainObject` + dedupe gates already
// sanitize foreign / hand-edited payloads, so reopen never poisons
// memory with non-string values or non-list slot_order entries.
@@ -114,7 +208,7 @@ export function applyPersistedNonFrameOverrides(
return { ...selection, overrides: next };
}
// ─── IMP-56 #90 u10 typed extract helpers for the two new persist axes ───
// ?€?€?€ IMP-56 #90 u10 ??typed extract helpers for the two new persist axes ?€?€?€
// Pure helpers that defensively sanitize Step-22 text_overrides and
// structure_overrides payloads off a `Partial<UserOverrides>` (typed by u10's
// userOverridesApi extension). They mirror the backend validation gates
@@ -174,8 +268,8 @@ export function extractPersistedStructureOverrides(
}
/**
* Remap persisted frames (`unit_id` template_id) to the in-memory
* `zone_frames` (region.id template_id) using the freshly built
* Remap persisted frames (`unit_id` ??template_id) to the in-memory
* `zone_frames` (region.id ??template_id) using the freshly built
* slidePlan zones. `unit_id` follows handleGenerate's convention:
* `zone.section_ids.join("+")`. Persisted entries whose unit_id no longer
* matches any zone (e.g. user changed zone_sections between sessions) are
@@ -203,8 +297,8 @@ export function remapPersistedFramesToZoneFrames(
}
/**
* Phase Z 초기 선택 상태 생성
* SlidePlan의 결과를 초기 값으로 사용 (Step 11까지의 결과 반영)
* Phase Z 珥덇린 ?좏깮 ?곹깭 ?앹꽦
* SlidePlan??寃곌낵瑜?珥덇린 媛믪쑝濡??ъ슜 (Step 11源뚯???寃곌낵 諛섏쁺)
*/
export function createInitialUserSelection(slidePlan?: SlidePlan | null): UserSelection {
const initialSections: Record<string, string[]> = {};
@@ -212,16 +306,16 @@ export function createInitialUserSelection(slidePlan?: SlidePlan | null): UserSe
if (slidePlan) {
slidePlan.zones.forEach(zone => {
// 1. 모든 섹션을 각자의 지정된 존에 할당 (초안 배치)
// 1. 紐⑤뱺 ?뱀뀡??媛곸옄??吏€?뺣맂 議댁뿉 ?좊떦 (珥덉븞 諛곗튂)
initialSections[zone.zone_id] = [...zone.section_ids];
// 2. 각 리전의 기본 frame.
// 2026-05-14 backend frame_match_strategy.frame_id 가 있을 때만 init.
// null 인 경우 (backend current_default_candidate=None 등) frame_candidates[0]
// 로 자동 채우지 않음 → SlideCanvas preview overlay 트리거 조건
// (override !== default) 안 발동. 사용자가 직접 frame 클릭해야 preview 보임.
// 배경 : 04-1 같은 case 에서 backend selection_path=rank_1 (env toggle 통과)
// 이어도 current_default=None 이면 default override mismatch preview 강제 발동.
// 2. 媛?由ъ쟾??湲곕낯 frame.
// 2026-05-14 ??backend frame_match_strategy.frame_id 媛€ ?덉쓣 ?뚮쭔 init.
// null ??寃쎌슦 (backend current_default_candidate=None ?? frame_candidates[0]
// 濡??먮룞 梨꾩슦吏€ ?딆쓬 ??SlideCanvas ??preview overlay ?몃━嫄?議곌굔
// (override !== default) ??諛쒕룞. ?ъ슜?먭? 吏곸젒 frame ?대┃?댁빞 preview 蹂댁엫.
// 諛곌꼍 : 04-1 媛숈? case ?먯꽌 backend selection_path=rank_1 (env toggle ?듦낵)
// ?댁뼱??current_default=None ?대㈃ default override mismatch 濡?preview 媛뺤젣 諛쒕룞.
zone.internal_regions.forEach(region => {
const topFrameId = region.frame_match_strategy.frame_id;
if (topFrameId) {
@@ -241,23 +335,23 @@ export function createInitialUserSelection(slidePlan?: SlidePlan | null): UserSe
zone_sections: initialSections,
zone_sizes: {},
zone_geometries: {},
// IMP-51 (#79) u11 image_overrides axis starts empty; entries land
// IMP-51 (#79) u11 ??image_overrides axis starts empty; entries land
// here via `saveImageOverride` (SlideCanvas drag/resize handler) and
// are seeded on reopen via `applyPersistedNonFrameOverrides`.
image_overrides: {},
// IMP-56 (#90) u15 Step-22 axes seeded empty. Entries land here
// IMP-56 (#90) u15 ??Step-22 axes seeded empty. Entries land here
// via `saveTextOverride` (u13 focusout capture) and
// `saveStructureOverride` (u14 overlay) and are restored on reopen
// via `applyPersistedNonFrameOverrides`.
text_overrides: {},
structure_overrides: {},
// IMP-55 (#93) u3 bool intent marker seeded `false` so a fresh
// IMP-55 (#93) u3 ??bool intent marker seeded `false` so a fresh
// MDX open (no persisted file, or persisted file with axis absent)
// never forwards `overrides.zoneSections` to the backend. The marker
// flips to `true` only via the real drag-drop path (Home.tsx u6) and
// is reset to `false` by layout apply/cancel auto-carry (u5/u12).
// `applyPersistedNonFrameOverrides` may restore a persisted boolean
// verbatim on reopen see the bool-only guard there.
// verbatim on reopen ??see the bool-only guard there.
manual_section_assignment: false,
},
};
@@ -281,12 +375,12 @@ export function saveZoneGeometry(
}
/**
* IMP-51 (#79) u11 record a single `image_id` slide-absolute percent
* IMP-51 (#79) u11 ??record a single `image_id` ??slide-absolute percent
* geometry on the in-memory selection. Mirrors `saveZoneGeometry` but on
* the 5th persisted axis (`image_overrides`); the SlideCanvas drag/resize
* handler (u8) emits one entry per pointer move, and u10's Home wiring
* funnels each emit through this helper before scheduling the debounced
* PUT. Pure / immutable returns a fresh `UserSelection`; the input is
* PUT. Pure / immutable ??returns a fresh `UserSelection`; the input is
* never mutated. Existing entries for the same `imageId` are replaced.
*/
export function saveImageOverride(
@@ -307,7 +401,7 @@ export function saveImageOverride(
}
/**
* IMP-56 (#90) u15 record a single text-line capture (zone_id, text_path,
* IMP-56 (#90) u15 ??record a single text-line capture (zone_id, text_path,
* value) onto the in-memory selection's `text_overrides` axis. Mirrors
* `saveImageOverride` (pure / immutable). u13's focusout capture emits one
* entry per finished edit; Home u15's handler funnels each emit through this
@@ -333,7 +427,7 @@ export function saveTextOverride(
}
/**
* IMP-56 (#90) u15 record a single structure capture (zone_id
* IMP-56 (#90) u15 ??record a single structure capture (zone_id ??
* {slot_order, hidden_slots}) onto the in-memory selection's
* `structure_overrides` axis. Scope-locked to slot reorder + hide (frame
* swap stays on the `frames` axis). u14's overlay emits one entry per
@@ -374,7 +468,7 @@ export function saveZoneSizes(selection: UserSelection, groupId: string, sizes:
}
/**
* 특정 섹션을 새로운 존으로 이동 (Drag & Drop)
* ?뱀젙 ?뱀뀡???덈줈??議댁쑝濡??대룞 (Drag & Drop)
*/
export function moveSectionToZone(
selection: UserSelection,
@@ -383,12 +477,12 @@ export function moveSectionToZone(
): UserSelection {
const newZoneSections = { ...selection.overrides.zone_sections };
// 1. 모든 존에서 해당 섹션 제거 (이동 전 위치 클리어)
// 1. 紐⑤뱺 議댁뿉???대떦 ?뱀뀡 ?쒓굅 (?대룞 ???꾩튂 ?대━??
Object.keys(newZoneSections).forEach(zid => {
newZoneSections[zid] = newZoneSections[zid].filter(id => id !== sectionId);
});
// 2. 타겟 존에 섹션 추가
// 2. ?€寃?議댁뿉 ?뱀뀡 異붽?
if (!newZoneSections[targetZoneId]) {
newZoneSections[targetZoneId] = [];
}
@@ -409,7 +503,7 @@ export function selectZone(selection: UserSelection, zoneId: string | null): Use
return {
...selection,
selectedZoneId: zoneId,
selectedRegionId: null, // Zone이 바뀌면 Region 선택 해제
selectedRegionId: null, // Zone??諛붾€뚮㈃ Region ?좏깮 ?댁젣
};
}
@@ -444,16 +538,16 @@ export function applyFrame(selection: UserSelection, regionId: string, frameId:
}
/**
* 현재 선택된 Zone 객체 반환
* ?꾩옱 ?좏깮??Zone 媛앹껜 諛섑솚
*/
export function getSelectedZone(slidePlan: SlidePlan | null, selection: UserSelection): Zone | null {
if (!slidePlan || !selection.selectedZoneId) return null;
// id 또는 zone_id 매칭
// id ?먮뒗 zone_id 留ㅼ묶
return slidePlan.zones.find(z => z.id === selection.selectedZoneId || z.zone_id === selection.selectedZoneId) || null;
}
/**
* 현재 선택된 Region 객체 반환
* ?꾩옱 ?좏깮??Region 媛앹껜 諛섑솚
*/
export function getSelectedRegion(zone: Zone | null, selection: UserSelection): InternalRegion | null {
if (!zone || !selection.selectedRegionId) return null;
@@ -461,21 +555,21 @@ export function getSelectedRegion(zone: Zone | null, selection: UserSelection):
}
/**
* 특정 Zone에 할당된 섹션 ID 목록 반환 (오버라이드 우선)
* ?뱀젙 Zone???좊떦???뱀뀡 ID 紐⑸줉 諛섑솚 (?ㅻ쾭?쇱씠???곗꽑)
*/
export function getSectionsForZone(zone: Zone, selection: UserSelection): string[] {
return selection.overrides.zone_sections[zone.zone_id] || zone.section_ids;
}
/**
* 최종 유효 레이아웃 ID 반환
* 理쒖쥌 ?좏슚 ?덉씠?꾩썐 ID 諛섑솚
*/
export function getEffectiveLayoutId(slidePlan: SlidePlan | null, selection: UserSelection): LayoutPresetId {
if (selection.overrides.layout_preset) return selection.overrides.layout_preset;
return slidePlan?.layout_preset || 'single';
}
// ─── IMP-44 (#73) u3 zone_geometries layout-mismatch validation ───────────
// ?€?€?€ IMP-44 (#73) u3 ??zone_geometries layout-mismatch validation ?€?€?€?€?€?€?€?€?€?€?€
// Pure helper paired with the backend [override-warning] guards added in u1
// (1-D horizontal-2 / vertical-2 branches of `build_layout_css`) and u2 (2-D
// `_override_to_grid_tracks` call site). Same WARN+DROP / KEEP-known contract,
@@ -484,7 +578,7 @@ export function getEffectiveLayoutId(slidePlan: SlidePlan | null, selection: Use
//
// Source of truth for expected positions = `computeZonePositions(layoutPreset)`
// (designAgentApi.ts), which mirrors backend `layouts.yaml` (positions field).
// Unknown layout (null / undefined / not in LAYOUT_PRESET_IDS) fail-safe
// Unknown layout (null / undefined / not in LAYOUT_PRESET_IDS) ??fail-safe
// drop-all: caller has no contract for projecting geometries onto an unknown
// preset, so we keep zero keys rather than passing them through verbatim.
@@ -511,7 +605,7 @@ export function validateZoneGeometriesAgainstLayout(
const safeGeoms =
geoms && typeof geoms === "object" && !Array.isArray(geoms) ? geoms : null;
// Unknown-layout fail-safe drop everything; no expected positions known.
// Unknown-layout fail-safe ??drop everything; no expected positions known.
if (typeof layoutPreset !== "string" || !LAYOUT_PRESET_IDS.has(layoutPreset)) {
if (safeGeoms) {
for (const [k, v] of Object.entries(safeGeoms)) {