Step 9 v4_all_judgments[] now exposes per-candidate min_height_px from catalog frame_contracts.visual_hints.min_height_px (None when contract unregistered). SlideCanvas pendingLayout zones render a red ring + 'min H Npx' badge when zone height falls below the active frame's threshold. Visual hint only; resize clamp (minSize=0.05) unchanged. 5 axes (single commit per Stage 5 plan): - u1 backend: src/phase_z2_pipeline.py — Step 9 builder adds min_height_px via single get_contract(c.template_id) lookup; reuses _contract for catalog_registered (no double-lookup). - u2 type: Front/client/src/types/designAgent.ts — FrameCandidate gains optional minHeightPx?: number. - u3 mapper: Front/client/src/services/designAgentApi.ts — maps snake-case min_height_px → camelCase minHeightPx on v4_all_judgments path; v4_candidates fallback remains undefined (graceful). - u4 active-frame lookup: Front/client/src/components/SlideCanvas.tsx — activeFrameId = overrideFrameId ?? defaultFrameId; activeCandidate via region.frame_candidates.find. - u5 hint render: Front/client/src/components/SlideCanvas.tsx — zoneHeightPx = height * SLIDE_H (logical px, no double-apply); compare against activeCandidate.minHeightPx in pendingLayout mode only; red border + badge when below. Tests: 5/5 pass in tests/test_phase_z2_step9_v4_all_judgments_min_height.py (source-string + catalog-shape guards + None propagation, registered and unregistered template_ids). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
582 lines
22 KiB
TypeScript
582 lines
22 KiB
TypeScript
/**
|
|
* 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";
|
|
|
|
/** 네트워크 지연 시뮬레이션 */
|
|
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<NormalizedContent> {
|
|
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<SlidePlan> {
|
|
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<LayoutCandidate[]> {
|
|
return MOCK_LAYOUT_CANDIDATES;
|
|
}
|
|
|
|
export async function getFrameCandidates(sectionId: string): Promise<FrameCandidate[]> {
|
|
return MOCK_FRAME_CANDIDATES_SECTION1;
|
|
}
|
|
|
|
export async function exportSlidePlan(slidePlan: SlidePlan, userSelection: any): Promise<any> {
|
|
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 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<string, string[]>; // step08 placeholder
|
|
display_strategy_candidates_by_zone: Record<string, string[]>; // step08 placeholder
|
|
}
|
|
|
|
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<string, string>;
|
|
/** zone_id (top/bottom/left/right/...) → slide-body 내부 0~1 비율.
|
|
* backend 의 build_layout_css 가 horizontal-2 / vertical-2 만 처리. */
|
|
zoneGeometries?: Record<string, { x: number; y: number; w: number; h: number }>;
|
|
/** 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<string, string[]>;
|
|
}
|
|
|
|
export async function runPipeline(
|
|
file: File,
|
|
overrides?: PipelineOverrides
|
|
): Promise<RunPipelineResult> {
|
|
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<LoadRunResult> {
|
|
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 ?? [],
|
|
])
|
|
),
|
|
};
|
|
|
|
// ── 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;
|
|
const LABEL_PRIORITY: Record<string, number> = {
|
|
use_as_is: 0,
|
|
light_edit: 1,
|
|
restructure: 2,
|
|
reject: 3,
|
|
};
|
|
const rawSource = (unit.v4_all_judgments?.length > 0)
|
|
? unit.v4_all_judgments
|
|
: (unit.v4_candidates ?? []);
|
|
const 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);
|
|
});
|
|
const frameCandidates: FrameCandidate[] = v4Source
|
|
.slice(0, TOP_N_FRAMES)
|
|
.map((c: any) => ({
|
|
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 등록 여부).
|
|
// 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 에만 있음. v4_candidates fallback 시 undefined (graceful).
|
|
minHeightPx: c.min_height_px ?? undefined,
|
|
}));
|
|
|
|
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 };
|
|
}
|
|
|