feat(frontend): add Front/ — Vite/React frontend with backend pipeline integration
Mirror of design_agent_front/design-agent/ for shipping alongside backend.
Vite plugin (vitePluginPhaseZApi) endpoints :
- POST /api/run — spawn `python -m src.phase_z2_pipeline` with overrides
- GET /api/sample-mdx?mdx=03/04/05 — fixed sample MDX
- GET /frame-preview/{n} — figma preview thumbnails
- GET /data/runs/{run_id}/{path} — pipeline artifacts (final.html, step*.json, ...)
Env toggle forward (보고용) :
PHASE_Z_ALLOW_RESTRUCTURE / PHASE_Z_ALLOW_REJECT / PHASE_Z_MAX_RANK=32
Components :
- LeftMdxPanel (03/04/05 fix list + section tree)
- SlideCanvas (iframe + slideOverrideCss prop for inline CSS inject)
- FramePanel (label priority + confidence sort)
- LayoutPanel
README with mermaid diagrams covering the 5-step demo flow.
node_modules / dist / .manus-logs / .env excluded via .gitignore.
This commit is contained in:
552
Front/client/src/services/designAgentApi.ts
Normal file
552
Front/client/src/services/designAgentApi.ts
Normal file
@@ -0,0 +1,552 @@
|
||||
/**
|
||||
* 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 / ...)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
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;
|
||||
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 }>;
|
||||
}
|
||||
|
||||
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,
|
||||
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,
|
||||
}));
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user