feat(#70): IMP-41 application_mode forwarding to FramePanel V4 badge tooltip (u1~u5)
Forward backend Step 9 `unit.application_candidates[]` (application_mode /
auto_applicable / delegated_to) onto FrameCandidate and surface the
application_mode as a Korean consequence phrase in the FramePanel V4-label
inline badge tooltip. Deterministic frontend-only refactor; no LLM call,
no V4-label color change, no outer composedTitle change.
u1: types/designAgent.ts — add optional applicationMode / autoApplicable /
delegatedTo on FrameCandidate (legacy fixtures keep undefined).
u2: services/applicationMode.ts (new) — pure helper exporting
ApplicationMode union, APPLICATION_MODE_TOOLTIP_KR (keyed by backend
mode VALUE, NOT V4 label), buildBadgeTitle, mergeApplicationCandidates.
u3: tests/imp41_application_mode.test.ts (new) — 13 Vitest cases pinning
composite output per mode, undefined/unknown→legacy fallback, merge by
template_id, skip missing/empty/non-string keys, first-wins on dupes,
empty/null/non-array input.
u4: services/designAgentApi.ts — bridge consumes mergeApplicationCandidates
and forwards three fields onto FrameCandidate while preserving
LABEL_PRIORITY sort and TOP_N_FRAMES slicing.
u5: components/FramePanel.tsx — V4-label badge `title` now calls
`buildBadgeTitle(candidate.label, candidate.applicationMode)`;
badge color className map preserved verbatim; outer composedTitle
untouched.
Scope-qualified verification (5 files, IMP-41 axis only):
- Vitest: client/tests/imp41_application_mode.test.ts — 13/13 PASS.
- Diff↔Plan parity: 5 files match Stage 2 plan, no scope creep.
- AI-isolation contract honored: tooltip values originate from backend
enum; no frontend re-derivation from V4 label.
- No spacing/font shrink; clipping resolution stays at layout/zone/frame
layer (feedback_phase_z_spacing_direction).
Pre-existing unrelated diagnostics (BottomActions.tsx,
imp47b_human_review_toast.test.tsx) remain open on their own axes and are
not gated by this commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { Zone, InternalRegion, UserSelection, FrameCandidate, SlidePlan } from '../types/designAgent';
|
||||
import { getSectionsForZone } from '../utils/slidePlanUtils';
|
||||
import { buildBadgeTitle } from '../services/applicationMode';
|
||||
|
||||
interface FramePanelProps {
|
||||
slidePlan: SlidePlan | null;
|
||||
@@ -235,6 +236,13 @@ export default function FramePanel({
|
||||
</span>
|
||||
)}
|
||||
{/* V4 label badge */}
|
||||
{/* IMP-41 u5 — tooltip delegated to pure helper
|
||||
`buildBadgeTitle` (services/applicationMode.ts).
|
||||
applicationMode is forwarded by designAgentApi.ts
|
||||
(u4) from Step 9 unit.application_candidates[];
|
||||
helper falls back to the raw V4 label when the
|
||||
mode is undefined or unknown. Badge color mapping
|
||||
is intentionally untouched per Stage 2 scope. */}
|
||||
{candidate.label && (
|
||||
<span
|
||||
className={`text-[8px] font-black uppercase tracking-tight px-1.5 py-0.5 rounded ${
|
||||
@@ -246,7 +254,7 @@ export default function FramePanel({
|
||||
? "bg-amber-100 text-amber-700"
|
||||
: "bg-red-100 text-red-700"
|
||||
}`}
|
||||
title={`V4 label: ${candidate.label}`}
|
||||
title={buildBadgeTitle(candidate.label, candidate.applicationMode)}
|
||||
>
|
||||
{candidate.label}
|
||||
</span>
|
||||
|
||||
64
Front/client/src/services/applicationMode.ts
Normal file
64
Front/client/src/services/applicationMode.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
// ─── IMP-41 u2 — application_mode helper (issue #70) ────────────────────────
|
||||
// Pure deterministic helpers for forwarding backend Step 9
|
||||
// `unit.application_candidates[]` to the FramePanel V4-label badge tooltip.
|
||||
//
|
||||
// Keyed by backend `application_mode` VALUE (NOT V4 label) — preserves the
|
||||
// AI-isolation contract: tooltip text is a read-only display of backend
|
||||
// authority, never re-derived on the frontend from V4 label.
|
||||
//
|
||||
// Source of truth = src/phase_z2_pipeline.py APPLICATION_MODE_BY_V4_LABEL
|
||||
// (:107-112) emitted via _application_candidates_for_unit() (:3071-3092)
|
||||
// onto unit.application_candidates[] in step09_application_plan.json.
|
||||
|
||||
/** Backend application_mode enumeration (verbatim from APPLICATION_MODE_BY_V4_LABEL). */
|
||||
export type ApplicationMode =
|
||||
| 'direct_insert'
|
||||
| 'same_frame_with_adjustment'
|
||||
| 'layout_or_region_change'
|
||||
| 'exclude';
|
||||
|
||||
/** Korean consequence phrases per issue #70 spec item #2. Keyed by mode VALUE. */
|
||||
export const APPLICATION_MODE_TOOLTIP_KR: Record<ApplicationMode, string> = {
|
||||
direct_insert: '코드 직접 적용',
|
||||
same_frame_with_adjustment: 'AI 보강 필요',
|
||||
layout_or_region_change: 'AI restructure 필요',
|
||||
exclude: 'render path 제외',
|
||||
};
|
||||
|
||||
/**
|
||||
* Compose the V4-label badge tooltip title. When `applicationMode` resolves
|
||||
* to a known mode the title shows the Korean consequence + raw mode token;
|
||||
* otherwise (undefined or unknown — legacy fixtures pre-IMP-32) it falls
|
||||
* back to the raw V4 label string per Stage 2 contract.
|
||||
*/
|
||||
export function buildBadgeTitle(
|
||||
label: string,
|
||||
applicationMode: string | undefined,
|
||||
): string {
|
||||
const consequence = applicationMode
|
||||
? APPLICATION_MODE_TOOLTIP_KR[applicationMode as ApplicationMode]
|
||||
: undefined;
|
||||
return consequence
|
||||
? `${consequence} (${applicationMode})`
|
||||
: `V4 label: ${label}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Map<template_id, applicationCandidate> from a Step 9
|
||||
* `unit.application_candidates[]` array. Entries with a non-string or empty
|
||||
* `template_id` are skipped. First occurrence wins on duplicate keys.
|
||||
* Pure — does NOT sort, slice, or filter by label/confidence.
|
||||
*/
|
||||
export function mergeApplicationCandidates(
|
||||
applicationCandidates: unknown,
|
||||
): Map<string, any> {
|
||||
const out = new Map<string, any>();
|
||||
if (!Array.isArray(applicationCandidates)) return out;
|
||||
for (const ac of applicationCandidates) {
|
||||
const key = (ac as any)?.template_id;
|
||||
if (typeof key === 'string' && key.length > 0 && !out.has(key)) {
|
||||
out.set(key, ac);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
MOCK_FRAME_CANDIDATES_SECTION1,
|
||||
} from "../data/mockDesignAgentData";
|
||||
|
||||
import { mergeApplicationCandidates } from "./applicationMode";
|
||||
|
||||
/** 네트워크 지연 시뮬레이션 */
|
||||
const simulateDelay = (ms: number = 800) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
@@ -527,9 +529,20 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
|
||||
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) => ({
|
||||
.map((c: any) => {
|
||||
const appMatch = applicationModeMap.get(c.template_id);
|
||||
return ({
|
||||
id: c.template_id,
|
||||
name: c.template_id,
|
||||
score: c.confidence ?? 0,
|
||||
@@ -559,7 +572,15 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
|
||||
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] ??
|
||||
|
||||
@@ -175,6 +175,19 @@ export interface FrameCandidate {
|
||||
reason?: string | null;
|
||||
/** Capacity vs. content shape audit (compute_capacity_fit output). */
|
||||
capacityFit?: CapacityFitEvidence | null;
|
||||
|
||||
// ─── IMP-41 application_mode forwarding (issue #70 u1) ─────────────────────
|
||||
// Source = src/phase_z2_pipeline.py APPLICATION_MODE_BY_V4_LABEL (:107-112),
|
||||
// emitted by _application_candidates_for_unit() into Step 9
|
||||
// unit.application_candidates[]. Optional — legacy fixtures pre-IMP-32 omit
|
||||
// these and the FramePanel tooltip falls back to the raw V4 label.
|
||||
|
||||
/** Application mode mapped from V4 label by backend (authoritative). */
|
||||
applicationMode?: 'direct_insert' | 'same_frame_with_adjustment' | 'layout_or_region_change' | 'exclude';
|
||||
/** True when backend marks the candidate as automatically applicable. */
|
||||
autoApplicable?: boolean;
|
||||
/** Delegation target step / actor (e.g. "step10_contract_check", "human_review"). */
|
||||
delegatedTo?: string | null;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
123
Front/client/tests/imp41_application_mode.test.ts
Normal file
123
Front/client/tests/imp41_application_mode.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
// IMP-41 u3 — Vitest coverage for application_mode helper (issue #70).
|
||||
//
|
||||
// Scope (Stage 2 unit u3 contract):
|
||||
// 1) buildBadgeTitle: composite output for each known mode + legacy fallback
|
||||
// (undefined applicationMode) + unknown fallback (string not in
|
||||
// APPLICATION_MODE_TOOLTIP_KR).
|
||||
// 2) mergeApplicationCandidates: array → Map<template_id, candidate>
|
||||
// semantics, including skip-missing-key and empty-input.
|
||||
//
|
||||
// Pure helper unit test — no React, no DOM, no fetch. Aligns with the
|
||||
// AI-isolation contract: assertions key by backend application_mode VALUE,
|
||||
// never by V4 label.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildBadgeTitle,
|
||||
mergeApplicationCandidates,
|
||||
APPLICATION_MODE_TOOLTIP_KR,
|
||||
} from "../src/services/applicationMode";
|
||||
|
||||
describe("buildBadgeTitle (IMP-41 u3)", () => {
|
||||
it("returns composite '<consequence> (<mode>)' for direct_insert", () => {
|
||||
expect(buildBadgeTitle("use_as_is", "direct_insert")).toBe(
|
||||
`${APPLICATION_MODE_TOOLTIP_KR.direct_insert} (direct_insert)`,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns composite output for same_frame_with_adjustment", () => {
|
||||
expect(
|
||||
buildBadgeTitle("light_edit", "same_frame_with_adjustment"),
|
||||
).toBe(
|
||||
`${APPLICATION_MODE_TOOLTIP_KR.same_frame_with_adjustment} (same_frame_with_adjustment)`,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns composite output for layout_or_region_change", () => {
|
||||
expect(
|
||||
buildBadgeTitle("restructure", "layout_or_region_change"),
|
||||
).toBe(
|
||||
`${APPLICATION_MODE_TOOLTIP_KR.layout_or_region_change} (layout_or_region_change)`,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns composite output for exclude", () => {
|
||||
expect(buildBadgeTitle("reject", "exclude")).toBe(
|
||||
`${APPLICATION_MODE_TOOLTIP_KR.exclude} (exclude)`,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to 'V4 label: <label>' when applicationMode is undefined (legacy fixtures pre-IMP-32)", () => {
|
||||
expect(buildBadgeTitle("use_as_is", undefined)).toBe("V4 label: use_as_is");
|
||||
});
|
||||
|
||||
it("falls back to 'V4 label: <label>' when applicationMode is an unknown string", () => {
|
||||
expect(buildBadgeTitle("light_edit", "some_future_mode")).toBe(
|
||||
"V4 label: light_edit",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeApplicationCandidates (IMP-41 u3)", () => {
|
||||
it("returns empty Map when input is undefined", () => {
|
||||
const result = mergeApplicationCandidates(undefined);
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("returns empty Map when input is null", () => {
|
||||
const result = mergeApplicationCandidates(null);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("returns empty Map when input is not an array", () => {
|
||||
expect(mergeApplicationCandidates({ template_id: "f01" }).size).toBe(0);
|
||||
expect(mergeApplicationCandidates("f01").size).toBe(0);
|
||||
expect(mergeApplicationCandidates(42).size).toBe(0);
|
||||
});
|
||||
|
||||
it("returns empty Map when input is an empty array", () => {
|
||||
expect(mergeApplicationCandidates([]).size).toBe(0);
|
||||
});
|
||||
|
||||
it("keys entries by template_id and preserves the candidate payload", () => {
|
||||
const ac1 = {
|
||||
template_id: "f01",
|
||||
label: "use_as_is",
|
||||
application_mode: "direct_insert",
|
||||
auto_applicable: true,
|
||||
delegated_to: null,
|
||||
};
|
||||
const ac2 = {
|
||||
template_id: "f17",
|
||||
label: "light_edit",
|
||||
application_mode: "same_frame_with_adjustment",
|
||||
auto_applicable: false,
|
||||
delegated_to: "step10_contract_check",
|
||||
};
|
||||
const result = mergeApplicationCandidates([ac1, ac2]);
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.get("f01")).toBe(ac1);
|
||||
expect(result.get("f17")).toBe(ac2);
|
||||
});
|
||||
|
||||
it("skips entries with missing or non-string template_id", () => {
|
||||
const result = mergeApplicationCandidates([
|
||||
{ label: "use_as_is" }, // missing template_id
|
||||
{ template_id: "", label: "light_edit" }, // empty string
|
||||
{ template_id: 17, label: "restructure" }, // non-string
|
||||
{ template_id: "f29", label: "reject" }, // valid
|
||||
]);
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.has("f29")).toBe(true);
|
||||
expect(result.has("")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the first occurrence on duplicate template_id keys (deterministic)", () => {
|
||||
const first = { template_id: "f01", label: "use_as_is" };
|
||||
const second = { template_id: "f01", label: "reject" };
|
||||
const result = mergeApplicationCandidates([first, second]);
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get("f01")).toBe(first);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user