Files
C.E.L_Slide_test2/Front/client/src/services/designAgentApi.ts
kyeongmin a79bd8bc43 feat(IMP-11): D-2 — frame min_height_px hint (backend → UI)
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>
2026-05-17 22:29:17 +09:00

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 };
}