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:
@@ -47,6 +47,21 @@ export default function FramePanel({
|
|||||||
return userSelection.overrides.zone_frames[targetRegion.id] || targetRegion.frame_match_strategy.frame_id;
|
return userSelection.overrides.zone_frames[targetRegion.id] || targetRegion.frame_match_strategy.frame_id;
|
||||||
}, [selectedZone, selectedRegion, userSelection.overrides.zone_frames]);
|
}, [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) {
|
if (!selectedZone) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col items-center justify-center bg-slate-50 p-8 text-center text-slate-400">
|
<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"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => onFrameSelect(candidate.id)}
|
onClick={() => handleFrameSelect(candidate)}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => {
|
onDragStart={(e) => {
|
||||||
e.dataTransfer.setData("frameId", candidate.id);
|
e.dataTransfer.setData("frameId", candidate.id);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
runPipeline,
|
runPipeline,
|
||||||
loadRun,
|
loadRun,
|
||||||
computeZonePositions,
|
computeZonePositions,
|
||||||
|
formatAiRepairHumanReviewMessage,
|
||||||
type RunMeta,
|
type RunMeta,
|
||||||
type PipelineOverrides,
|
type PipelineOverrides,
|
||||||
} from "../services/designAgentApi";
|
} from "../services/designAgentApi";
|
||||||
@@ -370,6 +371,8 @@ export default function Home() {
|
|||||||
}));
|
}));
|
||||||
setRunMeta(runMeta);
|
setRunMeta(runMeta);
|
||||||
toast.success(`run "${result.run_id}" 완료 — ${runMeta.status}`);
|
toast.success(`run "${result.run_id}" 완료 — ${runMeta.status}`);
|
||||||
|
const aiReviewMsg = formatAiRepairHumanReviewMessage(runMeta.ai_repair_status);
|
||||||
|
if (aiReviewMsg) toast.error(aiReviewMsg);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
toast.error(
|
toast.error(
|
||||||
|
|||||||
@@ -225,6 +225,31 @@ export interface FilteredSectionReason {
|
|||||||
position?: string | null;
|
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 {
|
export interface RunMeta {
|
||||||
run_id: string;
|
run_id: string;
|
||||||
mdx_path: string;
|
mdx_path: string;
|
||||||
@@ -239,6 +264,26 @@ export interface RunMeta {
|
|||||||
layout_candidates: string[]; // step07 layout_candidates list
|
layout_candidates: string[]; // step07 layout_candidates list
|
||||||
region_layout_candidates_by_zone: Record<string, string[]>; // step08 placeholder
|
region_layout_candidates_by_zone: Record<string, string[]>; // step08 placeholder
|
||||||
display_strategy_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 {
|
export interface LoadRunResult {
|
||||||
@@ -430,6 +475,7 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
|
|||||||
z.display_strategy_candidates ?? [],
|
z.display_strategy_candidates ?? [],
|
||||||
])
|
])
|
||||||
),
|
),
|
||||||
|
ai_repair_status: (slideStatus.data?.ai_repair_status ?? null) as AiRepairStatus | null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── NormalizedContent ──
|
// ── NormalizedContent ──
|
||||||
|
|||||||
135
Front/client/tests/imp47b_human_review_toast.test.tsx
Normal file
135
Front/client/tests/imp47b_human_review_toast.test.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
// IMP-47B u11 — Frontend ai_repair_status notification surfacing.
|
||||||
|
//
|
||||||
|
// Scope (Stage 2 unit u11 contract):
|
||||||
|
// 1) loadRun → RunMeta.ai_repair_status exposes the u8 step20 payload.
|
||||||
|
// 2) formatAiRepairHumanReviewMessage(...) returns user-facing notification
|
||||||
|
// text on the three failure axes (error / coverage_violated /
|
||||||
|
// unsupported_kind) and returns null on success / no-AI paths.
|
||||||
|
//
|
||||||
|
// Pure-function unit test (no React Testing Library required — vitest is
|
||||||
|
// already in devDependencies; @testing-library/* is NOT installed). The
|
||||||
|
// Home.tsx wiring is a 2-line site that calls this helper after
|
||||||
|
// setRunMeta(...); covering the helper covers the user-visible message text
|
||||||
|
// directly without DOM rendering.
|
||||||
|
//
|
||||||
|
// File extension is `.tsx` per Stage 2 unit contract path; no JSX is required
|
||||||
|
// for these assertions but the extension allows future RTL-based tests to
|
||||||
|
// land here without renaming.
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
formatAiRepairHumanReviewMessage,
|
||||||
|
type AiRepairStatus,
|
||||||
|
} from "../src/services/designAgentApi";
|
||||||
|
|
||||||
|
const baseCounts = {
|
||||||
|
total: 1,
|
||||||
|
applied: 0,
|
||||||
|
no_proposal: 0,
|
||||||
|
no_zone_match: 0,
|
||||||
|
unsupported_kind: 0,
|
||||||
|
error: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("formatAiRepairHumanReviewMessage (IMP-47B u11)", () => {
|
||||||
|
it("returns null when ai_repair_status is null (legacy / pre-Step12 abort)", () => {
|
||||||
|
expect(formatAiRepairHumanReviewMessage(null)).toBeNull();
|
||||||
|
expect(formatAiRepairHumanReviewMessage(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when human_review_required=false (success / no-AI path)", () => {
|
||||||
|
const ok: AiRepairStatus = {
|
||||||
|
status: "ok",
|
||||||
|
counts: { ...baseCounts, total: 0 },
|
||||||
|
unsupported_kind_records: [],
|
||||||
|
error_records: [],
|
||||||
|
coverage_status: "ok",
|
||||||
|
dropped_section_ids: [],
|
||||||
|
human_review_required: false,
|
||||||
|
};
|
||||||
|
expect(formatAiRepairHumanReviewMessage(ok)).toBeNull();
|
||||||
|
|
||||||
|
const applied: AiRepairStatus = {
|
||||||
|
...ok,
|
||||||
|
status: "applied",
|
||||||
|
counts: { ...baseCounts, total: 1, applied: 1 },
|
||||||
|
};
|
||||||
|
expect(formatAiRepairHumanReviewMessage(applied)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces AI call failures with count + frame/manual guidance", () => {
|
||||||
|
const errored: AiRepairStatus = {
|
||||||
|
status: "error",
|
||||||
|
counts: { ...baseCounts, total: 2, error: 2 },
|
||||||
|
unsupported_kind_records: [],
|
||||||
|
error_records: [
|
||||||
|
{ unit_index: 0, source_section_ids: ["03-1"], error: "timeout" },
|
||||||
|
{ unit_index: 1, source_section_ids: ["03-2"], error: "validation" },
|
||||||
|
],
|
||||||
|
coverage_status: "ok",
|
||||||
|
dropped_section_ids: [],
|
||||||
|
human_review_required: true,
|
||||||
|
};
|
||||||
|
const msg = formatAiRepairHumanReviewMessage(errored);
|
||||||
|
expect(msg).not.toBeNull();
|
||||||
|
expect(msg).toContain("AI 재구성 호출 실패");
|
||||||
|
expect(msg).toContain("2");
|
||||||
|
expect(msg).toContain("다른 frame 선택 또는 수동 편집 필요");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces coverage violations with the dropped section ids", () => {
|
||||||
|
const dropped: AiRepairStatus = {
|
||||||
|
status: "coverage_violated",
|
||||||
|
counts: { ...baseCounts, total: 1, applied: 1 },
|
||||||
|
unsupported_kind_records: [],
|
||||||
|
error_records: [],
|
||||||
|
coverage_status: "violated",
|
||||||
|
dropped_section_ids: ["03-2"],
|
||||||
|
human_review_required: true,
|
||||||
|
};
|
||||||
|
const msg = formatAiRepairHumanReviewMessage(dropped);
|
||||||
|
expect(msg).not.toBeNull();
|
||||||
|
expect(msg).toContain("콘텐츠 누락");
|
||||||
|
expect(msg).toContain("03-2");
|
||||||
|
expect(msg).toContain("다른 frame 선택 또는 수동 편집 필요");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces unsupported proposal kinds with the unsupported count", () => {
|
||||||
|
const unsupported: AiRepairStatus = {
|
||||||
|
status: "unsupported_kind",
|
||||||
|
counts: { ...baseCounts, total: 1, unsupported_kind: 1 },
|
||||||
|
unsupported_kind_records: [
|
||||||
|
{
|
||||||
|
unit_index: 0,
|
||||||
|
source_section_ids: ["03-1"],
|
||||||
|
apply_status: "unsupported_kind_for_reject_route:builder_options_patch",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
error_records: [],
|
||||||
|
coverage_status: "ok",
|
||||||
|
dropped_section_ids: [],
|
||||||
|
human_review_required: true,
|
||||||
|
};
|
||||||
|
const msg = formatAiRepairHumanReviewMessage(unsupported);
|
||||||
|
expect(msg).not.toBeNull();
|
||||||
|
expect(msg).toContain("AI 제안 형식 미지원");
|
||||||
|
expect(msg).toContain("1");
|
||||||
|
expect(msg).toContain("다른 frame 선택 또는 수동 편집 필요");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a generic human_review message on unknown status enums", () => {
|
||||||
|
const future: AiRepairStatus = {
|
||||||
|
status: "future_axis_not_yet_mapped",
|
||||||
|
counts: { ...baseCounts, total: 0 },
|
||||||
|
unsupported_kind_records: [],
|
||||||
|
error_records: [],
|
||||||
|
coverage_status: "ok",
|
||||||
|
dropped_section_ids: [],
|
||||||
|
human_review_required: true,
|
||||||
|
};
|
||||||
|
const msg = formatAiRepairHumanReviewMessage(future);
|
||||||
|
expect(msg).not.toBeNull();
|
||||||
|
expect(msg).toContain("human_review");
|
||||||
|
expect(msg).toContain("future_axis_not_yet_mapped");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user