/** * Design Agent - API Service Layer (MVP Simulation) * * 이 파일은 백엔드 파이프라인(Phase Z)의 로직을 프론트엔드에서 시뮬레이션합니다. * 백엔드 연동 없이도 실제 데이터 흐름을 확인할 수 있도록 구현되었습니다. */ import type { NormalizedContent, SlidePlan, LayoutCandidate, FrameCandidate, SectionContent, ContentObject, LayoutPresetId, } from "../types/designAgent"; import { MOCK_LAYOUT_CANDIDATES, MOCK_FRAME_CANDIDATES_SECTION1, } from "../data/mockDesignAgentData"; import { mergeApplicationCandidates } from "./applicationMode"; /** 네트워크 지연 시뮬레이션 */ const simulateDelay = (ms: number = 800) => new Promise((resolve) => setTimeout(resolve, ms)); // ───────────────────────────────────────────────────────────────────────────── // STAGE 1 & 2: MDX Parsing & Normalization Simulation // ───────────────────────────────────────────────────────────────────────────── /** * MDX 본문 텍스트 → SectionContent[] 분해. * 중목차 (##) = root section, 소목차 (###) = section.sub_sections. * 일반 텍스트는 가장 가까운 (소목차 있으면 소목차, 아니면 중목차) 의 content_objects 에 누적. */ function parseMdxText(text: string): SectionContent[] { const lines = text.split("\n"); const sections: SectionContent[] = []; let currentSection: SectionContent | null = null; let currentSubSection: SectionContent | null = null; lines.forEach((line) => { if (line.startsWith("## ")) { currentSection = { id: `section-${sections.length + 1}`, index: sections.length + 1, level: 2, title: line.replace(/^##\s+/, "").trim(), content_objects: [], sub_sections: [], }; sections.push(currentSection); currentSubSection = null; } else if (line.startsWith("### ") && currentSection) { const subIdx = (currentSection.sub_sections?.length ?? 0) + 1; currentSubSection = { id: `${currentSection.id}-sub-${subIdx}`, index: subIdx, level: 3, title: line.replace(/^###\s+/, "").trim(), content_objects: [], parentId: currentSection.id, }; currentSection.sub_sections!.push(currentSubSection); } else if (line.trim()) { const target = currentSubSection ?? currentSection; if (!target) return; const co: ContentObject = { id: `co-${target.id}-${target.content_objects.length + 1}`, type: "text_block", role: "detail", raw_payload: line.trim(), size_estimate: { line_count: 1 }, }; target.content_objects.push(co); } }); return sections; } export async function parseMdxFile(file: File): Promise { await simulateDelay(300); const text = await file.text(); // frontmatter / # title 추출 let title = file.name.replace(/\.mdx?$/, ""); let body = text; const fmMatch = body.match(/^---\n([\s\S]*?)\n---\n/); if (fmMatch) { const titleLine = fmMatch[1].split("\n").find((l) => l.startsWith("title:")); if (titleLine) title = titleLine.replace(/^title:\s*/, "").trim(); body = body.slice(fmMatch[0].length); } const h1Match = body.match(/^#\s+(.+)$/m); if (h1Match) title = h1Match[1].trim(); const sections = parseMdxText(body); if (sections.length === 0) { sections.push({ id: "section-1", index: 1, level: 2, title: "기본 섹션", content_objects: [ { id: "co-1", type: "text_block", role: "summary", raw_payload: "내용이 없습니다.", size_estimate: {}, }, ], }); } return { title, sections }; } // ───────────────────────────────────────────────────────────────────────────── // STAGE 2 & 3: Slide Plan & Zone Mapping Simulation // ───────────────────────────────────────────────────────────────────────────── export async function generateSlidePlan( content: NormalizedContent ): Promise { console.log("[MVP] STAGE 2: Mapping sections to zones..."); await simulateDelay(1200); const sectionCount = content.sections.length; // 섹션 개수에 따라 레이아웃 프리셋 결정 let layout_preset: LayoutPresetId = "single"; let zoneIds: string[] = ["main"]; if (sectionCount === 2) { layout_preset = "horizontal-2"; zoneIds = ["left", "right"]; } else if (sectionCount >= 3) { layout_preset = "grid-2x2"; zoneIds = ["tl", "tr", "bl", "br"]; } const zones = content.sections.slice(0, zoneIds.length).map((section, idx) => { const zone_id = zoneIds[idx]; return { id: `zone-${section.id}`, zone_id: zone_id, section_ids: [section.id], position: { x: 0, y: 0, width: 0.5, height: 0.5 }, internal_regions: [ { id: `region-${section.id}`, region_id: `region-${idx + 1}`, role: "primary" as const, content_type: "mixed" as const, ratio_estimate: 1, content_unit_ids: section.content_objects.map(co => co.id), frame_match_strategy: { kind: "frame_match" as const, frame_id: "frame-001", // 기본 Rank 1 매칭 display_strategy: "inline_full" as const, }, frame_candidates: MOCK_FRAME_CANDIDATES_SECTION1, } ] }; }); return { id: `plan-${Date.now()}`, title: content.title, layout_preset, zones, updatedAt: new Date().toISOString(), }; } // ... 나머지 API는 Mock 유지 export async function getLayoutCandidates(sectionId: string): Promise { return MOCK_LAYOUT_CANDIDATES; } export async function getFrameCandidates(sectionId: string): Promise { return MOCK_FRAME_CANDIDATES_SECTION1; } export async function exportSlidePlan(slidePlan: SlidePlan, userSelection: any): Promise { return { success: true, exportedAt: new Date().toISOString() }; } // ───────────────────────────────────────────────────────────────────────────── // Phase Z 실제 산출물 로드 (Phase 1 — 보고용 read-only viewer prototype) // // data/runs/{run_id}/phase_z2/ → client/public/data/runs/{run_id}/ (정적 export). // scripts/sync_phase_z_run_to_frontend.py 가 산출물 복사 담당. // // 매핑 : // step01_mdx_source.md → mdx 원문 // step01_mdx_upload.json → run metadata (run_id / mdx_path / slide_title) // step02_normalized.json → NormalizedContent.sections // step07_layout.json → SlidePlan.layout_preset + layout_candidates // step08_zone_region_ratios.json → Zone 별 region/display 후보 // step09_application_plan.json → SlidePlan.zones[].internal_regions[].frame_candidates // step20_slide_status.json → 최종 상태 (PASS / RENDERED_WITH_VISUAL_REGRESSION / ...) // ───────────────────────────────────────────────────────────────────────────── // IMP-10 D-1 : verbatim mirror of step20_slide_status.json.data.filtered_section_reasons[] // schema (src/phase_z2_pipeline.py:2217-2278). `source` / `position` only present on // the override-uncovered additive variant. Strings rendered verbatim — no enum redefinition. export interface FilteredSectionReason { section_ids: string[]; merge_type: string | null; template_id: string | null; v4_label: string | null; phase_z_status: string | null; score: number | null; selection_state: string; filter_reasons: string[]; source?: string; position?: string | null; } export interface AiRepairStatus { status: "ok" | "applied" | "unsupported_kind" | "coverage_violated" | "error" | string; counts: { total: number; applied: number; no_proposal: number; no_zone_match: number; unsupported_kind: number; error: number; }; // IMP-92 u3 — per-kind operational error aggregates plumbed from Step 12 // (u2 classify_operational_error). Optional for backward compatibility // with pre-u3 payloads — u5 formatter treats absence as silent. api_error_kinds?: { quota: number; billing: number; auth: number; other: number; }; unsupported_kind_records: Array<{ unit_index?: number | null; source_section_ids: string[]; apply_status: string; }>; error_records: Array<{ unit_index?: number | null; source_section_ids: string[]; error: string; // IMP-92 u3 — per-record operational error kind (quota|billing|auth|other|null). api_error_kind?: string | null; }>; coverage_status: string; dropped_section_ids: string[]; human_review_required: boolean; } export interface RunMeta { run_id: string; mdx_path: string; mdx_source: string; // step01_mdx_source.md raw text status: "PASS" | "RENDERED_WITH_VISUAL_REGRESSION" | "PARTIAL_COVERAGE" | "ABORTED" | string; visual_check_passed: boolean; full_mdx_coverage: boolean; filtered_section_ids: string[]; // step20 filtered_section_ids filtered_section_reasons: FilteredSectionReason[]; // step20 filtered_section_reasons preview_url: string; // /data/runs/{runId}/preview.png final_html_url: string; // /data/runs/{runId}/final.html layout_candidates: string[]; // step07 layout_candidates list region_layout_candidates_by_zone: Record; // step08 placeholder display_strategy_candidates_by_zone: Record; // step08 placeholder ai_repair_status: AiRepairStatus | null; } // IMP-92 u5 — Operational-only AI repair message formatter. // // Per the #84 operational-vs-non-operational replacement-plan contract, this // returns a user-visible toast string ONLY when ai_repair_status carries one // of the three actionable Anthropic API error kinds plumbed by u3 // (quota / billing / auth). Non-operational AI failures (validation, // coverage_violated, unsupported_kind, or generic "other" API errors) return // null so the auto-pipeline stays silent per feedback_auto_pipeline_first. // Messages mirror the issue body copy contract exactly (429/402/401 → // quota/billing/auth Korean strings). export function formatAiRepairHumanReviewMessage( ai: AiRepairStatus | null | undefined, ): string | null { if (!ai) return null; const kinds = ai.api_error_kinds; if (!kinds) return null; if (kinds.quota > 0) { return `API quota 부족 — 충전 필요 (${kinds.quota}건)`; } if (kinds.billing > 0) { return `API billing 문제 — 결제 정보 확인 (${kinds.billing}건)`; } if (kinds.auth > 0) { return `API key 무효 — .env 확인 (${kinds.auth}건)`; } return null; } export interface LoadRunResult { normalizedContent: NormalizedContent; slidePlan: SlidePlan; runMeta: RunMeta; } // ───────────────────────────────────────────────────────────────────────────── // runPipeline — POST /api/run (Vite plugin middleware) // 업로드된 MDX 파일을 backend Phase Z 파이프라인에 던지고 run_id 를 받음. // 이후 loadRun(run_id) 으로 산출물 fetch. // ───────────────────────────────────────────────────────────────────────────── export interface RunPipelineResult { success: boolean; run_id: string; exit_code: number | null; final_html_exists: boolean; preview_exists: boolean; stdout: string; stderr: string; error?: string; } export interface PipelineOverrides { layout?: string; /** unit_id (= "+".join(source_section_ids), e.g., "03-1" 또는 "03-1+03-2") → template_id */ frames?: Record; /** zone_id (top/bottom/left/right/...) → slide-body 내부 0~1 비율. * backend 의 build_layout_css 가 horizontal-2 / vertical-2 만 처리. */ zoneGeometries?: Record; /** IMP-08 B-3 : zone_id -> list of section_id assignments * (canonical ordinal `${parent}-sub-${n}`). Only forwarded when the * user explicitly diverges from the auto plan; default placements * are not echoed back to avoid polluting override provenance. */ zoneSections?: Record; } export async function runPipeline( file: File, overrides?: PipelineOverrides ): Promise { const content = await file.text(); const res = await fetch("/api/run", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ filename: file.name, content, overrides }), }); const data = (await res.json()) as RunPipelineResult; if (!res.ok && !data.run_id) { throw new Error(data.error || `pipeline failed (${res.status})`); } return data; } /** * Layout preset → zone 별 정규화 좌표 (0~1). * Backend 의 컨테이너 px 와 별도 — frontend 시각화는 비율만 사용. * 8 preset 모두 backend layouts.yaml 의 positions 와 동일한 순서/이름. */ export function computeZonePositions(preset: LayoutPresetId): Array<{ name: string; geometry: { x: number; y: number; width: number; height: number }; }> { switch (preset) { case "single": // backend layouts.yaml: positions = [primary] return [{ name: "primary", geometry: { x: 0, y: 0, width: 1, height: 1 } }]; case "horizontal-2": return [ { name: "top", geometry: { x: 0, y: 0, width: 1, height: 0.5 } }, { name: "bottom", geometry: { x: 0, y: 0.5, width: 1, height: 0.5 } }, ]; case "vertical-2": return [ { name: "left", geometry: { x: 0, y: 0, width: 0.5, height: 1 } }, { name: "right", geometry: { x: 0.5, y: 0, width: 0.5, height: 1 } }, ]; case "top-1-bottom-2": return [ { name: "top", geometry: { x: 0, y: 0, width: 1, height: 0.5 } }, { name: "bottom-left", geometry: { x: 0, y: 0.5, width: 0.5, height: 0.5 } }, { name: "bottom-right", geometry: { x: 0.5, y: 0.5, width: 0.5, height: 0.5 } }, ]; case "top-2-bottom-1": return [ { name: "top-left", geometry: { x: 0, y: 0, width: 0.5, height: 0.5 } }, { name: "top-right", geometry: { x: 0.5, y: 0, width: 0.5, height: 0.5 } }, { name: "bottom", geometry: { x: 0, y: 0.5, width: 1, height: 0.5 } }, ]; case "left-1-right-2": return [ { name: "left", geometry: { x: 0, y: 0, width: 0.5, height: 1 } }, { name: "right-top", geometry: { x: 0.5, y: 0, width: 0.5, height: 0.5 } }, { name: "right-bottom", geometry: { x: 0.5, y: 0.5, width: 0.5, height: 0.5 } }, ]; case "left-2-right-1": // backend layouts.yaml: positions = [left-top, right, left-bottom] (순서!) return [ { name: "left-top", geometry: { x: 0, y: 0, width: 0.5, height: 0.5 } }, { name: "right", geometry: { x: 0.5, y: 0, width: 0.5, height: 1 } }, { name: "left-bottom", geometry: { x: 0, y: 0.5, width: 0.5, height: 0.5 } }, ]; case "grid-2x2": return [ { name: "top-left", geometry: { x: 0, y: 0, width: 0.5, height: 0.5 } }, { name: "top-right", geometry: { x: 0.5, y: 0, width: 0.5, height: 0.5 } }, { name: "bottom-left", geometry: { x: 0, y: 0.5, width: 0.5, height: 0.5 } }, { name: "bottom-right", geometry: { x: 0.5, y: 0.5, width: 0.5, height: 0.5 } }, ]; default: return [{ name: "main", geometry: { x: 0, y: 0, width: 1, height: 1 } }]; } } /** confidence 점수 → 'high' / 'medium' / 'low' 분류 (frontend 시각용). */ function classifyConfidence(score: number): "high" | "medium" | "low" { if (score >= 0.85) return "high"; if (score >= 0.6) return "medium"; return "low"; } /** * 실제 Phase Z run 산출물을 로드하여 frontend type 으로 변환. * * @example * const { normalizedContent, slidePlan, runMeta } = await loadRun("mdx03_f29_fix_check"); */ export async function loadRun(runId: string): Promise { console.log(`[Phase Z] loadRun: ${runId}`); const base = `/data/runs/${runId}`; const fetchJson = async (path: string) => { const res = await fetch(`${base}/${path}`); 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) { throw new Error(`Failed to fetch ${path}: ${res.status} ${res.statusText}`); } return res.text(); }; const [ mdxSource, upload, normalized, layout, zoneRegion, applicationPlan, slideStatus, ] = await Promise.all([ fetchText("steps/step01_mdx_source.md"), fetchJson("steps/step01_mdx_upload.json"), fetchJson("steps/step02_normalized.json"), fetchJson("steps/step07_layout.json"), fetchJson("steps/step08_zone_region_ratios.json"), fetchJson("steps/step09_application_plan.json"), fetchJson("steps/step20_slide_status.json"), ]); // ── RunMeta ── const runMeta: RunMeta = { run_id: upload.data?.run_id ?? runId, mdx_path: upload.data?.mdx_path ?? "", mdx_source: mdxSource, status: slideStatus.data?.overall ?? "UNKNOWN", visual_check_passed: slideStatus.data?.visual_check_passed ?? false, full_mdx_coverage: slideStatus.data?.full_mdx_coverage ?? false, filtered_section_ids: slideStatus.data?.filtered_section_ids ?? [], filtered_section_reasons: slideStatus.data?.filtered_section_reasons ?? [], preview_url: `${base}/preview.png`, final_html_url: `${base}/final.html`, layout_candidates: layout.data?.layout_candidates ?? [], region_layout_candidates_by_zone: Object.fromEntries( (zoneRegion.data?.per_zone_plan ?? []).map((z: any) => [ z.position, z.region_layout_candidates ?? [], ]) ), display_strategy_candidates_by_zone: Object.fromEntries( (zoneRegion.data?.per_zone_plan ?? []).map((z: any) => [ z.position, z.display_strategy_candidates ?? [], ]) ), ai_repair_status: (slideStatus.data?.ai_repair_status ?? null) as AiRepairStatus | null, }; // ── NormalizedContent ── // step02 의 sections (defensive 매핑 — 없으면 빈 배열). // 각 section 의 raw_content 를 다시 파싱해서 sub_sections (소목차) 추출 → frontend // 트리 패널이 사용자가 더 세분화된 단위로 zone 에 drag drop 가능. const sectionsRaw: any[] = normalized.data?.sections ?? []; const normalizedContent: NormalizedContent = { title: upload.data?.slide_title ?? runId, sections: sectionsRaw.map((s: any, idx: number) => { const sectionId = s.section_id ?? `section-${idx + 1}`; const raw = s.raw_content ?? ""; // backend step02 의 raw_content 는 `##` 헤더 *없이* 본문만 들어있음. // parseMdxText 가 ### 부터 인식할 수 있도록 가짜 root prefix 붙임. const subParsed = parseMdxText(`## __ROOT__\n${raw}`); const reSubSections: SectionContent[] = subParsed.length > 0 && subParsed[0].sub_sections?.length ? subParsed[0].sub_sections.map((sub, subIdx) => ({ ...sub, id: `${sectionId}-sub-${subIdx + 1}`, parentId: sectionId, })) : []; // 본 section 의 content_objects = subParsed[0] 의 content_objects 또는 raw 통째. const ownContentObjects = subParsed.length > 0 && subParsed[0].content_objects.length > 0 ? subParsed[0].content_objects.map((co, i) => ({ ...co, id: `co-${sectionId}-${i + 1}`, })) : [ { id: `co-${sectionId}-1`, type: "text_block" as const, role: "detail" as const, size_estimate: { line_count: raw.split("\n").length }, raw_payload: raw, }, ]; return { id: sectionId, index: idx + 1, level: 2, title: s.title ?? "", content_objects: ownContentObjects, sub_sections: reSubSections, }; }), }; // ── SlidePlan ── const layoutPreset = (layout.data?.layout_preset ?? "single") as LayoutPresetId; const positions = computeZonePositions(layoutPreset); const units: any[] = applicationPlan.data?.units ?? []; const zones = units.map((unit: any, idx: number) => { const posEntry = positions[idx] ?? { name: `zone-${idx}`, geometry: { x: 0, y: 0, width: 1, height: 1 }, }; const sectionIds: string[] = (unit.unit_id ?? "") .split("+") .filter((s: string) => s.length > 0); // V4 score 상위 N 개를 카드로 표시 (사용자 lock 2026-05-08 — top 6). // 2026-05-14 — 사용자 룰 : "reject 외 다른 label 있으면 reject 는 상단 X". // sort 우선순위 = label (use_as_is > light_edit > restructure > reject) + confidence desc. // 모두 reject 인 경우 confidence desc 만 적용 (사용자 명시). const TOP_N_FRAMES = 6; // IMP-39 u4 (issue #68) — local LABEL_PRIORITY is now a documentation // mirror of templates/phase_z2/catalog/ranking_sort_policy.yaml (u1). // Primary ordering arrives pre-sorted from the backend selector // (src/phase_z2_pipeline.py lookup_v4_match_with_fallback :1186-1196 + // _build_application_plan_unit u3 payload fields). This constant is read // ONLY on the warn-fallback path below (legacy fixtures pre-u3 / payload // missing). Kept verbatim so the fallback ordering matches u1/u2 contract. const LABEL_PRIORITY: Record = { use_as_is: 0, light_edit: 1, restructure: 2, reject: 3, }; // IMP-29 u2 — source priority (deterministic, no LLM): // 1) unit.candidate_evidence (IMP-05 L2 canonical, 14 fields per entry) // 2) unit.v4_all_judgments (pre-IMP-05 audit array) // 3) unit.v4_candidates (legacy minimal) // fallback_chain alias is intentionally NOT read (Stage 2 guardrail). const candidateMap = new Map(); const pushCandidate = (c: any) => { if (!c) return; const key = c.template_id ?? c.id ?? c.frame_id; if (!key) return; if (!candidateMap.has(key)) candidateMap.set(key, c); }; // IMP-39 u4 (issue #68) — primary path: consume the backend Step 9 // payload as the single source of ordering truth. // • ``unit.sorted_candidate_evidence`` = policy-sorted selector trace // (src/phase_z2_pipeline.py :4163, alias of selection_trace[ // "candidates"] sorted by u2 at :1186-1196). Same IMP-05 L2 schema // consumed below (template_id, label, confidence, frame_number, // frame_id, rank, catalog_registered, capacity_fit, route_hint, ...). // • ``unit.ranking_sort_policy`` = full single-source policy dict // (policy_type / label_priority / unknown_label_priority / // tie_break_axes) forwarded for telemetry + fallback parity check. // When both are present we feed sorted_candidate_evidence through the // existing dedup map (first occurrence wins, mirrors backend // ``seen_template_ids`` semantics at :1204-1236) and SKIP the local // re-sort — backend "rank 1" then equals frontend frame_candidates[0] // by construction (Stage 1 root-cause fix). const sortedCandidateEvidence: any[] | null = Array.isArray( unit.sorted_candidate_evidence, ) ? unit.sorted_candidate_evidence : null; const rankingSortPolicy = unit.ranking_sort_policy ?? null; const backendPolicyPayloadPresent = sortedCandidateEvidence !== null && sortedCandidateEvidence.length > 0 && rankingSortPolicy !== null; let v4Source: any[]; if (backendPolicyPayloadPresent) { sortedCandidateEvidence!.forEach(pushCandidate); v4Source = Array.from(candidateMap.values()); } else { // IMP-39 u4 — warn-fallback path. Legacy fixtures predating u3 (or // any code path that strips the payload) lack the backend-sorted // evidence; ordering then derives from local LABEL_PRIORITY mirror. // Warning surfaces drift in dev console without hard-failing the UI // (graceful: production sample audit deck remains renderable). if (typeof console !== "undefined" && typeof console.warn === "function") { console.warn( `[IMP-39 u4] unit ${unit.unit_id ?? ""}: backend payload ` + "missing ranking_sort_policy / sorted_candidate_evidence — " + "falling back to local LABEL_PRIORITY (legacy fixture path).", ); } const candidateEvidence = Array.isArray(unit.candidate_evidence) ? unit.candidate_evidence : []; candidateEvidence.forEach(pushCandidate); (unit.v4_all_judgments ?? []).forEach(pushCandidate); (unit.v4_candidates ?? []).forEach(pushCandidate); const rawSource = Array.from(candidateMap.values()); v4Source = [...rawSource].sort((a: any, b: any) => { const lp = (LABEL_PRIORITY[a.label] ?? 99) - (LABEL_PRIORITY[b.label] ?? 99); if (lp !== 0) return lp; return (b.confidence ?? 0) - (a.confidence ?? 0); }); } // ─── IMP-41 u4 — application_candidates enrichment (issue #70) ─────────── // Backend Step 9 emits `unit.application_candidates[]` (src/phase_z2_pipeline.py // _application_candidates_for_unit, :3071-3092) one entry per v4 candidate with // application_mode / auto_applicable / delegated_to derived from // APPLICATION_MODE_BY_V4_LABEL (:107-112). Indexing delegated to the pure // helper `mergeApplicationCandidates` (services/applicationMode.ts) keyed // by template_id. Enrichment ONLY — does NOT alter candidate source // priority, sorting, or TOP_N_FRAMES slicing. const applicationModeMap = mergeApplicationCandidates(unit.application_candidates); const frameCandidates: FrameCandidate[] = v4Source .slice(0, TOP_N_FRAMES) .map((c: any) => { const appMatch = applicationModeMap.get(c.template_id); return ({ id: c.template_id, name: c.template_id, score: c.confidence ?? 0, confidence: classifyConfidence(c.confidence ?? 0), label: (c.label ?? "use_as_is") as FrameCandidate["label"], // data/figma_previews/{frame_number padded 2-digit}.png — 32 개 모두 존재. thumbnailUrl: c.frame_number != null ? `/frame-preview/${String(c.frame_number).padStart(2, "0")}` : undefined, // backend step09 의 catalog_registered (frame_contracts.yaml 등록 여부). // candidate_evidence 및 v4_all_judgments 에 있음. v4_candidates fallback 시 undefined. catalogRegistered: c.catalog_registered, // backend step09 의 min_height_px (frame_contracts.yaml visual_hints.min_height_px). // logical 1280x720 px 좌표계. contract 미등록 또는 visual_hints 부재 시 undefined. // v4_all_judgments 에만 있음. candidate_evidence / v4_candidates fallback 시 undefined (graceful). minHeightPx: c.min_height_px ?? undefined, // ─── IMP-05 L2 candidate_evidence fields (IMP-29 u2) ───────────────── // Populated when source = unit.candidate_evidence; otherwise silently // undefined for legacy fixtures (pre-IMP-05 fallback path). rank: c.rank, frameId: c.frame_id, v4Label: c.v4_label, phaseZStatus: c.phase_z_status, filteredForDirectExecution: c.filtered_for_direct_execution, routeHint: c.route_hint, decision: c.decision, reason: c.reason, capacityFit: c.capacity_fit, // ─── IMP-41 u2 — application_mode forwarding (issue #70) ─────────── // Source = unit.application_candidates[] indexed by template_id above. // Optional fields — undefined when no matching application_candidate // (legacy fixtures pre-IMP-32 or candidates filtered out at Step 9). applicationMode: appMatch?.application_mode, autoApplicable: appMatch?.auto_applicable, delegatedTo: appMatch?.delegated_to ?? null, }); }); const displayStrategy = ( runMeta.display_strategy_candidates_by_zone[posEntry.name]?.[0] ?? "inline_full" ) as "inline_full" | "inline_preview_with_details" | "details_only" | "dropped"; const regionId = runMeta.region_layout_candidates_by_zone[posEntry.name]?.[0] ?? "region-single"; return { id: `zone-${unit.unit_id}`, zone_id: posEntry.name, section_ids: sectionIds, position: posEntry.geometry, internal_regions: [ { id: `region-${unit.unit_id}-1`, region_id: regionId, role: "primary" as const, content_type: "text_block" as const, ratio_estimate: 1, content_unit_ids: [], frame_match_strategy: { kind: "frame_match" as const, frame_id: unit.current_default_candidate ?? null, display_strategy: displayStrategy, }, frame_candidates: frameCandidates, }, ], region_layout_type: regionId, }; }); const slidePlan: SlidePlan = { id: runId, title: normalizedContent.title, layout_preset: layoutPreset, zones, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; return { normalizedContent, slidePlan, runMeta }; }