feat(#76): IMP-47B u11 frontend human_review surfacing (hunk-split from IMP-41)

- AiRepairStatus interface mirrors backend step20 u8 schema
- formatAiRepairHumanReviewMessage(): pure helper for the three failure axes
  (error / coverage_violated / unsupported_kind) — null on success/no-AI
- Home.tsx: toast.error(aiReviewMsg) after run completion
- FramePanel.tsx: reject-click window.confirm guard ("frame 유지 + AI 재구성")
- imp47b_human_review_toast.test.tsx: 6 vitest cases (null/false/3 axes/other)

Verification (frontend node_modules junction from main worktree):
- vitest imp47b_human_review_toast.test.tsx: 6/6 passed
- vitest full suite: 19/19 passed (imp41_application_mode 13 + u11 6, zero regression)

Hunk-split rationale:
- stash@{0} (imp47b-frontend-u11-pre-rebase, captured before IMP-41 merged)
  contained inline IMP-41 helpers alongside u11 changes
- HEAD already has IMP-41 helper-based implementation (buildBadgeTitle /
  mergeApplicationCandidates from services/applicationMode.ts, f358604)
- This commit adds ONLY the u11 surface on top of HEAD's IMP-41 baseline
- No IMP-41 hunk regression: buildBadgeTitle / mergeApplicationCandidates /
  applicationMode forwarding preserved verbatim

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-22 00:34:32 +09:00
parent 1186ad8ae2
commit 2ef02f5f18
4 changed files with 200 additions and 1 deletions

View File

@@ -47,6 +47,21 @@ export default function FramePanel({
return userSelection.overrides.zone_frames[targetRegion.id] || targetRegion.frame_match_strategy.frame_id;
}, [selectedZone, selectedRegion, userSelection.overrides.zone_frames]);
const handleFrameSelect = React.useCallback(
(candidate: FrameCandidate) => {
const isReject = candidate.label === "reject";
const alreadyApplied = currentFrameId === candidate.id;
if (isReject && !alreadyApplied) {
const ok = window.confirm(
`"${candidate.name}" 은 V4 reject 라벨입니다.\n선택 시 frame 은 유지되고 AI 가 콘텐츠를 frame 구조에 맞게 재구성합니다.\n계속하시겠습니까?`,
);
if (!ok) return;
}
onFrameSelect(candidate.id);
},
[currentFrameId, onFrameSelect],
);
if (!selectedZone) {
return (
<div className="h-full flex flex-col items-center justify-center bg-slate-50 p-8 text-center text-slate-400">
@@ -152,7 +167,7 @@ export default function FramePanel({
className="w-full"
>
<button
onClick={() => onFrameSelect(candidate.id)}
onClick={() => handleFrameSelect(candidate)}
draggable
onDragStart={(e) => {
e.dataTransfer.setData("frameId", candidate.id);

View File

@@ -21,6 +21,7 @@ import {
runPipeline,
loadRun,
computeZonePositions,
formatAiRepairHumanReviewMessage,
type RunMeta,
type PipelineOverrides,
} from "../services/designAgentApi";
@@ -370,6 +371,8 @@ export default function Home() {
}));
setRunMeta(runMeta);
toast.success(`run "${result.run_id}" 완료 — ${runMeta.status}`);
const aiReviewMsg = formatAiRepairHumanReviewMessage(runMeta.ai_repair_status);
if (aiReviewMsg) toast.error(aiReviewMsg);
} catch (err) {
console.error(err);
toast.error(

View File

@@ -225,6 +225,31 @@ export interface FilteredSectionReason {
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;
};
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;
}>;
coverage_status: string;
dropped_section_ids: string[];
human_review_required: boolean;
}
export interface RunMeta {
run_id: string;
mdx_path: string;
@@ -239,6 +264,26 @@ export interface RunMeta {
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;
}
export function formatAiRepairHumanReviewMessage(
ai: AiRepairStatus | null | undefined,
): string | null {
if (!ai || !ai.human_review_required) return null;
if (ai.status === "error") {
const n = ai.counts?.error ?? ai.error_records?.length ?? 0;
return `AI 재구성 호출 실패 (${n}건) — 다른 frame 선택 또는 수동 편집 필요`;
}
if (ai.status === "coverage_violated") {
const dropped = (ai.dropped_section_ids || []).join(", ");
return `AI 재구성 후 콘텐츠 누락 (dropped: ${dropped || "?"}) — 다른 frame 선택 또는 수동 편집 필요`;
}
if (ai.status === "unsupported_kind") {
const n = ai.counts?.unsupported_kind ?? ai.unsupported_kind_records?.length ?? 0;
return `AI 제안 형식 미지원 (${n}건) — 다른 frame 선택 또는 수동 편집 필요`;
}
return `AI 재구성 human_review 필요 (status: ${ai.status})`;
}
export interface LoadRunResult {
@@ -430,6 +475,7 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
z.display_strategy_candidates ?? [],
])
),
ai_repair_status: (slideStatus.data?.ai_repair_status ?? null) as AiRepairStatus | null,
};
// ── NormalizedContent ──