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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 /
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user