Files
C.E.L_Slide_test2/Front/client/src/services/designAgentApi.ts
kyeongmin 028042aaa9
Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 23s
feat(#68): IMP-39 u1~u8 ranking_sort_policy single-source + backend↔frontend label-priority mirror
u1: templates/phase_z2/catalog/ranking_sort_policy.yaml — single-source policy
    (label_priority asc {use_as_is:0, light_edit:1, restructure:2, reject:3}
    + confidence desc + v4_rank asc tie-break).
u2: src/phase_z2_pipeline.py — apply_ranking_sort helper + lookup_v4_match_with_fallback
    applies policy AFTER IMP-38 raw-window selection (raw default_window + usable_count
    preserved on RAW all_judgments).
u3: src/phase_z2_pipeline.py — _build_application_plan_unit forwards ranking_sort_policy
    + sorted_candidate_evidence into Step 9 payload.
u4: Front/client/src/services/designAgentApi.ts — frame_candidates builder reads
    unit.sorted_candidate_evidence + unit.ranking_sort_policy first; local LABEL_PRIORITY
    retained only on warn-fallback path.
u5: tests/test_ranking_sort_policy.py — pure permutation coverage (sample-agnostic).
u6: tests/phase_z2/test_label_priority_synthetic.py + fixtures/ranking_sort_policy/
    synthetic_divergence.yaml — low-conf use_as_is behind high-conf restructure.
u7: tests/phase_z2/test_imp39_mdx04_env_toggle_e2e.py — samples/mdx_batch/04.mdx with
    AI_FALLBACK_ENABLED=off; backend selected_v4_rank == frontend frame_candidates[0].
u8: tests/phase_z2/test_imp39_corpus_audit.py — real corpus sweep over
    tests/matching/v4_full32_result.yaml (10 MDX sections); section IDs loaded
    dynamically (RULE 0 / RULE 7 sample-agnostic).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:12:07 +09:00

750 lines
30 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";
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<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 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<string, string[]>; // step08 placeholder
display_strategy_candidates_by_zone: Record<string, string[]>; // 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<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 ?? [],
])
),
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<string, number> = {
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<string, any>();
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 ?? "<unknown>"}: 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 };
}