Compare commits
89 Commits
0f0d3fa91f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 97b7833a1b | |||
| 6e9e3ee1fb | |||
| 2afedfc780 | |||
| 5484077a53 | |||
| ed391af2e8 | |||
| b9747c2f4a | |||
| f0d4494409 | |||
| 4da22adb43 | |||
| 943957562f | |||
| ec7471ed59 | |||
| 4e281a20d8 | |||
| 9062931863 | |||
| b4be6c1cd0 | |||
| 8648a468d9 | |||
| 028042aaa9 | |||
| 2e3747c5ab | |||
| e0c39f1bc1 | |||
| 5deeb97cf6 | |||
| c59864eb9a | |||
| 6aa7564509 | |||
| b1bbe27c38 | |||
| 896f273ffa | |||
| 842a46144c | |||
| c53722ad0b | |||
| cacc5b30db | |||
| d9d338416a | |||
| f3ef4d917c | |||
| 7c93031f9b | |||
| c1df656312 | |||
| 6f1c7367e0 | |||
| bd8bcf748b | |||
| 9388e25e76 | |||
| ee97f4fc78 | |||
| 79f9ea5c92 | |||
| 2ef02f5f18 | |||
| 1186ad8ae2 | |||
| f358604fb3 | |||
| 90503cadd6 | |||
| dceb10129f | |||
| a06dd3d4b0 | |||
| 15ef7c65e9 | |||
| c864fe0479 | |||
| c412f1ea75 | |||
| 182aa7c47f | |||
| 1efbf672bd | |||
| b4872ba6ce | |||
| 265d70ed91 | |||
| 909bf75edc | |||
| 2896bb691c | |||
| a71355e005 | |||
| b1897c01bc | |||
| 5d23b747ff | |||
| 447e702520 | |||
| 2ace54bce1 | |||
| 5590ef20b5 | |||
| 134f52d3d3 | |||
| 8c1e56366b | |||
| 101143e67b | |||
| 9389b8425b | |||
| 47f072ee05 | |||
| 8c60f7cc85 | |||
| e60aacc3dc | |||
| 02e2ae0afb | |||
| 8f06a4c99f | |||
| 191b6a9d85 | |||
| 2bb0acac19 | |||
| c37a554fb1 | |||
| 8c7d6935b1 | |||
| e32f632464 | |||
| 4289a500b6 | |||
| cbbc163860 | |||
| e10ec36617 | |||
| 23ba8b68cd | |||
| 614c53358e | |||
| 535c4848fd | |||
| 2827622858 | |||
| f3bff898fb | |||
| e9b3d2e9c0 | |||
| 7a52cebfaa | |||
| 7d5639ad72 | |||
| 56619a0239 | |||
| a79bd8bc43 | |||
| 0fb168befc | |||
| 1fb973297f | |||
| 201099e53b | |||
| 8f6cffc2a7 | |||
| ab2764c8d0 | |||
| 5191acad85 | |||
| a422d72c0b |
71
.github/workflows/multi-mdx-regression.yml
vendored
Normal file
71
.github/workflows/multi-mdx-regression.yml
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
name: Multi-MDX Regression (IMP-91)
|
||||
|
||||
# IMP-#91 u13 — auto-gate the mdx 01-05 acceptance set on every push to main
|
||||
# and on PRs targeting main. Failure of any integration test blocks the
|
||||
# commit. JSON report is emitted via pytest-json-report (u12 dep) and
|
||||
# uploaded as an artifact for u14/u15 status-board updater consumption.
|
||||
#
|
||||
# [[feedback_validation_first_for_closed_issues]] — fresh subprocess per CI run.
|
||||
# [[feedback_auto_pipeline_first]] — no manual review queue; deterministic gate.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
multi-mdx-regression:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
|
||||
- name: Install Chrome and ChromeDriver
|
||||
uses: browser-actions/setup-chrome@v1
|
||||
with:
|
||||
install-chromedriver: true
|
||||
|
||||
- name: Install project (dev extras + selenium)
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e ".[dev]"
|
||||
python -m pip install "selenium>=4.20"
|
||||
|
||||
- name: Run multi-mdx regression tests
|
||||
run: |
|
||||
python -m pytest -q -m integration \
|
||||
tests/integration/test_multi_mdx_regression.py \
|
||||
--json-report \
|
||||
--json-report-file=imp91-report.json \
|
||||
--json-report-omit keywords streams
|
||||
|
||||
- name: Upload pytest JSON report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: imp91-multi-mdx-report
|
||||
path: imp91-report.json
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Update status-board markers (IMP-91 u15)
|
||||
if: always()
|
||||
run: |
|
||||
python scripts/update_status_board.py \
|
||||
--report imp91-report.json \
|
||||
--board docs/architecture/PHASE-Z-PIPELINE-STATUS-BOARD.md
|
||||
|
||||
- name: Upload updated status board
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: imp91-status-board
|
||||
path: docs/architecture/PHASE-Z-PIPELINE-STATUS-BOARD.md
|
||||
if-no-files-found: warn
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -8,7 +8,11 @@ dist/
|
||||
build/
|
||||
.venv/
|
||||
node_modules/
|
||||
data/
|
||||
data/*
|
||||
# IMP-46 u6 — track only the frame_cache directory marker; cached payloads stay ignored.
|
||||
!data/frame_cache/
|
||||
data/frame_cache/*
|
||||
!data/frame_cache/.gitkeep
|
||||
|
||||
# session workspace (push X — 작업 흐름 trace, 사용자 결정 2026-05-08)
|
||||
forex/
|
||||
|
||||
@@ -1,99 +1,200 @@
|
||||
/**
|
||||
* BottomActions - 하단 액션 버튼 영역
|
||||
* BottomActions — Step 22 footer wire-up (IMP-56 #90 u20).
|
||||
*
|
||||
* 생성하기, 다운로드, 연동하기 버튼 컴포넌트
|
||||
* Two real endpoints replace the prior placeholder toasts:
|
||||
* • POST /api/connect (u18 / Front/vite.config.ts) — copies
|
||||
* data/runs/<run_id>/phase_z2/final.html + assets/ into the cel mirror
|
||||
* (`<CEL_PROJECT_ROOT>/public/slides/<slug>.html`).
|
||||
* • POST /api/export (u19 / Front/vite.config.ts) — returns a standalone
|
||||
* text/html body with every `url(assets/...)` ref inlined as base64
|
||||
* data URLs. Response is piped into a Blob → a[download] click chain so
|
||||
* the user receives `<run_id>.html` portable for file:// or any host.
|
||||
*
|
||||
* The prior `serializeSlidePlan` JSON-download path was a dead reference
|
||||
* (the export never existed in slidePlanUtils) and is removed here — the
|
||||
* "다운로드" button now means standalone HTML download via /api/export.
|
||||
* Both buttons disable when no run is loaded (runMeta == null) so the
|
||||
* UI cannot fire requests with an undefined run_id.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Sparkles, Download, Link2, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import type { SlidePlan, UserSelection } from "../types/designAgent";
|
||||
import { serializeSlidePlan } from "../utils/slidePlanUtils";
|
||||
import type { SlidePlan } from "../types/designAgent";
|
||||
import type { RunMeta } from "../services/designAgentApi";
|
||||
import { deriveUserOverridesKey } from "../utils/slidePlanUtils";
|
||||
|
||||
// ─── pure request builders (exported for vitest; jsdom-free) ─────────────
|
||||
// The component below uses these verbatim. Each returns a {url, body} pair
|
||||
// so the test surface is the *literal* HTTP payload sent to the u18 / u19
|
||||
// middlewares — any future shape drift fails here before the network call.
|
||||
|
||||
export function buildConnectRequest(
|
||||
run_id: string,
|
||||
slug: string,
|
||||
): { url: string; body: string } {
|
||||
return {
|
||||
url: "/api/connect",
|
||||
body: JSON.stringify({ run_id, slug }),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildExportRequest(
|
||||
run_id: string,
|
||||
): { url: string; body: string } {
|
||||
return {
|
||||
url: "/api/export",
|
||||
body: JSON.stringify({ run_id }),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDownloadFilename(run_id: string): string {
|
||||
return `${run_id}.html`;
|
||||
}
|
||||
|
||||
interface BottomActionsProps {
|
||||
slidePlan: SlidePlan | null;
|
||||
userSelection: UserSelection;
|
||||
runMeta: RunMeta | null;
|
||||
uploadedFile: File | null;
|
||||
isLoading: boolean;
|
||||
onGenerate: () => void;
|
||||
}
|
||||
|
||||
export default function BottomActions({
|
||||
slidePlan,
|
||||
userSelection,
|
||||
runMeta,
|
||||
uploadedFile,
|
||||
isLoading,
|
||||
onGenerate,
|
||||
}: BottomActionsProps) {
|
||||
const handleDownload = () => {
|
||||
if (!slidePlan) {
|
||||
toast.error("슬라이드 플랜이 없습니다. 먼저 생성하기를 눌러주세요.");
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
const runReady = !!runMeta && !!slidePlan;
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!runMeta) {
|
||||
toast.error("Run 산출물이 없습니다. 먼저 생성하기를 눌러주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const json = serializeSlidePlan(slidePlan, userSelection);
|
||||
console.log("[Download] SlidePlan JSON:", json);
|
||||
|
||||
// JSON 파일 다운로드
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `slide-plan-${Date.now()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success("SlidePlan JSON이 다운로드되었습니다.");
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const exportReq = buildExportRequest(runMeta.run_id);
|
||||
const resp = await fetch(exportReq.url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: exportReq.body,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
toast.error(`Export 실패 (${resp.status}): ${text.slice(0, 160)}`);
|
||||
return;
|
||||
}
|
||||
const blob = await resp.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = objectUrl;
|
||||
a.download = buildDownloadFilename(runMeta.run_id);
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
toast.success(`standalone HTML 다운로드 — ${runMeta.run_id}.html`);
|
||||
} catch (err) {
|
||||
toast.error(`Export 네트워크 오류: ${(err as Error).message}`);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = () => {
|
||||
toast.info("연동하기 기능은 파이프라인 연결 후 활성화됩니다.");
|
||||
const handleConnect = async () => {
|
||||
if (!runMeta) {
|
||||
toast.error("Run 산출물이 없습니다. 먼저 생성하기를 눌러주세요.");
|
||||
return;
|
||||
}
|
||||
if (!uploadedFile) {
|
||||
toast.error("MDX 파일이 없습니다 — slug 도출 불가.");
|
||||
return;
|
||||
}
|
||||
const slug = deriveUserOverridesKey(uploadedFile.name);
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
const connectReq = buildConnectRequest(runMeta.run_id, slug);
|
||||
const resp = await fetch(connectReq.url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: connectReq.body,
|
||||
});
|
||||
const payload = (await resp.json().catch(() => ({}))) as {
|
||||
success?: boolean;
|
||||
assets_copied?: number;
|
||||
error?: string;
|
||||
};
|
||||
if (!resp.ok || !payload.success) {
|
||||
toast.error(
|
||||
`Connect 실패 (${resp.status}): ${payload.error ?? "unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
toast.success(
|
||||
`cel 미러 연동 완료 — ${slug}.html (assets ${payload.assets_copied ?? 0}개 복사)`,
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(`Connect 네트워크 오류: ${(err as Error).message}`);
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-3 px-6 py-3 bg-white border-t border-slate-200">
|
||||
<div className="flex items-center gap-1.5 mr-2">
|
||||
<span className="w-5 h-5 rounded-full bg-blue-600 text-white text-xs flex items-center justify-center font-bold">4</span>
|
||||
<span className="text-xs text-slate-500 font-medium">액션</span>
|
||||
</div>
|
||||
{/* 생성하기 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={onGenerate}
|
||||
disabled={isLoading}
|
||||
className="gap-2 min-w-[120px]"
|
||||
className="gap-2 h-9 text-[11px] font-bold uppercase tracking-widest bg-slate-900 hover:bg-slate-800"
|
||||
size="default"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
생성 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
생성하기
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* 다운로드 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDownload}
|
||||
disabled={!slidePlan || isLoading}
|
||||
className="gap-2 min-w-[120px]"
|
||||
onClick={handleExport}
|
||||
disabled={!runReady || isExporting || isLoading}
|
||||
className="gap-2 h-9 text-[11px] font-bold uppercase tracking-widest border-slate-200"
|
||||
size="default"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
{isExporting ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
)}
|
||||
다운로드
|
||||
</Button>
|
||||
|
||||
{/* 연동하기 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleConnect}
|
||||
className="gap-2 min-w-[120px] text-slate-500"
|
||||
disabled={!runReady || isConnecting || isLoading}
|
||||
className="gap-2 h-9 text-[11px] font-bold uppercase tracking-widest border-slate-200"
|
||||
size="default"
|
||||
>
|
||||
<Link2 className="w-4 h-4" />
|
||||
{isConnecting ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Link2 className="w-3.5 h-3.5" />
|
||||
)}
|
||||
연동하기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
@@ -19,6 +20,19 @@ interface FramePanelProps {
|
||||
onNoDesignToggle: () => void;
|
||||
}
|
||||
|
||||
// IMP-#84 u1 — silent-automation contract: frame selection delegates directly
|
||||
// to onFrameSelect for every V4 label (use_as_is / light_edit / restructure /
|
||||
// reject). Prior IMP-47B u11 surfaced a window.confirm popup on reject; that
|
||||
// popup is informational UI noise per `feedback_auto_pipeline_first` and is
|
||||
// removed. Frame identity is preserved on reject (AI 재구성 = content-only,
|
||||
// per AI 격리 contract); the popup never gated that contract.
|
||||
export function applyFrameSelection(
|
||||
candidate: FrameCandidate,
|
||||
onFrameSelect: (frameId: string) => void,
|
||||
): void {
|
||||
onFrameSelect(candidate.id);
|
||||
}
|
||||
|
||||
export default function FramePanel({
|
||||
slidePlan,
|
||||
selectedZone,
|
||||
@@ -46,6 +60,13 @@ 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) => {
|
||||
applyFrameSelection(candidate, onFrameSelect);
|
||||
},
|
||||
[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">
|
||||
@@ -87,6 +108,62 @@ export default function FramePanel({
|
||||
// catalog 미등록 = backend Step 7-A 가 override 시도해도 skip.
|
||||
// catalogRegistered === false 만 체크 (undefined = 정보 없음, 일반 처리).
|
||||
const isCatalogMissing = candidate.catalogRegistered === false;
|
||||
|
||||
// ─── IMP-29 u3 — IMP-05 L2 candidate_evidence surface ───────────
|
||||
// All evidence fields optional; silent degradation when undefined
|
||||
// (pre-IMP-05 fixtures fall back to label/catalogRegistered only).
|
||||
const isFilteredDirect = candidate.filteredForDirectExecution === true;
|
||||
const hasDecision = candidate.decision === "selected" || candidate.decision === "skipped";
|
||||
const isSkipped = candidate.decision === "skipped";
|
||||
const isSelectedDecision = candidate.decision === "selected";
|
||||
const showRouteChip =
|
||||
candidate.routeHint && candidate.routeHint !== "direct_render";
|
||||
const showStatusChip =
|
||||
candidate.phaseZStatus && candidate.phaseZStatus !== "auto_renderable";
|
||||
const hasCapacityFit =
|
||||
candidate.capacityFit && candidate.capacityFit.fit_status;
|
||||
const capacityMismatch =
|
||||
hasCapacityFit && candidate.capacityFit!.fit_status !== "ok";
|
||||
|
||||
// Compose evidence tooltip lines (only when at least one signal present).
|
||||
const evidenceLines: string[] = [];
|
||||
if (candidate.decision) evidenceLines.push(`decision: ${candidate.decision}`);
|
||||
if (candidate.reason) evidenceLines.push(`reason: ${candidate.reason}`);
|
||||
if (candidate.routeHint) evidenceLines.push(`route: ${candidate.routeHint}`);
|
||||
if (candidate.phaseZStatus)
|
||||
evidenceLines.push(`phase_z_status: ${candidate.phaseZStatus}`);
|
||||
if (hasCapacityFit) {
|
||||
const cf = candidate.capacityFit!;
|
||||
const capacityLine =
|
||||
cf.fit_status === "ok"
|
||||
? `capacity: ok${
|
||||
typeof cf.item_count === "number"
|
||||
? ` (items=${cf.item_count})`
|
||||
: ""
|
||||
}`
|
||||
: `capacity: ${cf.fit_status}${
|
||||
cf.mismatch_reason ? ` — ${cf.mismatch_reason}` : ""
|
||||
}`;
|
||||
evidenceLines.push(capacityLine);
|
||||
}
|
||||
const evidenceTooltip =
|
||||
evidenceLines.length > 0 ? evidenceLines.join("\n") : undefined;
|
||||
|
||||
// Compose final tooltip: existing catalog/reject reasons first, then
|
||||
// evidence detail (preserves Phase Q tooltip semantics).
|
||||
const tooltipParts = [
|
||||
isCatalogMissing
|
||||
? "⚠ catalog 미등록 — render path 에서 적용 안 됨 (선택해도 backend 가 skip)"
|
||||
: null,
|
||||
isFilteredDirect
|
||||
? "⚠ filtered_for_direct_execution — MVP1 직접 렌더 경로 제외"
|
||||
: null,
|
||||
isReject ? "V4 reject — render path 비추천" : null,
|
||||
evidenceTooltip,
|
||||
].filter((s): s is string => Boolean(s));
|
||||
const composedTitle =
|
||||
tooltipParts.length > 0 ? tooltipParts.join("\n\n") : undefined;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={candidate.id}
|
||||
@@ -95,7 +172,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);
|
||||
@@ -105,17 +182,13 @@ export default function FramePanel({
|
||||
? 'border-blue-500 bg-white shadow-xl shadow-blue-500/10'
|
||||
: isCatalogMissing
|
||||
? 'border-slate-100 bg-slate-50/40 opacity-60 hover:opacity-90 hover:border-amber-200'
|
||||
: isFilteredDirect
|
||||
? 'border-slate-100 bg-slate-50/30 opacity-50 hover:opacity-90 hover:border-amber-200'
|
||||
: isReject
|
||||
? 'border-slate-100 bg-slate-50/30 opacity-50 hover:opacity-90 hover:border-slate-200'
|
||||
: 'border-slate-100 bg-slate-50/50 hover:border-slate-200 hover:bg-white'
|
||||
}`}
|
||||
title={
|
||||
isCatalogMissing
|
||||
? "⚠ catalog 미등록 — render path 에서 적용 안 됨 (선택해도 backend 가 skip)"
|
||||
: isReject
|
||||
? "V4 reject — render path 비추천"
|
||||
: undefined
|
||||
}
|
||||
title={composedTitle}
|
||||
>
|
||||
{/* Rank Badge */}
|
||||
<div className="absolute top-3 left-3 z-10">
|
||||
@@ -183,6 +256,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 ${
|
||||
@@ -194,11 +274,69 @@ 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>
|
||||
)}
|
||||
{/* IMP-29 u3 — route hint chip (skip when direct_render = default). */}
|
||||
{showRouteChip && (
|
||||
<span
|
||||
className="text-[8px] font-black uppercase tracking-tight px-1.5 py-0.5 rounded bg-slate-100 text-slate-600"
|
||||
title={`route_hint: ${candidate.routeHint}`}
|
||||
>
|
||||
{candidate.routeHint === "deterministic_minor_adjustment"
|
||||
? "adapt"
|
||||
: candidate.routeHint === "ai_adaptation_required"
|
||||
? "ai req"
|
||||
: candidate.routeHint === "design_reference_only"
|
||||
? "ref"
|
||||
: candidate.routeHint}
|
||||
</span>
|
||||
)}
|
||||
{/* IMP-29 u3 — phase_z status warning chip (skip when auto_renderable). */}
|
||||
{showStatusChip && (
|
||||
<span
|
||||
className="text-[8px] font-black uppercase tracking-tight px-1.5 py-0.5 rounded bg-amber-50 text-amber-700"
|
||||
title={`phase_z_status: ${candidate.phaseZStatus}`}
|
||||
>
|
||||
{candidate.phaseZStatus!.replace(/_/g, " ")}
|
||||
</span>
|
||||
)}
|
||||
{/* IMP-29 u3 — capacity_fit indicator (ok = subtle, mismatch = warning). */}
|
||||
{hasCapacityFit && (
|
||||
<span
|
||||
className={`text-[8px] font-black uppercase tracking-tight px-1.5 py-0.5 rounded ${
|
||||
capacityMismatch
|
||||
? "bg-amber-100 text-amber-700"
|
||||
: "bg-slate-100 text-slate-500"
|
||||
}`}
|
||||
title={`capacity_fit: ${candidate.capacityFit!.fit_status}${
|
||||
candidate.capacityFit!.mismatch_reason
|
||||
? ` — ${candidate.capacityFit!.mismatch_reason}`
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{capacityMismatch
|
||||
? `fit: ${candidate.capacityFit!.fit_status}`
|
||||
: "fit ok"}
|
||||
</span>
|
||||
)}
|
||||
{/* IMP-29 u3 — decision badge (Stage 2 contract: surface both selected & skipped). */}
|
||||
{hasDecision && (
|
||||
<span
|
||||
className={`text-[8px] font-black uppercase tracking-tight px-1.5 py-0.5 rounded ${
|
||||
isSelectedDecision
|
||||
? "bg-emerald-50 text-emerald-700"
|
||||
: "bg-red-50 text-red-600"
|
||||
}`}
|
||||
title={`decision: ${candidate.decision}${
|
||||
candidate.reason ? ` — ${candidate.reason}` : ""
|
||||
}`}
|
||||
>
|
||||
{isSkipped ? "skip" : "sel"}
|
||||
</span>
|
||||
)}
|
||||
{isSelected && (
|
||||
<div className="flex items-center gap-1 text-[8px] font-black text-emerald-500 uppercase">
|
||||
<Check className="w-2.5 h-2.5 stroke-[4]" />
|
||||
|
||||
@@ -21,6 +21,19 @@ import type {
|
||||
UserSelection,
|
||||
NormalizedContent,
|
||||
} from "../types/designAgent";
|
||||
import {
|
||||
IMAGE_RESIZE_MIN_SIZE_PERCENT,
|
||||
clampImagePercentGeometry,
|
||||
clampZoneMove,
|
||||
crossedDragThreshold,
|
||||
type ImageDragDirection,
|
||||
} from "./slideCanvasDragMath";
|
||||
import type {
|
||||
ImageOverridesOverride,
|
||||
StructureOverridesOverride,
|
||||
StructureOverridePerZone,
|
||||
} from "../services/userOverridesApi";
|
||||
import StructureEditOverlay from "./StructureEditOverlay";
|
||||
|
||||
interface SlideCanvasProps {
|
||||
slidePlan: SlidePlan | null;
|
||||
@@ -28,10 +41,6 @@ interface SlideCanvasProps {
|
||||
userSelection: UserSelection;
|
||||
/** Phase Z 가 만든 final.html URL (iframe 으로 표시). */
|
||||
finalHtmlUrl?: string;
|
||||
/** 슬라이드 단위 inline CSS override (catalog/template 무변, iframe contentDocument 에
|
||||
* 동적 inject). Home 이 mdx 별 default visual 보완 등을 지정. 빈 문자열/undefined =
|
||||
* inject 안 함. 사용자 lock 2026-05-14 — slide-level only. */
|
||||
slideOverrideCss?: string;
|
||||
/** 파이프라인 실행 중 표시 (loading state). */
|
||||
isPipelineRunning?: boolean;
|
||||
/** Phase 2 : pending layout 모드 — final.html iframe 숨기고 빈 layout zone 만 표시. */
|
||||
@@ -51,16 +60,134 @@ interface SlideCanvasProps {
|
||||
onZoneResize?: (
|
||||
geometries: Record<string, { x: number; y: number; w: number; h: number }>
|
||||
) => void;
|
||||
/** IMP-51 (#79) u8 — persisted slide-absolute image geometries
|
||||
* (image_id → {x,y,w,h} as percent of 1280×720, range 0–100). Mirrors
|
||||
* the u3 typed-client `ImageOverride` contract and the u7 stamper that
|
||||
* emits CSS `left/top/width/height: {value}%`. Forward-compat optional;
|
||||
* u11 wires this from `userSelection.overrides.image_overrides`. When
|
||||
* present, SlideCanvas displays the persisted geometry instead of the
|
||||
* iframe-measured baseline. */
|
||||
imageOverrides?: ImageOverridesOverride;
|
||||
/** IMP-51 (#79) u8 — emitted when the user drags or resizes a stamped
|
||||
* user-content image. Geometry is slide-absolute percent (0–100 of
|
||||
* 1280×720), matching the persisted axis schema (u3 typed client) and
|
||||
* the u7 CSS injection that writes the values directly into
|
||||
* `left/top/width/height: {value}%`. u10 wires this to a persistence
|
||||
* handler that updates `image_overrides` on user_overrides.json. */
|
||||
onImageResize?: (
|
||||
imageId: string,
|
||||
geometry: { x: number; y: number; w: number; h: number }
|
||||
) => void;
|
||||
/** IMP-90 (#90) u13 — focusout-emitted capture; u15 debounces + PUTs. */
|
||||
onTextEdit?: (capture: TextEditCapture) => void;
|
||||
/** IMP-90 (#90) u14 — persisted structure overrides per zone
|
||||
* (slot_order + hidden_slots). When `editMode === "structure"` the
|
||||
* StructureEditOverlay reads from this to render the current state. */
|
||||
structureOverrides?: StructureOverridesOverride;
|
||||
/** IMP-90 (#90) u14 — emitted whenever the user reorders or hides a
|
||||
* slot in structure-mode. u15 will debounce + PUT to /api/user-
|
||||
* overrides; u14 only exposes the capture. SCOPE LOCK: inner shape is
|
||||
* `{slot_order, hidden_slots}` only (frame swap stays on `frames` axis). */
|
||||
onStructureEdit?: (zoneId: string, capture: StructureOverridePerZone) => void;
|
||||
}
|
||||
|
||||
const SLIDE_W = 1280;
|
||||
const SLIDE_H = 720;
|
||||
|
||||
// IMP-90 (#90) u11 — discriminated edit mode. Replaces the prior single
|
||||
// `isEditMode` boolean. u11 introduces the enum + the toolbar UI surface;
|
||||
// gesture gating (text contentEditable vs structure reorder vs image-zone
|
||||
// drag/resize) stays unified behind `isEditMode = editMode !== 'off'` so
|
||||
// existing behavior is preserved byte-identical. u12 will discriminate the
|
||||
// gestures per mode (mutually exclusive). The 'off' state is the no-edit
|
||||
// baseline; 'image-zone' bundles image edit (#79) + zone resize (#81)
|
||||
// because both are pointer-driven canvas gestures on slide geometry.
|
||||
export type EditMode = "off" | "text" | "structure" | "image-zone";
|
||||
export const EDIT_MODES: ReadonlyArray<EditMode> = ["text", "structure", "image-zone"];
|
||||
/** Pure helper — given the current edit mode and the user's requested mode,
|
||||
* return the next mode. Clicking the active mode toggles back to 'off';
|
||||
* clicking a different mode switches; explicit 'off' always exits. */
|
||||
export function nextEditMode(current: EditMode, requested: EditMode): EditMode {
|
||||
if (requested === "off") return "off";
|
||||
return current === requested ? "off" : requested;
|
||||
}
|
||||
|
||||
// IMP-90 (#90) u12 — per-mode gesture gating. Pure helper deriving the
|
||||
// boolean gates that drive SlideCanvas's useEffect branches (designMode
|
||||
// + iframe-side image click listener) and JSX conditionals (iframe
|
||||
// pointer-events, zone resize/drag affordances, image overlay). The
|
||||
// mapping enforces the mutually-exclusive contract from the issue body:
|
||||
// text -> contentEditable + iframe pointer-events:auto only.
|
||||
// structure -> nothing here; u14 will plant the structure overlay.
|
||||
// image-zone -> zone resize/drag + image overlay; iframe pe:auto so
|
||||
// in-iframe user-content images can be click-selected.
|
||||
// off -> every gate false (baseline).
|
||||
// pendingLayout fully suppresses every gate — mirrors the existing
|
||||
// useEffect (line ~248) that forces editMode='off' on pendingLayout
|
||||
// entry. The helper still defensively returns all-false so a stray
|
||||
// pendingLayout=true with a non-'off' editMode never leaks gestures.
|
||||
export interface EditModeGates {
|
||||
textEditing: boolean;
|
||||
imageSelection: boolean;
|
||||
iframePointerAuto: boolean;
|
||||
zoneGestures: boolean;
|
||||
imageOverlay: boolean;
|
||||
}
|
||||
export function computeEditModeGates(
|
||||
editMode: EditMode,
|
||||
isPendingLayout: boolean
|
||||
): EditModeGates {
|
||||
if (isPendingLayout) {
|
||||
return {
|
||||
textEditing: false,
|
||||
imageSelection: false,
|
||||
iframePointerAuto: false,
|
||||
zoneGestures: false,
|
||||
imageOverlay: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
textEditing: editMode === "text",
|
||||
imageSelection: editMode === "image-zone",
|
||||
iframePointerAuto: editMode === "text" || editMode === "image-zone",
|
||||
zoneGestures: editMode === "image-zone",
|
||||
imageOverlay: editMode === "image-zone",
|
||||
};
|
||||
}
|
||||
|
||||
// IMP-90 (#90) u13 — pure helper resolving a contentEditable focusout
|
||||
// target into (zoneId, textPath, value). data-text-path stamped by u8 at
|
||||
// Step 13; .zone[data-zone-position] from Phase Z slide-base. Non-stamped
|
||||
// targets return null so capture silently skips. u15 will debounce + PUT.
|
||||
export interface TextEditCaptureTarget {
|
||||
closest(selector: string): TextEditCaptureTarget | null;
|
||||
getAttribute(name: string): string | null;
|
||||
textContent: string | null;
|
||||
}
|
||||
export interface TextEditCapture {
|
||||
zoneId: string;
|
||||
textPath: string;
|
||||
value: string;
|
||||
}
|
||||
export function deriveTextEditCapture(
|
||||
target: TextEditCaptureTarget | null
|
||||
): TextEditCapture | null {
|
||||
if (!target) return null;
|
||||
const lineEl = target.closest("[data-text-path]");
|
||||
if (!lineEl) return null;
|
||||
const textPath = lineEl.getAttribute("data-text-path");
|
||||
if (!textPath) return null;
|
||||
const zoneEl = lineEl.closest(".zone[data-zone-position]");
|
||||
if (!zoneEl) return null;
|
||||
const zoneId = zoneEl.getAttribute("data-zone-position");
|
||||
if (!zoneId) return null;
|
||||
return { zoneId, textPath, value: (lineEl.textContent ?? "").trim() };
|
||||
}
|
||||
|
||||
export default function SlideCanvas({
|
||||
slidePlan,
|
||||
userSelection,
|
||||
finalHtmlUrl,
|
||||
slideOverrideCss,
|
||||
isPipelineRunning,
|
||||
isPendingLayout,
|
||||
pendingLayoutId,
|
||||
@@ -70,6 +197,11 @@ export default function SlideCanvas({
|
||||
onSlideClick,
|
||||
onSectionDrop,
|
||||
onZoneResize,
|
||||
imageOverrides,
|
||||
onImageResize,
|
||||
onTextEdit,
|
||||
structureOverrides,
|
||||
onStructureEdit,
|
||||
}: SlideCanvasProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
@@ -91,10 +223,36 @@ export default function SlideCanvas({
|
||||
// Step B : section drag-drop drop target. 사용자가 LeftMdxPanel 의 section 카드
|
||||
// 를 drag 해서 zone 에 drop 시 그 zone 에 section 할당. dragOver 시 강조 표시.
|
||||
const [dragOverZoneId, setDragOverZoneId] = useState<string | null>(null);
|
||||
// IMP-51 (#79) u8 — measured user-content image bboxes inside iframe
|
||||
// (slide-absolute percent of 1280×720, range 0–100). key = data-image-id
|
||||
// stamped by u4 (`src/image_id_stamper.py`). Populated in the iframe
|
||||
// onLoad measure block alongside measuredZones / measuredSlideBody.
|
||||
// Units intentionally match the persisted `image_overrides` axis (u3
|
||||
// typed client) and the u7 CSS injection so the overlay math has a
|
||||
// single coord space across measured/persisted/emitted values. Used as
|
||||
// the baseline geometry when no persisted override exists for that id;
|
||||
// `imageOverrides` prop (u11-fed) wins when present.
|
||||
const [measuredImages, setMeasuredImages] = useState<
|
||||
Record<string, { x: number; y: number; w: number; h: number }>
|
||||
>({});
|
||||
// IMP-51 (#79) u8 — currently selected user-content image id (= the one
|
||||
// whose drag/resize handles are shown). Set by the click-listener
|
||||
// installed inside the iframe contentDocument when edit mode is active.
|
||||
// Reset on finalHtmlUrl change and on edit-mode exit so stale ids never
|
||||
// leak across runs.
|
||||
const [selectedImageId, setSelectedImageId] = useState<string | null>(null);
|
||||
// HTML 편집 모드 — 글벗 패턴 (designMode + contentEditable + outline CSS) 차용.
|
||||
// 활성 시 iframe 안 텍스트 element 직접 클릭하여 수정 가능. backend 반영은 별 작업.
|
||||
// pendingLayout 과 배타적 (충돌 방지).
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
// IMP-90 (#90) u11 — `editMode` enum replaces the prior boolean. The
|
||||
// `isEditMode` shim is kept ONLY for the pendingLayout coupling +
|
||||
// zone-wrapper visual cues (border / hover / selected styling) that
|
||||
// fire whenever any edit mode is active. u12 routes gesture-activating
|
||||
// gates through `editGates` so text / structure / image-zone gestures
|
||||
// are mutually exclusive.
|
||||
const [editMode, setEditMode] = useState<EditMode>("off");
|
||||
const isEditMode = editMode !== "off";
|
||||
const editGates = computeEditModeGates(editMode, !!isPendingLayout);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
// 편집 모드 toggle 시 iframe contentDocument 에 글벗 패턴 적용 / 해제.
|
||||
@@ -118,7 +276,22 @@ export default function SlideCanvas({
|
||||
|
||||
const editableTags = ["DIV", "P", "H1", "H2", "H3", "H4", "SPAN", "LI", "TD", "TH", "FIGCAPTION"];
|
||||
let inputHandler: ((e: Event) => void) | null = null;
|
||||
if (isEditMode) {
|
||||
// IMP-90 (#90) u13 — focusout (= bubbling blur) emits one capture per
|
||||
// finished line edit; u15 will debounce + PUT.
|
||||
let textEditCaptureHandler: ((e: Event) => void) | null = null;
|
||||
// IMP-51 (#79) u8 — user-content image click listeners installed
|
||||
// inside the iframe contentDocument. Tracked here so the cleanup
|
||||
// callback can remove them when edit mode exits (or iframe reloads).
|
||||
const imageClickBindings: Array<{ el: HTMLImageElement; handler: (e: Event) => void; prevCursor: string; prevOutline: string }> = [];
|
||||
|
||||
// IMP-90 (#90) u12 — text-editing gate: only the 'text' editMode
|
||||
// turns designMode on + makes the editable tags contentEditable.
|
||||
// The else branch tears the prior state down so leaving text mode
|
||||
// (to structure / image-zone / off) immediately disables in-place
|
||||
// text editing — required for mutual exclusivity vs the image-zone
|
||||
// overlay's drag/resize gestures (a contentEditable cursor would
|
||||
// otherwise be placed by every image click).
|
||||
if (editGates.textEditing) {
|
||||
doc.designMode = "on";
|
||||
doc.querySelectorAll(".slide *").forEach((el) => {
|
||||
if (editableTags.includes((el as HTMLElement).tagName)) {
|
||||
@@ -130,6 +303,14 @@ export default function SlideCanvas({
|
||||
onContentEdit?.();
|
||||
};
|
||||
doc.addEventListener("input", inputHandler);
|
||||
|
||||
textEditCaptureHandler = (ev: Event) => {
|
||||
const cap = deriveTextEditCapture(
|
||||
ev.target as unknown as TextEditCaptureTarget | null
|
||||
);
|
||||
if (cap) onTextEdit?.(cap);
|
||||
};
|
||||
doc.addEventListener("focusout", textEditCaptureHandler);
|
||||
} else {
|
||||
doc.designMode = "off";
|
||||
doc.querySelectorAll("[contenteditable]").forEach((el) => {
|
||||
@@ -137,23 +318,103 @@ export default function SlideCanvas({
|
||||
});
|
||||
}
|
||||
|
||||
// IMP-90 (#90) u12 — image-selection gate: only the 'image-zone'
|
||||
// editMode wires the in-iframe user-content image click → selection.
|
||||
// Selector mirrors USER_CONTENT_IMAGE_SELECTOR in image_id_stamper.py
|
||||
// (requires data-image-id which the stamper always emits). Decorative
|
||||
// / frame imgs lacking the role attribute are NOT clickable. The
|
||||
// else branch clears `selectedImageId` so the React-side overlay
|
||||
// never lingers on a non-image-zone edit mode.
|
||||
if (editGates.imageSelection) {
|
||||
const imgEls = doc.querySelectorAll<HTMLImageElement>(
|
||||
'.slide img[data-image-role="user-content"][data-image-id]'
|
||||
);
|
||||
imgEls.forEach((imgEl) => {
|
||||
const imgId = imgEl.dataset.imageId;
|
||||
if (!imgId) return;
|
||||
const handler = (ev: Event) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
setSelectedImageId(imgId);
|
||||
};
|
||||
const prevCursor = imgEl.style.cursor;
|
||||
const prevOutline = imgEl.style.outline;
|
||||
imgEl.style.cursor = "pointer";
|
||||
imgEl.style.outline = "1px dashed rgba(16, 185, 129, 0.55)";
|
||||
imgEl.addEventListener("click", handler);
|
||||
imageClickBindings.push({ el: imgEl, handler, prevCursor, prevOutline });
|
||||
});
|
||||
} else {
|
||||
setSelectedImageId(null);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (inputHandler && doc) {
|
||||
doc.removeEventListener("input", inputHandler);
|
||||
}
|
||||
if (textEditCaptureHandler && doc) {
|
||||
doc.removeEventListener("focusout", textEditCaptureHandler);
|
||||
}
|
||||
imageClickBindings.forEach(({ el, handler, prevCursor, prevOutline }) => {
|
||||
el.removeEventListener("click", handler);
|
||||
el.style.cursor = prevCursor;
|
||||
el.style.outline = prevOutline;
|
||||
});
|
||||
};
|
||||
}, [isEditMode, finalHtmlUrl, onContentEdit]);
|
||||
}, [editGates.textEditing, editGates.imageSelection, finalHtmlUrl, onContentEdit, onTextEdit]);
|
||||
|
||||
// pendingLayout 진입 시 편집 모드 자동 OFF (충돌 방지).
|
||||
useEffect(() => {
|
||||
if (isPendingLayout && isEditMode) setIsEditMode(false);
|
||||
if (isPendingLayout && isEditMode) setEditMode("off");
|
||||
}, [isPendingLayout, isEditMode]);
|
||||
|
||||
// IMP-90 (#90) u14 — discover slot keys per zone for the structure
|
||||
// overlay. Source = iframe DOM `data-text-path="{slot_key}.{line_index}"`
|
||||
// attributes stamped by u8 (`src/text_path_stamper.py`). Unique slot_key
|
||||
// prefixes per `.zone[data-zone-position]` form the overlay's slot list.
|
||||
// Discovery runs only when entering structure mode (and resets on exit
|
||||
// or iframe reload) so off / text / image-zone modes never pay this
|
||||
// traversal cost.
|
||||
const [slotKeysByZone, setSlotKeysByZone] = useState<
|
||||
Record<string, string[]>
|
||||
>({});
|
||||
useEffect(() => {
|
||||
if (editMode !== "structure" || isPendingLayout) {
|
||||
setSlotKeysByZone({});
|
||||
return;
|
||||
}
|
||||
const doc = iframeRef.current?.contentDocument;
|
||||
if (!doc) return;
|
||||
const next: Record<string, string[]> = {};
|
||||
doc.querySelectorAll(".zone[data-zone-position]").forEach((zEl) => {
|
||||
const zoneId = (zEl as HTMLElement).getAttribute("data-zone-position");
|
||||
if (!zoneId) return;
|
||||
const seen = new Set<string>();
|
||||
const keys: string[] = [];
|
||||
zEl.querySelectorAll("[data-text-path]").forEach((lineEl) => {
|
||||
const path = (lineEl as HTMLElement).getAttribute("data-text-path");
|
||||
if (!path) return;
|
||||
const lastDot = path.lastIndexOf(".");
|
||||
const slotKey = lastDot > 0 ? path.slice(0, lastDot) : path;
|
||||
if (slotKey && !seen.has(slotKey)) {
|
||||
seen.add(slotKey);
|
||||
keys.push(slotKey);
|
||||
}
|
||||
});
|
||||
next[zoneId] = keys;
|
||||
});
|
||||
setSlotKeysByZone(next);
|
||||
}, [editMode, isPendingLayout, finalHtmlUrl]);
|
||||
|
||||
// finalHtmlUrl 이 바뀌면 (= 다른 run / 재실행) stale 측정값 reset.
|
||||
// 새 iframe 의 onLoad 가 발화하면서 measuredZones 다시 채움.
|
||||
useEffect(() => {
|
||||
setMeasuredZones({});
|
||||
setMeasuredSlideBody(null);
|
||||
// IMP-51 (#79) u8 — image measurements + selection are per-render;
|
||||
// drop both so the new iframe's onLoad starts clean.
|
||||
setMeasuredImages({});
|
||||
setSelectedImageId(null);
|
||||
}, [finalHtmlUrl]);
|
||||
|
||||
// 16:9 비율 유지하며 컨테이너에 통째로 fit (스크롤 X).
|
||||
@@ -186,6 +447,12 @@ export default function SlideCanvas({
|
||||
// 슬라이드 박스 표시 조건 — final.html 있거나 pendingLayout 모드.
|
||||
const showSlideBox = (finalHtmlUrl || isPendingLayout) && !isPipelineRunning;
|
||||
|
||||
// IMP-14 (Step 13 A-4) — backend slide_base.html 가 embedded vs standalone CSS
|
||||
// contract 를 `?embedded=1` query 로 소유. 기존 query string 보존하면서 flag 만 추가.
|
||||
const embeddedSrc = finalHtmlUrl
|
||||
? `${finalHtmlUrl}${finalHtmlUrl.includes("?") ? "&" : "?"}embedded=1`
|
||||
: undefined;
|
||||
|
||||
// wrapper 는 scaled 크기를 가지므로 layout 상 fit. 안의 슬라이드는 1280×720 으로
|
||||
// top-left origin scale 후 wrapper 안에 정확히 맞춤.
|
||||
const W_SCALED = SLIDE_W * scale;
|
||||
@@ -245,28 +512,50 @@ export default function SlideCanvas({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 편집 모드 toggle 버튼 — normal mode + final.html 있을 때만.
|
||||
글벗 패턴 차용 — designMode + contentEditable. backend 반영은 별 작업. */}
|
||||
{/* IMP-90 (#90) u11 — discriminated edit-mode toolbar.
|
||||
Replaces the prior single ✏ toggle. Three modes (text /
|
||||
structure / image-zone) are mutually exclusive; clicking the
|
||||
active mode toggles back to 'off'. Gesture gating per mode is
|
||||
u12 — u11 only plants the state + UI surface, so all three
|
||||
modes currently share the same `isEditMode` shim behavior. */}
|
||||
{!isPendingLayout && finalHtmlUrl && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsEditMode((p) => !p);
|
||||
}}
|
||||
className={`absolute top-2 right-2 z-30 text-[10px] font-bold uppercase tracking-tighter px-2.5 py-1 rounded shadow transition ${
|
||||
isEditMode
|
||||
? "bg-emerald-500 text-white hover:bg-emerald-600 ring-2 ring-emerald-200"
|
||||
: "bg-white text-slate-700 hover:bg-slate-100 border border-slate-200"
|
||||
}`}
|
||||
<div
|
||||
data-testid="edit-mode-toolbar"
|
||||
className="absolute top-2 right-2 z-30 flex gap-1"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title={
|
||||
isEditMode
|
||||
? "편집 모드 — 텍스트 클릭하여 수정. 다시 클릭하여 종료. (변경은 frontend 만, backend 반영 미구현)"
|
||||
: "텍스트 직접 편집 모드 진입"
|
||||
}
|
||||
>
|
||||
{isEditMode ? "✏ 편집 중 (클릭 종료)" : "✏ 편집"}
|
||||
</button>
|
||||
{EDIT_MODES.map((mode) => {
|
||||
const active = editMode === mode;
|
||||
const label =
|
||||
mode === "text" ? "✏ 텍스트" : mode === "structure" ? "▦ 구조" : "🖼 이미지/존";
|
||||
const title =
|
||||
mode === "text"
|
||||
? "텍스트 편집 — 텍스트 클릭하여 직접 수정"
|
||||
: mode === "structure"
|
||||
? "구조 편집 — slot 순서 / 숨김 변경 (u14 펜딩)"
|
||||
: "이미지/존 편집 — 이미지 드래그·리사이즈 + 존 리사이즈";
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
data-testid={`edit-mode-${mode}`}
|
||||
aria-pressed={active}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditMode((prev) => nextEditMode(prev, mode));
|
||||
}}
|
||||
className={`text-[10px] font-bold uppercase tracking-tighter px-2.5 py-1 rounded shadow transition ${
|
||||
active
|
||||
? "bg-emerald-500 text-white hover:bg-emerald-600 ring-2 ring-emerald-200"
|
||||
: "bg-white text-slate-700 hover:bg-slate-100 border border-slate-200"
|
||||
}`}
|
||||
title={title}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
@@ -283,46 +572,27 @@ export default function SlideCanvas({
|
||||
>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={finalHtmlUrl}
|
||||
src={embeddedSrc}
|
||||
title="Phase Z 렌더 결과"
|
||||
className="w-full h-full border-0 block"
|
||||
scrolling="no"
|
||||
sandbox="allow-same-origin"
|
||||
style={{ pointerEvents: isEditMode ? "auto" : "none" }}
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
// IMP-90 (#90) u12 — iframe pointer-events gate. 'text' needs
|
||||
// pe:auto so the user can click into text fields; 'image-zone'
|
||||
// needs pe:auto so user-content image clicks can reach the
|
||||
// in-iframe click handler that drives `selectedImageId`.
|
||||
// 'structure' and 'off' keep pe:none — structure has no
|
||||
// in-iframe gesture (u14 will overlay React-side controls).
|
||||
style={{ pointerEvents: editGates.iframePointerAuto ? "auto" : "none" }}
|
||||
onLoad={(e) => {
|
||||
// final.html 은 standalone 표시용으로 body 에 padding / flex center /
|
||||
// min-height: 100vh 가 있어서, iframe 안에서는 슬라이드가 잘림.
|
||||
// .slide (1280×720) 만 보이도록 reset CSS 를 contentDocument 에 주입.
|
||||
// IMP-14 (Step 13 A-4) — embedded vs standalone CSS reset 은 backend
|
||||
// slide_base.html 가 `?embedded=1` query 로 소유. frontend 가 더 이상
|
||||
// reset CSS 를 contentDocument 에 inject 하지 않음. embedded query 가
|
||||
// backend auto-mode detection script 를 trigger 해서 html.embedded
|
||||
// class 를 붙이고 standalone-only body 규칙을 reset.
|
||||
try {
|
||||
const doc = (e.currentTarget as HTMLIFrameElement).contentDocument;
|
||||
if (!doc) return;
|
||||
const style = doc.createElement("style");
|
||||
style.textContent = `
|
||||
html, body {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
min-height: 0 !important;
|
||||
height: 720px !important;
|
||||
width: 1280px !important;
|
||||
background: transparent !important;
|
||||
display: block !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
.slide {
|
||||
box-shadow: none !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
`;
|
||||
doc.head.appendChild(style);
|
||||
|
||||
// 2026-05-14 — slide-level override CSS (catalog/template 무변).
|
||||
// Home 이 mdx 별 default visual 보완 (bullet 간격 / zone 비율 등) 지정.
|
||||
if (slideOverrideCss && slideOverrideCss.trim()) {
|
||||
const overrideStyle = doc.createElement("style");
|
||||
overrideStyle.setAttribute("data-purpose", "slide-level-override");
|
||||
overrideStyle.textContent = slideOverrideCss;
|
||||
doc.head.appendChild(overrideStyle);
|
||||
}
|
||||
|
||||
// ── Zone DOM 측정 ──
|
||||
// backend final.html 의 .zone[data-zone-position="..."] 요소를
|
||||
@@ -361,6 +631,33 @@ export default function SlideCanvas({
|
||||
h: r.height / SLIDE_H,
|
||||
});
|
||||
}
|
||||
|
||||
// ── IMP-51 (#79) u8 — user-content image bbox 측정 ──
|
||||
// u4 stamper 가 부착한 data-image-id 가 있는 img 만 잡음
|
||||
// (decorative / frame img 제외). 측정 결과는 1280×720 기준
|
||||
// 슬라이드-절대 percent (0–100) — image_overrides axis (u3
|
||||
// 타입 + u7 CSS `left/top/width/height: {value}%` 주입) 와
|
||||
// 동일한 좌표계라서 측정 / 영구 저장 / emit 가 1:1 매칭됨.
|
||||
const imageEls = doc.querySelectorAll<HTMLImageElement>(
|
||||
'.slide img[data-image-role="user-content"][data-image-id]'
|
||||
);
|
||||
const measuredImg: Record<
|
||||
string,
|
||||
{ x: number; y: number; w: number; h: number }
|
||||
> = {};
|
||||
imageEls.forEach((imgEl) => {
|
||||
const id = imgEl.dataset.imageId;
|
||||
if (!id) return;
|
||||
const r = imgEl.getBoundingClientRect();
|
||||
if (r.width <= 0 || r.height <= 0) return;
|
||||
measuredImg[id] = {
|
||||
x: (r.left / SLIDE_W) * 100,
|
||||
y: (r.top / SLIDE_H) * 100,
|
||||
w: (r.width / SLIDE_W) * 100,
|
||||
h: (r.height / SLIDE_H) * 100,
|
||||
};
|
||||
});
|
||||
setMeasuredImages(measuredImg);
|
||||
} catch (err) {
|
||||
console.warn("[SlideCanvas] iframe inject/measure 실패:", err);
|
||||
}
|
||||
@@ -475,10 +772,11 @@ export default function SlideCanvas({
|
||||
const makeResizeHandler = (
|
||||
direction: ResizeDir
|
||||
) => (ev: React.MouseEvent<HTMLDivElement>) => {
|
||||
// resize 는 pendingLayout 모드에서만 — 첫 초안 (normal) 과 편집 모드에서는
|
||||
// frame HTML 이 reflow 못 해서 의미 없음. layout 변경 후 빈 layout 에서만
|
||||
// zone 자유 배치.
|
||||
if (!isPendingLayout || !onZoneResize) return;
|
||||
// resize 는 pendingLayout OR image-zone 편집 모드 활성. 2026-05-22
|
||||
// demo hot-fix — frame partial 에 @container aspect-ratio 회전이
|
||||
// 들어가서 fixed px 제약 사라짐. IMP-90 u12: text/structure 모드
|
||||
// 에서는 zone resize 비활성 (mutually exclusive per editGates).
|
||||
if ((!isPendingLayout && !editGates.zoneGestures) || !onZoneResize) return;
|
||||
if (!measuredSlideBody) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
@@ -495,6 +793,12 @@ export default function SlideCanvas({
|
||||
const affectsTop = direction === "top" || direction === "nw" || direction === "ne";
|
||||
const affectsBottom = direction === "bottom" || direction === "sw" || direction === "se";
|
||||
|
||||
// 2026-05-22 demo hot-fix — iframe 이 마우스 가로채서 mouseup leak 일어남
|
||||
// (편집 모드에서 iframe pointerEvents=auto). drag 동안 iframe 강제 none.
|
||||
const iframeEl = iframeRef.current;
|
||||
const prevIframePE = iframeEl ? iframeEl.style.pointerEvents : "";
|
||||
if (iframeEl) iframeEl.style.pointerEvents = "none";
|
||||
|
||||
const onMove = (mv: MouseEvent) => {
|
||||
const dx = (mv.clientX - startMouseX) / slideBodyWidthPx;
|
||||
const dy = (mv.clientY - startMouseY) / slideBodyHeightPx;
|
||||
@@ -521,6 +825,7 @@ export default function SlideCanvas({
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
if (iframeEl) iframeEl.style.pointerEvents = prevIframePE;
|
||||
};
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
@@ -542,7 +847,10 @@ export default function SlideCanvas({
|
||||
ev: React.MouseEvent<HTMLDivElement>
|
||||
) => {
|
||||
ev.stopPropagation();
|
||||
const canDrag = !!(isPendingLayout && measuredSlideBody && onZoneResize);
|
||||
// IMP-90 u12: zone drag is image-zone-mode-only (text /
|
||||
// structure suppress canDrag; non-zoneGestures click still
|
||||
// triggers onZoneClick via the !dragged branch on mouse-up).
|
||||
const canDrag = !!((isPendingLayout || editGates.zoneGestures) && measuredSlideBody && onZoneResize);
|
||||
const startMouseX = ev.clientX;
|
||||
const startMouseY = ev.clientY;
|
||||
const startGeom = { ...localGeom };
|
||||
@@ -553,25 +861,26 @@ export default function SlideCanvas({
|
||||
? H_SCALED * measuredSlideBody!.h
|
||||
: 1;
|
||||
let dragged = false;
|
||||
const dragThresholdPx = 5;
|
||||
|
||||
// 2026-05-22 demo hot-fix — same iframe pointer-events fix as makeResizeHandler.
|
||||
const iframeEl = iframeRef.current;
|
||||
const prevIframePE = iframeEl ? iframeEl.style.pointerEvents : "";
|
||||
if (iframeEl) iframeEl.style.pointerEvents = "none";
|
||||
|
||||
const onMove = (mv: MouseEvent) => {
|
||||
if (!canDrag) return;
|
||||
const dxPx = mv.clientX - startMouseX;
|
||||
const dyPx = mv.clientY - startMouseY;
|
||||
if (!dragged && Math.hypot(dxPx, dyPx) > dragThresholdPx) {
|
||||
if (!dragged && crossedDragThreshold(dxPx, dyPx)) {
|
||||
dragged = true;
|
||||
}
|
||||
if (dragged) {
|
||||
const dx = dxPx / slideBodyWidthPx;
|
||||
const dy = dyPx / slideBodyHeightPx;
|
||||
const newX = Math.max(
|
||||
0,
|
||||
Math.min(1 - startGeom.w, startGeom.x + dx)
|
||||
);
|
||||
const newY = Math.max(
|
||||
0,
|
||||
Math.min(1 - startGeom.h, startGeom.y + dy)
|
||||
const { x: newX, y: newY } = clampZoneMove(
|
||||
startGeom,
|
||||
dxPx,
|
||||
dyPx,
|
||||
slideBodyWidthPx,
|
||||
slideBodyHeightPx
|
||||
);
|
||||
onZoneResize!({
|
||||
[zone.zone_id]: {
|
||||
@@ -586,6 +895,7 @@ export default function SlideCanvas({
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
if (iframeEl) iframeEl.style.pointerEvents = prevIframePE;
|
||||
if (!dragged) {
|
||||
// 단순 click 으로 처리 — onZoneClick.
|
||||
onZoneClick?.(zone.id);
|
||||
@@ -612,6 +922,28 @@ export default function SlideCanvas({
|
||||
: null;
|
||||
const previewUrl = previewCandidate?.thumbnailUrl ?? null;
|
||||
|
||||
// IMP-11 u4: active frame lookup — distinct axis from preview.
|
||||
// preview is shown only when override differs from default; active is
|
||||
// always defined as override-if-present-else-default. Used by u5 to
|
||||
// compare the active frame's catalog min_height_px against zone height.
|
||||
const activeFrameId = overrideFrameId ?? defaultFrameId;
|
||||
const activeCandidate = activeFrameId
|
||||
? region?.frame_candidates?.find((c) => c.id === activeFrameId)
|
||||
: undefined;
|
||||
|
||||
// IMP-11 u5: catalog min_height_px violation hint. height is already
|
||||
// a fraction of SLIDE_H (1280x720 logical px coordinate space), so
|
||||
// logical px = height * SLIDE_H. measuredSlideBody.h is intentionally
|
||||
// not re-multiplied (double-apply would shrink the comparison value).
|
||||
// Hint is pendingLayout-only; resize clamp (minSize=0.05) is unchanged.
|
||||
const zoneHeightPx = isPendingLayout ? height * SLIDE_H : null;
|
||||
const minHeightPx = activeCandidate?.minHeightPx ?? null;
|
||||
const belowMinHeight =
|
||||
isPendingLayout &&
|
||||
minHeightPx != null &&
|
||||
zoneHeightPx != null &&
|
||||
zoneHeightPx < minHeightPx;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={zone.id}
|
||||
@@ -659,6 +991,8 @@ export default function SlideCanvas({
|
||||
} ${
|
||||
isDragOver
|
||||
? "border-4 border-emerald-500 bg-emerald-100/30 shadow-[0_0_0_4px_rgba(16,185,129,0.3)]"
|
||||
: isSelected && isEditMode
|
||||
? "border-2 border-emerald-500 bg-emerald-500/10 shadow-[0_0_0_2px_rgba(16,185,129,0.25)]"
|
||||
: isSelected && !isEditMode
|
||||
? "border-2 border-blue-500 bg-blue-500/10 shadow-[0_0_0_2px_rgba(59,130,246,0.2)]"
|
||||
: !isEditMode
|
||||
@@ -695,6 +1029,18 @@ export default function SlideCanvas({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* IMP-11 u5: red border + 'min H Npx' badge when zone height
|
||||
is below the active frame's catalog min_height_px. Visual
|
||||
hint only, no clamp/resize behavior change. */}
|
||||
{belowMinHeight && minHeightPx != null && (
|
||||
<>
|
||||
<div className="absolute inset-0 pointer-events-none border-2 border-red-500" />
|
||||
<span className="absolute bottom-1 right-1 text-[9px] font-black uppercase tracking-tighter px-1.5 py-0.5 rounded bg-red-500 text-white shadow pointer-events-none">
|
||||
min H {minHeightPx}px
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* zone 라벨 — 좌상단. 주 라벨 = section ids (S1, S1+S2),
|
||||
부 라벨 = backend zone position (top, bottom, primary). */}
|
||||
<div className="absolute top-1 left-1 flex items-center gap-1 pointer-events-none">
|
||||
@@ -723,11 +1069,14 @@ export default function SlideCanvas({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step C : zone resize handles — 8 방향. pendingLayout 모드만 활성
|
||||
(frame html 의 fixed px 디자인 한계로 첫 초안 / 편집 모드 resize 의미 X).
|
||||
{/* Step C : zone resize handles — 8 방향. pendingLayout OR image-zone
|
||||
편집 모드 활성. 2026-05-22 demo hot-fix — frame partial 에 @container
|
||||
aspect-ratio 회전 들어간 후 fixed px 제약 사라져 image-zone 모드 resize
|
||||
도 의미 있음. IMP-90 u12: text / structure 모드에서는 zone resize
|
||||
affordance 미노출 (editGates.zoneGestures = image-zone only).
|
||||
edge handle (top/bottom/left/right) : 한 boundary 이동
|
||||
corner handle (nw/ne/sw/se) : 두 boundary 동시. */}
|
||||
{isPendingLayout && onZoneResize && (
|
||||
{(isPendingLayout || editGates.zoneGestures) && onZoneResize && (
|
||||
<>
|
||||
{/* top edge */}
|
||||
<div
|
||||
@@ -795,9 +1144,291 @@ export default function SlideCanvas({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* IMP-54 u1: edit-mode body-drag gesture surfaces.
|
||||
wrapper sets pointerEvents:none in edit mode (see above) to
|
||||
preserve iframe text-edit clicks (A8 guardrail), so the
|
||||
wrapper-level handleZoneMouseDown is unreachable in edit mode.
|
||||
These 4 perimeter strips + top-left grip provide a separate
|
||||
pointer-event surface routing into handleZoneMouseDown.
|
||||
zIndex 25 sits BELOW the 8 resize handles (z-30) so resize
|
||||
gesture wins in overlap regions, and ABOVE the iframe so the
|
||||
strips intercept the perimeter while the un-covered iframe
|
||||
interior keeps text-edit reachability intact.
|
||||
pendingLayout mode already has wrapper pointerEvents:auto,
|
||||
so these surfaces are only needed in edit mode.
|
||||
IMP-90 u12: image-zone-mode-only — text / structure 모드는
|
||||
zone drag 안 함 (editGates.zoneGestures = false 두 모드 모두). */}
|
||||
{editGates.zoneGestures && !isPendingLayout && onZoneResize && (
|
||||
<>
|
||||
<div
|
||||
onMouseDown={handleZoneMouseDown}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute top-0 left-0 right-0 h-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
|
||||
style={{ pointerEvents: "auto", zIndex: 25 }}
|
||||
title="zone 이동 — 드래그"
|
||||
/>
|
||||
<div
|
||||
onMouseDown={handleZoneMouseDown}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute bottom-0 left-0 right-0 h-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
|
||||
style={{ pointerEvents: "auto", zIndex: 25 }}
|
||||
title="zone 이동 — 드래그"
|
||||
/>
|
||||
<div
|
||||
onMouseDown={handleZoneMouseDown}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute top-0 left-0 bottom-0 w-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
|
||||
style={{ pointerEvents: "auto", zIndex: 25 }}
|
||||
title="zone 이동 — 드래그"
|
||||
/>
|
||||
<div
|
||||
onMouseDown={handleZoneMouseDown}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute top-0 right-0 bottom-0 w-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
|
||||
style={{ pointerEvents: "auto", zIndex: 25 }}
|
||||
title="zone 이동 — 드래그"
|
||||
/>
|
||||
{/* visible grip affordance — placed below the section label
|
||||
(top-1 left-1 container) so the two don't overlap. */}
|
||||
<div
|
||||
onMouseDown={handleZoneMouseDown}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute top-7 left-1 w-3 h-3 bg-emerald-500/70 border border-emerald-700 rounded-full cursor-grab active:cursor-grabbing shadow hover:scale-125 transition"
|
||||
style={{ pointerEvents: "auto", zIndex: 25 }}
|
||||
title="zone 이동 — 드래그하여 위치 변경"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* IMP-90 (#90) u14 — structure edit overlay (slot reorder +
|
||||
hide). Renders only in `editMode === "structure"` over each
|
||||
measured zone, positioned at the zone's top-right inside the
|
||||
slide-absolute coord space. Slot keys come from u14 iframe
|
||||
traversal (`slotKeysByZone`). Mutations emit through
|
||||
onStructureEdit; u15 will debounce + PUT. */}
|
||||
{!isPendingLayout && editMode === "structure" && finalHtmlUrl &&
|
||||
slidePlan?.zones.map((zone) => {
|
||||
const m = measuredZones[zone.zone_id];
|
||||
if (!m) return null;
|
||||
const slotKeys = slotKeysByZone[zone.zone_id] ?? [];
|
||||
const current = structureOverrides?.[zone.zone_id];
|
||||
return (
|
||||
<div
|
||||
key={`struct-${zone.id}`}
|
||||
className="absolute z-30"
|
||||
style={{
|
||||
left: m.x * W_SCALED,
|
||||
top: m.y * H_SCALED,
|
||||
width: m.w * W_SCALED,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<StructureEditOverlay
|
||||
zoneId={zone.zone_id}
|
||||
slotKeys={slotKeys}
|
||||
current={current}
|
||||
onChange={onStructureEdit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* ── IMP-51 (#79) u8 — user-content image edit overlay ──
|
||||
Activates only in edit mode when an image_id appears in either
|
||||
`imageOverrides` (u11-fed persisted axis) or `measuredImages`
|
||||
(iframe-measured baseline). pendingLayout suppresses the image
|
||||
overlay so zone editing and image editing never compete for the
|
||||
same pointer events.
|
||||
|
||||
For every stamped user-content image we render a transparent
|
||||
wrapper at the image's slide-absolute coords. Wrapper picks up
|
||||
the body-drag gesture (move the image without resizing). When
|
||||
the image is the `selectedImageId` we additionally render 8
|
||||
resize handles. Aspect ratio is LOCKED on corner drags by
|
||||
default; holding Shift during the drag unlocks it (matches the
|
||||
issue contract "corner_resize_ratio_default_locked_shift_unlock").
|
||||
|
||||
Coordinate space: slide-absolute percent (0–100) throughout —
|
||||
measured / persisted / emitted values share the same units as
|
||||
the u7 CSS injector (`left/top/width/height: {value}%`) and the
|
||||
u3 typed-client `ImageOverride` contract. CSS values are
|
||||
written verbatim ({geom.x}%, no scale factor) and pixel deltas
|
||||
from MouseEvent are converted to percent via
|
||||
`(dx_px / W_SCALED) * 100` so the round-trip drag → save →
|
||||
re-render produces identical geometry. IMP-51 (#79) u9 moved
|
||||
the resize / move math to `clampImagePercentGeometry` in
|
||||
`slideCanvasDragMath.ts` so the boundary contract Codex #16
|
||||
verified is exercised directly by vitest (mirror of how IMP-54
|
||||
u3 split the zone math out of SlideCanvas). */}
|
||||
{/* IMP-90 u12: image overlay is image-zone-mode-only. text /
|
||||
structure 모드에서는 image drag/resize affordance 미노출
|
||||
(editGates.imageOverlay = false). pendingLayout 도 동일하게
|
||||
suppress (computeEditModeGates 가 모두 false 반환). */}
|
||||
{!isPendingLayout && editGates.imageOverlay && finalHtmlUrl && onImageResize &&
|
||||
Object.entries({ ...measuredImages, ...(imageOverrides ?? {}) }).map(
|
||||
([imageId]) => {
|
||||
const persisted = imageOverrides?.[imageId];
|
||||
const measured = measuredImages[imageId];
|
||||
// override 우선; 없으면 measured baseline. 둘 다 없으면 skip.
|
||||
const geom = persisted ?? measured;
|
||||
if (!geom) return null;
|
||||
const isSelected = selectedImageId === imageId;
|
||||
|
||||
const beginDrag = (
|
||||
ev: React.MouseEvent<HTMLDivElement>,
|
||||
direction: ImageDragDirection
|
||||
) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setSelectedImageId(imageId);
|
||||
const startMouseX = ev.clientX;
|
||||
const startMouseY = ev.clientY;
|
||||
const startGeom = { ...geom };
|
||||
|
||||
// 2026-05-22 demo hot-fix parity — iframe 이 마우스 가로
|
||||
// 채서 mouseup leak 일어남 (편집 모드에서 pe=auto).
|
||||
const iframeEl = iframeRef.current;
|
||||
const prevIframePE = iframeEl ? iframeEl.style.pointerEvents : "";
|
||||
if (iframeEl) iframeEl.style.pointerEvents = "none";
|
||||
|
||||
const isCorner =
|
||||
direction === "nw" ||
|
||||
direction === "ne" ||
|
||||
direction === "sw" ||
|
||||
direction === "se";
|
||||
|
||||
const onMove = (mv: MouseEvent) => {
|
||||
// Convert pixel delta on the on-screen scaled slide
|
||||
// back into percent-of-slide so all downstream math
|
||||
// shares the persisted axis's coord space. W_SCALED /
|
||||
// H_SCALED already include the wrapper scale factor,
|
||||
// so dividing then multiplying by 100 gives a stable
|
||||
// value regardless of viewport zoom.
|
||||
const dx = ((mv.clientX - startMouseX) / W_SCALED) * 100;
|
||||
const dy = ((mv.clientY - startMouseY) / H_SCALED) * 100;
|
||||
// IMP-51 (#79) u9 — boundary contract lives in the
|
||||
// pure helper so vitest can verify it directly.
|
||||
// Aspect lock is default on for corner handles and
|
||||
// released when Shift is held.
|
||||
const aspectLocked = isCorner && !mv.shiftKey;
|
||||
const next = clampImagePercentGeometry(
|
||||
startGeom,
|
||||
dx,
|
||||
dy,
|
||||
direction,
|
||||
aspectLocked,
|
||||
IMAGE_RESIZE_MIN_SIZE_PERCENT,
|
||||
);
|
||||
onImageResize(imageId, next);
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
if (iframeEl) iframeEl.style.pointerEvents = prevIframePE;
|
||||
};
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`img-overlay-${imageId}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-image-overlay-id={imageId}
|
||||
onMouseDown={(ev) => beginDrag(ev, "move")}
|
||||
className={`absolute z-30 ${
|
||||
isSelected
|
||||
? "border-2 border-emerald-500 bg-emerald-500/5 shadow-[0_0_0_2px_rgba(16,185,129,0.25)]"
|
||||
: "border border-dashed border-emerald-400/60 hover:border-emerald-500"
|
||||
} cursor-grab active:cursor-grabbing`}
|
||||
style={{
|
||||
left: `${geom.x}%`,
|
||||
top: `${geom.y}%`,
|
||||
width: `${geom.w}%`,
|
||||
height: `${geom.h}%`,
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
title={
|
||||
isSelected
|
||||
? "이미지 이동 — 드래그 / 모서리 핸들 = 크기 (Shift = 비율 해제)"
|
||||
: "클릭하여 선택"
|
||||
}
|
||||
>
|
||||
<span className="absolute top-1 left-1 text-[9px] font-black uppercase tracking-tighter px-1.5 py-0.5 rounded bg-emerald-600/90 text-white shadow pointer-events-none">
|
||||
IMG
|
||||
</span>
|
||||
|
||||
{isSelected && (
|
||||
<>
|
||||
{/* edges */}
|
||||
<div
|
||||
onMouseDown={(ev) => beginDrag(ev, "top")}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute -top-1 left-1/4 w-1/2 h-2 bg-emerald-500/70 hover:bg-emerald-500 rounded cursor-ns-resize z-40 transition shadow"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title="상단"
|
||||
/>
|
||||
<div
|
||||
onMouseDown={(ev) => beginDrag(ev, "bottom")}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute -bottom-1 left-1/4 w-1/2 h-2 bg-emerald-500/70 hover:bg-emerald-500 rounded cursor-ns-resize z-40 transition shadow"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title="하단"
|
||||
/>
|
||||
<div
|
||||
onMouseDown={(ev) => beginDrag(ev, "left")}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute top-1/4 -left-1 h-1/2 w-2 bg-emerald-500/70 hover:bg-emerald-500 rounded cursor-ew-resize z-40 transition shadow"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title="좌측"
|
||||
/>
|
||||
<div
|
||||
onMouseDown={(ev) => beginDrag(ev, "right")}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute top-1/4 -right-1 h-1/2 w-2 bg-emerald-500/70 hover:bg-emerald-500 rounded cursor-ew-resize z-40 transition shadow"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title="우측"
|
||||
/>
|
||||
{/* corners — aspect locked by default, Shift unlocks */}
|
||||
<div
|
||||
onMouseDown={(ev) => beginDrag(ev, "nw")}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute -top-1 -left-1 w-3 h-3 bg-white border-2 border-emerald-500 rounded-sm cursor-nwse-resize z-40 hover:scale-125 transition shadow"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title="좌상단 (Shift = 비율 해제)"
|
||||
/>
|
||||
<div
|
||||
onMouseDown={(ev) => beginDrag(ev, "ne")}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute -top-1 -right-1 w-3 h-3 bg-white border-2 border-emerald-500 rounded-sm cursor-nesw-resize z-40 hover:scale-125 transition shadow"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title="우상단 (Shift = 비율 해제)"
|
||||
/>
|
||||
<div
|
||||
onMouseDown={(ev) => beginDrag(ev, "sw")}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute -bottom-1 -left-1 w-3 h-3 bg-white border-2 border-emerald-500 rounded-sm cursor-nesw-resize z-40 hover:scale-125 transition shadow"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title="좌하단 (Shift = 비율 해제)"
|
||||
/>
|
||||
<div
|
||||
onMouseDown={(ev) => beginDrag(ev, "se")}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="absolute -bottom-1 -right-1 w-4 h-4 bg-white border-2 border-emerald-500 rounded-sm cursor-nwse-resize z-40 hover:scale-125 transition shadow"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
title="우하단 (Shift = 비율 해제)"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
165
Front/client/src/components/StructureEditOverlay.tsx
Normal file
165
Front/client/src/components/StructureEditOverlay.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* IMP-90 (#90) u14 — Structure edit overlay.
|
||||
*
|
||||
* React component + pure helpers that present a per-zone slot list with
|
||||
* reorder (↑ / ↓) and hide (👁 / 🚫) affordances. Mounted by SlideCanvas
|
||||
* when `editMode === "structure"`. Emits a `StructureOverridePerZone`
|
||||
* tuple `{slot_order, hidden_slots}` through `onChange`; u15 will debounce
|
||||
* + PUT this to `/api/user-overrides` (NOT u14 scope), and u16 reads the
|
||||
* persisted axis at the next CLI generate run.
|
||||
*
|
||||
* SCOPE LOCK (binding contract):
|
||||
* - inner shape = `{slot_order, hidden_slots}` ONLY.
|
||||
* - frame swap stays on the existing `frames` axis (u6 backend resolver
|
||||
* rejects frame-swap-shaped inner keys).
|
||||
* - per-slot text content NEVER mutated here — `text_overrides` axis
|
||||
* (u4/u5/u13) handles that exclusively.
|
||||
*
|
||||
* The exported pure helpers (`resolveEffectiveSlotOrder`, `moveItem`) are
|
||||
* the unit's vitest surface; React rendering is NOT tested because the
|
||||
* Front package devDependencies do not include jsdom / @testing-library
|
||||
* (verified by u11/u12/u13 test pattern).
|
||||
*/
|
||||
import type {
|
||||
StructureOverridePerZone,
|
||||
} from "../services/userOverridesApi";
|
||||
|
||||
export interface StructureEditOverlayProps {
|
||||
zoneId: string;
|
||||
/** Discovered slot keys for this zone (e.g. from iframe DOM
|
||||
* `data-text-path` prefixes). Order = backend default. */
|
||||
slotKeys: ReadonlyArray<string>;
|
||||
/** Current persisted override (or undefined). `slot_order` reorders the
|
||||
* discovered keys; missing keys keep backend order at the tail. */
|
||||
current?: StructureOverridePerZone;
|
||||
/** Emitted on every user mutation. u15 wires this to autosave. */
|
||||
onChange?: (zoneId: string, next: StructureOverridePerZone) => void;
|
||||
}
|
||||
|
||||
/** Apply `slot_order` override to the discovered slot list. Unknown
|
||||
* override entries are dropped; missing discovered keys are appended in
|
||||
* backend order so the user never loses a slot by partial-override. */
|
||||
export function resolveEffectiveSlotOrder(
|
||||
slotKeys: ReadonlyArray<string>,
|
||||
slotOrder?: ReadonlyArray<string> | null,
|
||||
): string[] {
|
||||
if (!slotOrder || slotOrder.length === 0) return [...slotKeys];
|
||||
const allowed = new Set(slotKeys);
|
||||
const seen = new Set<string>();
|
||||
const ordered: string[] = [];
|
||||
for (const k of slotOrder) {
|
||||
if (typeof k === "string" && allowed.has(k) && !seen.has(k)) {
|
||||
ordered.push(k);
|
||||
seen.add(k);
|
||||
}
|
||||
}
|
||||
for (const k of slotKeys) {
|
||||
if (!seen.has(k)) ordered.push(k);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
/** Move `arr[index]` by `delta` positions. Out-of-range returns a fresh
|
||||
* copy of the input (defensive: caller can always treat the result as a
|
||||
* new reference). */
|
||||
export function moveItem<T>(
|
||||
arr: ReadonlyArray<T>,
|
||||
index: number,
|
||||
delta: number,
|
||||
): T[] {
|
||||
const next = arr.slice();
|
||||
const target = index + delta;
|
||||
if (
|
||||
index < 0 ||
|
||||
index >= next.length ||
|
||||
target < 0 ||
|
||||
target >= next.length
|
||||
) {
|
||||
return next;
|
||||
}
|
||||
const tmp = next[index];
|
||||
next[index] = next[target];
|
||||
next[target] = tmp;
|
||||
return next;
|
||||
}
|
||||
|
||||
export default function StructureEditOverlay({
|
||||
zoneId,
|
||||
slotKeys,
|
||||
current,
|
||||
onChange,
|
||||
}: StructureEditOverlayProps) {
|
||||
const effective = resolveEffectiveSlotOrder(slotKeys, current?.slot_order);
|
||||
const hidden = new Set(current?.hidden_slots ?? []);
|
||||
const emit = (nextOrder: string[], nextHidden: Set<string>) => {
|
||||
onChange?.(zoneId, {
|
||||
slot_order: nextOrder,
|
||||
hidden_slots: Array.from(nextHidden),
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div
|
||||
data-testid={`structure-overlay-${zoneId}`}
|
||||
className="bg-white/95 border border-emerald-300 rounded shadow p-2 flex flex-col gap-1 text-[10px]"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
>
|
||||
<div className="font-bold uppercase tracking-wider text-emerald-700 mb-1">
|
||||
▦ {zoneId}
|
||||
</div>
|
||||
{effective.length === 0 ? (
|
||||
<div className="text-slate-400 italic">slot 없음</div>
|
||||
) : (
|
||||
effective.map((key, i) => (
|
||||
<div
|
||||
key={key}
|
||||
data-testid={`slot-${zoneId}-${key}`}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<span
|
||||
className={`flex-1 truncate ${
|
||||
hidden.has(key) ? "text-slate-400 line-through" : "text-slate-700"
|
||||
}`}
|
||||
>
|
||||
{key}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`slot-up-${zoneId}-${key}`}
|
||||
disabled={i === 0}
|
||||
onClick={() => emit(moveItem(effective, i, -1), hidden)}
|
||||
className="px-1 rounded border border-slate-200 disabled:opacity-30 hover:bg-slate-100"
|
||||
title="위로"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`slot-down-${zoneId}-${key}`}
|
||||
disabled={i === effective.length - 1}
|
||||
onClick={() => emit(moveItem(effective, i, 1), hidden)}
|
||||
className="px-1 rounded border border-slate-200 disabled:opacity-30 hover:bg-slate-100"
|
||||
title="아래로"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`slot-hide-${zoneId}-${key}`}
|
||||
aria-pressed={hidden.has(key)}
|
||||
onClick={() => {
|
||||
const nh = new Set(hidden);
|
||||
if (nh.has(key)) nh.delete(key);
|
||||
else nh.add(key);
|
||||
emit(effective, nh);
|
||||
}}
|
||||
className="px-1 rounded border border-slate-200 hover:bg-slate-100"
|
||||
title={hidden.has(key) ? "표시" : "숨김"}
|
||||
>
|
||||
{hidden.has(key) ? "🚫" : "👁"}
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
225
Front/client/src/components/slideCanvasDragMath.test.ts
Normal file
225
Front/client/src/components/slideCanvasDragMath.test.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
// IMP-54 u4 — vitest coverage for the pure drag-math helpers extracted in u3
|
||||
// (`Front/client/src/components/slideCanvasDragMath.ts`).
|
||||
//
|
||||
// Stage 2 contract (`Stage 2 Exit Report → implementation_units → u4`):
|
||||
// • Threshold pass/fail at 5 px (strict `Math.hypot > 5`).
|
||||
// • Clamp negative delta to 0 on both axes.
|
||||
// • Clamp max-edge delta to `1 - startGeom.w` (x) and `1 - startGeom.h` (y).
|
||||
//
|
||||
// The helpers are pure (no React, no DOM) so we drive them directly with
|
||||
// numeric inputs — no fake timers, no fetch stubs, no component mount.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DRAG_THRESHOLD_PX,
|
||||
IMAGE_RESIZE_MIN_SIZE_PERCENT,
|
||||
clampImagePercentGeometry,
|
||||
clampZoneMove,
|
||||
crossedDragThreshold,
|
||||
type ImagePercentGeom,
|
||||
type ZoneFracGeom,
|
||||
} from "./slideCanvasDragMath";
|
||||
|
||||
describe("DRAG_THRESHOLD_PX", () => {
|
||||
it("is 5", () => {
|
||||
expect(DRAG_THRESHOLD_PX).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("crossedDragThreshold", () => {
|
||||
it("returns false for zero movement (still a click)", () => {
|
||||
expect(crossedDragThreshold(0, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false just below threshold — 3,4 → hypot 5 with strict >", () => {
|
||||
expect(crossedDragThreshold(3, 4)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false at exactly the threshold along each axis", () => {
|
||||
// strict inequality: Math.hypot(5, 0) === 5, not > 5
|
||||
expect(crossedDragThreshold(5, 0)).toBe(false);
|
||||
expect(crossedDragThreshold(0, 5)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true once distance exceeds threshold", () => {
|
||||
expect(crossedDragThreshold(4, 4)).toBe(true); // hypot ≈ 5.6568
|
||||
expect(crossedDragThreshold(6, 0)).toBe(true);
|
||||
expect(crossedDragThreshold(0, 6)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats negative deltas symmetrically (Euclidean distance)", () => {
|
||||
expect(crossedDragThreshold(-3, -4)).toBe(false);
|
||||
expect(crossedDragThreshold(-4, -4)).toBe(true);
|
||||
expect(crossedDragThreshold(-6, 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampZoneMove", () => {
|
||||
// 1000 × 1000 slide body so 1 px == 0.001 frac — keeps the arithmetic
|
||||
// exact and the boundary deltas (1000 px) round-trip back to `1 - w/h`.
|
||||
const W = 1000;
|
||||
const H = 1000;
|
||||
const baseGeom: ZoneFracGeom = { x: 0.1, y: 0.2, w: 0.3, h: 0.4 };
|
||||
|
||||
it("applies in-bounds delta as startGeom + (dPx / slideBodySize)", () => {
|
||||
expect(clampZoneMove(baseGeom, 100, 50, W, H)).toEqual({
|
||||
x: 0.2,
|
||||
y: 0.25,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps negative delta to 0 on both axes", () => {
|
||||
expect(clampZoneMove(baseGeom, -1000, -1000, W, H)).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps max-edge delta to (1 - w) on x and (1 - h) on y", () => {
|
||||
expect(clampZoneMove(baseGeom, 1000, 1000, W, H)).toEqual({
|
||||
x: 1 - baseGeom.w, // 0.7
|
||||
y: 1 - baseGeom.h, // 0.6
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps the two axes independently (negative x, in-bounds y)", () => {
|
||||
expect(clampZoneMove(baseGeom, -1000, 50, W, H)).toEqual({
|
||||
x: 0,
|
||||
y: 0.25,
|
||||
});
|
||||
});
|
||||
|
||||
it("honours non-square slide bodies via per-axis division", () => {
|
||||
// dxPx 100 / 500 = 0.2 fr; dyPx 100 / 250 = 0.4 fr (hits the y boundary).
|
||||
// x is checked with toBeCloseTo because 0.1 + 0.2 is the canonical IEEE-754
|
||||
// floating-point trap (0.30000000000000004) — the clamp logic is correct,
|
||||
// it just inherits JS number precision. y stays exact since it clamps to
|
||||
// the boundary `1 - h`.
|
||||
const result = clampZoneMove(baseGeom, 100, 100, 500, 250);
|
||||
expect(result.x).toBeCloseTo(0.3, 10);
|
||||
expect(result.y).toBe(1 - baseGeom.h); // 0.6
|
||||
});
|
||||
|
||||
it("returns only { x, y } — width / height are preserved by the caller", () => {
|
||||
const out = clampZoneMove(baseGeom, 0, 0, W, H);
|
||||
expect(out).toEqual({ x: 0.1, y: 0.2 });
|
||||
expect("w" in out).toBe(false);
|
||||
expect("h" in out).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// IMP-51 (#79) u9 — image overlay resize / move math.
|
||||
// Boundary contract (must match the inline u8 math Codex #16 verified):
|
||||
// • slide-bound invariant — x+w ≤ 100 ∧ y+h ≤ 100 for ALL valid inputs,
|
||||
// including small-near-edge geoms where the existing minSize floor
|
||||
// would otherwise have pushed past the slide bound.
|
||||
// • aspect-locked corner — baseAspect = startGeom.w / startGeom.h is
|
||||
// preserved exactly; the wFloor uses `min(minSize, maxW, maxH*baseAspect)`
|
||||
// so a floor application never violates either axis.
|
||||
// The two concrete Codex #15 reproductions are encoded explicitly below
|
||||
// so a future regression on the boundary math fails this suite directly.
|
||||
describe("IMAGE_RESIZE_MIN_SIZE_PERCENT", () => {
|
||||
it("is 2 (percent of slide bbox)", () => {
|
||||
expect(IMAGE_RESIZE_MIN_SIZE_PERCENT).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampImagePercentGeometry", () => {
|
||||
const baseGeom: ImagePercentGeom = { x: 10, y: 10, w: 20, h: 10 };
|
||||
|
||||
describe("direction = 'move'", () => {
|
||||
it("translates and clamps both axes; preserves w/h", () => {
|
||||
expect(
|
||||
clampImagePercentGeometry(baseGeom, 5, 7, "move", false),
|
||||
).toEqual({ x: 15, y: 17, w: 20, h: 10 });
|
||||
});
|
||||
|
||||
it("clamps negative deltas to (0, 0)", () => {
|
||||
expect(
|
||||
clampImagePercentGeometry(baseGeom, -1000, -1000, "move", false),
|
||||
).toEqual({ x: 0, y: 0, w: 20, h: 10 });
|
||||
});
|
||||
|
||||
it("clamps max-edge deltas to (100 - w, 100 - h)", () => {
|
||||
expect(
|
||||
clampImagePercentGeometry(baseGeom, 1000, 1000, "move", false),
|
||||
).toEqual({ x: 80, y: 90, w: 20, h: 10 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge resize — independent per-axis clamp", () => {
|
||||
it("right edge clamps width to 100 - startGeom.x", () => {
|
||||
const out = clampImagePercentGeometry(baseGeom, 1000, 0, "right", false);
|
||||
expect(out).toEqual({ x: 10, y: 10, w: 90, h: 10 });
|
||||
expect(out.x + out.w).toBeLessThanOrEqual(100);
|
||||
});
|
||||
|
||||
it("left drag dx=-100 emits {x:0,y:10,w:30,h:10} (Codex regression)", () => {
|
||||
// From Codex #15 / #16 verification — ordinary left drag past the
|
||||
// slide edge should pin x at 0 and grow w by the original x amount.
|
||||
expect(
|
||||
clampImagePercentGeometry(baseGeom, -100, 0, "left", false),
|
||||
).toEqual({ x: 0, y: 10, w: 30, h: 10 });
|
||||
});
|
||||
|
||||
it("near-edge right resize keeps x + w ≤ 100 (Codex #15 reproduction)", () => {
|
||||
// Pre-fix: minSize=2 floor applied AFTER span clamp would emit
|
||||
// {x:99, w:2} so x+w=101. Post-fix: floor caps at maxW=1.
|
||||
const start: ImagePercentGeom = { x: 99, y: 10, w: 0.5, h: 10 };
|
||||
const out = clampImagePercentGeometry(start, 1, 0, "right", false);
|
||||
expect(out).toEqual({ x: 99, y: 10, w: 1, h: 10 });
|
||||
expect(out.x + out.w).toBe(100);
|
||||
});
|
||||
|
||||
it("top/bottom edges are symmetric to left/right", () => {
|
||||
const bottom = clampImagePercentGeometry(baseGeom, 0, 1000, "bottom", false);
|
||||
expect(bottom).toEqual({ x: 10, y: 10, w: 20, h: 90 });
|
||||
const top = clampImagePercentGeometry(baseGeom, 0, -100, "top", false);
|
||||
expect(top).toEqual({ x: 10, y: 0, w: 20, h: 20 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("corner resize — aspect locked (default Shift-off)", () => {
|
||||
it("NW drag dx=-100,dy=-100 emits {x:0,y:5,w:30,h:15} (Codex regression)", () => {
|
||||
// From Codex #16 verification — aspect-locked NW past the slide
|
||||
// edge: rightEdge=30, bottomEdge=20, baseAspect=2. Independent
|
||||
// clamps give x=0,w=30,y=0,h=20. Aspect block then picks the
|
||||
// limiting axis: newH = 30/2 = 15 (≤20). Re-anchor: y = 20 - 15 = 5.
|
||||
expect(
|
||||
clampImagePercentGeometry(baseGeom, -100, -100, "nw", true),
|
||||
).toEqual({ x: 0, y: 5, w: 30, h: 15 });
|
||||
});
|
||||
|
||||
it("tiny near-corner NE resize stays within bounds (Codex #15 reproduction)", () => {
|
||||
// Pre-fix: dual-axis minSize floor would emit w=2, h=2 with
|
||||
// re-anchor pushing x+w past 100. Post-fix: wFloor caps at
|
||||
// min(2, maxW=1, maxH*baseAspect=1) = 1, so newW=1, newH=1.
|
||||
const start: ImagePercentGeom = { x: 99, y: 99, w: 0.5, h: 0.5 };
|
||||
const out = clampImagePercentGeometry(start, 1, -1, "ne", true);
|
||||
expect(out).toEqual({ x: 99, y: 98.5, w: 1, h: 1 });
|
||||
expect(out.x + out.w).toBeLessThanOrEqual(100);
|
||||
expect(out.y + out.h).toBeLessThanOrEqual(100);
|
||||
});
|
||||
|
||||
it("preserves baseAspect exactly when the floor is hit", () => {
|
||||
// 2:1 aspect ratio (w=20, h=10); large negative drag past edges
|
||||
// hits wFloor. newW/newH ratio must equal baseAspect.
|
||||
const out = clampImagePercentGeometry(
|
||||
baseGeom, -1000, -1000, "nw", true,
|
||||
);
|
||||
expect(out.w / out.h).toBeCloseTo(baseGeom.w / baseGeom.h, 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("corner resize — Shift unlock (independent edges)", () => {
|
||||
it("SE without aspect lock degenerates to right + bottom edges", () => {
|
||||
const corner = clampImagePercentGeometry(baseGeom, 1000, 1000, "se", false);
|
||||
const sides = clampImagePercentGeometry(
|
||||
clampImagePercentGeometry(baseGeom, 1000, 0, "right", false),
|
||||
0, 1000, "bottom", false,
|
||||
);
|
||||
expect(corner).toEqual(sides);
|
||||
});
|
||||
});
|
||||
});
|
||||
201
Front/client/src/components/slideCanvasDragMath.ts
Normal file
201
Front/client/src/components/slideCanvasDragMath.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
// IMP-54 u3 — pure drag math extracted from SlideCanvas.tsx
|
||||
// `handleZoneMouseDown` (`Front/client/src/components/SlideCanvas.tsx:537-598`).
|
||||
//
|
||||
// Resize math (`makeResizeHandler` at SlideCanvas.tsx:465-523) is intentionally
|
||||
// NOT touched — it has its own independent geometry model (per-side
|
||||
// `affectsLeft/Right/Top/Bottom`, `minSize`, `1 - startGeom.x/y` cap) that
|
||||
// must not regress.
|
||||
//
|
||||
// Two responsibilities live here:
|
||||
//
|
||||
// 1. Drag-vs-click classification — a pointer must travel more than
|
||||
// `DRAG_THRESHOLD_PX` (Euclidean distance from the mousedown origin)
|
||||
// before mousedown→mousemove is treated as a drag. Below the
|
||||
// threshold the gesture stays a click, which the caller surfaces as
|
||||
// `onZoneClick(zone.id)` in `onUp`.
|
||||
//
|
||||
// 2. Pixel-delta → slide-body fraction conversion plus clamp to keep the
|
||||
// moved zone fully inside the slide body. Width/height are preserved
|
||||
// verbatim by this helper — only `x` and `y` move.
|
||||
//
|
||||
// Both helpers are pure (no React, no DOM, no side effects) so vitest can
|
||||
// drive them directly. The numeric contract is the inline behavior that
|
||||
// existed before the extraction; this file is a relocation, not a behavior
|
||||
// change.
|
||||
|
||||
export const DRAG_THRESHOLD_PX = 5;
|
||||
|
||||
// IMP-51 (#79) u9 — image overlay resize / move math extracted from
|
||||
// SlideCanvas.tsx `beginDrag` onMove (lines 1092–1219 of the u8 patch).
|
||||
// Slide-absolute percent coordinate space (0–100 on both axes), matching
|
||||
// the persisted `image_overrides` axis (`src/user_overrides_io.py` u1
|
||||
// KNOWN_AXES) and the typed client `ImageOverride` shape (`userOverridesApi.ts`
|
||||
// u3). The math is the contract Codex #16 verified post-u8 — this file
|
||||
// is a relocation, not a behavior change. SlideCanvas calls it from a
|
||||
// single hook so future tweaks need to update one place + the vitest
|
||||
// suite alongside.
|
||||
export const IMAGE_RESIZE_MIN_SIZE_PERCENT = 2;
|
||||
|
||||
/** Image overlay geometry in slide-absolute percent (each component ∈ [0, 100]).
|
||||
* Mirrors `ImageOverride` from `services/userOverridesApi.ts` (u3) so this
|
||||
* shape moves end-to-end through stamper → overlay → persisted axis. */
|
||||
export interface ImagePercentGeom {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export type ImageDragDirection =
|
||||
| "move"
|
||||
| "left"
|
||||
| "right"
|
||||
| "top"
|
||||
| "bottom"
|
||||
| "nw"
|
||||
| "ne"
|
||||
| "sw"
|
||||
| "se";
|
||||
|
||||
/** Apply a percent-space drag delta to `startGeom` per `direction` and clamp.
|
||||
*
|
||||
* Contract (must match the inline u8 math Codex #16 verified):
|
||||
* • `direction === "move"` → translate only; w/h preserved verbatim;
|
||||
* x/y clamped to `[0, 100 - w]` and `[0, 100 - h]`.
|
||||
* • Edge handle (`left|right|top|bottom`) → one axis only; opposite
|
||||
* edge pinned so x+w ≤ 100 and y+h ≤ 100 hold.
|
||||
* • Corner handle (`nw|ne|sw|se`) with `aspectLocked=false` → two
|
||||
* independent edges (same per-edge clamp as above).
|
||||
* • Corner handle with `aspectLocked=true` → preserves
|
||||
* `baseAspect = startGeom.w / startGeom.h`; the pinned-opposite-corner
|
||||
* stays fixed; the floored axis is `w` and `h` is re-derived so the
|
||||
* aspect ratio is exact even at the minSize floor.
|
||||
*
|
||||
* `minSize` is best-effort: when the available span (e.g. `100 - startGeom.x`
|
||||
* for `affectsRight`) is below `minSize`, the floor caps at the span itself
|
||||
* so the slide-bound invariant (x+w ≤ 100 ∧ y+h ≤ 100) is never violated.
|
||||
* Pure / deterministic / no DOM access — vitest drives it directly. */
|
||||
export function clampImagePercentGeometry(
|
||||
startGeom: ImagePercentGeom,
|
||||
dxPercent: number,
|
||||
dyPercent: number,
|
||||
direction: ImageDragDirection,
|
||||
aspectLocked: boolean,
|
||||
minSize: number = IMAGE_RESIZE_MIN_SIZE_PERCENT,
|
||||
): ImagePercentGeom {
|
||||
if (direction === "move") {
|
||||
const x = Math.max(0, Math.min(100 - startGeom.w, startGeom.x + dxPercent));
|
||||
const y = Math.max(0, Math.min(100 - startGeom.h, startGeom.y + dyPercent));
|
||||
return { x, y, w: startGeom.w, h: startGeom.h };
|
||||
}
|
||||
|
||||
const affectsLeft =
|
||||
direction === "left" || direction === "nw" || direction === "sw";
|
||||
const affectsRight =
|
||||
direction === "right" || direction === "ne" || direction === "se";
|
||||
const affectsTop =
|
||||
direction === "top" || direction === "nw" || direction === "ne";
|
||||
const affectsBottom =
|
||||
direction === "bottom" || direction === "sw" || direction === "se";
|
||||
const isCorner =
|
||||
direction === "nw" ||
|
||||
direction === "ne" ||
|
||||
direction === "sw" ||
|
||||
direction === "se";
|
||||
|
||||
const rightEdge = startGeom.x + startGeom.w;
|
||||
const bottomEdge = startGeom.y + startGeom.h;
|
||||
let x = startGeom.x;
|
||||
let y = startGeom.y;
|
||||
let w = startGeom.w;
|
||||
let h = startGeom.h;
|
||||
|
||||
if (affectsRight) {
|
||||
const maxW = 100 - startGeom.x;
|
||||
const floor = Math.min(minSize, maxW);
|
||||
w = Math.max(floor, Math.min(maxW, startGeom.w + dxPercent));
|
||||
}
|
||||
if (affectsBottom) {
|
||||
const maxH = 100 - startGeom.y;
|
||||
const floor = Math.min(minSize, maxH);
|
||||
h = Math.max(floor, Math.min(maxH, startGeom.h + dyPercent));
|
||||
}
|
||||
if (affectsLeft) {
|
||||
const floor = Math.min(minSize, rightEdge);
|
||||
x = Math.max(0, Math.min(rightEdge - floor, startGeom.x + dxPercent));
|
||||
w = rightEdge - x;
|
||||
}
|
||||
if (affectsTop) {
|
||||
const floor = Math.min(minSize, bottomEdge);
|
||||
y = Math.max(0, Math.min(bottomEdge - floor, startGeom.y + dyPercent));
|
||||
h = bottomEdge - y;
|
||||
}
|
||||
|
||||
if (isCorner && aspectLocked) {
|
||||
const baseAspect =
|
||||
startGeom.w > 0 && startGeom.h > 0 ? startGeom.w / startGeom.h : 1;
|
||||
if (baseAspect > 0) {
|
||||
const maxW = affectsLeft ? rightEdge : 100 - startGeom.x;
|
||||
const maxH = affectsTop ? bottomEdge : 100 - startGeom.y;
|
||||
let newW = w;
|
||||
let newH = newW / baseAspect;
|
||||
if (newH > maxH) {
|
||||
newH = maxH;
|
||||
newW = newH * baseAspect;
|
||||
}
|
||||
if (newW > maxW) {
|
||||
newW = maxW;
|
||||
newH = newW / baseAspect;
|
||||
}
|
||||
const wFloor = Math.min(minSize, maxW, maxH * baseAspect);
|
||||
if (newW < wFloor) {
|
||||
newW = wFloor;
|
||||
newH = newW / baseAspect;
|
||||
}
|
||||
w = newW;
|
||||
h = newH;
|
||||
x = affectsLeft ? rightEdge - w : startGeom.x;
|
||||
y = affectsTop ? bottomEdge - h : startGeom.y;
|
||||
}
|
||||
}
|
||||
|
||||
return { x, y, w, h };
|
||||
}
|
||||
|
||||
/** Returns true once the pointer has travelled far enough from the mousedown
|
||||
* origin to be treated as a drag rather than a click. */
|
||||
export function crossedDragThreshold(dxPx: number, dyPx: number): boolean {
|
||||
return Math.hypot(dxPx, dyPx) > DRAG_THRESHOLD_PX;
|
||||
}
|
||||
|
||||
/** Zone geometry in slide-body fraction space (each component ∈ [0, 1]).
|
||||
* Mirrors the shape the SlideCanvas pipeline already uses for
|
||||
* `localGeom` / `overrideGeom` / `onZoneResize` payloads. */
|
||||
export interface ZoneFracGeom {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** Convert a pixel-space drag delta into a slide-body fraction delta, apply
|
||||
* it to `startGeom.{x, y}`, and clamp so the zone never escapes the slide
|
||||
* body (`x ∈ [0, 1 - w]`, `y ∈ [0, 1 - h]`). `w` and `h` are not modified.
|
||||
*
|
||||
* The caller (`SlideCanvas.tsx` `handleZoneMouseDown` onMove) guarantees
|
||||
* `slideBodyWidthPx > 0` and `slideBodyHeightPx > 0` via the
|
||||
* `measuredSlideBody` precondition, so this helper does not re-guard
|
||||
* divide-by-zero. */
|
||||
export function clampZoneMove(
|
||||
startGeom: ZoneFracGeom,
|
||||
dxPx: number,
|
||||
dyPx: number,
|
||||
slideBodyWidthPx: number,
|
||||
slideBodyHeightPx: number,
|
||||
): { x: number; y: number } {
|
||||
const dx = dxPx / slideBodyWidthPx;
|
||||
const dy = dyPx / slideBodyHeightPx;
|
||||
const x = Math.max(0, Math.min(1 - startGeom.w, startGeom.x + dx));
|
||||
const y = Math.max(0, Math.min(1 - startGeom.h, startGeom.y + dy));
|
||||
return { x, y };
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* Home - 메인 페이지 (Zone-Centric 슬라이드 빌더)
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect } from "react";
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { DesignAgentState, LayoutPresetId, Zone } from "../types/designAgent";
|
||||
import {
|
||||
@@ -15,22 +15,37 @@ import {
|
||||
getSelectedRegion,
|
||||
moveSectionToZone,
|
||||
saveZoneSizes,
|
||||
saveImageOverride,
|
||||
saveTextOverride,
|
||||
saveStructureOverride,
|
||||
deriveUserOverridesKey,
|
||||
applyPersistedNonFrameOverrides,
|
||||
remapPersistedFramesToZoneFrames,
|
||||
validateZoneGeometriesAgainstLayout,
|
||||
} from "../utils/slidePlanUtils";
|
||||
import {
|
||||
parseMdxFile,
|
||||
runPipeline,
|
||||
loadRun,
|
||||
computeZonePositions,
|
||||
formatAiRepairHumanReviewMessage,
|
||||
type RunMeta,
|
||||
type PipelineOverrides,
|
||||
} from "../services/designAgentApi";
|
||||
import {
|
||||
flushUserOverrides,
|
||||
getUserOverrides,
|
||||
saveUserOverrides,
|
||||
type UserOverrides,
|
||||
} from "../services/userOverridesApi";
|
||||
|
||||
import LeftMdxPanel from "../components/LeftMdxPanel";
|
||||
import SlideCanvas from "../components/SlideCanvas";
|
||||
import LayoutPanel from "../components/LayoutPanel";
|
||||
import FramePanel from "../components/FramePanel";
|
||||
import BottomActions from "../components/BottomActions";
|
||||
import {
|
||||
Sparkles, Download, Link2, Loader2,
|
||||
Sparkles, Loader2,
|
||||
CheckCircle2, HelpCircle,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -62,6 +77,14 @@ export default function Home() {
|
||||
// section drag drop + frame 선택). null 이면 평소 모드 (final.html 표시).
|
||||
const [pendingLayout, setPendingLayout] = useState<LayoutPresetId | null>(null);
|
||||
|
||||
// IMP-52 u6 — restore-on-reopen: persisted user_overrides.json fetched at
|
||||
// handleFileUpload time. layout / zone_geometries / zone_sections are
|
||||
// seeded into userSelection immediately (so handleGenerate forwards them
|
||||
// as CLI args). frames are stashed here because their on-disk key
|
||||
// (unit_id = section_ids joined by "+") only maps to region.id after
|
||||
// loadRun rebuilds the slidePlan — see handleGenerate post-loadRun.
|
||||
const persistedOverridesRef = useRef<Partial<UserOverrides>>({});
|
||||
|
||||
// pendingLayout 활성 시 effective slidePlan = pendingZones 가 swap 된 plan.
|
||||
// 그 외 = default state.slidePlan. 모든 zone / region lookup (handleFrameSelect /
|
||||
// getSelectedZone / SlideCanvas) 이 일관되게 이 effectiveSlidePlan 사용.
|
||||
@@ -135,6 +158,31 @@ export default function Home() {
|
||||
}
|
||||
carriedZoneSections[targetPos].push(...zone.section_ids);
|
||||
});
|
||||
// IMP-44 (#73) u4 — clear in-memory zone_geometries on layout flip.
|
||||
// The persisted keys were valid for the *prior* preset; carrying them
|
||||
// forward into the new preset would either trip the u1/u2 backend
|
||||
// [override-warning] guards (foreign keys dropped, override_applied
|
||||
// forced back to None) or partially apply on shared keys. Drop them
|
||||
// up-front so the new layout starts from a clean even-split baseline,
|
||||
// and persist a clear sentinel (null) so a subsequent reopen does not
|
||||
// resurrect the stale snapshot from user_overrides.json.
|
||||
const priorGeoms = p.userSelection.overrides.zone_geometries;
|
||||
const hadPriorGeoms =
|
||||
priorGeoms && typeof priorGeoms === "object" && Object.keys(priorGeoms).length > 0;
|
||||
if (p.uploadedFile && hadPriorGeoms) {
|
||||
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||
void saveUserOverrides(key, { zone_geometries: null });
|
||||
}
|
||||
// IMP-55 (#93) u12 — persist the marker reset to disk so a stale
|
||||
// `manual_section_assignment: true` from a prior drag (written via
|
||||
// u6's co-PUT) cannot survive the layout apply. The in-memory reset
|
||||
// on line 192 protects the current session, but a page reload would
|
||||
// re-seed from disk via u3's restore branch and re-arm the u7 gate.
|
||||
// Unconditional — apply always resets, independent of hadPriorGeoms.
|
||||
if (p.uploadedFile) {
|
||||
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||
void saveUserOverrides(key, { manual_section_assignment: false });
|
||||
}
|
||||
return {
|
||||
...p,
|
||||
userSelection: {
|
||||
@@ -143,6 +191,18 @@ export default function Home() {
|
||||
...p.userSelection.overrides,
|
||||
layout_preset: layoutId,
|
||||
zone_sections: carriedZoneSections,
|
||||
zone_geometries: {},
|
||||
// IMP-55 (#93) u5 — reset the bool intent marker to `false` on
|
||||
// layout apply. `carriedZoneSections` above is auto-carry (old
|
||||
// zone.section_ids → new layout positions), NOT user drag-drop
|
||||
// intent. Without this explicit reset the spread of
|
||||
// `...p.userSelection.overrides` would carry a prior-drag `true`
|
||||
// into the new layout, causing handleGenerate (u7) to forward
|
||||
// auto-carried assignments as user overrides and re-trigger the
|
||||
// PARTIAL_COVERAGE regression. The marker flips back to `true`
|
||||
// only when the user actually drag-drops a section in the new
|
||||
// layout (u6 handleSectionDrop).
|
||||
manual_section_assignment: false,
|
||||
},
|
||||
selectedZoneId: null,
|
||||
selectedRegionId: null,
|
||||
@@ -157,10 +217,27 @@ export default function Home() {
|
||||
// pending 모드 취소 → 평소 (final.html iframe) 모드 복귀.
|
||||
const handleCancelPendingLayout = useCallback(() => {
|
||||
setPendingLayout(null);
|
||||
setState((p) => ({
|
||||
...p,
|
||||
userSelection: createInitialUserSelection(p.slidePlan),
|
||||
}));
|
||||
setState((p) => {
|
||||
// IMP-55 (#93) u12 — persist marker=false to disk on cancel. In-memory
|
||||
// the u3 seed via createInitialUserSelection already pins false (u5
|
||||
// contract), but if a prior drag-drop wrote `true` to disk via u6's
|
||||
// co-PUT, that value would survive a reopen and re-arm the u7
|
||||
// forwarding gate on the next page load. Symmetric with the apply
|
||||
// path's disk PUT above.
|
||||
if (p.uploadedFile) {
|
||||
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||
void saveUserOverrides(key, { manual_section_assignment: false });
|
||||
}
|
||||
return {
|
||||
...p,
|
||||
// IMP-55 (#93) u5 — cancel discards all pending overrides via
|
||||
// `createInitialUserSelection`, whose u3 seed pins
|
||||
// `manual_section_assignment: false`. In-memory reset is implicit
|
||||
// via the seed; u12 adds the disk-side PUT above to keep persisted
|
||||
// state consistent so a reopen does not re-arm the marker.
|
||||
userSelection: createInitialUserSelection(p.slidePlan),
|
||||
};
|
||||
});
|
||||
setHasPendingChanges(false);
|
||||
}, []);
|
||||
|
||||
@@ -179,7 +256,19 @@ export default function Home() {
|
||||
|
||||
try {
|
||||
const content = await parseMdxFile(file);
|
||||
setState((p) => ({ ...p, normalizedContent: content, isLoading: false }));
|
||||
// IMP-52 u6 — restore-on-reopen. Key = MDX stem (matches backend
|
||||
// u2 fallback's Path(args.mdx_path).stem). getUserOverrides returns
|
||||
// {} on miss / corrupt / network failure (u5 contract) so the upload
|
||||
// path never fails on a fresh MDX.
|
||||
const overridesKey = deriveUserOverridesKey(file.name);
|
||||
const persisted = await getUserOverrides(overridesKey);
|
||||
persistedOverridesRef.current = persisted;
|
||||
setState((p) => ({
|
||||
...p,
|
||||
normalizedContent: content,
|
||||
userSelection: applyPersistedNonFrameOverrides(p.userSelection, persisted),
|
||||
isLoading: false,
|
||||
}));
|
||||
toast.success(`"${file.name}" 분석 완료 — 하단 버튼으로 슬라이드 생성하세요.`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -194,22 +283,6 @@ export default function Home() {
|
||||
// 호출되는 단일 callback. handleFileUpload 가 자동 분석 trigger.
|
||||
const [selectedSample, setSelectedSample] = useState<"03" | "04" | "05" | null>(null);
|
||||
|
||||
// 2026-05-14 — mdx 별 slide-level CSS override (catalog/template 무변, frontend layer only).
|
||||
// SlideCanvas 의 iframe onLoad 에서 동적 inject. 사용자 룰 : "보고용 슬라이드 결과물 단위"
|
||||
// 변경. mdx04 의 default (rank 1 = process_product_two_way) 일 때만 적용 — 사용자 frame
|
||||
// override 후 (rank 2 = bim_dx_comparison_table 등) 다른 frame 시 무적용.
|
||||
const MDX04_DEFAULT_OVERRIDE_CSS = `
|
||||
.slide-body {
|
||||
grid-template-rows: 0.38fr 0.60fr !important;
|
||||
gap: 1.5% !important;
|
||||
}
|
||||
.f29b__cell .text-line + .text-line { margin-top: 1px !important; }
|
||||
.f29b__cell:nth-child(n+3) {
|
||||
padding-top: 3px !important;
|
||||
margin-top: 2px !important;
|
||||
}
|
||||
`.trim();
|
||||
|
||||
const handleSelectSample = useCallback(async (which: "03" | "04" | "05") => {
|
||||
try {
|
||||
const res = await fetch(`/api/sample-mdx?mdx=${encodeURIComponent(which)}`);
|
||||
@@ -256,9 +329,11 @@ export default function Home() {
|
||||
const overrides: PipelineOverrides = {};
|
||||
const sourcePlan = effectiveSlidePlan;
|
||||
if (sourcePlan && state.slidePlan) {
|
||||
const defaultLayout = state.slidePlan.layout_preset;
|
||||
// 2026-05-22 demo hot-fix — 이전 비교 가드 (default !== override) 제거.
|
||||
// restore loop 이 default = override 로 sync 시 override 안 보내고 backend
|
||||
// default fallback 발생. user 가 명시한 layout 이 있으면 무조건 보냄.
|
||||
const overrideLayout = state.userSelection.overrides.layout_preset;
|
||||
if (overrideLayout && overrideLayout !== defaultLayout) {
|
||||
if (overrideLayout) {
|
||||
overrides.layout = overrideLayout;
|
||||
}
|
||||
const frames: Record<string, string> = {};
|
||||
@@ -296,9 +371,70 @@ export default function Home() {
|
||||
|
||||
// zone-geometry override — backend 의 build_layout_css 에 전달 (horizontal-2 /
|
||||
// vertical-2 만 적용). zone_id (top/bottom/...) → slide-body 내부 0~1 비율.
|
||||
// IMP-44 (#73) u4 — validate against the active layout *before* the
|
||||
// round-trip so foreign-preset keys never reach the backend. Mirrors
|
||||
// the u1/u2 WARN+DROP guards on the frontend side: dropped keys surface
|
||||
// as a toast (so the user knows why their resize "vanished"), and only
|
||||
// the `kept` subset is forwarded. The active layout = the layout the
|
||||
// backend will use, which is `overrides.layout` when the user has set
|
||||
// one, else the default slidePlan preset (mirrors backend resolution).
|
||||
const zoneGeometries = state.userSelection.overrides.zone_geometries;
|
||||
if (zoneGeometries && Object.keys(zoneGeometries).length > 0) {
|
||||
overrides.zoneGeometries = zoneGeometries;
|
||||
const activeLayout = overrides.layout ?? sourcePlan.layout_preset;
|
||||
const validation = validateZoneGeometriesAgainstLayout(
|
||||
zoneGeometries,
|
||||
activeLayout,
|
||||
);
|
||||
if (Object.keys(validation.dropped).length > 0) {
|
||||
toast.error(
|
||||
`zone_geometries layout-mismatch: dropped ${Object.keys(validation.dropped).join(", ")} (expected ${validation.expectedPositions.join(", ") || "—"}; layout=${activeLayout}).`,
|
||||
);
|
||||
}
|
||||
if (Object.keys(validation.kept).length > 0) {
|
||||
overrides.zoneGeometries = validation.kept;
|
||||
}
|
||||
}
|
||||
|
||||
// IMP-55 (#93) u7 — Replace the IMP-08 B-3 self-compare with the bool
|
||||
// `manual_section_assignment` intent marker gate. The prior code built
|
||||
// `defaultByZone` from `sourcePlan.zones` and compared against the
|
||||
// user's `overrides.zone_sections`, but `sourcePlan === effectiveSlidePlan`
|
||||
// (Home.tsx:305) and `effectiveSlidePlan.zones === pendingZones`
|
||||
// (Home.tsx:649), which is itself derived from
|
||||
// `state.userSelection.overrides.zone_sections` via slidePlanUtils.ts.
|
||||
// The comparison was degenerate (user input vs itself), so real drag-drop
|
||||
// swaps were classified `sameAsDefault` and silently dropped from
|
||||
// `overrides.zoneSections` — the exact regression IMP-55 fixes.
|
||||
// - true → forward `zone_sections` filtered to zone_ids that exist in
|
||||
// `sourcePlan.zones` (cross-layout safety so foreign zone keys from a
|
||||
// stale persisted layout never reach backend `--override-section-
|
||||
// assignment`). u6 is the SOLE setter of true (real drag-drop).
|
||||
// - false → skip. Backend determines assignment from its own default
|
||||
// policy. u3 seeds false on first load, u5 resets false on layout
|
||||
// apply auto-carry, u12 persists false so a stale disk `true` cannot
|
||||
// survive a reopen-after-apply window.
|
||||
// No `sameAsDefault` heuristic — the marker is the source of intent.
|
||||
const manualMarker =
|
||||
state.userSelection.overrides.manual_section_assignment;
|
||||
if (manualMarker === true) {
|
||||
const userZoneSections = state.userSelection.overrides.zone_sections;
|
||||
if (userZoneSections) {
|
||||
const validZoneIds = new Set(
|
||||
sourcePlan.zones.map((z) => z.zone_id),
|
||||
);
|
||||
const zoneSectionsForward: Record<string, string[]> = {};
|
||||
for (const [zoneId, sids] of Object.entries(userZoneSections)) {
|
||||
if (!validZoneIds.has(zoneId)) continue;
|
||||
if (!Array.isArray(sids)) continue;
|
||||
const cleaned = sids.filter(
|
||||
(s) => typeof s === "string" && s.trim(),
|
||||
);
|
||||
zoneSectionsForward[zoneId] = cleaned;
|
||||
}
|
||||
if (Object.keys(zoneSectionsForward).length > 0) {
|
||||
overrides.zoneSections = zoneSectionsForward;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,6 +446,8 @@ export default function Home() {
|
||||
? `(overrides: ${[
|
||||
overrides.layout && `layout=${overrides.layout}`,
|
||||
overrides.frames && `frames=${Object.keys(overrides.frames).length}`,
|
||||
overrides.zoneSections &&
|
||||
`zoneSections=${Object.keys(overrides.zoneSections).length}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ")})`
|
||||
@@ -317,6 +455,20 @@ export default function Home() {
|
||||
toast.info(`Phase Z 파이프라인 실행 중... ${overrideSummary}`);
|
||||
|
||||
try {
|
||||
// IMP-52 u10 — Force-commit any pending debounced PUTs before backend
|
||||
// reads user_overrides.json on pipeline entry. Without this, a user
|
||||
// who changes an override (300ms debounce window) and immediately
|
||||
// clicks Generate would race the PUT against /api/run; the u2
|
||||
// fallback could then load a stale persisted document.
|
||||
await flushUserOverrides();
|
||||
// IMP-42 u4 — unconditional DIAG console.log on the handleGenerate
|
||||
// entry-to-backend boundary. Surfaces the override payload + uploaded
|
||||
// file name so the user can see exactly what crossed the wire when
|
||||
// the pipeline fails silently. No env gate (silence is the bug).
|
||||
console.log("[DIAG raw overrides]", {
|
||||
file: state.uploadedFile.name,
|
||||
overrides,
|
||||
});
|
||||
const result = await runPipeline(state.uploadedFile, overrides);
|
||||
|
||||
if (!result.success || !result.final_html_exists) {
|
||||
@@ -330,15 +482,46 @@ export default function Home() {
|
||||
}
|
||||
|
||||
const { normalizedContent, slidePlan, runMeta } = await loadRun(result.run_id);
|
||||
setState((p) => ({
|
||||
...p,
|
||||
normalizedContent,
|
||||
// IMP-52 u6 — post-loadRun frame remap. persistedOverridesRef holds
|
||||
// the user_overrides.json read at handleFileUpload time. Frames there
|
||||
// are keyed by unit_id (section_ids joined by "+"); the in-memory
|
||||
// zone_frames is keyed by region.id. Remap against the new slidePlan
|
||||
// zones so SlideCanvas's override-vs-default preview indicator shows
|
||||
// the user's persisted choice without forcing them to re-click.
|
||||
const restoredZoneFrames = remapPersistedFramesToZoneFrames(
|
||||
slidePlan,
|
||||
userSelection: createInitialUserSelection(slidePlan),
|
||||
isLoading: false,
|
||||
}));
|
||||
persistedOverridesRef.current.frames as Record<string, string> | undefined,
|
||||
);
|
||||
setState((p) => {
|
||||
// IMP-52 u6 — restore-on-reopen: re-layer the persisted non-frame
|
||||
// axes (layout / zone_geometries / zone_sections) onto the post-load
|
||||
// `base`. `createInitialUserSelection` rebuilds from slidePlan and
|
||||
// drops anything the backend fallback could not round-trip through
|
||||
// a CLI arg — `zone_geometries` in particular has no slidePlan
|
||||
// representation, so without this merge the user would see their
|
||||
// resized zones revert on every Generate.
|
||||
const base = applyPersistedNonFrameOverrides(
|
||||
createInitialUserSelection(slidePlan),
|
||||
persistedOverridesRef.current,
|
||||
);
|
||||
return {
|
||||
...p,
|
||||
normalizedContent,
|
||||
slidePlan,
|
||||
userSelection: {
|
||||
...base,
|
||||
overrides: {
|
||||
...base.overrides,
|
||||
zone_frames: { ...base.overrides.zone_frames, ...restoredZoneFrames },
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
};
|
||||
});
|
||||
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(
|
||||
@@ -346,16 +529,46 @@ export default function Home() {
|
||||
);
|
||||
setState((p) => ({ ...p, isLoading: false }));
|
||||
}
|
||||
}, [state.uploadedFile]);
|
||||
}, [state.uploadedFile, state.slidePlan, state.userSelection, pendingZones, pendingLayout]);
|
||||
|
||||
// ── 섹션 드래그 앤 드롭 (Zone으로 재배치) ──
|
||||
const handleSectionDrop = useCallback((sectionId: string, zoneId: string) => {
|
||||
setState((p) => {
|
||||
const newSelection = moveSectionToZone(p.userSelection, sectionId, zoneId);
|
||||
return {
|
||||
...p,
|
||||
userSelection: selectZone(newSelection, zoneId) // 이동된 존 자동 선택
|
||||
const zoneSelected = selectZone(newSelection, zoneId); // 이동된 존 자동 선택
|
||||
// IMP-55 (#93) u6 — flip the bool intent marker to `true` on real
|
||||
// user drag-drop. Inverse of the u5 reset (layout apply/cancel
|
||||
// auto-carry → false). handleGenerate (u7) gates `overrides.zoneSections`
|
||||
// forwarding on this marker, so an unflipped drop would never reach
|
||||
// the backend (the IMP-55 self-compare regression). The marker is
|
||||
// flipped BEFORE persistence so the in-memory selection and the
|
||||
// co-PUT body stay in sync atomically.
|
||||
const finalSelection = {
|
||||
...zoneSelected,
|
||||
overrides: {
|
||||
...zoneSelected.overrides,
|
||||
manual_section_assignment: true,
|
||||
},
|
||||
};
|
||||
// IMP-52 u7 — persist the post-drop zone_sections snapshot. The on-disk
|
||||
// schema axis (`zone_sections`) shares the in-memory shape (zone_id →
|
||||
// section_ids), so we forward the full mutated value; the u4 PUT path
|
||||
// replaces this axis atomically while preserving the foreign axes.
|
||||
// p.uploadedFile gate skips persistence before any MDX is loaded —
|
||||
// the demo-mode initial render path would otherwise PUT to the empty
|
||||
// key. saveUserOverrides is debounced (300ms) and per-key coalesced.
|
||||
// IMP-55 (#93) u6 — co-PUT `manual_section_assignment: true` in the
|
||||
// SAME body so the disk file never has the post-drop zone_sections
|
||||
// without the marker (would otherwise look like an unmotivated
|
||||
// IMP-52 zone_sections write to the u9 backend fallback).
|
||||
if (p.uploadedFile) {
|
||||
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||
void saveUserOverrides(key, {
|
||||
zone_sections: finalSelection.overrides.zone_sections,
|
||||
manual_section_assignment: true,
|
||||
});
|
||||
}
|
||||
return { ...p, userSelection: finalSelection };
|
||||
});
|
||||
setRightTab("frame");
|
||||
setHasPendingChanges(true);
|
||||
@@ -381,10 +594,18 @@ export default function Home() {
|
||||
|
||||
// ── Layout 선택 ──
|
||||
const handleLayoutSelect = useCallback((layoutId: string) => {
|
||||
setState((p) => ({
|
||||
...p,
|
||||
userSelection: applyLayout(p.userSelection, layoutId as LayoutPresetId)
|
||||
}));
|
||||
setState((p) => {
|
||||
const newSelection = applyLayout(p.userSelection, layoutId as LayoutPresetId);
|
||||
// IMP-52 u7 — persist the selected layout preset id. The on-disk
|
||||
// `layout` axis is a single string; `applyLayout` validates the
|
||||
// preset id before mutating the selection, so the value here is
|
||||
// already the LayoutPresetId we want to round-trip.
|
||||
if (p.uploadedFile) {
|
||||
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||
void saveUserOverrides(key, { layout: layoutId });
|
||||
}
|
||||
return { ...p, userSelection: newSelection };
|
||||
});
|
||||
setHasPendingChanges(true);
|
||||
}, []);
|
||||
|
||||
@@ -397,22 +618,64 @@ export default function Home() {
|
||||
}, []);
|
||||
|
||||
const handleZoneResize = useCallback((geometries: Record<string, { x: number; y: number; w: number; h: number }>) => {
|
||||
setState((p) => ({
|
||||
...p,
|
||||
userSelection: {
|
||||
...p.userSelection,
|
||||
overrides: {
|
||||
...p.userSelection.overrides,
|
||||
zone_geometries: {
|
||||
...p.userSelection.overrides.zone_geometries,
|
||||
...geometries
|
||||
}
|
||||
}
|
||||
setState((p) => {
|
||||
const mergedGeometries = {
|
||||
...p.userSelection.overrides.zone_geometries,
|
||||
...geometries,
|
||||
};
|
||||
// IMP-52 u7 — persist the merged zone_geometries snapshot. Resize
|
||||
// gestures fire repeatedly during a drag; the 300ms u5 debounce
|
||||
// collapses them into a single PUT at gesture-end, so we don't
|
||||
// need to gate on resize-finished here.
|
||||
if (p.uploadedFile) {
|
||||
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||
void saveUserOverrides(key, { zone_geometries: mergedGeometries });
|
||||
}
|
||||
}));
|
||||
return {
|
||||
...p,
|
||||
userSelection: {
|
||||
...p.userSelection,
|
||||
overrides: {
|
||||
...p.userSelection.overrides,
|
||||
zone_geometries: mergedGeometries,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
setHasPendingChanges(true);
|
||||
}, []);
|
||||
|
||||
// IMP-51 (#79) u10 — wire SlideCanvas's user-content image drag/resize
|
||||
// emit into the 5th persisted axis. Mirrors handleZoneResize exactly:
|
||||
// • merge the single (imageId → {x,y,w,h}) tick onto the prior
|
||||
// in-memory `image_overrides` map via the u11 `saveImageOverride`
|
||||
// helper so the immutable update path is shared with the test suite,
|
||||
// • forward the full merged snapshot through `saveUserOverrides`
|
||||
// (the u3 typed client) under the `image_overrides` key — the 300ms
|
||||
// debounce defined alongside `zone_geometries` collapses the
|
||||
// per-mousemove emits into one PUT at gesture-end,
|
||||
// • flip `hasPendingChanges` so the "선택대로 재생성하기" CTA appears.
|
||||
// Coordinates are slide-absolute percent (0–100) from u8/u9 — passed
|
||||
// through unchanged so the on-disk schema matches the SlideCanvas
|
||||
// overlay, the stamper selector (u4), and the render-time CSS
|
||||
// injector (u7) without any per-zone transform.
|
||||
const handleImageResize = useCallback(
|
||||
(imageId: string, geometry: { x: number; y: number; w: number; h: number }) => {
|
||||
setState((p) => {
|
||||
const nextSelection = saveImageOverride(p.userSelection, imageId, geometry);
|
||||
if (p.uploadedFile) {
|
||||
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||
void saveUserOverrides(key, {
|
||||
image_overrides: nextSelection.overrides.image_overrides,
|
||||
});
|
||||
}
|
||||
return { ...p, userSelection: nextSelection };
|
||||
});
|
||||
setHasPendingChanges(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 편집 모드 텍스트 변경 시 hasPendingChanges 활성. useCallback 으로 reference 안정화 —
|
||||
// SlideCanvas 의 useEffect 가 매번 rerun 안 하도록 (resize drag 매 mousemove 마다
|
||||
// re-render 시 useEffect retrigger → iframe contentEditable 재설정 = 매우 느림).
|
||||
@@ -420,6 +683,51 @@ export default function Home() {
|
||||
setHasPendingChanges(true);
|
||||
}, []);
|
||||
|
||||
// IMP-56 (#90) u15 — wire SlideCanvas u13 focusout capture into the new
|
||||
// `text_overrides` persist axis. Mirrors handleImageResize: merge the
|
||||
// (zoneId, textPath, value) tick via `saveTextOverride` (u15 pure helper)
|
||||
// and schedule the 300ms-debounced PUT under the `text_overrides` axis.
|
||||
// Per-axis coalescing in `saveUserOverrides` collapses rapid edits in
|
||||
// the same line into a single PUT; per-key buckets isolate cross-MDX.
|
||||
const handleTextEdit = useCallback(
|
||||
(capture: { zoneId: string; textPath: string; value: string }) => {
|
||||
setState((p) => {
|
||||
const nextSelection = saveTextOverride(
|
||||
p.userSelection, capture.zoneId, capture.textPath, capture.value,
|
||||
);
|
||||
if (p.uploadedFile) {
|
||||
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||
void saveUserOverrides(key, {
|
||||
text_overrides: nextSelection.overrides.text_overrides,
|
||||
});
|
||||
}
|
||||
return { ...p, userSelection: nextSelection };
|
||||
});
|
||||
setHasPendingChanges(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// IMP-56 (#90) u15 — wire SlideCanvas u14 structure overlay capture into
|
||||
// the `structure_overrides` axis. Scope-locked to {slot_order,
|
||||
// hidden_slots} — frame swap stays on the existing `frames` axis.
|
||||
const handleStructureEdit = useCallback(
|
||||
(zoneId: string, perZone: { slot_order?: string[]; hidden_slots?: string[] }) => {
|
||||
setState((p) => {
|
||||
const nextSelection = saveStructureOverride(p.userSelection, zoneId, perZone);
|
||||
if (p.uploadedFile) {
|
||||
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||
void saveUserOverrides(key, {
|
||||
structure_overrides: nextSelection.overrides.structure_overrides,
|
||||
});
|
||||
}
|
||||
return { ...p, userSelection: nextSelection };
|
||||
});
|
||||
setHasPendingChanges(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// pending mode 일 때 effectiveSlidePlan = pendingZones 가 swap 된 plan.
|
||||
// 그 외 = state.slidePlan. 모든 zone / region lookup 이 일관되게 이걸 사용 →
|
||||
// pending mode 의 region.id ("pending-region-N") 가 zone_frames key 로 들어가
|
||||
@@ -431,17 +739,6 @@ export default function Home() {
|
||||
return state.slidePlan;
|
||||
}, [pendingZones, state.slidePlan, pendingLayout]);
|
||||
|
||||
// 2026-05-14 — slide-level CSS override 계산. mdx04 default (rank 1 = process_product_two_way)
|
||||
// 일 때만 적용 (catalog 무변, slide 결과물에만 inject). 사용자 frame override 후 다른
|
||||
// frame 시 무적용 (rank 2 의 frame visual 유지).
|
||||
const slideOverrideCss = useMemo<string | undefined>(() => {
|
||||
if (selectedSample !== "04") return undefined;
|
||||
const zone04_2 = state.slidePlan?.zones.find((z) => z.zone_id === "bottom");
|
||||
const frameId = zone04_2?.internal_regions[0]?.frame_match_strategy.frame_id;
|
||||
if (frameId !== "process_product_two_way") return undefined;
|
||||
return MDX04_DEFAULT_OVERRIDE_CSS;
|
||||
}, [selectedSample, state.slidePlan]);
|
||||
|
||||
// ── Frame 선택 ──
|
||||
const handleFrameSelect = useCallback((frameId: string) => {
|
||||
const zone = getSelectedZone(effectiveSlidePlan, state.userSelection);
|
||||
@@ -452,10 +749,40 @@ export default function Home() {
|
||||
return;
|
||||
}
|
||||
|
||||
setState((p) => ({
|
||||
...p,
|
||||
userSelection: applyFrame(p.userSelection, region.id, frameId)
|
||||
}));
|
||||
setState((p) => {
|
||||
const newSelection = applyFrame(p.userSelection, region.id, frameId);
|
||||
// IMP-52 u7 — persist frames keyed by `unit_id`. The on-disk schema
|
||||
// uses `unit_id = zone.section_ids.join("+")` (the same convention
|
||||
// handleGenerate uses when forwarding `overrides.frames` to the
|
||||
// backend CLI). `zone_frames` is keyed by region.id, so we walk
|
||||
// the effectiveSlidePlan zones to translate. Only true user
|
||||
// overrides are persisted — `createInitialUserSelection` pre-fills
|
||||
// `zone_frames[region.id]` with `region.frame_match_strategy.frame_id`
|
||||
// (backend default) for every region, so we mirror handleGenerate's
|
||||
// `overrideFrameId !== defaultFrameId` gate to avoid leaking defaults
|
||||
// into user_overrides.json. Zones with no sections are skipped.
|
||||
if (p.uploadedFile && effectiveSlidePlan) {
|
||||
const framesByUnitId: Record<string, string> = {};
|
||||
for (const z of effectiveSlidePlan.zones) {
|
||||
const r = z.internal_regions[0];
|
||||
if (!r) continue;
|
||||
if (!Array.isArray(z.section_ids) || z.section_ids.length === 0) continue;
|
||||
const unitId = z.section_ids.join("+");
|
||||
const overrideId = newSelection.overrides.zone_frames?.[r.id];
|
||||
const defaultFrameId = r.frame_match_strategy.frame_id;
|
||||
if (
|
||||
typeof overrideId === "string" &&
|
||||
overrideId.length > 0 &&
|
||||
overrideId !== defaultFrameId
|
||||
) {
|
||||
framesByUnitId[unitId] = overrideId;
|
||||
}
|
||||
}
|
||||
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||
void saveUserOverrides(key, { frames: framesByUnitId });
|
||||
}
|
||||
return { ...p, userSelection: newSelection };
|
||||
});
|
||||
setHasPendingChanges(true);
|
||||
}, [effectiveSlidePlan, state.userSelection]);
|
||||
|
||||
@@ -496,6 +823,31 @@ export default function Home() {
|
||||
>
|
||||
{runMeta.status}
|
||||
</span>
|
||||
{runMeta.filtered_section_ids.length > 0 && (
|
||||
<details className="relative">
|
||||
<summary className="text-[10px] font-bold px-1.5 py-0.5 bg-amber-100 text-amber-700 rounded uppercase tracking-wider cursor-pointer list-none">
|
||||
Filtered: {runMeta.filtered_section_ids.length}
|
||||
</summary>
|
||||
<div className="absolute top-full mt-1 left-0 z-50 bg-white border border-slate-200 rounded shadow-lg p-3 w-96 max-h-96 overflow-y-auto">
|
||||
{runMeta.filtered_section_reasons.map((r, i) => (
|
||||
<div key={i} className="mb-2 pb-2 border-b border-slate-100 last:border-0 last:mb-0 last:pb-0 text-[11px]">
|
||||
<div className="font-mono text-slate-700">{r.section_ids.join(", ")}</div>
|
||||
<div className="text-slate-500">selection_state: <span className="font-mono">{r.selection_state}</span></div>
|
||||
{r.merge_type && <div className="text-slate-500">merge_type: <span className="font-mono">{r.merge_type}</span></div>}
|
||||
{r.template_id && <div className="text-slate-500">template_id: <span className="font-mono">{r.template_id}</span></div>}
|
||||
{r.v4_label && <div className="text-slate-500">v4_label: <span className="font-mono">{r.v4_label}</span></div>}
|
||||
{r.phase_z_status && <div className="text-slate-500">phase_z_status: <span className="font-mono">{r.phase_z_status}</span></div>}
|
||||
{r.score !== null && <div className="text-slate-500">score: <span className="font-mono">{r.score}</span></div>}
|
||||
{r.source && <div className="text-slate-500">source: <span className="font-mono">{r.source}</span></div>}
|
||||
{r.position && <div className="text-slate-500">position: <span className="font-mono">{r.position}</span></div>}
|
||||
<ul className="mt-1 list-disc list-inside text-slate-600">
|
||||
{r.filter_reasons.map((reason, j) => <li key={j} className="font-mono">{reason}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -547,7 +899,6 @@ export default function Home() {
|
||||
normalizedContent={state.normalizedContent}
|
||||
userSelection={state.userSelection}
|
||||
finalHtmlUrl={runMeta?.final_html_url}
|
||||
slideOverrideCss={slideOverrideCss}
|
||||
isPipelineRunning={state.isLoading}
|
||||
isPendingLayout={!!pendingLayout}
|
||||
pendingLayoutId={pendingLayout}
|
||||
@@ -563,6 +914,11 @@ export default function Home() {
|
||||
onSectionDrop={handleSectionDrop}
|
||||
onLayoutResize={handleLayoutResize}
|
||||
onZoneResize={handleZoneResize}
|
||||
imageOverrides={state.userSelection.overrides.image_overrides}
|
||||
onImageResize={handleImageResize}
|
||||
onTextEdit={handleTextEdit}
|
||||
structureOverrides={state.userSelection.overrides.structure_overrides}
|
||||
onStructureEdit={handleStructureEdit}
|
||||
/>
|
||||
</main>
|
||||
|
||||
@@ -605,11 +961,13 @@ export default function Home() {
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-tighter">Phase Z Engine Active</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" onClick={() => toast.info("연동하기 기능은 준비 중입니다.")} className="gap-2 h-9 text-[11px] font-bold uppercase tracking-widest border-slate-200"><Link2 className="w-3.5 h-3.5" />Connect</Button>
|
||||
<Button variant="outline" onClick={() => toast.info("다운로드 기능은 준비 중입니다.")} disabled={!state.slidePlan} className="gap-2 h-9 text-[11px] font-bold uppercase tracking-widest border-slate-200"><Download className="w-3.5 h-3.5" />Download</Button>
|
||||
<Button onClick={() => toast.success("슬라이드 설정이 확정되었습니다.")} disabled={!state.slidePlan || state.isLoading} className="gap-2 h-9 text-[11px] font-bold uppercase tracking-widest bg-slate-900 hover:bg-slate-800"><Sparkles className="w-3.5 h-3.5" />Finalize Slide</Button>
|
||||
</div>
|
||||
<BottomActions
|
||||
slidePlan={state.slidePlan}
|
||||
runMeta={runMeta}
|
||||
uploadedFile={state.uploadedFile}
|
||||
isLoading={state.isLoading}
|
||||
onGenerate={handleGenerate}
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
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));
|
||||
@@ -207,6 +209,58 @@ export async function exportSlidePlan(slidePlan: SlidePlan, userSelection: any):
|
||||
// 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;
|
||||
@@ -214,11 +268,42 @@ export interface RunMeta {
|
||||
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 {
|
||||
@@ -251,17 +336,34 @@ export interface PipelineOverrides {
|
||||
/** 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
|
||||
overrides?: PipelineOverrides,
|
||||
// IMP-43 (#72) u6 — optional prev RUN_ID for incremental rerun. When set,
|
||||
// the vite plugin forwards `--reuse-from <PREV_RUN_ID>` to the backend
|
||||
// and the pipeline resumes at Step 7 (Step 0/1/2/5/6 artifacts copied
|
||||
// from the prior run). When omitted / empty, the POST body is
|
||||
// byte-identical to pre-u6 (no reuseFromRunId key → no flag forwarded).
|
||||
reuseFromRunId?: string,
|
||||
): Promise<RunPipelineResult> {
|
||||
const content = await file.text();
|
||||
const body: Record<string, unknown> = {
|
||||
filename: file.name,
|
||||
content,
|
||||
overrides,
|
||||
};
|
||||
if (reuseFromRunId) body.reuseFromRunId = reuseFromRunId;
|
||||
const res = await fetch("/api/run", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename: file.name, content, overrides }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = (await res.json()) as RunPipelineResult;
|
||||
if (!res.ok && !data.run_id) {
|
||||
@@ -388,6 +490,8 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
|
||||
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 ?? [],
|
||||
@@ -403,6 +507,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 ──
|
||||
@@ -472,23 +577,103 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
|
||||
// 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,
|
||||
};
|
||||
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);
|
||||
});
|
||||
// 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) => ({
|
||||
.map((c: any) => {
|
||||
const appMatch = applicationModeMap.get(c.template_id);
|
||||
return ({
|
||||
id: c.template_id,
|
||||
name: c.template_id,
|
||||
score: c.confidence ?? 0,
|
||||
@@ -500,9 +685,33 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
|
||||
? `/frame-preview/${String(c.frame_number).padStart(2, "0")}`
|
||||
: undefined,
|
||||
// backend step09 의 catalog_registered (frame_contracts.yaml 등록 여부).
|
||||
// v4_all_judgments 에만 있음. v4_candidates fallback 시 undefined.
|
||||
// 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] ??
|
||||
|
||||
287
Front/client/src/services/userOverridesApi.ts
Normal file
287
Front/client/src/services/userOverridesApi.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
// IMP-52 u5 — typed frontend client for `/api/user-overrides/:key` (GET + PUT).
|
||||
//
|
||||
// The on-disk schema (KNOWN_AXES) and endpoint contract are owned by:
|
||||
// • src/user_overrides_io.py (Python — backend pipeline fallback, u1/u2)
|
||||
// • Front/vite.config.ts (handleGet/PutUserOverrides, u3/u4)
|
||||
// This module is the typed view used by Home.tsx restore-on-reopen (u6) and
|
||||
// the four mutation handlers (u7). It does NOT own the schema — any change
|
||||
// to KNOWN_AXES must land in u1/u4 first, then reflect here.
|
||||
//
|
||||
// IMP-51 (#79) u3 — added `image_overrides` (5th axis). `image_id` → percent-
|
||||
// of-slide {x,y,w,h}. Mirrors src/user_overrides_io.py KNOWN_AXES (u1) and
|
||||
// Front/vite.config.ts KNOWN_USER_OVERRIDES_AXES (u2). Backend stamper +
|
||||
// render-time CSS injection ride on u4~u7; the SlideCanvas drag/resize
|
||||
// handles that drive this axis ride on u8~u11.
|
||||
//
|
||||
// Contract (Stage 2 unit u5 summary):
|
||||
// • Typed `getUserOverrides(key)` → returns `Partial<UserOverrides>` from
|
||||
// the GET endpoint. Missing / corrupt / non-object payloads degrade to
|
||||
// `{}` so the frontend reopen flow never crashes on a fresh MDX.
|
||||
// • Typed `saveUserOverrides(key, partial)` → schedules a 300ms-debounced
|
||||
// PUT carrying ONLY the axes the user has mutated since the last flush.
|
||||
// Per-axis coalescing: a later call overwrites the same axis in the
|
||||
// pending payload; axes the user did not mutate are NOT sent (the
|
||||
// server-side merge in u4 preserves them on disk).
|
||||
// • Per-key debounce buckets — rapid edits to MDX "03" do not delay the
|
||||
// flush for MDX "04".
|
||||
// • Explicit clear sentinel: `partial[axis] = null` forwards to the PUT
|
||||
// body verbatim so u4 `mergeUserOverrides` can `delete` the axis on disk.
|
||||
// • `flushUserOverrides()` / `flushUserOverrides(key)` force an immediate
|
||||
// PUT (used by tests + Home.tsx Generate flow to ensure outstanding
|
||||
// writes commit before pipeline run).
|
||||
|
||||
const ENDPOINT_BASE = "/api/user-overrides";
|
||||
const DEBOUNCE_MS = 300;
|
||||
|
||||
// ── Schema (mirror of backend KNOWN_AXES; see header comment) ───────────────
|
||||
|
||||
/** unit_id → template_id. unit_id = source_section_ids joined by "+". */
|
||||
export type FramesOverride = Record<string, string>;
|
||||
|
||||
/** zone_id → 0-1 normalized geometry inside slide-body. */
|
||||
export type ZoneGeometryOverride = {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
};
|
||||
export type ZoneGeometriesOverride = Record<string, ZoneGeometryOverride>;
|
||||
|
||||
/** zone_id → ordered list of section_ids assigned to that zone. */
|
||||
export type ZoneSectionsOverride = Record<string, string[]>;
|
||||
|
||||
/**
|
||||
* IMP-51 #79 u3 — image_id → percent-of-slide geometry. Matches the user-
|
||||
* content image selector `.slide img[data-image-role="user-content"]`
|
||||
* (stamper in u4) and the render-time CSS injection map (u7). Coordinates
|
||||
* are slide-absolute percent (0–100) so SlideCanvas drag handles (u8~u11)
|
||||
* map 1:1 with the persisted axis without per-zone transforms.
|
||||
*/
|
||||
export type ImageOverride = {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
};
|
||||
export type ImageOverridesOverride = Record<string, ImageOverride>;
|
||||
|
||||
/**
|
||||
* IMP-55 #93 u1 — bool intent marker that gates whether persisted
|
||||
* `zone_sections` are consumed by the backend pipeline. Frontend sets
|
||||
* `true` only on a real user drag-drop (Home.tsx handleSectionDrop, u6)
|
||||
* and `false` on layout apply/cancel auto-carry (u5/u12). Mirrors the
|
||||
* Python KNOWN_AXES (`manual_section_assignment`) added in u1 and the
|
||||
* Vite KNOWN_USER_OVERRIDES_AXES allowlist entry added in u1.
|
||||
*/
|
||||
export type ManualSectionAssignmentOverride = boolean;
|
||||
|
||||
/**
|
||||
* IMP-56 #90 u10 — Step-22 text-edit persist axis. Keyed by `zone_id`; the
|
||||
* inner mapping is `text_path` (= `{slot_key}.{line_index}`) → line value.
|
||||
* The `text_path` stamp is emitted by `src/text_path_stamper.py` (u8) and
|
||||
* applied at Step 13 (u9); the value is consumed by `text_override_resolver`
|
||||
* (u4) and applied at Step 12 (u5). Stale paths (frame swap / layout
|
||||
* regression between sessions) are tolerated by the backend resolver as
|
||||
* `skipped`, NOT raised — so the on-disk axis is forward-compat with layout
|
||||
* and frame churn. Mirrors Python `KNOWN_AXES` entry (u1) and Vite
|
||||
* `KNOWN_USER_OVERRIDES_AXES` allowlist entry (u3).
|
||||
*/
|
||||
export type TextOverridesPerZone = Record<string, string>;
|
||||
export type TextOverridesOverride = Record<string, TextOverridesPerZone>;
|
||||
|
||||
/**
|
||||
* IMP-56 #90 u10 — Step-22 structure-edit persist axis. Keyed by `zone_id`;
|
||||
* the inner mapping is SCOPE-LOCKED to `{slot_order, hidden_slots}` — slot
|
||||
* reorder + slot hide only. Frame swap stays on the existing `frames` axis;
|
||||
* the `structure_override_resolver` (u6) rejects frame-swap-shaped inner
|
||||
* keys at the validate gate so Phase Z's no-AI-HTML-structure invariant
|
||||
* holds across this persisted axis too. Per-slot `list[str]` line content
|
||||
* is NEVER mutated by the u7 Step-12 apply — that is the `text_overrides`
|
||||
* axis above. Mirrors Python `KNOWN_AXES` entry (u2) and Vite
|
||||
* `KNOWN_USER_OVERRIDES_AXES` allowlist entry (u3).
|
||||
*/
|
||||
export type StructureOverridePerZone = {
|
||||
slot_order?: string[];
|
||||
hidden_slots?: string[];
|
||||
};
|
||||
export type StructureOverridesOverride = Record<string, StructureOverridePerZone>;
|
||||
|
||||
/** Full on-disk schema. All axes optional — file may carry any subset. */
|
||||
export interface UserOverrides {
|
||||
layout: string;
|
||||
frames: FramesOverride;
|
||||
zone_geometries: ZoneGeometriesOverride;
|
||||
zone_sections: ZoneSectionsOverride;
|
||||
image_overrides: ImageOverridesOverride;
|
||||
manual_section_assignment: ManualSectionAssignmentOverride;
|
||||
text_overrides: TextOverridesOverride;
|
||||
structure_overrides: StructureOverridesOverride;
|
||||
}
|
||||
|
||||
/** Partial-mutation payload. `null` is the explicit clear sentinel (mirrors u4). */
|
||||
export type UserOverridesPartial = {
|
||||
[K in keyof UserOverrides]?: UserOverrides[K] | null;
|
||||
};
|
||||
|
||||
// ── Per-key debounce buckets ────────────────────────────────────────────────
|
||||
|
||||
type PendingBucket = {
|
||||
partial: UserOverridesPartial;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
waiters: Array<{
|
||||
resolve: (merged: Partial<UserOverrides>) => void;
|
||||
reject: (err: unknown) => void;
|
||||
}>;
|
||||
};
|
||||
|
||||
const buckets = new Map<string, PendingBucket>();
|
||||
|
||||
function getBucket(key: string): PendingBucket {
|
||||
let b = buckets.get(key);
|
||||
if (!b) {
|
||||
b = { partial: {}, timer: null, waiters: [] };
|
||||
buckets.set(key, b);
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
// ── GET ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch the persisted user_overrides for `key` (MDX stem). Returns `{}` on
|
||||
* any failure mode (network error, 4xx/5xx, non-object body) so the caller
|
||||
* can use it unconditionally during MDX reopen without branching on
|
||||
* error paths.
|
||||
*/
|
||||
export async function getUserOverrides(
|
||||
key: string,
|
||||
): Promise<Partial<UserOverrides>> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${ENDPOINT_BASE}/${encodeURIComponent(key)}`, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
if (!res.ok) return {};
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = await res.json();
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
return {};
|
||||
}
|
||||
return parsed as Partial<UserOverrides>;
|
||||
}
|
||||
|
||||
// ── PUT (debounced) ─────────────────────────────────────────────────────────
|
||||
|
||||
async function flushBucket(
|
||||
key: string,
|
||||
bucket: PendingBucket,
|
||||
): Promise<void> {
|
||||
const payload = bucket.partial;
|
||||
const waiters = bucket.waiters;
|
||||
bucket.partial = {};
|
||||
bucket.timer = null;
|
||||
bucket.waiters = [];
|
||||
|
||||
let merged: Partial<UserOverrides> = {};
|
||||
try {
|
||||
const res = await fetch(`${ENDPOINT_BASE}/${encodeURIComponent(key)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.ok) {
|
||||
try {
|
||||
const parsed = (await res.json()) as unknown;
|
||||
if (
|
||||
typeof parsed === "object" &&
|
||||
parsed !== null &&
|
||||
!Array.isArray(parsed)
|
||||
) {
|
||||
merged = parsed as Partial<UserOverrides>;
|
||||
}
|
||||
} catch {
|
||||
// server returned 200 with non-JSON body → treat as empty merged
|
||||
}
|
||||
} else {
|
||||
const err = new Error(`PUT ${ENDPOINT_BASE}/${key} → ${res.status}`);
|
||||
waiters.forEach((w) => w.reject(err));
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
waiters.forEach((w) => w.reject(err));
|
||||
return;
|
||||
}
|
||||
waiters.forEach((w) => w.resolve(merged));
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a debounced PUT to persist the mutated axes. Resolves with the
|
||||
* server-side merged document when the debounced PUT eventually fires.
|
||||
* Multiple rapid calls for the same `key` coalesce into a single PUT;
|
||||
* a later call's value for a given axis overrides an earlier pending value.
|
||||
* Calls for different `key`s are isolated.
|
||||
*/
|
||||
export function saveUserOverrides(
|
||||
key: string,
|
||||
partial: UserOverridesPartial,
|
||||
): Promise<Partial<UserOverrides>> {
|
||||
const bucket = getBucket(key);
|
||||
// Per-axis coalescing — later mutations replace earlier pending values.
|
||||
for (const axis of Object.keys(partial) as Array<keyof UserOverridesPartial>) {
|
||||
bucket.partial[axis] = partial[axis] as never;
|
||||
}
|
||||
const p = new Promise<Partial<UserOverrides>>((resolve, reject) => {
|
||||
bucket.waiters.push({ resolve, reject });
|
||||
});
|
||||
if (bucket.timer !== null) clearTimeout(bucket.timer);
|
||||
bucket.timer = setTimeout(() => {
|
||||
void flushBucket(key, bucket);
|
||||
}, DEBOUNCE_MS);
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-flush pending debounced writes. With no arg, flushes ALL pending
|
||||
* keys (used before pipeline runs so the backend reads the latest file).
|
||||
* With a key, flushes only that key's bucket.
|
||||
*
|
||||
* Resolves after every flushed bucket's PUT completes. Per-bucket errors
|
||||
* are swallowed at the flush level — the original caller's
|
||||
* saveUserOverrides() promise still rejects to its owner via the waiter.
|
||||
*/
|
||||
export async function flushUserOverrides(key?: string): Promise<void> {
|
||||
const targets: Array<[string, PendingBucket]> = [];
|
||||
if (key !== undefined) {
|
||||
const b = buckets.get(key);
|
||||
if (b && b.timer !== null) targets.push([key, b]);
|
||||
} else {
|
||||
buckets.forEach((b, k) => {
|
||||
if (b.timer !== null) targets.push([k, b]);
|
||||
});
|
||||
}
|
||||
const flushPromises = targets.map(([k, b]) => {
|
||||
if (b.timer !== null) {
|
||||
clearTimeout(b.timer);
|
||||
b.timer = null;
|
||||
}
|
||||
return flushBucket(k, b);
|
||||
});
|
||||
await Promise.all(flushPromises);
|
||||
}
|
||||
|
||||
/** Test-only — clears all pending buckets without firing PUTs. */
|
||||
export function __resetUserOverridesBuckets_FOR_TEST(): void {
|
||||
buckets.forEach((b) => {
|
||||
if (b.timer !== null) clearTimeout(b.timer);
|
||||
});
|
||||
buckets.clear();
|
||||
}
|
||||
@@ -116,6 +116,23 @@ export interface InternalRegion {
|
||||
frame_candidates: FrameCandidate[];
|
||||
}
|
||||
|
||||
/** IMP-05 L2 candidate_evidence.capacity_fit — backend capacity vs. content shape audit.
|
||||
* Source = src/phase_z2_pipeline.py compute_capacity_fit(). All fields optional —
|
||||
* frontend tolerates absence for pre-IMP-05 fixtures and contract-less templates. */
|
||||
export interface CapacityFitEvidence {
|
||||
item_count?: number | null;
|
||||
source_shape?: string | null;
|
||||
capacity?: {
|
||||
strict?: number | null;
|
||||
min?: number | null;
|
||||
max?: number | null;
|
||||
truncate_at?: number | null;
|
||||
pad_to?: number | null;
|
||||
} | null;
|
||||
fit_status?: string | null;
|
||||
mismatch_reason?: string | null;
|
||||
}
|
||||
|
||||
/** 프레임 후보 (V4 매칭 결과) */
|
||||
export interface FrameCandidate {
|
||||
id: string;
|
||||
@@ -127,6 +144,50 @@ export interface FrameCandidate {
|
||||
/** backend frame_contracts.yaml 에 catalog 등록 여부. false 면 사용자가 override
|
||||
* 시도해도 Step 7-A 가 skip (render path 미연결). UI 회색 + "render path 미적용" 표시. */
|
||||
catalogRegistered?: boolean;
|
||||
/** IMP-11 D-2 — frame contract visual_hints.min_height_px (logical 1280x720 px).
|
||||
* Source = templates/phase_z2/catalog/frame_contracts.yaml visual_hints.min_height_px.
|
||||
* Undefined when contract unregistered or visual_hints absent (frontend tolerates undefined). */
|
||||
minHeightPx?: number;
|
||||
|
||||
// ─── IMP-05 L2 candidate_evidence fields (IMP-29 u1) ───────────────────────
|
||||
// Source = src/phase_z2_pipeline.py lookup_v4_match_with_fallback() candidate_trace.
|
||||
// All fields optional — pre-IMP-05 fixtures fall back to v4_all_judgments/v4_candidates
|
||||
// (deterministic, no LLM) and silently leave these undefined.
|
||||
|
||||
/** Candidate rank in V4 chain (1-based; 1 = primary). */
|
||||
rank?: number;
|
||||
/** Figma frame node id (backend `frame_id`). Distinct from `id` (= template_id). */
|
||||
frameId?: string;
|
||||
/** Alias of `label`. Kept separate for Codex IMP-05 L2 schema parity. */
|
||||
v4Label?: 'use_as_is' | 'light_edit' | 'restructure' | 'reject';
|
||||
/** Phase Z status enum (e.g. "auto_renderable", "fallback_candidate"). Open vocabulary. */
|
||||
phaseZStatus?: string;
|
||||
/** True when status is outside MVP1_ALLOWED_STATUSES (= excluded from direct render path). */
|
||||
filteredForDirectExecution?: boolean;
|
||||
/** Execution route mapped from `label` (direct_render / deterministic_minor_adjustment /
|
||||
* ai_adaptation_required / design_reference_only). Null on unknown labels. */
|
||||
routeHint?: 'direct_render' | 'deterministic_minor_adjustment' | 'ai_adaptation_required' | 'design_reference_only' | null;
|
||||
/** Selection outcome ("selected" or "skipped"). */
|
||||
decision?: 'selected' | 'skipped';
|
||||
/** Human-readable rationale (e.g. "primary_selected", "fallback_selected",
|
||||
* "duplicate_template_id", "skipped_no_contract", "capacity_mismatch:...",
|
||||
* "phase_z_status_not_allowed:..."). */
|
||||
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;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -145,6 +206,36 @@ export interface UserSelection {
|
||||
zone_sections: Record<string, string[]>; // zoneId -> sectionIds[]
|
||||
zone_sizes: Record<string, number[]>; // layoutGroupId -> [size1, size2, ...]
|
||||
zone_geometries: Record<string, { x: number; y: number; w: number; h: number }>; // zone_id -> geometry
|
||||
// IMP-51 (#79) u11 — image_id → slide-absolute percent geometry (0–100
|
||||
// on each axis). image_id is stamped by `src/image_id_stamper.py` (u4)
|
||||
// on user-content `<img>` tags; the same key is consumed by the u7 CSS
|
||||
// injector and the SlideCanvas u8 overlay. Shape mirrors the on-disk
|
||||
// `image_overrides` axis (KNOWN_AXES, src/user_overrides_io.py u1) and
|
||||
// the typed-client `ImageOverridesOverride` (services/userOverridesApi.ts u3).
|
||||
image_overrides: Record<string, { x: number; y: number; w: number; h: number }>;
|
||||
// IMP-55 (#93) u3 — bool intent marker gating whether the backend
|
||||
// consumes persisted `zone_sections` as a user override. Set to `true`
|
||||
// only by the real drag-drop path (Home.tsx handleSectionDrop, u6); set
|
||||
// back to `false` by the layout apply/cancel auto-carry path (u5/u12).
|
||||
// handleGenerate (u7) reads this flag to decide whether to forward
|
||||
// `overrides.zoneSections` to the backend, replacing the pre-IMP-55
|
||||
// self-compare against `effectiveSlidePlan`. Seeded `false` in
|
||||
// `createInitialUserSelection` and only restored on reopen when the
|
||||
// persisted value is a real boolean (slidePlanUtils.ts u3 layering).
|
||||
// Mirrors the on-disk axis added in u1 — Python KNOWN_AXES
|
||||
// (src/user_overrides_io.py), Vite KNOWN_USER_OVERRIDES_AXES
|
||||
// (Front/vite.config.ts), and `ManualSectionAssignmentOverride`
|
||||
// (services/userOverridesApi.ts).
|
||||
manual_section_assignment: boolean;
|
||||
// IMP-56 #90 u10/u15 — Step-22 text + structure persist axes. Mirrors
|
||||
// services/userOverridesApi.ts (`TextOverridesOverride` /
|
||||
// `StructureOverridesOverride`). `text_overrides[zoneId][textPath] = value`
|
||||
// is fed by SlideCanvas u13 focusout capture + Home u15 autosave;
|
||||
// `structure_overrides[zoneId] = {slot_order, hidden_slots}` is fed by
|
||||
// u14 overlay + u15 autosave. Both seeded `{}` in createInitialUserSelection
|
||||
// and restored on reopen via applyPersistedNonFrameOverrides.
|
||||
text_overrides: Record<string, Record<string, string>>;
|
||||
structure_overrides: Record<string, { slot_order?: string[]; hidden_slots?: string[] }>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,206 @@
|
||||
import type { UserSelection, SlidePlan, Zone, InternalRegion, LayoutPresetId } from "../types/designAgent";
|
||||
import type {
|
||||
StructureOverridePerZone,
|
||||
StructureOverridesOverride,
|
||||
TextOverridesOverride,
|
||||
TextOverridesPerZone,
|
||||
UserOverrides,
|
||||
} from "../services/userOverridesApi";
|
||||
import { computeZonePositions } from "../services/designAgentApi";
|
||||
|
||||
// ─── IMP-52 u6 — restore-on-reopen helpers (pure, exported for testing) ────
|
||||
// These helpers compose persisted `user_overrides.json` payloads (typed by
|
||||
// the u5 service) onto the in-memory `UserSelection`. They live here rather
|
||||
// than inline in Home.tsx so vitest can drive them in a node environment
|
||||
// without booting React or pulling in the radix-ui / lucide UI deps that
|
||||
// Home.tsx requires. Home.tsx wires these into:
|
||||
// • handleFileUpload (pre-Generate layout / zone_geometries / zone_sections
|
||||
// seed so handleGenerate's CLI-args build picks them up)
|
||||
// • handleGenerate post-loadRun (frame remap unit_id → region.id over the
|
||||
// freshly built slidePlan)
|
||||
// The on-disk schema and clear-sentinel semantics are owned by:
|
||||
// • src/user_overrides_io.py (KNOWN_AXES, u1)
|
||||
// • Front/vite.config.ts mergeUserOverrides (u4)
|
||||
// • Front/client/src/services/userOverridesApi.ts (UserOverrides type, u5)
|
||||
// Any KNOWN_AXES drift must land in those files first.
|
||||
|
||||
/**
|
||||
* Derive the `/api/user-overrides/:key` MDX-stem key from a filename.
|
||||
* Strips a trailing `.mdx` (case-insensitive). The key matches the Python
|
||||
* `Path(args.mdx_path).stem` derivation used by the backend fallback (u2),
|
||||
* so the same persisted file is read from both ends without translation.
|
||||
*/
|
||||
export function deriveUserOverridesKey(filename: string): string {
|
||||
return filename.replace(/\.mdx$/i, "");
|
||||
}
|
||||
|
||||
const LAYOUT_PRESET_IDS = new Set<string>([
|
||||
"single",
|
||||
"horizontal-2",
|
||||
"vertical-2",
|
||||
"top-1-bottom-2",
|
||||
"top-2-bottom-1",
|
||||
"left-1-right-2",
|
||||
"left-2-right-1",
|
||||
"grid-2x2",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Layer the three non-frame axes from a persisted `user_overrides.json`
|
||||
* payload onto an existing `UserSelection`. Foreign / unrecognized payload
|
||||
* shapes are silently ignored — the u5 GET path already returns `{}` on
|
||||
* corrupt files, but we revalidate here so hand-edited files or future
|
||||
* forward-compat axes cannot poison the in-memory state.
|
||||
*
|
||||
* Frames are NOT layered here because the on-disk key (`unit_id` =
|
||||
* section_ids joined by `+`) only resolves after the slidePlan zones are
|
||||
* known. Use `remapPersistedFramesToZoneFrames` in the post-loadRun step.
|
||||
*/
|
||||
export function applyPersistedNonFrameOverrides(
|
||||
selection: UserSelection,
|
||||
persisted: Partial<UserOverrides> | null | undefined,
|
||||
): UserSelection {
|
||||
if (!persisted || typeof persisted !== "object") return selection;
|
||||
const next = { ...selection.overrides };
|
||||
if (typeof persisted.layout === "string" && LAYOUT_PRESET_IDS.has(persisted.layout)) {
|
||||
next.layout_preset = persisted.layout as LayoutPresetId;
|
||||
}
|
||||
if (
|
||||
persisted.zone_geometries &&
|
||||
typeof persisted.zone_geometries === "object" &&
|
||||
!Array.isArray(persisted.zone_geometries)
|
||||
) {
|
||||
next.zone_geometries = { ...persisted.zone_geometries };
|
||||
}
|
||||
if (
|
||||
persisted.zone_sections &&
|
||||
typeof persisted.zone_sections === "object" &&
|
||||
!Array.isArray(persisted.zone_sections)
|
||||
) {
|
||||
next.zone_sections = { ...persisted.zone_sections };
|
||||
}
|
||||
// IMP-51 (#79) u11 — layer the 5th persisted axis (`image_overrides`) by
|
||||
// the same array / non-object guard the zone_geometries branch uses. The
|
||||
// u3 typed client (services/userOverridesApi.ts) shape and the on-disk
|
||||
// KNOWN_AXES entry (src/user_overrides_io.py u1) are both flat dicts
|
||||
// (image_id → {x,y,w,h} percent-of-slide), so a shallow copy is enough.
|
||||
if (
|
||||
persisted.image_overrides &&
|
||||
typeof persisted.image_overrides === "object" &&
|
||||
!Array.isArray(persisted.image_overrides)
|
||||
) {
|
||||
next.image_overrides = { ...persisted.image_overrides };
|
||||
}
|
||||
// IMP-55 (#93) u3 — restore the bool intent marker only when the persisted
|
||||
// value is a real `boolean`. A missing axis, `null` (the u4 clear sentinel
|
||||
// observed post-flush), or any non-boolean shape (string "true", 1, {})
|
||||
// intentionally falls through to the `createInitialUserSelection` seed of
|
||||
// `false`. This is the fail-closed half of the marker contract: the
|
||||
// backend pipeline (u9) consumes persisted `zone_sections` only when
|
||||
// `manual_section_assignment is True`, so anything other than a real
|
||||
// `true` MUST end up as `false` in memory to avoid resurrecting stale
|
||||
// auto-carry assignments as user intent. Both `true` and `false` are
|
||||
// restored verbatim (the explicit `false` from u12's apply/cancel write
|
||||
// is meaningful — it pins the marker off across reopens).
|
||||
if (typeof persisted.manual_section_assignment === "boolean") {
|
||||
next.manual_section_assignment = persisted.manual_section_assignment;
|
||||
}
|
||||
// IMP-56 (#90) u15 — layer the two Step-22 persist axes through the
|
||||
// u10 extract helpers; their `_isPlainObject` + dedupe gates already
|
||||
// sanitize foreign / hand-edited payloads, so reopen never poisons
|
||||
// memory with non-string values or non-list slot_order entries.
|
||||
next.text_overrides = extractPersistedTextOverrides(persisted);
|
||||
next.structure_overrides = extractPersistedStructureOverrides(persisted);
|
||||
return { ...selection, overrides: next };
|
||||
}
|
||||
|
||||
// ─── IMP-56 #90 u10 — typed extract helpers for the two new persist axes ───
|
||||
// Pure helpers that defensively sanitize Step-22 text_overrides and
|
||||
// structure_overrides payloads off a `Partial<UserOverrides>` (typed by u10's
|
||||
// userOverridesApi extension). They mirror the backend validation gates
|
||||
// (`text_override_resolver` u4 / `structure_override_resolver` u6) on the
|
||||
// frontend so a hand-edited or schema-drift payload cannot poison memory.
|
||||
// Layering onto `UserSelection.overrides` arrives in u14~u16; until then
|
||||
// capture / autosave / restore wiring units consume these as typed.
|
||||
|
||||
function _isPlainObject(x: unknown): x is Record<string, unknown> {
|
||||
return !!x && typeof x === "object" && !Array.isArray(x);
|
||||
}
|
||||
|
||||
function _dedupeStringList(arr: unknown): string[] {
|
||||
if (!Array.isArray(arr)) return [];
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const k of arr) {
|
||||
if (typeof k === "string" && k.length > 0 && !seen.has(k)) {
|
||||
seen.add(k);
|
||||
out.push(k);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function extractPersistedTextOverrides(
|
||||
persisted: Partial<UserOverrides> | null | undefined,
|
||||
): TextOverridesOverride {
|
||||
const raw = persisted?.text_overrides;
|
||||
if (!_isPlainObject(raw)) return {};
|
||||
const out: TextOverridesOverride = {};
|
||||
for (const [zoneId, perZone] of Object.entries(raw)) {
|
||||
if (!zoneId || !_isPlainObject(perZone)) continue;
|
||||
const safe: TextOverridesPerZone = {};
|
||||
for (const [textPath, value] of Object.entries(perZone)) {
|
||||
if (textPath && typeof value === "string") safe[textPath] = value;
|
||||
}
|
||||
out[zoneId] = safe;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function extractPersistedStructureOverrides(
|
||||
persisted: Partial<UserOverrides> | null | undefined,
|
||||
): StructureOverridesOverride {
|
||||
const raw = persisted?.structure_overrides;
|
||||
if (!_isPlainObject(raw)) return {};
|
||||
const out: StructureOverridesOverride = {};
|
||||
for (const [zoneId, perZone] of Object.entries(raw)) {
|
||||
if (!zoneId || !_isPlainObject(perZone)) continue;
|
||||
const safe: StructureOverridePerZone = {};
|
||||
if (Array.isArray(perZone.slot_order)) safe.slot_order = _dedupeStringList(perZone.slot_order);
|
||||
if (Array.isArray(perZone.hidden_slots)) safe.hidden_slots = _dedupeStringList(perZone.hidden_slots);
|
||||
out[zoneId] = safe;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remap persisted frames (`unit_id` → template_id) to the in-memory
|
||||
* `zone_frames` (region.id → template_id) using the freshly built
|
||||
* slidePlan zones. `unit_id` follows handleGenerate's convention:
|
||||
* `zone.section_ids.join("+")`. Persisted entries whose unit_id no longer
|
||||
* matches any zone (e.g. user changed zone_sections between sessions) are
|
||||
* silently dropped.
|
||||
*/
|
||||
export function remapPersistedFramesToZoneFrames(
|
||||
slidePlan: SlidePlan | null | undefined,
|
||||
framesByUnitId: Record<string, string> | null | undefined,
|
||||
): Record<string, string> {
|
||||
if (!slidePlan || !framesByUnitId || typeof framesByUnitId !== "object") {
|
||||
return {};
|
||||
}
|
||||
const out: Record<string, string> = {};
|
||||
for (const zone of slidePlan.zones) {
|
||||
const region = zone.internal_regions[0];
|
||||
if (!region) continue;
|
||||
if (!Array.isArray(zone.section_ids) || zone.section_ids.length === 0) continue;
|
||||
const unitId = zone.section_ids.join("+");
|
||||
const templateId = framesByUnitId[unitId];
|
||||
if (typeof templateId === "string" && templateId.length > 0) {
|
||||
out[region.id] = templateId;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase Z 초기 선택 상태 생성
|
||||
@@ -39,6 +241,24 @@ export function createInitialUserSelection(slidePlan?: SlidePlan | null): UserSe
|
||||
zone_sections: initialSections,
|
||||
zone_sizes: {},
|
||||
zone_geometries: {},
|
||||
// IMP-51 (#79) u11 — image_overrides axis starts empty; entries land
|
||||
// here via `saveImageOverride` (SlideCanvas drag/resize handler) and
|
||||
// are seeded on reopen via `applyPersistedNonFrameOverrides`.
|
||||
image_overrides: {},
|
||||
// IMP-56 (#90) u15 — Step-22 axes seeded empty. Entries land here
|
||||
// via `saveTextOverride` (u13 focusout capture) and
|
||||
// `saveStructureOverride` (u14 overlay) and are restored on reopen
|
||||
// via `applyPersistedNonFrameOverrides`.
|
||||
text_overrides: {},
|
||||
structure_overrides: {},
|
||||
// IMP-55 (#93) u3 — bool intent marker seeded `false` so a fresh
|
||||
// MDX open (no persisted file, or persisted file with axis absent)
|
||||
// never forwards `overrides.zoneSections` to the backend. The marker
|
||||
// flips to `true` only via the real drag-drop path (Home.tsx u6) and
|
||||
// is reset to `false` by layout apply/cancel auto-carry (u5/u12).
|
||||
// `applyPersistedNonFrameOverrides` may restore a persisted boolean
|
||||
// verbatim on reopen — see the bool-only guard there.
|
||||
manual_section_assignment: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -60,6 +280,86 @@ export function saveZoneGeometry(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* IMP-51 (#79) u11 — record a single `image_id` → slide-absolute percent
|
||||
* geometry on the in-memory selection. Mirrors `saveZoneGeometry` but on
|
||||
* the 5th persisted axis (`image_overrides`); the SlideCanvas drag/resize
|
||||
* handler (u8) emits one entry per pointer move, and u10's Home wiring
|
||||
* funnels each emit through this helper before scheduling the debounced
|
||||
* PUT. Pure / immutable — returns a fresh `UserSelection`; the input is
|
||||
* never mutated. Existing entries for the same `imageId` are replaced.
|
||||
*/
|
||||
export function saveImageOverride(
|
||||
selection: UserSelection,
|
||||
imageId: string,
|
||||
geometry: { x: number; y: number; w: number; h: number },
|
||||
): UserSelection {
|
||||
return {
|
||||
...selection,
|
||||
overrides: {
|
||||
...selection.overrides,
|
||||
image_overrides: {
|
||||
...selection.overrides.image_overrides,
|
||||
[imageId]: geometry,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* IMP-56 (#90) u15 — record a single text-line capture (zone_id, text_path,
|
||||
* value) onto the in-memory selection's `text_overrides` axis. Mirrors
|
||||
* `saveImageOverride` (pure / immutable). u13's focusout capture emits one
|
||||
* entry per finished edit; Home u15's handler funnels each emit through this
|
||||
* helper before scheduling the debounced PUT (`saveUserOverrides` 300ms).
|
||||
*/
|
||||
export function saveTextOverride(
|
||||
selection: UserSelection,
|
||||
zoneId: string,
|
||||
textPath: string,
|
||||
value: string,
|
||||
): UserSelection {
|
||||
const prevZone = selection.overrides.text_overrides[zoneId] ?? {};
|
||||
return {
|
||||
...selection,
|
||||
overrides: {
|
||||
...selection.overrides,
|
||||
text_overrides: {
|
||||
...selection.overrides.text_overrides,
|
||||
[zoneId]: { ...prevZone, [textPath]: value },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* IMP-56 (#90) u15 — record a single structure capture (zone_id ↦
|
||||
* {slot_order, hidden_slots}) onto the in-memory selection's
|
||||
* `structure_overrides` axis. Scope-locked to slot reorder + hide (frame
|
||||
* swap stays on the `frames` axis). u14's overlay emits one entry per
|
||||
* user mutation; Home u15's handler funnels each emit through this
|
||||
* helper before scheduling the debounced PUT.
|
||||
*/
|
||||
export function saveStructureOverride(
|
||||
selection: UserSelection,
|
||||
zoneId: string,
|
||||
perZone: StructureOverridePerZone,
|
||||
): UserSelection {
|
||||
return {
|
||||
...selection,
|
||||
overrides: {
|
||||
...selection.overrides,
|
||||
structure_overrides: {
|
||||
...selection.overrides.structure_overrides,
|
||||
[zoneId]: {
|
||||
...(perZone.slot_order !== undefined && { slot_order: [...perZone.slot_order] }),
|
||||
...(perZone.hidden_slots !== undefined && { hidden_slots: [...perZone.hidden_slots] }),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function saveZoneSizes(selection: UserSelection, groupId: string, sizes: number[]): UserSelection {
|
||||
return {
|
||||
...selection,
|
||||
@@ -174,3 +474,77 @@ export function getEffectiveLayoutId(slidePlan: SlidePlan | null, selection: Use
|
||||
if (selection.overrides.layout_preset) return selection.overrides.layout_preset;
|
||||
return slidePlan?.layout_preset || 'single';
|
||||
}
|
||||
|
||||
// ─── IMP-44 (#73) u3 — zone_geometries layout-mismatch validation ───────────
|
||||
// Pure helper paired with the backend [override-warning] guards added in u1
|
||||
// (1-D horizontal-2 / vertical-2 branches of `build_layout_css`) and u2 (2-D
|
||||
// `_override_to_grid_tracks` call site). Same WARN+DROP / KEEP-known contract,
|
||||
// but expressed on the frontend so handleGenerate (u4) can validate against
|
||||
// the active layout *before* forwarding and surface a toast on dropped keys.
|
||||
//
|
||||
// Source of truth for expected positions = `computeZonePositions(layoutPreset)`
|
||||
// (designAgentApi.ts), which mirrors backend `layouts.yaml` (positions field).
|
||||
// Unknown layout (null / undefined / not in LAYOUT_PRESET_IDS) ⇒ fail-safe
|
||||
// drop-all: caller has no contract for projecting geometries onto an unknown
|
||||
// preset, so we keep zero keys rather than passing them through verbatim.
|
||||
|
||||
export interface ZoneGeometryValue {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface ZoneGeometriesValidationResult {
|
||||
kept: Record<string, ZoneGeometryValue>;
|
||||
dropped: Record<string, ZoneGeometryValue>;
|
||||
expectedPositions: string[];
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
export function validateZoneGeometriesAgainstLayout(
|
||||
geoms: Record<string, ZoneGeometryValue> | null | undefined,
|
||||
layoutPreset: LayoutPresetId | string | null | undefined,
|
||||
): ZoneGeometriesValidationResult {
|
||||
const kept: Record<string, ZoneGeometryValue> = {};
|
||||
const dropped: Record<string, ZoneGeometryValue> = {};
|
||||
const safeGeoms =
|
||||
geoms && typeof geoms === "object" && !Array.isArray(geoms) ? geoms : null;
|
||||
|
||||
// Unknown-layout fail-safe — drop everything; no expected positions known.
|
||||
if (typeof layoutPreset !== "string" || !LAYOUT_PRESET_IDS.has(layoutPreset)) {
|
||||
if (safeGeoms) {
|
||||
for (const [k, v] of Object.entries(safeGeoms)) {
|
||||
dropped[k] = v;
|
||||
}
|
||||
}
|
||||
return {
|
||||
kept,
|
||||
dropped,
|
||||
expectedPositions: [],
|
||||
valid: Object.keys(dropped).length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
const expectedPositions = computeZonePositions(
|
||||
layoutPreset as LayoutPresetId,
|
||||
).map((p) => p.name);
|
||||
const expectedSet = new Set(expectedPositions);
|
||||
|
||||
if (safeGeoms) {
|
||||
for (const [k, v] of Object.entries(safeGeoms)) {
|
||||
if (expectedSet.has(k)) {
|
||||
kept[k] = v;
|
||||
} else {
|
||||
dropped[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kept,
|
||||
dropped,
|
||||
expectedPositions,
|
||||
valid: Object.keys(dropped).length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
117
Front/client/tests/handle_generate_diag.test.ts
Normal file
117
Front/client/tests/handle_generate_diag.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
// IMP-42 u4 — Source-slice coverage for the unconditional handleGenerate
|
||||
// DIAG console.log on the frontend → backend boundary (issue #71).
|
||||
//
|
||||
// Scope (Stage 2 unit u4 contract):
|
||||
// 1) A single `console.log("[DIAG raw overrides]", ...)` call exists
|
||||
// inside handleGenerate and precedes the runPipeline call site.
|
||||
// 2) The DIAG call is unconditional — not wrapped in `if (...)` / `?:` /
|
||||
// env-var gate / `__DEV__`-style guard. "Silence is the bug" per
|
||||
// Stage 1 scope-lock (Codex #3) and the Step 13 backend mirror
|
||||
// already landed in u3.
|
||||
// 3) The DIAG payload carries shape-only metadata — uploaded file name
|
||||
// and the override payload object — without referencing raw MDX
|
||||
// content or any other sample-specific identifier (RULE 0).
|
||||
//
|
||||
// Why source-slice (per Stage 2 plan): Home.tsx handleGenerate is wired to
|
||||
// React state, toast, and a 700-line component tree; the cheapest way to
|
||||
// pin a single-line surface and prove placement relative to runPipeline is
|
||||
// to read the source and assert ordering. No React rendering, no fetch
|
||||
// mock, no DOM. Mirrors the existing pure-helper pattern in
|
||||
// tests/imp41_application_mode.test.ts.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const HOME_TSX_PATH = resolve(__dirname, "..", "src", "pages", "Home.tsx");
|
||||
const HOME_TSX_SOURCE = readFileSync(HOME_TSX_PATH, "utf-8");
|
||||
|
||||
// Locate the handleGenerate callback body. The closing brace of
|
||||
// useCallback's `async () => { ... }` is the next line whose indent matches
|
||||
// the opening `useCallback(async () => {` exactly — but a simpler proxy is
|
||||
// "from the handleGenerate keyword to the next useCallback declaration or
|
||||
// the end-of-file." This is sufficient to scope every assertion below to
|
||||
// the right function body.
|
||||
function sliceHandleGenerateBody(source: string): string {
|
||||
const startMarker = "const handleGenerate = useCallback(async () =>";
|
||||
const startIdx = source.indexOf(startMarker);
|
||||
if (startIdx === -1) {
|
||||
throw new Error("handleGenerate declaration not found in Home.tsx");
|
||||
}
|
||||
// End at the next top-level `const ` that begins a new useCallback /
|
||||
// useMemo / hook binding. handleGenerate is followed by additional
|
||||
// hooks (handleFileUpload sibling pattern); slicing to the next
|
||||
// declaration is more than enough to capture the full body.
|
||||
const afterStart = source.slice(startIdx + startMarker.length);
|
||||
const nextDeclIdx = afterStart.search(/\n {2}const [A-Za-z]/);
|
||||
return nextDeclIdx === -1 ? afterStart : afterStart.slice(0, nextDeclIdx);
|
||||
}
|
||||
|
||||
const HANDLE_GENERATE_BODY = sliceHandleGenerateBody(HOME_TSX_SOURCE);
|
||||
|
||||
describe("handleGenerate [DIAG raw overrides] (IMP-42 u4)", () => {
|
||||
it("emits exactly one console.log labelled '[DIAG raw overrides]' inside handleGenerate", () => {
|
||||
const matches = HANDLE_GENERATE_BODY.match(
|
||||
/console\.log\(\s*"\[DIAG raw overrides\]"/g,
|
||||
);
|
||||
expect(matches).not.toBeNull();
|
||||
// Exactly one DIAG site per Stage 2 contract — multiple calls would
|
||||
// either be a copy-paste regression or evidence that the helper
|
||||
// moved without removing the old site.
|
||||
expect(matches?.length).toBe(1);
|
||||
});
|
||||
|
||||
it("places the DIAG console.log before the runPipeline call site", () => {
|
||||
const diagIdx = HANDLE_GENERATE_BODY.indexOf('console.log("[DIAG raw overrides]"');
|
||||
const runPipelineIdx = HANDLE_GENERATE_BODY.indexOf(
|
||||
"runPipeline(state.uploadedFile, overrides)",
|
||||
);
|
||||
expect(diagIdx).toBeGreaterThan(-1);
|
||||
expect(runPipelineIdx).toBeGreaterThan(-1);
|
||||
expect(diagIdx).toBeLessThan(runPipelineIdx);
|
||||
});
|
||||
|
||||
it("is unconditional — no env-var gate or if-guard wraps the DIAG call", () => {
|
||||
// Slice the 80 chars immediately preceding the DIAG console.log and
|
||||
// confirm none of the common gating patterns appear directly above.
|
||||
const diagIdx = HANDLE_GENERATE_BODY.indexOf('console.log("[DIAG raw overrides]"');
|
||||
const preface = HANDLE_GENERATE_BODY.slice(Math.max(0, diagIdx - 200), diagIdx);
|
||||
// Stage 1 contract: silence is the bug. Any gate here is a regression.
|
||||
expect(preface).not.toMatch(/if\s*\([^)]*\)\s*$/m);
|
||||
expect(preface).not.toMatch(/process\.env/);
|
||||
expect(preface).not.toMatch(/import\.meta\.env/);
|
||||
expect(preface).not.toMatch(/__DEV__/);
|
||||
expect(preface).not.toMatch(/DIAG_VERBOSE/i);
|
||||
expect(preface).not.toMatch(/DEBUG/);
|
||||
});
|
||||
|
||||
it("forwards the file name and overrides object as shape-only payload", () => {
|
||||
// The DIAG payload must include the uploaded file name (so the user
|
||||
// can correlate the log line with the MDX they uploaded) and the
|
||||
// overrides object (so the user can see what crossed the wire).
|
||||
// It must NOT spread MDX text content or any other large blob —
|
||||
// sample-agnostic and reviewable in a single log line.
|
||||
const diagIdx = HANDLE_GENERATE_BODY.indexOf('console.log("[DIAG raw overrides]"');
|
||||
const window = HANDLE_GENERATE_BODY.slice(diagIdx, diagIdx + 300);
|
||||
// Both fields appear in the payload object literal.
|
||||
expect(window).toMatch(/file:\s*state\.uploadedFile\.name/);
|
||||
expect(window).toMatch(/\boverrides\b/);
|
||||
// Sanity: the payload does not pass MDX raw content / a File blob.
|
||||
expect(window).not.toMatch(/mdxContent|rawMdx|normalizedContent/);
|
||||
});
|
||||
|
||||
it("runs after flushUserOverrides() so the persisted PUT is already committed", () => {
|
||||
// Ordering invariant from IMP-52 u10 (already in place):
|
||||
// flushUserOverrides() → DIAG → runPipeline
|
||||
// Asserts the DIAG sits between the flush and the network call so the
|
||||
// logged overrides match what backend reads from disk.
|
||||
const flushIdx = HANDLE_GENERATE_BODY.indexOf("await flushUserOverrides()");
|
||||
const diagIdx = HANDLE_GENERATE_BODY.indexOf('console.log("[DIAG raw overrides]"');
|
||||
const runPipelineIdx = HANDLE_GENERATE_BODY.indexOf(
|
||||
"runPipeline(state.uploadedFile, overrides)",
|
||||
);
|
||||
expect(flushIdx).toBeGreaterThan(-1);
|
||||
expect(diagIdx).toBeGreaterThan(flushIdx);
|
||||
expect(diagIdx).toBeLessThan(runPipelineIdx);
|
||||
});
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
257
Front/client/tests/imp47b_human_review_toast.test.tsx
Normal file
257
Front/client/tests/imp47b_human_review_toast.test.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
// IMP-92 u5 — Frontend AI repair operational-only formatter test surface.
|
||||
//
|
||||
// Scope (Stage 2 unit u5 contract):
|
||||
// 1) formatAiRepairHumanReviewMessage(...) surfaces a user-facing toast
|
||||
// ONLY on the three operational Anthropic API error kinds (quota /
|
||||
// billing / auth) classified by Step 12 u2
|
||||
// (classify_operational_error) and aggregated through u3
|
||||
// ai_repair_status.api_error_kinds.
|
||||
// 2) Non-operational AI failures (validation / coverage_violated /
|
||||
// unsupported_kind / generic "other") return null so the
|
||||
// auto-pipeline stays silent per feedback_auto_pipeline_first and
|
||||
// the #84 operational-vs-non-operational replacement-plan contract.
|
||||
// 3) Replaces the prior IMP-47B u11 surface — previously rendered toasts
|
||||
// for error / coverage_violated / unsupported_kind. After IMP-92 the
|
||||
// ONLY operational reaches the user; non-operational stays silent.
|
||||
//
|
||||
// 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 (`Home.tsx:438`) that calls this helper
|
||||
// after `setRunMeta(...)`; covering the helper covers the user-visible
|
||||
// message text directly without DOM rendering.
|
||||
//
|
||||
// The test file path is preserved from IMP-47B u11 (Stage 2 plan
|
||||
// `Front/client/tests/imp47b_human_review_toast.test.tsx`); the assertions
|
||||
// inside reflect the IMP-92 u5 operational-only contract.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
formatAiRepairHumanReviewMessage,
|
||||
type AiRepairStatus,
|
||||
} from "../src/services/designAgentApi";
|
||||
|
||||
const baseCounts = {
|
||||
total: 0,
|
||||
applied: 0,
|
||||
no_proposal: 0,
|
||||
no_zone_match: 0,
|
||||
unsupported_kind: 0,
|
||||
error: 0,
|
||||
};
|
||||
|
||||
const zeroKinds = { quota: 0, billing: 0, auth: 0, other: 0 };
|
||||
|
||||
describe("formatAiRepairHumanReviewMessage (IMP-92 u5 — operational-only)", () => {
|
||||
it("returns null when ai_repair_status is null / undefined", () => {
|
||||
expect(formatAiRepairHumanReviewMessage(null)).toBeNull();
|
||||
expect(formatAiRepairHumanReviewMessage(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on success / no-AI path (no operational kind present)", () => {
|
||||
const ok: AiRepairStatus = {
|
||||
status: "ok",
|
||||
counts: { ...baseCounts },
|
||||
api_error_kinds: { ...zeroKinds },
|
||||
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 quota operational alert (Anthropic 429 / RateLimitError)", () => {
|
||||
const ai: AiRepairStatus = {
|
||||
status: "error",
|
||||
counts: { ...baseCounts, total: 2, error: 2 },
|
||||
api_error_kinds: { quota: 2, billing: 0, auth: 0, other: 0 },
|
||||
unsupported_kind_records: [],
|
||||
error_records: [
|
||||
{
|
||||
unit_index: 0,
|
||||
source_section_ids: ["03-1"],
|
||||
error: "RateLimitError: rate_limit_exceeded",
|
||||
api_error_kind: "quota",
|
||||
},
|
||||
{
|
||||
unit_index: 1,
|
||||
source_section_ids: ["03-2"],
|
||||
error: "RateLimitError: rate_limit_exceeded",
|
||||
api_error_kind: "quota",
|
||||
},
|
||||
],
|
||||
coverage_status: "ok",
|
||||
dropped_section_ids: [],
|
||||
human_review_required: true,
|
||||
};
|
||||
const msg = formatAiRepairHumanReviewMessage(ai);
|
||||
expect(msg).not.toBeNull();
|
||||
expect(msg).toContain("API quota");
|
||||
expect(msg).toContain("충전 필요");
|
||||
expect(msg).toContain("2");
|
||||
});
|
||||
|
||||
it("surfaces billing operational alert (Anthropic 402 / PermissionDeniedError)", () => {
|
||||
const ai: AiRepairStatus = {
|
||||
status: "error",
|
||||
counts: { ...baseCounts, total: 1, error: 1 },
|
||||
api_error_kinds: { quota: 0, billing: 1, auth: 0, other: 0 },
|
||||
unsupported_kind_records: [],
|
||||
error_records: [
|
||||
{
|
||||
unit_index: 0,
|
||||
source_section_ids: ["03-1"],
|
||||
error: "PermissionDeniedError: insufficient credits",
|
||||
api_error_kind: "billing",
|
||||
},
|
||||
],
|
||||
coverage_status: "ok",
|
||||
dropped_section_ids: [],
|
||||
human_review_required: true,
|
||||
};
|
||||
const msg = formatAiRepairHumanReviewMessage(ai);
|
||||
expect(msg).not.toBeNull();
|
||||
expect(msg).toContain("API billing");
|
||||
expect(msg).toContain("결제 정보 확인");
|
||||
expect(msg).toContain("1");
|
||||
});
|
||||
|
||||
it("surfaces auth operational alert (Anthropic 401 / AuthenticationError)", () => {
|
||||
const ai: AiRepairStatus = {
|
||||
status: "error",
|
||||
counts: { ...baseCounts, total: 1, error: 1 },
|
||||
api_error_kinds: { quota: 0, billing: 0, auth: 1, other: 0 },
|
||||
unsupported_kind_records: [],
|
||||
error_records: [
|
||||
{
|
||||
unit_index: 0,
|
||||
source_section_ids: ["03-1"],
|
||||
error: "AuthenticationError: invalid x-api-key",
|
||||
api_error_kind: "auth",
|
||||
},
|
||||
],
|
||||
coverage_status: "ok",
|
||||
dropped_section_ids: [],
|
||||
human_review_required: true,
|
||||
};
|
||||
const msg = formatAiRepairHumanReviewMessage(ai);
|
||||
expect(msg).not.toBeNull();
|
||||
expect(msg).toContain("API key 무효");
|
||||
expect(msg).toContain(".env");
|
||||
expect(msg).toContain("1");
|
||||
});
|
||||
|
||||
it("returns null on generic non-operational 'other' API error (silent)", () => {
|
||||
const ai: AiRepairStatus = {
|
||||
status: "error",
|
||||
counts: { ...baseCounts, total: 1, error: 1 },
|
||||
api_error_kinds: { quota: 0, billing: 0, auth: 0, other: 1 },
|
||||
unsupported_kind_records: [],
|
||||
error_records: [
|
||||
{
|
||||
unit_index: 0,
|
||||
source_section_ids: ["03-1"],
|
||||
error: "ValidationError: proposal failed schema",
|
||||
api_error_kind: "other",
|
||||
},
|
||||
],
|
||||
coverage_status: "ok",
|
||||
dropped_section_ids: [],
|
||||
human_review_required: true,
|
||||
};
|
||||
expect(formatAiRepairHumanReviewMessage(ai)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on coverage_violated (non-operational, silent)", () => {
|
||||
const ai: AiRepairStatus = {
|
||||
status: "coverage_violated",
|
||||
counts: { ...baseCounts, total: 1, applied: 1 },
|
||||
api_error_kinds: { ...zeroKinds },
|
||||
unsupported_kind_records: [],
|
||||
error_records: [],
|
||||
coverage_status: "violated",
|
||||
dropped_section_ids: ["03-2"],
|
||||
human_review_required: true,
|
||||
};
|
||||
expect(formatAiRepairHumanReviewMessage(ai)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on unsupported_kind (non-operational, silent)", () => {
|
||||
const ai: AiRepairStatus = {
|
||||
status: "unsupported_kind",
|
||||
counts: { ...baseCounts, total: 1, unsupported_kind: 1 },
|
||||
api_error_kinds: { ...zeroKinds },
|
||||
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,
|
||||
};
|
||||
expect(formatAiRepairHumanReviewMessage(ai)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on legacy ai_repair_status without api_error_kinds (pre-u3 runs)", () => {
|
||||
// Backward-compat: payloads emitted before u3 plumbing landed don't
|
||||
// carry api_error_kinds. Operational-only contract treats the absence
|
||||
// as "no operational signal" → silent (no toast).
|
||||
const legacy: AiRepairStatus = {
|
||||
status: "error",
|
||||
counts: { ...baseCounts, total: 1, error: 1 },
|
||||
// api_error_kinds intentionally omitted
|
||||
unsupported_kind_records: [],
|
||||
error_records: [
|
||||
{ unit_index: 0, source_section_ids: ["03-1"], error: "timeout" },
|
||||
],
|
||||
coverage_status: "ok",
|
||||
dropped_section_ids: [],
|
||||
human_review_required: true,
|
||||
};
|
||||
expect(formatAiRepairHumanReviewMessage(legacy)).toBeNull();
|
||||
});
|
||||
|
||||
it("prioritises quota when multiple operational kinds co-occur", () => {
|
||||
// Defensive: a run that accumulated quota + billing errors across
|
||||
// multiple AI repair attempts surfaces the quota line first (the
|
||||
// most-frequently actionable per the issue body ordering).
|
||||
const ai: AiRepairStatus = {
|
||||
status: "error",
|
||||
counts: { ...baseCounts, total: 2, error: 2 },
|
||||
api_error_kinds: { quota: 1, billing: 1, auth: 0, other: 0 },
|
||||
unsupported_kind_records: [],
|
||||
error_records: [
|
||||
{
|
||||
unit_index: 0,
|
||||
source_section_ids: ["03-1"],
|
||||
error: "RateLimitError",
|
||||
api_error_kind: "quota",
|
||||
},
|
||||
{
|
||||
unit_index: 1,
|
||||
source_section_ids: ["03-2"],
|
||||
error: "PermissionDeniedError",
|
||||
api_error_kind: "billing",
|
||||
},
|
||||
],
|
||||
coverage_status: "ok",
|
||||
dropped_section_ids: [],
|
||||
human_review_required: true,
|
||||
};
|
||||
const msg = formatAiRepairHumanReviewMessage(ai);
|
||||
expect(msg).not.toBeNull();
|
||||
expect(msg).toContain("API quota");
|
||||
});
|
||||
});
|
||||
122
Front/client/tests/imp84_framepanel_reject_silent.test.ts
Normal file
122
Front/client/tests/imp84_framepanel_reject_silent.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
// IMP-#84 u1 — FramePanel reject silent-automation contract.
|
||||
//
|
||||
// Stage 2 unit u1 scope:
|
||||
// 1) `applyFrameSelection(candidate, onFrameSelect)` invokes onFrameSelect
|
||||
// with candidate.id verbatim for EVERY V4 label
|
||||
// (use_as_is / light_edit / restructure / reject) — no window.confirm
|
||||
// gate, no label-conditional branch, no frame swap.
|
||||
// 2) Source-presence checks pin the FramePanel.tsx wiring so the runtime
|
||||
// button → handler → helper chain stays intact even though we cannot
|
||||
// mount React (no jsdom / RTL / happy-dom in Front devDependencies —
|
||||
// verified against the IMP-56 u20 `imp90_bottom_actions.test.ts` and
|
||||
// IMP-92 u5 `imp47b_human_review_toast.test.tsx` precedent that
|
||||
// explicitly skip DOM mounting).
|
||||
// 3) No `window.confirm` substring remains in FramePanel.tsx after u1.
|
||||
//
|
||||
// Out of scope (Stage 2 exit-report contract):
|
||||
// - Home.tsx:523-524 `toast.error(aiReviewMsg)` (#92 operational-only).
|
||||
// - FramePanel reject badge/tooltip read-only labels at L102/L147/L156
|
||||
// (no popup trigger; preserved as silent operator hint).
|
||||
// - Backend `zone.provisional` emission (handled by u2 template-only).
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { applyFrameSelection } from "../src/components/FramePanel";
|
||||
import type { FrameCandidate } from "../src/types/designAgent";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const FRAME_PANEL_SOURCE = readFileSync(
|
||||
resolve(__dirname, "../src/components/FramePanel.tsx"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
function makeCandidate(
|
||||
label: FrameCandidate["label"],
|
||||
id: string,
|
||||
): FrameCandidate {
|
||||
return {
|
||||
id,
|
||||
name: `Frame ${id}`,
|
||||
score: 0.5,
|
||||
confidence: "medium",
|
||||
label,
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyFrameSelection (IMP-#84 u1 — silent-automation contract)", () => {
|
||||
it("forwards candidate.id to onFrameSelect for use_as_is label", () => {
|
||||
const onFrameSelect = vi.fn();
|
||||
applyFrameSelection(makeCandidate("use_as_is", "frame_a"), onFrameSelect);
|
||||
expect(onFrameSelect).toHaveBeenCalledTimes(1);
|
||||
expect(onFrameSelect).toHaveBeenCalledWith("frame_a");
|
||||
});
|
||||
|
||||
it("forwards candidate.id to onFrameSelect for light_edit label", () => {
|
||||
const onFrameSelect = vi.fn();
|
||||
applyFrameSelection(makeCandidate("light_edit", "frame_b"), onFrameSelect);
|
||||
expect(onFrameSelect).toHaveBeenCalledTimes(1);
|
||||
expect(onFrameSelect).toHaveBeenCalledWith("frame_b");
|
||||
});
|
||||
|
||||
it("forwards candidate.id to onFrameSelect for restructure label", () => {
|
||||
const onFrameSelect = vi.fn();
|
||||
applyFrameSelection(makeCandidate("restructure", "frame_c"), onFrameSelect);
|
||||
expect(onFrameSelect).toHaveBeenCalledTimes(1);
|
||||
expect(onFrameSelect).toHaveBeenCalledWith("frame_c");
|
||||
});
|
||||
|
||||
it("forwards candidate.id to onFrameSelect for reject label — no popup, no frame swap", () => {
|
||||
// Reject is the silent-automation pivot case: prior IMP-47B u11 gated
|
||||
// this path with window.confirm; post-IMP-#84 the helper invokes
|
||||
// onFrameSelect with the reject frame.id directly. Backend / AI 격리
|
||||
// contract handles AI 재구성 (content-only, frame preserved).
|
||||
const onFrameSelect = vi.fn();
|
||||
applyFrameSelection(makeCandidate("reject", "frame_d"), onFrameSelect);
|
||||
expect(onFrameSelect).toHaveBeenCalledTimes(1);
|
||||
expect(onFrameSelect).toHaveBeenCalledWith("frame_d");
|
||||
});
|
||||
|
||||
it("does not call onFrameSelect more than once per invocation", () => {
|
||||
const onFrameSelect = vi.fn();
|
||||
applyFrameSelection(makeCandidate("reject", "frame_e"), onFrameSelect);
|
||||
applyFrameSelection(makeCandidate("use_as_is", "frame_f"), onFrameSelect);
|
||||
expect(onFrameSelect).toHaveBeenCalledTimes(2);
|
||||
expect(onFrameSelect).toHaveBeenNthCalledWith(1, "frame_e");
|
||||
expect(onFrameSelect).toHaveBeenNthCalledWith(2, "frame_f");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FramePanel.tsx source — silent-automation wiring pins (IMP-#84 u1)", () => {
|
||||
it("has no window.confirm(...) call (popup removed; narrative mentions in comments are allowed)", () => {
|
||||
// Match the call form `window.confirm(` rather than the bare substring
|
||||
// so that explanatory comments documenting the removed popup are not
|
||||
// flagged. A re-introduced call would carry an opening paren.
|
||||
expect(FRAME_PANEL_SOURCE).not.toMatch(/\bwindow\.confirm\s*\(/);
|
||||
});
|
||||
|
||||
it("does not embed the legacy reject-confirm Korean prompt body", () => {
|
||||
// Prior IMP-47B u11 string fragment; absence guards against re-introduction.
|
||||
expect(FRAME_PANEL_SOURCE).not.toContain("V4 reject 라벨입니다");
|
||||
expect(FRAME_PANEL_SOURCE).not.toContain("계속하시겠습니까?");
|
||||
});
|
||||
|
||||
it("wires the button onClick to handleFrameSelect(candidate)", () => {
|
||||
expect(FRAME_PANEL_SOURCE).toContain(
|
||||
"onClick={() => handleFrameSelect(candidate)}",
|
||||
);
|
||||
});
|
||||
|
||||
it("delegates handleFrameSelect body to applyFrameSelection", () => {
|
||||
expect(FRAME_PANEL_SOURCE).toContain(
|
||||
"applyFrameSelection(candidate, onFrameSelect)",
|
||||
);
|
||||
});
|
||||
|
||||
it("exports applyFrameSelection as a named export for caller-independent reuse", () => {
|
||||
expect(FRAME_PANEL_SOURCE).toMatch(
|
||||
/export function applyFrameSelection\(/,
|
||||
);
|
||||
});
|
||||
});
|
||||
90
Front/client/tests/imp90_bottom_actions.test.ts
Normal file
90
Front/client/tests/imp90_bottom_actions.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
// IMP-56 (#90) u20 — vitest coverage for the pure request builders exported
|
||||
// by `BottomActions`. The React component itself is not rendered (jsdom /
|
||||
// @testing-library NOT in Front devDependencies — verified against the prior
|
||||
// u14 `imp90_structure_overlay.test.tsx` pattern); we test the deterministic
|
||||
// pieces that drive the network payload sent to the u18 / u19 middlewares.
|
||||
//
|
||||
// Upstream / downstream contracts (verified by prior units):
|
||||
// - u18 /api/connect : body shape = { run_id, slug } (Front/vite.config.ts
|
||||
// handleConnectMirror — `imp90_connect_endpoint.test.ts`).
|
||||
// - u19 /api/export : body shape = { run_id }; response = raw text/html
|
||||
// with `Content-Disposition: attachment; filename="<run_id>.html"`
|
||||
// (Front/vite.config.ts handleExportStandalone —
|
||||
// `imp90_export_endpoint.test.ts`).
|
||||
//
|
||||
// u20 scope: builders only. Any drift in URL or JSON shape fails here before
|
||||
// the request leaves the client. Toast / fetch / blob plumbing is not tested
|
||||
// (it would require jsdom + a fetch mock; the existing server-side tests
|
||||
// already pin the wire contract).
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildConnectRequest,
|
||||
buildExportRequest,
|
||||
buildDownloadFilename,
|
||||
} from "../src/components/BottomActions";
|
||||
|
||||
describe("buildConnectRequest", () => {
|
||||
it("targets /api/connect", () => {
|
||||
const { url } = buildConnectRequest("run_42", "mdx_03");
|
||||
expect(url).toBe("/api/connect");
|
||||
});
|
||||
|
||||
it("emits { run_id, slug } JSON body — matches u18 middleware shape", () => {
|
||||
const { body } = buildConnectRequest("run_42", "mdx_03");
|
||||
expect(JSON.parse(body)).toEqual({ run_id: "run_42", slug: "mdx_03" });
|
||||
});
|
||||
|
||||
it("preserves zero-length and unicode run_id verbatim (server validates)", () => {
|
||||
const { body } = buildConnectRequest("", "x");
|
||||
expect(JSON.parse(body)).toEqual({ run_id: "", slug: "x" });
|
||||
const { body: uni } = buildConnectRequest("런", "슬러그");
|
||||
expect(JSON.parse(uni)).toEqual({ run_id: "런", slug: "슬러그" });
|
||||
});
|
||||
|
||||
it("does not leak extra keys (frame swap / overrides etc.)", () => {
|
||||
const { body } = buildConnectRequest("r", "s");
|
||||
expect(Object.keys(JSON.parse(body)).sort()).toEqual(["run_id", "slug"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildExportRequest", () => {
|
||||
it("targets /api/export", () => {
|
||||
const { url } = buildExportRequest("run_42");
|
||||
expect(url).toBe("/api/export");
|
||||
});
|
||||
|
||||
it("emits { run_id } JSON body — matches u19 middleware shape", () => {
|
||||
const { body } = buildExportRequest("run_42");
|
||||
expect(JSON.parse(body)).toEqual({ run_id: "run_42" });
|
||||
});
|
||||
|
||||
it("does not leak extra keys (slug / format etc.)", () => {
|
||||
const { body } = buildExportRequest("r");
|
||||
expect(Object.keys(JSON.parse(body))).toEqual(["run_id"]);
|
||||
});
|
||||
|
||||
it("preserves zero-length and unicode run_id verbatim (server validates)", () => {
|
||||
expect(JSON.parse(buildExportRequest("").body)).toEqual({ run_id: "" });
|
||||
expect(JSON.parse(buildExportRequest("런").body)).toEqual({ run_id: "런" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDownloadFilename", () => {
|
||||
it("returns <run_id>.html for the a[download] click chain", () => {
|
||||
expect(buildDownloadFilename("run_42")).toBe("run_42.html");
|
||||
});
|
||||
|
||||
it("appends exactly one .html suffix even when run_id already ends in .html", () => {
|
||||
// The server-side `Content-Disposition` already carries the same
|
||||
// filename; we mirror it verbatim so browser default behavior wins.
|
||||
// We intentionally do NOT strip a trailing `.html` — run_id is the
|
||||
// backend's `Path(args.mdx_path).stem`-style key, which never contains
|
||||
// a dot suffix (validated by `isValidUserOverridesKey` at u18/u19).
|
||||
expect(buildDownloadFilename("foo.html")).toBe("foo.html.html");
|
||||
});
|
||||
|
||||
it("returns just .html for empty run_id (server rejects upstream)", () => {
|
||||
expect(buildDownloadFilename("")).toBe(".html");
|
||||
});
|
||||
});
|
||||
282
Front/client/tests/imp90_connect_endpoint.test.ts
Normal file
282
Front/client/tests/imp90_connect_endpoint.test.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
// IMP-56 (#90) u18 — vitest coverage for the vite POST /api/connect
|
||||
// middleware and its supporting mirrorDirRecursive helper.
|
||||
//
|
||||
// Scope:
|
||||
// 1) mirrorDirRecursive (pure helper):
|
||||
// - absent src → returns 0 (no-throw, no dst creation).
|
||||
// - file-only src → flat copy + count.
|
||||
// - nested src → recursive copy + count.
|
||||
// - overwrites pre-existing dst files (cel mirror semantics).
|
||||
// 2) handleConnectMirror (POST):
|
||||
// - method != POST → false (chain continues; next middleware may handle).
|
||||
// - invalid JSON / non-object body → 400.
|
||||
// - missing run_id or slug → 400.
|
||||
// - invalid run_id or slug (key gate / path traversal) → 400.
|
||||
// - final.html missing → 404.
|
||||
// - success without run-assets dir → 200, assets_copied: 0, html copy ok.
|
||||
// - success with run-assets dir → 200, assets_copied = file count, dst dir
|
||||
// populated.
|
||||
// - dstSlidesDir auto-created when celRoot/public/slides missing.
|
||||
//
|
||||
// Tests exercise the pure handler with mock req/res — no real vite server.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
handleConnectMirror,
|
||||
mirrorDirRecursive,
|
||||
} from "../../vite.config";
|
||||
|
||||
function makeMockRes() {
|
||||
const state = {
|
||||
statusCode: 0,
|
||||
headers: {} as Record<string, string>,
|
||||
body: "",
|
||||
ended: false,
|
||||
};
|
||||
return {
|
||||
state,
|
||||
res: {
|
||||
writeHead(status: number, headers?: Record<string, string>) {
|
||||
state.statusCode = status;
|
||||
if (headers) state.headers = headers;
|
||||
},
|
||||
end(body?: string) {
|
||||
state.body = body ?? "";
|
||||
state.ended = true;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeMockReq(opts: {
|
||||
method?: string;
|
||||
}): EventEmitter & { method?: string; send: (body: string) => void } {
|
||||
const ee = new EventEmitter() as EventEmitter & {
|
||||
method?: string;
|
||||
send: (body: string) => void;
|
||||
};
|
||||
ee.method = opts.method;
|
||||
ee.send = (body: string) => {
|
||||
if (body.length > 0) ee.emit("data", Buffer.from(body, "utf-8"));
|
||||
ee.emit("end");
|
||||
};
|
||||
return ee;
|
||||
}
|
||||
|
||||
function seedRun(daRoot: string, runId: string, htmlBody: string): string {
|
||||
const runDir = path.join(daRoot, "data", "runs", runId, "phase_z2");
|
||||
fs.mkdirSync(runDir, { recursive: true });
|
||||
const html = path.join(runDir, "final.html");
|
||||
fs.writeFileSync(html, htmlBody, "utf-8");
|
||||
return runDir;
|
||||
}
|
||||
|
||||
describe("mirrorDirRecursive (IMP-56 #90 u18)", () => {
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u18-mirror-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns 0 and does not throw when src absent", () => {
|
||||
const dst = path.join(tmp, "dst");
|
||||
const n = mirrorDirRecursive(path.join(tmp, "missing"), dst);
|
||||
expect(n).toBe(0);
|
||||
expect(fs.existsSync(dst)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 0 when src exists but is a file (not a directory)", () => {
|
||||
const srcFile = path.join(tmp, "src.txt");
|
||||
fs.writeFileSync(srcFile, "x", "utf-8");
|
||||
const dst = path.join(tmp, "dst");
|
||||
const n = mirrorDirRecursive(srcFile, dst);
|
||||
expect(n).toBe(0);
|
||||
expect(fs.existsSync(dst)).toBe(false);
|
||||
});
|
||||
|
||||
it("flat-copies file entries and returns the file count", () => {
|
||||
const src = path.join(tmp, "src");
|
||||
fs.mkdirSync(src);
|
||||
fs.writeFileSync(path.join(src, "a.css"), "/*a*/", "utf-8");
|
||||
fs.writeFileSync(path.join(src, "b.png"), "PNG", "utf-8");
|
||||
const dst = path.join(tmp, "dst");
|
||||
const n = mirrorDirRecursive(src, dst);
|
||||
expect(n).toBe(2);
|
||||
expect(fs.readFileSync(path.join(dst, "a.css"), "utf-8")).toBe("/*a*/");
|
||||
expect(fs.readFileSync(path.join(dst, "b.png"), "utf-8")).toBe("PNG");
|
||||
});
|
||||
|
||||
it("recurses into nested directories and counts only files", () => {
|
||||
const src = path.join(tmp, "src");
|
||||
fs.mkdirSync(path.join(src, "nested", "deep"), { recursive: true });
|
||||
fs.writeFileSync(path.join(src, "root.txt"), "r", "utf-8");
|
||||
fs.writeFileSync(path.join(src, "nested", "n.txt"), "n", "utf-8");
|
||||
fs.writeFileSync(path.join(src, "nested", "deep", "d.txt"), "d", "utf-8");
|
||||
const dst = path.join(tmp, "dst");
|
||||
const n = mirrorDirRecursive(src, dst);
|
||||
expect(n).toBe(3);
|
||||
expect(fs.readFileSync(path.join(dst, "nested", "deep", "d.txt"), "utf-8"))
|
||||
.toBe("d");
|
||||
});
|
||||
|
||||
it("overwrites pre-existing files in dst (cel mirror semantics)", () => {
|
||||
const src = path.join(tmp, "src");
|
||||
fs.mkdirSync(src);
|
||||
fs.writeFileSync(path.join(src, "a.css"), "NEW", "utf-8");
|
||||
const dst = path.join(tmp, "dst");
|
||||
fs.mkdirSync(dst);
|
||||
fs.writeFileSync(path.join(dst, "a.css"), "OLD", "utf-8");
|
||||
mirrorDirRecursive(src, dst);
|
||||
expect(fs.readFileSync(path.join(dst, "a.css"), "utf-8")).toBe("NEW");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleConnectMirror (IMP-56 #90 u18)", () => {
|
||||
let daRoot: string;
|
||||
let celRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
daRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u18-da-"));
|
||||
celRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u18-cel-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(daRoot, { recursive: true, force: true });
|
||||
fs.rmSync(celRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns false (next chained) when method != POST", () => {
|
||||
const req = makeMockReq({ method: "GET" });
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handleConnectMirror(req, res, daRoot, celRoot);
|
||||
expect(handled).toBe(false);
|
||||
expect(state.ended).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 400 on invalid JSON body", () => {
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handleConnectMirror(req, res, daRoot, celRoot);
|
||||
expect(handled).toBe(true);
|
||||
req.send("{not-json}");
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body).error).toBe("invalid JSON");
|
||||
});
|
||||
|
||||
it("returns 400 when body is not a JSON object (array root)", () => {
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleConnectMirror(req, res, daRoot, celRoot);
|
||||
req.send(JSON.stringify(["not", "an", "object"]));
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body).error).toBe("body must be a JSON object");
|
||||
});
|
||||
|
||||
it("returns 400 when run_id or slug is missing", () => {
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleConnectMirror(req, res, daRoot, celRoot);
|
||||
req.send(JSON.stringify({ run_id: "abc" })); // slug missing
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body).error).toBe("missing run_id or slug");
|
||||
});
|
||||
|
||||
it("returns 400 when run_id contains path traversal", () => {
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleConnectMirror(req, res, daRoot, celRoot);
|
||||
req.send(JSON.stringify({ run_id: "../escape", slug: "03" }));
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body).error).toBe("invalid run_id or slug");
|
||||
});
|
||||
|
||||
it("returns 400 when slug contains a forward slash", () => {
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleConnectMirror(req, res, daRoot, celRoot);
|
||||
req.send(JSON.stringify({ run_id: "valid_id", slug: "03/etc" }));
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body).error).toBe("invalid run_id or slug");
|
||||
});
|
||||
|
||||
it("returns 404 when final.html does not exist for run_id", () => {
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleConnectMirror(req, res, daRoot, celRoot);
|
||||
req.send(JSON.stringify({ run_id: "ghost_run", slug: "03" }));
|
||||
expect(state.statusCode).toBe(404);
|
||||
expect(JSON.parse(state.body).error).toBe("final.html not found");
|
||||
});
|
||||
|
||||
it("copies final.html to cel/public/slides/<slug>.html on success", () => {
|
||||
seedRun(daRoot, "mdx03_run", "<html>03</html>");
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleConnectMirror(req, res, daRoot, celRoot);
|
||||
req.send(JSON.stringify({ run_id: "mdx03_run", slug: "03" }));
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
const dstHtml = path.join(celRoot, "public", "slides", "03.html");
|
||||
expect(fs.existsSync(dstHtml)).toBe(true);
|
||||
expect(fs.readFileSync(dstHtml, "utf-8")).toBe("<html>03</html>");
|
||||
const body = JSON.parse(state.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.run_id).toBe("mdx03_run");
|
||||
expect(body.slug).toBe("03");
|
||||
expect(body.assets_copied).toBe(0);
|
||||
expect(body.html_target).toBe(dstHtml);
|
||||
});
|
||||
|
||||
it("auto-creates cel/public/slides when missing", () => {
|
||||
seedRun(daRoot, "mdx04_run", "<html>04</html>");
|
||||
expect(fs.existsSync(path.join(celRoot, "public", "slides"))).toBe(false);
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleConnectMirror(req, res, daRoot, celRoot);
|
||||
req.send(JSON.stringify({ run_id: "mdx04_run", slug: "04" }));
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(fs.existsSync(path.join(celRoot, "public", "slides", "04.html"))).toBe(true);
|
||||
});
|
||||
|
||||
it("mirrors assets/ recursively when present in the run dir", () => {
|
||||
const runDir = seedRun(daRoot, "mdx05_run", "<html>05</html>");
|
||||
fs.mkdirSync(path.join(runDir, "assets", "css"), { recursive: true });
|
||||
fs.writeFileSync(path.join(runDir, "assets", "main.css"), "*{}", "utf-8");
|
||||
fs.writeFileSync(path.join(runDir, "assets", "css", "extra.css"), "p{}", "utf-8");
|
||||
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleConnectMirror(req, res, daRoot, celRoot);
|
||||
req.send(JSON.stringify({ run_id: "mdx05_run", slug: "05" }));
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(JSON.parse(state.body).assets_copied).toBe(2);
|
||||
expect(fs.readFileSync(path.join(celRoot, "public", "slides", "assets", "main.css"), "utf-8"))
|
||||
.toBe("*{}");
|
||||
expect(fs.readFileSync(path.join(celRoot, "public", "slides", "assets", "css", "extra.css"), "utf-8"))
|
||||
.toBe("p{}");
|
||||
});
|
||||
|
||||
it("overwrites pre-existing cel slide html (re-Connect semantics)", () => {
|
||||
seedRun(daRoot, "mdx03_run", "NEW");
|
||||
const dstSlidesDir = path.join(celRoot, "public", "slides");
|
||||
fs.mkdirSync(dstSlidesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dstSlidesDir, "03.html"), "OLD", "utf-8");
|
||||
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleConnectMirror(req, res, daRoot, celRoot);
|
||||
req.send(JSON.stringify({ run_id: "mdx03_run", slug: "03" }));
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(fs.readFileSync(path.join(dstSlidesDir, "03.html"), "utf-8")).toBe("NEW");
|
||||
});
|
||||
});
|
||||
219
Front/client/tests/imp90_edit_mode_gating.test.tsx
Normal file
219
Front/client/tests/imp90_edit_mode_gating.test.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
// IMP-90 (#90) u12 — vitest coverage for `computeEditModeGates`, the pure
|
||||
// helper that drives SlideCanvas's mutually-exclusive gesture gating.
|
||||
// u11 introduced the `EditMode` enum + toolbar; u12 splits the prior
|
||||
// `isEditMode` shim (which fired ALL gates whenever any edit mode was
|
||||
// active) into 5 per-gate booleans:
|
||||
// textEditing — designMode + contentEditable (text mode only).
|
||||
// imageSelection — in-iframe user-content image click listener
|
||||
// (image-zone mode only).
|
||||
// iframePointerAuto — iframe pointer-events:auto so in-iframe gestures
|
||||
// (text caret OR image click) can reach the doc.
|
||||
// text mode + image-zone mode; structure stays
|
||||
// pe:none because u14 will overlay React controls.
|
||||
// zoneGestures — zone resize 8-handle ring + drag perimeter strips
|
||||
// + canDrag in handleZoneMouseDown
|
||||
// (image-zone mode only).
|
||||
// imageOverlay — React-side image edit overlay (image-zone only).
|
||||
//
|
||||
// Mutually-exclusive contract (from the issue body's "discriminated edit
|
||||
// mode"): no editMode value enables both `textEditing` and either
|
||||
// `imageSelection` or `zoneGestures` simultaneously. structure mode is
|
||||
// the no-op placeholder — u14 will plant the structure overlay there.
|
||||
// pendingLayout fully suppresses every gate (mirrors the existing
|
||||
// useEffect that forces editMode='off' on pendingLayout entry).
|
||||
//
|
||||
// Scope guard: this test exercises the pure helper only — no React
|
||||
// rendering, no DOM. testing-library/react is NOT in devDependencies
|
||||
// (verified in Front/package.json); helper-level coverage is the
|
||||
// established u11 pattern.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
computeEditModeGates,
|
||||
type EditMode,
|
||||
type EditModeGates,
|
||||
} from "../src/components/SlideCanvas";
|
||||
|
||||
const ALL_MODES: EditMode[] = ["off", "text", "structure", "image-zone"];
|
||||
|
||||
describe("computeEditModeGates (IMP-90 u12) — pendingLayout suppression", () => {
|
||||
it.each<EditMode>(ALL_MODES)(
|
||||
"pendingLayout=true forces every gate false (editMode=%s)",
|
||||
(mode) => {
|
||||
const g = computeEditModeGates(mode, true);
|
||||
expect(g).toEqual<EditModeGates>({
|
||||
textEditing: false,
|
||||
imageSelection: false,
|
||||
iframePointerAuto: false,
|
||||
zoneGestures: false,
|
||||
imageOverlay: false,
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("computeEditModeGates (IMP-90 u12) — off baseline", () => {
|
||||
it("editMode=off pendingLayout=false: every gate false", () => {
|
||||
expect(computeEditModeGates("off", false)).toEqual<EditModeGates>({
|
||||
textEditing: false,
|
||||
imageSelection: false,
|
||||
iframePointerAuto: false,
|
||||
zoneGestures: false,
|
||||
imageOverlay: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeEditModeGates (IMP-90 u12) — text mode", () => {
|
||||
const g = computeEditModeGates("text", false);
|
||||
|
||||
it("textEditing = true (designMode + contentEditable activate)", () => {
|
||||
expect(g.textEditing).toBe(true);
|
||||
});
|
||||
it("iframePointerAuto = true (caret needs to reach the doc)", () => {
|
||||
expect(g.iframePointerAuto).toBe(true);
|
||||
});
|
||||
it("imageSelection = false (no in-iframe image click listener)", () => {
|
||||
expect(g.imageSelection).toBe(false);
|
||||
});
|
||||
it("zoneGestures = false (no zone resize / drag affordances)", () => {
|
||||
expect(g.zoneGestures).toBe(false);
|
||||
});
|
||||
it("imageOverlay = false (no React-side image overlay)", () => {
|
||||
expect(g.imageOverlay).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeEditModeGates (IMP-90 u12) — structure mode", () => {
|
||||
const g = computeEditModeGates("structure", false);
|
||||
|
||||
// structure mode is the u14 placeholder — no gestures here yet. All five
|
||||
// gates stay false so the iframe and React overlays remain quiescent
|
||||
// until u14 plants the structure overlay on the React layer.
|
||||
it("every gate false (u14 will plant the structure overlay later)", () => {
|
||||
expect(g).toEqual<EditModeGates>({
|
||||
textEditing: false,
|
||||
imageSelection: false,
|
||||
iframePointerAuto: false,
|
||||
zoneGestures: false,
|
||||
imageOverlay: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeEditModeGates (IMP-90 u12) — image-zone mode", () => {
|
||||
const g = computeEditModeGates("image-zone", false);
|
||||
|
||||
it("textEditing = false (contentEditable would steal image clicks)", () => {
|
||||
expect(g.textEditing).toBe(false);
|
||||
});
|
||||
it("imageSelection = true (in-iframe img click → selectedImageId)", () => {
|
||||
expect(g.imageSelection).toBe(true);
|
||||
});
|
||||
it("iframePointerAuto = true (so image clicks reach the doc)", () => {
|
||||
expect(g.iframePointerAuto).toBe(true);
|
||||
});
|
||||
it("zoneGestures = true (zone resize + drag affordances visible)", () => {
|
||||
expect(g.zoneGestures).toBe(true);
|
||||
});
|
||||
it("imageOverlay = true (React-side overlay renders the drag handles)", () => {
|
||||
expect(g.imageOverlay).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeEditModeGates (IMP-90 u12) — mutually exclusive contract", () => {
|
||||
it("text mode never co-activates image-zone gates (imageSelection / zoneGestures / imageOverlay)", () => {
|
||||
const g = computeEditModeGates("text", false);
|
||||
expect(g.textEditing).toBe(true);
|
||||
expect(g.imageSelection).toBe(false);
|
||||
expect(g.zoneGestures).toBe(false);
|
||||
expect(g.imageOverlay).toBe(false);
|
||||
});
|
||||
|
||||
it("image-zone mode never co-activates text gates (textEditing)", () => {
|
||||
const g = computeEditModeGates("image-zone", false);
|
||||
expect(g.imageSelection).toBe(true);
|
||||
expect(g.textEditing).toBe(false);
|
||||
});
|
||||
|
||||
it.each<EditMode>(ALL_MODES)(
|
||||
"for every editMode (%s), textEditing AND zoneGestures are NEVER both true",
|
||||
(mode) => {
|
||||
const g = computeEditModeGates(mode, false);
|
||||
expect(g.textEditing && g.zoneGestures).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it.each<EditMode>(ALL_MODES)(
|
||||
"for every editMode (%s), textEditing AND imageOverlay are NEVER both true",
|
||||
(mode) => {
|
||||
const g = computeEditModeGates(mode, false);
|
||||
expect(g.textEditing && g.imageOverlay).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it.each<EditMode>(ALL_MODES)(
|
||||
"for every editMode (%s), textEditing AND imageSelection are NEVER both true",
|
||||
(mode) => {
|
||||
const g = computeEditModeGates(mode, false);
|
||||
expect(g.textEditing && g.imageSelection).toBe(false);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("computeEditModeGates (IMP-90 u12) — iframePointerAuto coupling", () => {
|
||||
// pe:auto is the iframe-side prerequisite for ANY in-iframe gesture
|
||||
// (text caret OR image click). The helper must NOT advertise an
|
||||
// in-iframe gate as active while pe is none, or those gestures would
|
||||
// be silently swallowed by the wrapper.
|
||||
it.each<EditMode>(ALL_MODES)(
|
||||
"textEditing → iframePointerAuto (editMode=%s)",
|
||||
(mode) => {
|
||||
const g = computeEditModeGates(mode, false);
|
||||
if (g.textEditing) expect(g.iframePointerAuto).toBe(true);
|
||||
}
|
||||
);
|
||||
it.each<EditMode>(ALL_MODES)(
|
||||
"imageSelection → iframePointerAuto (editMode=%s)",
|
||||
(mode) => {
|
||||
const g = computeEditModeGates(mode, false);
|
||||
if (g.imageSelection) expect(g.iframePointerAuto).toBe(true);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("computeEditModeGates (IMP-90 u12) — referential transparency", () => {
|
||||
it("multiple calls with the same inputs return equal output", () => {
|
||||
const a = computeEditModeGates("image-zone", false);
|
||||
const b = computeEditModeGates("image-zone", false);
|
||||
const c = computeEditModeGates("image-zone", false);
|
||||
expect(a).toEqual(b);
|
||||
expect(b).toEqual(c);
|
||||
});
|
||||
|
||||
it("does not mutate captured state across calls (independent invocations)", () => {
|
||||
const a = computeEditModeGates("text", false);
|
||||
const _b = computeEditModeGates("image-zone", false);
|
||||
// a must still reflect text mode after b's call.
|
||||
expect(a.textEditing).toBe(true);
|
||||
expect(a.imageSelection).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeEditModeGates (IMP-90 u12) — gate truthtable snapshot", () => {
|
||||
// Snapshot for human-readable inspection — the per-mode flag layout
|
||||
// is the contract u13 (text capture) and u14 (structure overlay)
|
||||
// will build against. Any change requires updating both this test
|
||||
// AND the consuming gates in SlideCanvas.tsx.
|
||||
it("non-pendingLayout truthtable matches the u12 contract", () => {
|
||||
const rows = (["off", "text", "structure", "image-zone"] as EditMode[]).map(
|
||||
(m) => ({ mode: m, ...computeEditModeGates(m, false) })
|
||||
);
|
||||
expect(rows).toEqual([
|
||||
{ mode: "off", textEditing: false, imageSelection: false, iframePointerAuto: false, zoneGestures: false, imageOverlay: false },
|
||||
{ mode: "text", textEditing: true, imageSelection: false, iframePointerAuto: true, zoneGestures: false, imageOverlay: false },
|
||||
{ mode: "structure", textEditing: false, imageSelection: false, iframePointerAuto: false, zoneGestures: false, imageOverlay: false },
|
||||
{ mode: "image-zone", textEditing: false, imageSelection: true, iframePointerAuto: true, zoneGestures: true, imageOverlay: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
133
Front/client/tests/imp90_edit_mode_state.test.tsx
Normal file
133
Front/client/tests/imp90_edit_mode_state.test.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
// IMP-90 (#90) u11 — vitest coverage for the discriminated EditMode enum
|
||||
// and its pure transition helper `nextEditMode`. Replaces the prior single
|
||||
// `isEditMode` boolean state. u11 introduces ONLY the state surface + the
|
||||
// toolbar UI; gesture gating per mode is u12 (mutually exclusive) and must
|
||||
// not regress this contract.
|
||||
//
|
||||
// Scope (Stage 2 unit u11 contract):
|
||||
// 1) EDIT_MODES is the canonical ['text','structure','image-zone'] list
|
||||
// in toolbar render order. 'off' is intentionally excluded from the
|
||||
// iterable because it is the implicit baseline (no button); the
|
||||
// toolbar only renders the three active modes per the u11 design.
|
||||
// 2) nextEditMode is a pure (current, requested) -> EditMode mapping
|
||||
// with three rules:
|
||||
// - requested === 'off' -> 'off' (explicit exit)
|
||||
// - requested === current -> 'off' (toggle exit)
|
||||
// - requested !== current && != 'off'-> requested (mode switch)
|
||||
// 3) The helper is referentially transparent — no side effects, no
|
||||
// React, no useState, no DOM. SlideCanvas wires it as the useState
|
||||
// updater callback (`setEditMode((prev) => nextEditMode(prev, m))`),
|
||||
// so covering the helper here covers every toolbar click outcome
|
||||
// directly without DOM rendering. (@testing-library/react is NOT in
|
||||
// devDependencies; this mirrors the imp47b_human_review_toast pattern.)
|
||||
// 4) The exported EditMode type union must contain exactly the four
|
||||
// members 'off' | 'text' | 'structure' | 'image-zone'. The runtime
|
||||
// EDIT_MODES list intentionally excludes 'off' (see (1) above).
|
||||
//
|
||||
// Forward-compat note: u12 will discriminate per-mode gating but MUST NOT
|
||||
// alter the (current, requested) -> next contract verified here. Any
|
||||
// change to the toggle/switch/exit semantics is a scope-violation against
|
||||
// the u11 binding contract.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
EDIT_MODES,
|
||||
nextEditMode,
|
||||
type EditMode,
|
||||
} from "../src/components/SlideCanvas";
|
||||
|
||||
describe("EDIT_MODES (IMP-90 u11 — toolbar render order)", () => {
|
||||
it("contains exactly the three active modes in toolbar order", () => {
|
||||
expect(EDIT_MODES).toEqual(["text", "structure", "image-zone"]);
|
||||
});
|
||||
|
||||
it("excludes 'off' — baseline is implicit, no toolbar button", () => {
|
||||
expect(EDIT_MODES).not.toContain("off" as EditMode);
|
||||
});
|
||||
|
||||
it("has length 3", () => {
|
||||
expect(EDIT_MODES.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextEditMode (IMP-90 u11 — pure transition helper)", () => {
|
||||
describe("explicit 'off' request always exits", () => {
|
||||
it.each<EditMode>(["off", "text", "structure", "image-zone"])(
|
||||
"current=%s, requested=off -> off",
|
||||
(current) => {
|
||||
expect(nextEditMode(current, "off")).toBe("off");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("clicking the active mode toggles back to 'off'", () => {
|
||||
it.each<EditMode>(["text", "structure", "image-zone"])(
|
||||
"current=%s, requested=%s -> off",
|
||||
(mode) => {
|
||||
expect(nextEditMode(mode, mode)).toBe("off");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("clicking a different mode switches", () => {
|
||||
const cases: Array<[EditMode, EditMode]> = [
|
||||
["off", "text"],
|
||||
["off", "structure"],
|
||||
["off", "image-zone"],
|
||||
["text", "structure"],
|
||||
["text", "image-zone"],
|
||||
["structure", "text"],
|
||||
["structure", "image-zone"],
|
||||
["image-zone", "text"],
|
||||
["image-zone", "structure"],
|
||||
];
|
||||
it.each(cases)("current=%s, requested=%s -> requested", (current, requested) => {
|
||||
expect(nextEditMode(current, requested)).toBe(requested);
|
||||
});
|
||||
});
|
||||
|
||||
it("is referentially transparent — multiple calls with same inputs return same output", () => {
|
||||
const a = nextEditMode("text", "structure");
|
||||
const b = nextEditMode("text", "structure");
|
||||
const c = nextEditMode("text", "structure");
|
||||
expect(a).toBe("structure");
|
||||
expect(b).toBe("structure");
|
||||
expect(c).toBe("structure");
|
||||
});
|
||||
|
||||
it("never returns a value outside the EditMode union", () => {
|
||||
const all: EditMode[] = ["off", "text", "structure", "image-zone"];
|
||||
for (const current of all) {
|
||||
for (const requested of all) {
|
||||
const result = nextEditMode(current, requested);
|
||||
expect(all).toContain(result);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves toggle semantics under repeated identical clicks", () => {
|
||||
// off -> text -> off -> text -> off (toggle behavior)
|
||||
let m: EditMode = "off";
|
||||
m = nextEditMode(m, "text");
|
||||
expect(m).toBe("text");
|
||||
m = nextEditMode(m, "text");
|
||||
expect(m).toBe("off");
|
||||
m = nextEditMode(m, "text");
|
||||
expect(m).toBe("text");
|
||||
m = nextEditMode(m, "text");
|
||||
expect(m).toBe("off");
|
||||
});
|
||||
|
||||
it("preserves switch semantics across distinct mode clicks", () => {
|
||||
// off -> text -> structure -> image-zone -> off (via toggle)
|
||||
let m: EditMode = "off";
|
||||
m = nextEditMode(m, "text");
|
||||
expect(m).toBe("text");
|
||||
m = nextEditMode(m, "structure");
|
||||
expect(m).toBe("structure");
|
||||
m = nextEditMode(m, "image-zone");
|
||||
expect(m).toBe("image-zone");
|
||||
m = nextEditMode(m, "image-zone");
|
||||
expect(m).toBe("off");
|
||||
});
|
||||
});
|
||||
255
Front/client/tests/imp90_export_endpoint.test.ts
Normal file
255
Front/client/tests/imp90_export_endpoint.test.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
// IMP-56 (#90) u19 — vitest coverage for the vite POST /api/export
|
||||
// middleware and its supporting inlineAssetsAsDataUrls helper.
|
||||
//
|
||||
// Scope:
|
||||
// 1) inlineAssetsAsDataUrls (pure helper):
|
||||
// - no url(assets/...) refs → passthrough.
|
||||
// - single PNG ref → inlined as base64 data: URL with image/png mime.
|
||||
// - multiple refs → all inlined.
|
||||
// - SVG ref → image/svg+xml mime.
|
||||
// - missing asset file → left as-is (no throw, no rewrite).
|
||||
// - data:/http:/ URLs (non-asset) → untouched.
|
||||
// 2) handleExportStandalone (POST):
|
||||
// - method != POST → false (chain continues; next middleware may handle).
|
||||
// - invalid JSON / non-object body → 400.
|
||||
// - missing run_id → 400.
|
||||
// - invalid run_id (key gate / path traversal) → 400.
|
||||
// - final.html missing → 404.
|
||||
// - success → 200 with Content-Disposition: attachment; filename=...,
|
||||
// Content-Type: text/html; charset=utf-8, body = inlined HTML.
|
||||
//
|
||||
// Tests exercise the pure handler with mock req/res — no real vite server.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
handleExportStandalone,
|
||||
inlineAssetsAsDataUrls,
|
||||
} from "../../vite.config";
|
||||
|
||||
function makeMockRes() {
|
||||
const state = {
|
||||
statusCode: 0,
|
||||
headers: {} as Record<string, string>,
|
||||
body: "",
|
||||
ended: false,
|
||||
};
|
||||
return {
|
||||
state,
|
||||
res: {
|
||||
writeHead(status: number, headers?: Record<string, string>) {
|
||||
state.statusCode = status;
|
||||
if (headers) state.headers = headers;
|
||||
},
|
||||
end(body?: string) {
|
||||
state.body = body ?? "";
|
||||
state.ended = true;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeMockReq(opts: {
|
||||
method?: string;
|
||||
}): EventEmitter & { method?: string; send: (body: string) => void } {
|
||||
const ee = new EventEmitter() as EventEmitter & {
|
||||
method?: string;
|
||||
send: (body: string) => void;
|
||||
};
|
||||
ee.method = opts.method;
|
||||
ee.send = (body: string) => {
|
||||
if (body.length > 0) ee.emit("data", Buffer.from(body, "utf-8"));
|
||||
ee.emit("end");
|
||||
};
|
||||
return ee;
|
||||
}
|
||||
|
||||
function seedRun(
|
||||
daRoot: string,
|
||||
runId: string,
|
||||
htmlBody: string,
|
||||
assets?: Record<string, Buffer | string>,
|
||||
): string {
|
||||
const runDir = path.join(daRoot, "data", "runs", runId, "phase_z2");
|
||||
fs.mkdirSync(runDir, { recursive: true });
|
||||
const html = path.join(runDir, "final.html");
|
||||
fs.writeFileSync(html, htmlBody, "utf-8");
|
||||
if (assets) {
|
||||
for (const [rel, buf] of Object.entries(assets)) {
|
||||
const dst = path.join(runDir, "assets", rel);
|
||||
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
||||
fs.writeFileSync(dst, buf);
|
||||
}
|
||||
}
|
||||
return runDir;
|
||||
}
|
||||
|
||||
describe("inlineAssetsAsDataUrls (IMP-56 #90 u19)", () => {
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u19-inline-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns html unchanged when no url(assets/...) refs are present", () => {
|
||||
const html = "<html><style>body{color:red;}</style><body>hi</body></html>";
|
||||
expect(inlineAssetsAsDataUrls(html, tmp)).toBe(html);
|
||||
});
|
||||
|
||||
it("inlines a single PNG asset as a base64 data: URL with image/png mime", () => {
|
||||
fs.mkdirSync(path.join(tmp, "frame_x"), { recursive: true });
|
||||
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
fs.writeFileSync(path.join(tmp, "frame_x", "a.png"), pngBytes);
|
||||
const html = "background: url(assets/frame_x/a.png);";
|
||||
const out = inlineAssetsAsDataUrls(html, tmp);
|
||||
expect(out).toContain(`url("data:image/png;base64,${pngBytes.toString("base64")}")`);
|
||||
expect(out).not.toContain("url(assets/frame_x/a.png)");
|
||||
});
|
||||
|
||||
it("inlines multiple refs across the same HTML body", () => {
|
||||
fs.mkdirSync(path.join(tmp, "f"), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmp, "f", "one.png"), Buffer.from("ONE"));
|
||||
fs.writeFileSync(path.join(tmp, "f", "two.png"), Buffer.from("TWO"));
|
||||
const html = "a{background:url(assets/f/one.png)} b{background:url(assets/f/two.png)}";
|
||||
const out = inlineAssetsAsDataUrls(html, tmp);
|
||||
expect(out).toContain(`data:image/png;base64,${Buffer.from("ONE").toString("base64")}`);
|
||||
expect(out).toContain(`data:image/png;base64,${Buffer.from("TWO").toString("base64")}`);
|
||||
});
|
||||
|
||||
it("uses image/svg+xml mime for .svg refs", () => {
|
||||
fs.mkdirSync(path.join(tmp, "f"), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmp, "f", "icon.svg"), "<svg/>", "utf-8");
|
||||
const html = "url(assets/f/icon.svg)";
|
||||
const out = inlineAssetsAsDataUrls(html, tmp);
|
||||
expect(out).toContain("data:image/svg+xml;base64,");
|
||||
});
|
||||
|
||||
it("leaves the ref untouched when the asset file is missing", () => {
|
||||
const html = "url(assets/missing/file.png)";
|
||||
const out = inlineAssetsAsDataUrls(html, tmp);
|
||||
expect(out).toBe(html);
|
||||
});
|
||||
|
||||
it("does not touch data: or http(s): url() values (only matches assets/...)", () => {
|
||||
const html =
|
||||
"x{background:url(data:image/png;base64,AAA)} " +
|
||||
"y{background:url(https://cdn.x/a.png)}";
|
||||
expect(inlineAssetsAsDataUrls(html, tmp)).toBe(html);
|
||||
});
|
||||
|
||||
it("handles quoted url(...) refs (single and double quotes)", () => {
|
||||
fs.mkdirSync(path.join(tmp, "q"), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmp, "q", "k.png"), Buffer.from("K"));
|
||||
const html =
|
||||
"a{background:url('assets/q/k.png')} b{background:url(\"assets/q/k.png\")}";
|
||||
const out = inlineAssetsAsDataUrls(html, tmp);
|
||||
const data = `data:image/png;base64,${Buffer.from("K").toString("base64")}`;
|
||||
expect(out.split(data).length - 1).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleExportStandalone (IMP-56 #90 u19)", () => {
|
||||
let daRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
daRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp90-u19-da-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(daRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns false (next chained) when method != POST", () => {
|
||||
const req = makeMockReq({ method: "GET" });
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handleExportStandalone(req, res, daRoot);
|
||||
expect(handled).toBe(false);
|
||||
expect(state.ended).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 400 on invalid JSON body", () => {
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handleExportStandalone(req, res, daRoot);
|
||||
expect(handled).toBe(true);
|
||||
req.send("{nope");
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body).error).toBe("invalid JSON");
|
||||
});
|
||||
|
||||
it("returns 400 when body is not a JSON object (array root)", () => {
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleExportStandalone(req, res, daRoot);
|
||||
req.send(JSON.stringify(["x"]));
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body).error).toBe("body must be a JSON object");
|
||||
});
|
||||
|
||||
it("returns 400 when run_id is missing", () => {
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleExportStandalone(req, res, daRoot);
|
||||
req.send(JSON.stringify({}));
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body).error).toBe("missing run_id");
|
||||
});
|
||||
|
||||
it("returns 400 when run_id contains path traversal", () => {
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleExportStandalone(req, res, daRoot);
|
||||
req.send(JSON.stringify({ run_id: "../escape" }));
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body).error).toBe("invalid run_id");
|
||||
});
|
||||
|
||||
it("returns 404 when final.html does not exist for run_id", () => {
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleExportStandalone(req, res, daRoot);
|
||||
req.send(JSON.stringify({ run_id: "ghost_run" }));
|
||||
expect(state.statusCode).toBe(404);
|
||||
expect(JSON.parse(state.body).error).toBe("final.html not found");
|
||||
});
|
||||
|
||||
it("returns 200 with text/html body + Content-Disposition on success", () => {
|
||||
seedRun(daRoot, "mdx03_run", "<html><body>03</body></html>");
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleExportStandalone(req, res, daRoot);
|
||||
req.send(JSON.stringify({ run_id: "mdx03_run" }));
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(state.headers["Content-Type"]).toBe("text/html; charset=utf-8");
|
||||
expect(state.headers["Content-Disposition"]).toBe(
|
||||
'attachment; filename="mdx03_run.html"',
|
||||
);
|
||||
expect(state.body).toBe("<html><body>03</body></html>");
|
||||
});
|
||||
|
||||
it("inlines assets in final.html when run dir has assets/", () => {
|
||||
const pngBytes = Buffer.from("PNGDATA");
|
||||
seedRun(
|
||||
daRoot,
|
||||
"mdx05_run",
|
||||
"<html><body><div style=\"background: url(assets/f/x.png)\"></div></body></html>",
|
||||
{ "f/x.png": pngBytes },
|
||||
);
|
||||
const req = makeMockReq({ method: "POST" });
|
||||
const { res, state } = makeMockRes();
|
||||
handleExportStandalone(req, res, daRoot);
|
||||
req.send(JSON.stringify({ run_id: "mdx05_run" }));
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(state.body).toContain(
|
||||
`data:image/png;base64,${pngBytes.toString("base64")}`,
|
||||
);
|
||||
expect(state.body).not.toContain("url(assets/f/x.png)");
|
||||
});
|
||||
});
|
||||
150
Front/client/tests/imp90_structure_overlay.test.tsx
Normal file
150
Front/client/tests/imp90_structure_overlay.test.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
// IMP-90 (#90) u14 — vitest coverage for the pure helpers exported by
|
||||
// `StructureEditOverlay`. The React component itself is not rendered
|
||||
// (jsdom / @testing-library NOT in Front devDependencies — verified in
|
||||
// `Front/package.json`); we test the deterministic pieces that drive its
|
||||
// JSX: `resolveEffectiveSlotOrder` (effective-order resolution under
|
||||
// override) and `moveItem` (immutable reorder primitive).
|
||||
//
|
||||
// Upstream / downstream contracts (verified by prior units):
|
||||
// - u2 KNOWN_AXES += structure_overrides (Python backend).
|
||||
// - u3 vite allowlist += structure_overrides.
|
||||
// - u6 structure_override_resolver — inner shape locked to
|
||||
// {slot_order, hidden_slots}; frame swap REJECTED to existing
|
||||
// frames axis.
|
||||
// - u10 typed-client `StructureOverridePerZone` + extract helper.
|
||||
// - u15 (next) will debounce + PUT the emitted capture.
|
||||
//
|
||||
// u14 scope: pure helpers only. React render path is verified by Codex
|
||||
// auditor via static read of the JSX (no runtime test possible without
|
||||
// jsdom). Tests below are intentionally side-effect-free.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
resolveEffectiveSlotOrder,
|
||||
moveItem,
|
||||
} from "../src/components/StructureEditOverlay";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// resolveEffectiveSlotOrder
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("resolveEffectiveSlotOrder — no override", () => {
|
||||
it("returns a fresh copy of the discovered keys when slotOrder is undefined", () => {
|
||||
const discovered = ["a", "b", "c"];
|
||||
const out = resolveEffectiveSlotOrder(discovered, undefined);
|
||||
expect(out).toEqual(["a", "b", "c"]);
|
||||
expect(out).not.toBe(discovered);
|
||||
});
|
||||
it("returns a fresh copy when slotOrder is null", () => {
|
||||
const out = resolveEffectiveSlotOrder(["a", "b"], null);
|
||||
expect(out).toEqual(["a", "b"]);
|
||||
});
|
||||
it("returns a fresh copy when slotOrder is empty []", () => {
|
||||
const out = resolveEffectiveSlotOrder(["a", "b"], []);
|
||||
expect(out).toEqual(["a", "b"]);
|
||||
});
|
||||
it("handles empty discovered list (no slots in zone)", () => {
|
||||
expect(resolveEffectiveSlotOrder([], undefined)).toEqual([]);
|
||||
expect(resolveEffectiveSlotOrder([], ["x"])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveEffectiveSlotOrder — full override", () => {
|
||||
it("reorders all discovered keys per slotOrder", () => {
|
||||
expect(
|
||||
resolveEffectiveSlotOrder(["a", "b", "c"], ["c", "a", "b"]),
|
||||
).toEqual(["c", "a", "b"]);
|
||||
});
|
||||
it("is idempotent when slotOrder matches discovered order", () => {
|
||||
expect(
|
||||
resolveEffectiveSlotOrder(["a", "b", "c"], ["a", "b", "c"]),
|
||||
).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveEffectiveSlotOrder — partial / drift override", () => {
|
||||
it("appends missing discovered keys in backend order at the tail", () => {
|
||||
// user reordered b -> first, but c was added later by backend.
|
||||
expect(
|
||||
resolveEffectiveSlotOrder(["a", "b", "c"], ["b", "a"]),
|
||||
).toEqual(["b", "a", "c"]);
|
||||
});
|
||||
it("drops override entries that no longer exist in discovered keys", () => {
|
||||
// user had slot 'x' before; backend dropped it.
|
||||
expect(
|
||||
resolveEffectiveSlotOrder(["a", "b"], ["x", "a", "b"]),
|
||||
).toEqual(["a", "b"]);
|
||||
});
|
||||
it("dedupes duplicate entries within slotOrder", () => {
|
||||
expect(
|
||||
resolveEffectiveSlotOrder(["a", "b", "c"], ["a", "a", "b"]),
|
||||
).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
it("dedupe + drop + append all together (stress)", () => {
|
||||
expect(
|
||||
resolveEffectiveSlotOrder(
|
||||
["a", "b", "c", "d"],
|
||||
["d", "x", "d", "a", "ghost"],
|
||||
),
|
||||
).toEqual(["d", "a", "b", "c"]);
|
||||
});
|
||||
it("ignores non-string entries in slotOrder", () => {
|
||||
const bogus = ["a", null as unknown as string, undefined as unknown as string, "b"];
|
||||
expect(resolveEffectiveSlotOrder(["a", "b"], bogus)).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// moveItem
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("moveItem — happy paths", () => {
|
||||
it("moves index 0 down by 1 (swap with index 1)", () => {
|
||||
expect(moveItem(["a", "b", "c"], 0, 1)).toEqual(["b", "a", "c"]);
|
||||
});
|
||||
it("moves index 2 up by 1 (swap with index 1)", () => {
|
||||
expect(moveItem(["a", "b", "c"], 2, -1)).toEqual(["a", "c", "b"]);
|
||||
});
|
||||
it("moves across larger delta (swap with target)", () => {
|
||||
expect(moveItem(["a", "b", "c", "d"], 0, 2)).toEqual(["c", "b", "a", "d"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveItem — bounds", () => {
|
||||
it("no-op (fresh copy) when moving first up", () => {
|
||||
const src = ["a", "b", "c"];
|
||||
const out = moveItem(src, 0, -1);
|
||||
expect(out).toEqual(["a", "b", "c"]);
|
||||
expect(out).not.toBe(src);
|
||||
});
|
||||
it("no-op when moving last down", () => {
|
||||
expect(moveItem(["a", "b", "c"], 2, 1)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
it("no-op when index negative", () => {
|
||||
expect(moveItem(["a", "b"], -1, 1)).toEqual(["a", "b"]);
|
||||
});
|
||||
it("no-op when index past end", () => {
|
||||
expect(moveItem(["a", "b"], 5, -1)).toEqual(["a", "b"]);
|
||||
});
|
||||
it("no-op when target falls out of range from large delta", () => {
|
||||
expect(moveItem(["a", "b", "c"], 1, 99)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
it("no-op on empty array (any index)", () => {
|
||||
expect(moveItem<string>([], 0, 1)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveItem — immutability", () => {
|
||||
it("never mutates the input array", () => {
|
||||
const src = ["a", "b", "c"];
|
||||
moveItem(src, 0, 1);
|
||||
expect(src).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
it("returns a new reference even when no-op", () => {
|
||||
const src = ["a", "b"];
|
||||
expect(moveItem(src, 0, -1)).not.toBe(src);
|
||||
});
|
||||
it("preserves T-typed values (number array)", () => {
|
||||
expect(moveItem([1, 2, 3], 0, 1)).toEqual([2, 1, 3]);
|
||||
});
|
||||
});
|
||||
259
Front/client/tests/imp90_text_edit_capture.test.tsx
Normal file
259
Front/client/tests/imp90_text_edit_capture.test.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
// IMP-90 (#90) u13 — vitest coverage for `deriveTextEditCapture`, the pure
|
||||
// helper that resolves a contentEditable focusout target into the
|
||||
// (zone_id, text_path, value) capture tuple emitted by SlideCanvas.
|
||||
//
|
||||
// Upstream contract (verified by prior units):
|
||||
// - u8 `src/text_path_stamper.py` stamps `data-text-path="{slot_key}.{
|
||||
// line_index}"` on every rendered text-line opening tag at Step 13.
|
||||
// - u9 wires the stamper into `render_slide` so the final.html consumed
|
||||
// by SlideCanvas's iframe carries those attributes.
|
||||
// - Phase Z slide-base wraps every zone in `.zone[data-zone-position]`
|
||||
// (verified at SlideCanvas.tsx onLoad measure block).
|
||||
//
|
||||
// u13 scope: derive the capture tuple from any descendant of a stamped
|
||||
// line, OR the stamped line itself. Non-stamped targets (slide-base
|
||||
// title/footer, decorative spans outside the zone tree) return null so
|
||||
// the focusout handler silently skips them — never crashes.
|
||||
//
|
||||
// Forward-compat note: u15 will debounce + PUT the capture; u15 MUST NOT
|
||||
// alter the (target) -> {zoneId, textPath, value} | null contract verified
|
||||
// here. Any change to the resolution semantics is a scope-violation
|
||||
// against the u13 binding contract.
|
||||
//
|
||||
// jsdom is NOT in devDependencies (verified in Front/package.json); this
|
||||
// test mocks `TextEditCaptureTarget` with structurally-typed objects per
|
||||
// the established u11/u12 pure-helper pattern.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
deriveTextEditCapture,
|
||||
type TextEditCapture,
|
||||
type TextEditCaptureTarget,
|
||||
} from "../src/components/SlideCanvas";
|
||||
|
||||
// --- minimal closest-aware mock builders -----------------------------
|
||||
// Each node only needs to know which selectors it matches and its
|
||||
// parent chain — `closest` is implemented by walking parent pointers.
|
||||
|
||||
interface MockNodeSpec {
|
||||
matches: string[];
|
||||
attrs?: Record<string, string>;
|
||||
text?: string | null;
|
||||
parent?: MockNode | null;
|
||||
}
|
||||
interface MockNode extends TextEditCaptureTarget {
|
||||
matches(sel: string): boolean;
|
||||
parent: MockNode | null;
|
||||
}
|
||||
function makeNode(spec: MockNodeSpec): MockNode {
|
||||
const node: MockNode = {
|
||||
parent: spec.parent ?? null,
|
||||
matches(sel: string) {
|
||||
return spec.matches.includes(sel);
|
||||
},
|
||||
closest(sel: string): TextEditCaptureTarget | null {
|
||||
let cur: MockNode | null = node;
|
||||
while (cur) {
|
||||
if (cur.matches(sel)) return cur;
|
||||
cur = cur.parent;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
getAttribute(name: string): string | null {
|
||||
return spec.attrs?.[name] ?? null;
|
||||
},
|
||||
textContent: spec.text === undefined ? null : spec.text,
|
||||
};
|
||||
return node;
|
||||
}
|
||||
|
||||
// Canonical zone + line scaffold used across happy-path tests.
|
||||
// `null` for any field is preserved verbatim so edge cases (missing attr /
|
||||
// null textContent) can exercise the helper's defensive branches.
|
||||
function makeZoneLineScaffold(opts: {
|
||||
zoneId?: string | null;
|
||||
textPath?: string | null;
|
||||
lineText?: string | null;
|
||||
}) {
|
||||
const zone = makeNode({
|
||||
matches: [".zone[data-zone-position]"],
|
||||
attrs: opts.zoneId === null ? {} : { "data-zone-position": opts.zoneId ?? "top" },
|
||||
});
|
||||
const line = makeNode({
|
||||
matches: ["[data-text-path]"],
|
||||
attrs:
|
||||
opts.textPath === null
|
||||
? {}
|
||||
: { "data-text-path": opts.textPath ?? "row_1_left_body.0" },
|
||||
text: opts.lineText === undefined ? "hello world" : opts.lineText,
|
||||
parent: zone,
|
||||
});
|
||||
return { zone, line };
|
||||
}
|
||||
|
||||
describe("deriveTextEditCapture (IMP-90 u13) — null inputs / non-stamped", () => {
|
||||
it("returns null when target is null", () => {
|
||||
expect(deriveTextEditCapture(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no ancestor has data-text-path (e.g., slide title)", () => {
|
||||
const title = makeNode({
|
||||
matches: [".slide-title"],
|
||||
text: "Phase Z 슬라이드",
|
||||
});
|
||||
expect(deriveTextEditCapture(title)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the stamped line has no enclosing zone", () => {
|
||||
// Decorative line stamped by the future u8 but rendered outside a
|
||||
// zone (e.g., footer pill). u13 silently skips — caller never sees
|
||||
// a half-resolved capture.
|
||||
const orphanLine = makeNode({
|
||||
matches: ["[data-text-path]"],
|
||||
attrs: { "data-text-path": "footer.0" },
|
||||
text: "결론",
|
||||
});
|
||||
expect(deriveTextEditCapture(orphanLine)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveTextEditCapture (IMP-90 u13) — happy path", () => {
|
||||
it("resolves (zoneId, textPath, value) when target IS the stamped line", () => {
|
||||
const { line } = makeZoneLineScaffold({
|
||||
zoneId: "top",
|
||||
textPath: "row_1_left_body.0",
|
||||
lineText: "분석 결과",
|
||||
});
|
||||
expect(deriveTextEditCapture(line)).toEqual<TextEditCapture>({
|
||||
zoneId: "top",
|
||||
textPath: "row_1_left_body.0",
|
||||
value: "분석 결과",
|
||||
});
|
||||
});
|
||||
|
||||
it("walks up to the stamped line when target is a nested descendant", () => {
|
||||
const { zone, line } = makeZoneLineScaffold({
|
||||
zoneId: "bottom_l",
|
||||
textPath: "left_body.2",
|
||||
lineText: "wrapped",
|
||||
});
|
||||
// emulate a SPAN inside the stamped line (e.g., bold inline span)
|
||||
const innerSpan = makeNode({
|
||||
matches: ["span.highlight"],
|
||||
text: "ignored — closest walks to the line",
|
||||
parent: line,
|
||||
});
|
||||
void zone;
|
||||
expect(deriveTextEditCapture(innerSpan)).toEqual<TextEditCapture>({
|
||||
zoneId: "bottom_l",
|
||||
textPath: "left_body.2",
|
||||
value: "wrapped",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the line's textContent without HTML normalization", () => {
|
||||
const { line } = makeZoneLineScaffold({
|
||||
zoneId: "primary",
|
||||
textPath: "headline.0",
|
||||
lineText: " spaced inner words ",
|
||||
});
|
||||
// u13 trims outer whitespace but does NOT collapse interior whitespace
|
||||
// — value mirrors what user typed, modulo blur-edge trim.
|
||||
expect(deriveTextEditCapture(line)?.value).toBe("spaced inner words");
|
||||
});
|
||||
|
||||
it("returns empty string when textContent is null (edge: empty line)", () => {
|
||||
const { line } = makeZoneLineScaffold({
|
||||
zoneId: "top",
|
||||
textPath: "row_1_left_body.0",
|
||||
lineText: null,
|
||||
});
|
||||
expect(deriveTextEditCapture(line)?.value).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string when textContent is whitespace-only", () => {
|
||||
const { line } = makeZoneLineScaffold({
|
||||
zoneId: "top",
|
||||
textPath: "row_1_left_body.0",
|
||||
lineText: " \n \t ",
|
||||
});
|
||||
expect(deriveTextEditCapture(line)?.value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveTextEditCapture (IMP-90 u13) — missing attribute defensiveness", () => {
|
||||
it("returns null when data-text-path attribute is absent on the matched line", () => {
|
||||
// Should not happen with the u8 stamper, but a downstream mutation
|
||||
// (e.g., user pasting a fresh element) could create a stamped-class
|
||||
// node without the actual attribute. u13 stays defensive.
|
||||
const zone = makeNode({
|
||||
matches: [".zone[data-zone-position]"],
|
||||
attrs: { "data-zone-position": "top" },
|
||||
});
|
||||
const lineNoPath = makeNode({
|
||||
matches: ["[data-text-path]"],
|
||||
attrs: {},
|
||||
text: "hello",
|
||||
parent: zone,
|
||||
});
|
||||
expect(deriveTextEditCapture(lineNoPath)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when data-zone-position attribute is absent on the matched zone", () => {
|
||||
const zoneNoId = makeNode({
|
||||
matches: [".zone[data-zone-position]"],
|
||||
attrs: {},
|
||||
});
|
||||
const line = makeNode({
|
||||
matches: ["[data-text-path]"],
|
||||
attrs: { "data-text-path": "row_1_left_body.0" },
|
||||
text: "hello",
|
||||
parent: zoneNoId,
|
||||
});
|
||||
expect(deriveTextEditCapture(line)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveTextEditCapture (IMP-90 u13) — referential transparency", () => {
|
||||
it("multiple calls with the same target return equal captures", () => {
|
||||
const { line } = makeZoneLineScaffold({
|
||||
zoneId: "top",
|
||||
textPath: "row_1_left_body.0",
|
||||
lineText: "stable",
|
||||
});
|
||||
const a = deriveTextEditCapture(line);
|
||||
const b = deriveTextEditCapture(line);
|
||||
expect(a).toEqual(b);
|
||||
expect(a).not.toBe(b); // fresh objects each call (caller-friendly)
|
||||
});
|
||||
|
||||
it("does not mutate the target element (attrs / parent / textContent unchanged)", () => {
|
||||
const { line, zone } = makeZoneLineScaffold({
|
||||
zoneId: "top",
|
||||
textPath: "row_1_left_body.0",
|
||||
lineText: "immutable",
|
||||
});
|
||||
deriveTextEditCapture(line);
|
||||
expect(line.getAttribute("data-text-path")).toBe("row_1_left_body.0");
|
||||
expect(line.textContent).toBe("immutable");
|
||||
expect(zone.getAttribute("data-zone-position")).toBe("top");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveTextEditCapture (IMP-90 u13) — zone id pass-through", () => {
|
||||
// u13 does not validate the zone id shape — Phase Z slide-base owns the
|
||||
// canonical zone position vocabulary, and u15 / pipeline-side resolver
|
||||
// (u4) re-validate downstream. u13 just forwards whatever the stamped
|
||||
// DOM declared.
|
||||
const ZONE_IDS = ["top", "bottom_l", "bottom_r", "primary", "secondary"];
|
||||
it.each(ZONE_IDS)("preserves zone id '%s' verbatim", (zid) => {
|
||||
const { line } = makeZoneLineScaffold({
|
||||
zoneId: zid,
|
||||
textPath: `${zid}.0`,
|
||||
lineText: "x",
|
||||
});
|
||||
const cap = deriveTextEditCapture(line);
|
||||
expect(cap?.zoneId).toBe(zid);
|
||||
expect(cap?.textPath).toBe(`${zid}.0`);
|
||||
});
|
||||
});
|
||||
250
Front/client/tests/run_pipeline_reuse_from.test.ts
Normal file
250
Front/client/tests/run_pipeline_reuse_from.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
// IMP-43 (#72) u6 — /api/run reuseFromRunId forwarding coverage.
|
||||
//
|
||||
// Stage 2 unit scope:
|
||||
// 1) Front/client/src/services/designAgentApi.ts `runPipeline`:
|
||||
// • accepts an optional 3rd arg `reuseFromRunId: string`.
|
||||
// • includes `reuseFromRunId` in the POST body when truthy.
|
||||
// • OMITS `reuseFromRunId` from the body when absent / empty / undefined
|
||||
// → byte-identical to the pre-u6 POST contract (absent flag = full
|
||||
// pipeline; backend u1 guard never sees an empty PREV_RUN_ID).
|
||||
// • leaves `filename`, `content`, and `overrides` untouched alongside
|
||||
// the new field (no payload-shape regression).
|
||||
// 2) Front/vite.config.ts `/api/run` handler:
|
||||
// • declares `reuseFromRunId?: string` in the payload type so a typed
|
||||
// client cannot send a payload the server silently drops.
|
||||
// • destructures `reuseFromRunId` from `payload` (sibling of
|
||||
// `overrides`, NOT nested under it — the backend u1 post-merge
|
||||
// guard treats reuse as a pipeline mode, not an override).
|
||||
// • forwards `--reuse-from <PREV_RUN_ID>` to spawn cliArgs guarded by
|
||||
// a truthy check (empty string / undefined ⇒ no flag, per Stage 2
|
||||
// contract: invalid CLI args must never reach argparse).
|
||||
// • places the forward block AFTER the `--override-section-assignment`
|
||||
// loop so the spawn argv preserves backend argparse's no-positional-
|
||||
// before-flag expectation and so `--override-frame` (still allowed
|
||||
// by the u1 guard) is positioned ahead of `--reuse-from`.
|
||||
//
|
||||
// runPipeline is exercised with a duck-typed `File` plus a `vi.stubGlobal`
|
||||
// fetch mock — mirrors the user_overrides_service.test.ts pattern. The
|
||||
// vite handler is source-sliced (mirrors handle_generate_diag.test.ts)
|
||||
// because the handler spawns python and a real /api/run round-trip is
|
||||
// out of unit-test scope.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { runPipeline } from "../src/services/designAgentApi";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// vite.config.ts source — read once for the handler source-slice assertions.
|
||||
// Path: Front/client/tests/ → Front/vite.config.ts (two levels up).
|
||||
// ---------------------------------------------------------------------------
|
||||
const VITE_CONFIG_PATH = resolve(__dirname, "..", "..", "vite.config.ts");
|
||||
const VITE_CONFIG_SOURCE = readFileSync(VITE_CONFIG_PATH, "utf-8");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fetch mock — minimal Response stub mirroring runPipeline's `.ok` + `.json()`
|
||||
// + `.status` surface. Same shape as the user_overrides_service.test.ts
|
||||
// helper so the two test files stay drift-free.
|
||||
// ---------------------------------------------------------------------------
|
||||
type MockResponse = {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
json: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
function mockResponse(body: unknown, ok = true, status = 200): MockResponse {
|
||||
return { ok, status, json: async () => body };
|
||||
}
|
||||
|
||||
const SUCCESS_BODY = {
|
||||
success: true,
|
||||
run_id: "test_run_id_20260524",
|
||||
exit_code: 0,
|
||||
final_html_exists: true,
|
||||
preview_exists: true,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
// Duck-typed File — runPipeline reads only `.name` and `.text()`. Avoids a
|
||||
// hard dependency on the global File constructor (varies across node /
|
||||
// jsdom / happy-dom test environments).
|
||||
function makeFakeFile(name: string, content: string): File {
|
||||
return {
|
||||
name,
|
||||
text: async () => content,
|
||||
} as unknown as File;
|
||||
}
|
||||
|
||||
let fetchMock: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function lastPostBody(): Record<string, unknown> {
|
||||
const lastCall = fetchMock.mock.calls.at(-1);
|
||||
if (!lastCall) throw new Error("fetch was not called");
|
||||
const init = lastCall[1] as RequestInit | undefined;
|
||||
if (!init?.body) throw new Error("fetch was called without a body");
|
||||
return JSON.parse(String(init.body));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// runPipeline (designAgentApi.ts) — forwarding/omission coverage
|
||||
// ============================================================================
|
||||
|
||||
describe("runPipeline reuseFromRunId forwarding (IMP-43 #72 u6)", () => {
|
||||
it("posts to /api/run via POST with JSON content-type", async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY));
|
||||
await runPipeline(makeFakeFile("03.mdx", "# title"));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("/api/run");
|
||||
expect((init as RequestInit).method).toBe("POST");
|
||||
expect((init as RequestInit).headers).toMatchObject({
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes reuseFromRunId in the POST body when provided", async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY));
|
||||
await runPipeline(
|
||||
makeFakeFile("03.mdx", "# title"),
|
||||
undefined,
|
||||
"mdx03_20260524080000",
|
||||
);
|
||||
const body = lastPostBody();
|
||||
expect(body.reuseFromRunId).toBe("mdx03_20260524080000");
|
||||
expect(body.filename).toBe("03.mdx");
|
||||
expect(body.content).toBe("# title");
|
||||
});
|
||||
|
||||
it("omits reuseFromRunId when 3rd arg is undefined (pre-u6 byte-identical)", async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY));
|
||||
await runPipeline(makeFakeFile("03.mdx", "# title"));
|
||||
const body = lastPostBody();
|
||||
expect("reuseFromRunId" in body).toBe(false);
|
||||
// Pre-u6 contract: filename/content are the only keys when overrides
|
||||
// is undefined (JSON.stringify drops undefined values; pre-u6 emitted
|
||||
// `JSON.stringify({filename, content, overrides})` with the same
|
||||
// drop-undefined behaviour, so the wire body is byte-identical).
|
||||
expect(Object.keys(body).sort()).toEqual(["content", "filename"]);
|
||||
});
|
||||
|
||||
it("omits reuseFromRunId but keeps overrides when only overrides provided", async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY));
|
||||
await runPipeline(makeFakeFile("03.mdx", "# title"), {
|
||||
frames: { "03-1": "frame_07" },
|
||||
});
|
||||
const body = lastPostBody();
|
||||
expect("reuseFromRunId" in body).toBe(false);
|
||||
expect(Object.keys(body).sort()).toEqual([
|
||||
"content",
|
||||
"filename",
|
||||
"overrides",
|
||||
]);
|
||||
expect(body.overrides).toEqual({ frames: { "03-1": "frame_07" } });
|
||||
});
|
||||
|
||||
it("omits reuseFromRunId when passed an empty string (truthy guard)", async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY));
|
||||
await runPipeline(makeFakeFile("03.mdx", "# title"), undefined, "");
|
||||
const body = lastPostBody();
|
||||
expect("reuseFromRunId" in body).toBe(false);
|
||||
});
|
||||
|
||||
it("forwards reuseFromRunId alongside frame overrides (the only u1-permitted combo)", async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY));
|
||||
await runPipeline(
|
||||
makeFakeFile("03.mdx", "# title"),
|
||||
{ frames: { "03-1+03-2": "frame_07" } },
|
||||
"mdx03_20260524080000",
|
||||
);
|
||||
const body = lastPostBody();
|
||||
expect(body.overrides).toEqual({ frames: { "03-1+03-2": "frame_07" } });
|
||||
expect(body.reuseFromRunId).toBe("mdx03_20260524080000");
|
||||
});
|
||||
|
||||
it("returns the parsed RunPipelineResult on success", async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY));
|
||||
const res = await runPipeline(
|
||||
makeFakeFile("03.mdx", "# title"),
|
||||
undefined,
|
||||
"mdx03_20260524080000",
|
||||
);
|
||||
expect(res.success).toBe(true);
|
||||
expect(res.run_id).toBe("test_run_id_20260524");
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// /api/run handler (vite.config.ts) — source-slice forwarding contract
|
||||
// ============================================================================
|
||||
|
||||
describe("/api/run handler reuseFromRunId source-slice (IMP-43 #72 u6)", () => {
|
||||
it("declares reuseFromRunId?: string on the /api/run payload type", () => {
|
||||
// Payload type at the top of the /api/run handler body. The
|
||||
// optional-string declaration is the single source-of-truth for what
|
||||
// shape the handler accepts; a typed frontend client (u5 saveUserOverrides
|
||||
// sibling pattern) cannot silently send a payload the server drops.
|
||||
expect(VITE_CONFIG_SOURCE).toMatch(/reuseFromRunId\?:\s*string\s*;/);
|
||||
});
|
||||
|
||||
it("destructures reuseFromRunId from payload alongside filename/content/overrides", () => {
|
||||
expect(VITE_CONFIG_SOURCE).toMatch(
|
||||
/const\s*\{\s*filename\s*,\s*content\s*,\s*overrides\s*,\s*reuseFromRunId\s*\}\s*=\s*payload\s*;/,
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards --reuse-from <PREV_RUN_ID> after the override-section-assignment loop", () => {
|
||||
// Stage 2 contract: reuse_from is a pipeline mode, not an override.
|
||||
// The forward block must sit AFTER the last override loop so the spawn
|
||||
// argv preserves the order documented in the u1 backend post-merge
|
||||
// guard (overrides parsed first; reuse_from precondition runs against
|
||||
// the merged overrides view).
|
||||
const reuseFromIdx = VITE_CONFIG_SOURCE.indexOf('"--reuse-from"');
|
||||
const zoneSectionsIdx = VITE_CONFIG_SOURCE.indexOf(
|
||||
'"--override-section-assignment"',
|
||||
);
|
||||
expect(reuseFromIdx).toBeGreaterThan(-1);
|
||||
expect(zoneSectionsIdx).toBeGreaterThan(-1);
|
||||
expect(reuseFromIdx).toBeGreaterThan(zoneSectionsIdx);
|
||||
});
|
||||
|
||||
it("guards the forward with a truthy check on reuseFromRunId", () => {
|
||||
// Empty string / undefined ⇒ no flag pushed (Stage 2 contract: invalid
|
||||
// CLI args must never reach argparse — the backend u1 guard would
|
||||
// fail-closed with `reuse_artifact_missing` on the empty PREV_RUN_ID).
|
||||
const reuseFromIdx = VITE_CONFIG_SOURCE.indexOf('"--reuse-from"');
|
||||
expect(reuseFromIdx).toBeGreaterThan(-1);
|
||||
const preface = VITE_CONFIG_SOURCE.slice(
|
||||
Math.max(0, reuseFromIdx - 200),
|
||||
reuseFromIdx,
|
||||
);
|
||||
expect(preface).toMatch(/if\s*\(\s*reuseFromRunId/);
|
||||
expect(preface).toMatch(/typeof\s+reuseFromRunId\s*===\s*"string"/);
|
||||
});
|
||||
|
||||
it("pushes reuseFromRunId as the --reuse-from argument value (no string interpolation)", () => {
|
||||
// The CLI value must be the raw PREV_RUN_ID — no `=` join, no quoting
|
||||
// (spawn is shell:false). Mirrors the `--override-layout` shape.
|
||||
const reuseFromIdx = VITE_CONFIG_SOURCE.indexOf('"--reuse-from"');
|
||||
expect(reuseFromIdx).toBeGreaterThan(-1);
|
||||
// Window spans both before (`cliArgs.push(`) and after
|
||||
// (`reuseFromRunId)`) the literal so the full push expression is
|
||||
// captured.
|
||||
const window = VITE_CONFIG_SOURCE.slice(
|
||||
Math.max(0, reuseFromIdx - 100),
|
||||
reuseFromIdx + 200,
|
||||
);
|
||||
expect(window).toMatch(
|
||||
/cliArgs\.push\(\s*"--reuse-from"\s*,\s*reuseFromRunId\s*\)/,
|
||||
);
|
||||
});
|
||||
});
|
||||
851
Front/client/tests/user_overrides_endpoint.test.ts
Normal file
851
Front/client/tests/user_overrides_endpoint.test.ts
Normal file
@@ -0,0 +1,851 @@
|
||||
// IMP-52 u3/u4 — vitest coverage for the vite `/api/user-overrides/:key`
|
||||
// GET and PUT endpoints and their supporting helpers.
|
||||
//
|
||||
// Scope:
|
||||
// u3 (read path):
|
||||
// 1) isValidUserOverridesKey: accept MDX-stem keys (03, 03__DX_BIM,
|
||||
// a-b.c), reject empty / leading-dot / `..` / `/` / `\` /
|
||||
// disallowed chars. Mirrors src/user_overrides_io.validate_key so
|
||||
// backend (u2) and frontend endpoint (u3) agree on every key.
|
||||
// 2) userOverridesPath: returns <root>/data/user_overrides/<key>.json.
|
||||
// 3) handleGetUserOverrides: method != GET → false (next chained for
|
||||
// PUT); invalid key → 400; missing file → 200 {}; corrupt JSON /
|
||||
// non-object root → 200 {} (graceful degrade per u1 load contract);
|
||||
// valid object JSON → 200 with parsed payload echoed back.
|
||||
//
|
||||
// u4 (write path):
|
||||
// 4) mergeUserOverrides: only KNOWN_USER_OVERRIDES_AXES mutated;
|
||||
// foreign top-level keys preserved; null clears axis; non-axis
|
||||
// partial keys dropped (allowlist).
|
||||
// 5) atomicWriteUserOverrides: tmp + rename; parent dir auto-created.
|
||||
// 6) handlePutUserOverrides: method != PUT → false (next chained);
|
||||
// invalid key → 400; invalid JSON → 400; non-object body → 400;
|
||||
// success → 200 with merged result; partial-merge preserves axes
|
||||
// not in payload; foreign-key preserve on disk; allowlist drops
|
||||
// unknown payload keys; explicit null clears; corrupt existing →
|
||||
// recover to clean state.
|
||||
//
|
||||
// Tests exercise the pure handlers with mock req/res — no real vite server.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
KNOWN_USER_OVERRIDES_AXES,
|
||||
USER_OVERRIDES_KEY_RE,
|
||||
atomicWriteUserOverrides,
|
||||
handleGetUserOverrides,
|
||||
handlePutUserOverrides,
|
||||
isValidUserOverridesKey,
|
||||
mergeUserOverrides,
|
||||
userOverridesPath,
|
||||
} from "../../vite.config";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mock res helper — captures writeHead(status, headers) + end(body) so the
|
||||
// handler can be invoked synchronously without spawning a TCP socket.
|
||||
// ---------------------------------------------------------------------------
|
||||
function makeMockRes() {
|
||||
const state = {
|
||||
statusCode: 0,
|
||||
headers: {} as Record<string, string>,
|
||||
body: "",
|
||||
ended: false,
|
||||
};
|
||||
return {
|
||||
state,
|
||||
res: {
|
||||
writeHead(status: number, headers?: Record<string, string>) {
|
||||
state.statusCode = status;
|
||||
if (headers) state.headers = headers;
|
||||
},
|
||||
end(body?: string) {
|
||||
state.body = body ?? "";
|
||||
state.ended = true;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("USER_OVERRIDES_KEY_RE (IMP-52 u3)", () => {
|
||||
it("matches Python validate_key regex literally", () => {
|
||||
// The pattern locked in src/user_overrides_io.py:_KEY_RE — any drift here
|
||||
// means backend pipeline fallback (u2) and the vite endpoint disagree on
|
||||
// which keys are routable, which is the single failure mode that would
|
||||
// silently lose persisted overrides.
|
||||
expect(USER_OVERRIDES_KEY_RE.source).toBe(
|
||||
"^[A-Za-z0-9_][A-Za-z0-9_.\\-]*$",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidUserOverridesKey (IMP-52 u3)", () => {
|
||||
it("accepts MDX-stem-style keys actually used in samples/mdx/", () => {
|
||||
// 03 / 04 / 05 are the wired sample MDXs (vite.config.ts:SAMPLE_MDX_MAP).
|
||||
expect(isValidUserOverridesKey("03")).toBe(true);
|
||||
expect(isValidUserOverridesKey("04")).toBe(true);
|
||||
expect(isValidUserOverridesKey("05")).toBe(true);
|
||||
// Stage 1 EVIDENCE references 03__DX_BIM... — must round-trip.
|
||||
expect(isValidUserOverridesKey("03__DX_BIM")).toBe(true);
|
||||
expect(isValidUserOverridesKey("a-b.c")).toBe(true);
|
||||
expect(isValidUserOverridesKey("a")).toBe(true);
|
||||
expect(isValidUserOverridesKey("_leading_underscore")).toBe(true);
|
||||
expect(isValidUserOverridesKey("9starts_with_digit")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects empty and whitespace-only keys", () => {
|
||||
expect(isValidUserOverridesKey("")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects path-traversal substrings", () => {
|
||||
// `..` rejected explicitly even if the rest of the regex would allow it
|
||||
// — `a..b` would otherwise pass the char class.
|
||||
expect(isValidUserOverridesKey("..")).toBe(false);
|
||||
expect(isValidUserOverridesKey("a..b")).toBe(false);
|
||||
expect(isValidUserOverridesKey("../escape")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects path separators", () => {
|
||||
expect(isValidUserOverridesKey("a/b")).toBe(false);
|
||||
expect(isValidUserOverridesKey("a\\b")).toBe(false);
|
||||
expect(isValidUserOverridesKey("/")).toBe(false);
|
||||
expect(isValidUserOverridesKey("\\")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects keys starting with a non-word character", () => {
|
||||
expect(isValidUserOverridesKey(".hidden")).toBe(false);
|
||||
expect(isValidUserOverridesKey("-leading-dash")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects characters outside [A-Za-z0-9_.-]", () => {
|
||||
expect(isValidUserOverridesKey("a b")).toBe(false);
|
||||
expect(isValidUserOverridesKey("a:b")).toBe(false);
|
||||
expect(isValidUserOverridesKey("a*b")).toBe(false);
|
||||
expect(isValidUserOverridesKey("a%2Fb")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("userOverridesPath (IMP-52 u3)", () => {
|
||||
it("resolves <root>/data/user_overrides/<key>.json regardless of OS sep", () => {
|
||||
const root = path.join("X:", "design_agent");
|
||||
const got = userOverridesPath(root, "03");
|
||||
expect(got).toBe(path.join(root, "data", "user_overrides", "03.json"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleGetUserOverrides (IMP-52 u3)", () => {
|
||||
let tmpRoot: string;
|
||||
let overridesDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp52-u3-"));
|
||||
overridesDir = path.join(tmpRoot, "data", "user_overrides");
|
||||
fs.mkdirSync(overridesDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns false (next chained) when method != GET", () => {
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handleGetUserOverrides(
|
||||
{ method: "PUT", url: "/03" },
|
||||
res,
|
||||
tmpRoot,
|
||||
);
|
||||
expect(handled).toBe(false);
|
||||
// Crucial for u4: PUT must reach its own middleware unobstructed.
|
||||
expect(state.ended).toBe(false);
|
||||
expect(state.statusCode).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 400 on invalid key (path traversal)", () => {
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handleGetUserOverrides(
|
||||
{ method: "GET", url: "/../escape" },
|
||||
res,
|
||||
tmpRoot,
|
||||
);
|
||||
expect(handled).toBe(true);
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body)).toEqual({ error: "invalid key" });
|
||||
});
|
||||
|
||||
it("returns 400 on invalid key (missing key segment)", () => {
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handleGetUserOverrides(
|
||||
{ method: "GET", url: "/" },
|
||||
res,
|
||||
tmpRoot,
|
||||
);
|
||||
expect(handled).toBe(true);
|
||||
expect(state.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 200 {} on missing file (graceful degrade)", () => {
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handleGetUserOverrides(
|
||||
{ method: "GET", url: "/03" },
|
||||
res,
|
||||
tmpRoot,
|
||||
);
|
||||
expect(handled).toBe(true);
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(state.body).toBe("{}");
|
||||
});
|
||||
|
||||
it("returns 200 {} on corrupt JSON (graceful degrade)", () => {
|
||||
fs.writeFileSync(path.join(overridesDir, "03.json"), "{not json", "utf-8");
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handleGetUserOverrides(
|
||||
{ method: "GET", url: "/03" },
|
||||
res,
|
||||
tmpRoot,
|
||||
);
|
||||
expect(handled).toBe(true);
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(state.body).toBe("{}");
|
||||
});
|
||||
|
||||
it("returns 200 {} when JSON root is not an object", () => {
|
||||
// Mirrors u1 load() which treats non-object roots as corrupt — covers
|
||||
// both arrays and primitives so the frontend never receives a shape
|
||||
// the typed service (u5) can't deserialize.
|
||||
fs.writeFileSync(
|
||||
path.join(overridesDir, "arr.json"),
|
||||
JSON.stringify([1, 2, 3]),
|
||||
"utf-8",
|
||||
);
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handleGetUserOverrides(
|
||||
{ method: "GET", url: "/arr" },
|
||||
res,
|
||||
tmpRoot,
|
||||
);
|
||||
expect(handled).toBe(true);
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(state.body).toBe("{}");
|
||||
|
||||
fs.writeFileSync(path.join(overridesDir, "num.json"), "42", "utf-8");
|
||||
const { res: res2, state: state2 } = makeMockRes();
|
||||
handleGetUserOverrides({ method: "GET", url: "/num" }, res2, tmpRoot);
|
||||
expect(state2.statusCode).toBe(200);
|
||||
expect(state2.body).toBe("{}");
|
||||
});
|
||||
|
||||
it("returns 200 with parsed JSON object on hit", () => {
|
||||
const payload = {
|
||||
layout: "two_zone_split",
|
||||
frames: { "03-1+03-2": "frame_07" },
|
||||
zone_geometries: {
|
||||
top: { x: 0.05, y: 0.1, w: 0.9, h: 0.3 },
|
||||
},
|
||||
zone_sections: { top: ["03-1", "03-2"] },
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(overridesDir, "03.json"),
|
||||
JSON.stringify(payload),
|
||||
"utf-8",
|
||||
);
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handleGetUserOverrides(
|
||||
{ method: "GET", url: "/03" },
|
||||
res,
|
||||
tmpRoot,
|
||||
);
|
||||
expect(handled).toBe(true);
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(state.headers["Content-Type"]).toBe(
|
||||
"application/json; charset=utf-8",
|
||||
);
|
||||
expect(JSON.parse(state.body)).toEqual(payload);
|
||||
});
|
||||
|
||||
it("preserves foreign top-level keys in the response", () => {
|
||||
// Forward-compat with future axes (e.g., zone_sizes, image_overrides).
|
||||
// u1 save() preserves them on the disk side; u3 GET must surface them
|
||||
// so the frontend service (u5) can decide whether to act on them.
|
||||
const payload = {
|
||||
layout: "single_zone",
|
||||
zone_sizes: { top: 0.42 }, // not part of KNOWN_AXES yet
|
||||
custom_extension: { foo: "bar" },
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(overridesDir, "future.json"),
|
||||
JSON.stringify(payload),
|
||||
"utf-8",
|
||||
);
|
||||
const { res, state } = makeMockRes();
|
||||
handleGetUserOverrides({ method: "GET", url: "/future" }, res, tmpRoot);
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(JSON.parse(state.body)).toEqual(payload);
|
||||
});
|
||||
|
||||
it("strips the leading slash and ignores query string when keying", () => {
|
||||
fs.writeFileSync(
|
||||
path.join(overridesDir, "03.json"),
|
||||
JSON.stringify({ layout: "x" }),
|
||||
"utf-8",
|
||||
);
|
||||
const { res, state } = makeMockRes();
|
||||
handleGetUserOverrides(
|
||||
{ method: "GET", url: "/03?ts=1747884800" },
|
||||
res,
|
||||
tmpRoot,
|
||||
);
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(JSON.parse(state.body)).toEqual({ layout: "x" });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IMP-52 u4 — PUT endpoint coverage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("KNOWN_USER_OVERRIDES_AXES (IMP-52 u4 + IMP-56 #90 u3 allowlist sync)", () => {
|
||||
it("matches the Python KNOWN_AXES tuple in src/user_overrides_io.py", () => {
|
||||
// The on-disk schema is shared with backend pipeline fallback (u2).
|
||||
// Any drift here means a PUT could write an axis that the Python
|
||||
// load() ignores, or vice-versa, silently losing user overrides.
|
||||
// IMP-56 #90 u3 closes the prior `slide_css` gap (IMP-45 #74) and
|
||||
// pre-wires `text_overrides` (IMP-56 #90 u1) +
|
||||
// `structure_overrides` (IMP-56 #90 u2) — full 9-axis mirror of the
|
||||
// Python tuple, same order.
|
||||
expect(KNOWN_USER_OVERRIDES_AXES).toEqual([
|
||||
"layout",
|
||||
"zone_geometries",
|
||||
"zone_sections",
|
||||
"frames",
|
||||
"image_overrides",
|
||||
"slide_css",
|
||||
"manual_section_assignment",
|
||||
"text_overrides",
|
||||
"structure_overrides",
|
||||
]);
|
||||
});
|
||||
|
||||
it("includes the 3 axes added by IMP-56 #90 u3 (allowlist sync)", () => {
|
||||
// Spot-check the diff in addition to the full-equality assertion so a
|
||||
// future edit that drops one of the new axes fails with a localized
|
||||
// error rather than a 9-vs-N tuple-diff that obscures intent.
|
||||
expect(KNOWN_USER_OVERRIDES_AXES).toContain("slide_css");
|
||||
expect(KNOWN_USER_OVERRIDES_AXES).toContain("text_overrides");
|
||||
expect(KNOWN_USER_OVERRIDES_AXES).toContain("structure_overrides");
|
||||
expect(KNOWN_USER_OVERRIDES_AXES.length).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeUserOverrides (IMP-55 #93 u1) — manual_section_assignment bool axis", () => {
|
||||
it("merges bool true / false literally and clears on null", () => {
|
||||
// The PUT handler must treat the bool axis like any other allowlisted
|
||||
// axis: replace on write, preserve when absent, delete on null. Tests
|
||||
// both true→false flip and explicit null-clear so the backend (u9)
|
||||
// sees the exact frontend intent.
|
||||
let merged = mergeUserOverrides({}, { manual_section_assignment: true });
|
||||
expect(merged.manual_section_assignment).toBe(true);
|
||||
|
||||
merged = mergeUserOverrides(merged, { manual_section_assignment: false });
|
||||
expect(merged.manual_section_assignment).toBe(false);
|
||||
|
||||
merged = mergeUserOverrides(merged, { manual_section_assignment: null });
|
||||
expect("manual_section_assignment" in merged).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves bool axis when partial touches only a sibling axis", () => {
|
||||
const existing = { manual_section_assignment: true, layout: "old" };
|
||||
const merged = mergeUserOverrides(existing, { layout: "new" });
|
||||
expect(merged.manual_section_assignment).toBe(true);
|
||||
expect(merged.layout).toBe("new");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeUserOverrides (IMP-52 u4)", () => {
|
||||
it("only mutates KNOWN_AXES present in partial", () => {
|
||||
const existing = {
|
||||
layout: "old",
|
||||
frames: { "03-1": "frame_01" },
|
||||
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
|
||||
zone_sections: { top: ["03-1"] },
|
||||
};
|
||||
const merged = mergeUserOverrides(existing, { layout: "new" });
|
||||
expect(merged.layout).toBe("new");
|
||||
// axes not in partial are preserved
|
||||
expect(merged.frames).toEqual({ "03-1": "frame_01" });
|
||||
expect(merged.zone_geometries).toEqual({
|
||||
top: { x: 0, y: 0, w: 1, h: 0.5 },
|
||||
});
|
||||
expect(merged.zone_sections).toEqual({ top: ["03-1"] });
|
||||
});
|
||||
|
||||
it("preserves foreign top-level keys in existing", () => {
|
||||
// Forward-compat: future axes (zone_sizes, schema_version, etc.) on
|
||||
// disk must survive PUT writes that only touch the 5 in-scope axes.
|
||||
// `image_overrides` is no longer a foreign key after IMP-51 #79 u2 —
|
||||
// it joined KNOWN_USER_OVERRIDES_AXES — so we probe with axes that
|
||||
// are still NOT in the allowlist.
|
||||
const existing = {
|
||||
layout: "old",
|
||||
zone_sizes: { top: 0.42 },
|
||||
schema_version: 2,
|
||||
};
|
||||
const merged = mergeUserOverrides(existing, { layout: "new" });
|
||||
expect(merged.zone_sizes).toEqual({ top: 0.42 });
|
||||
expect(merged.schema_version).toBe(2);
|
||||
});
|
||||
|
||||
it("clears axis when partial value is null (explicit clear)", () => {
|
||||
const existing = { layout: "x", frames: { "03-1": "f01" } };
|
||||
const merged = mergeUserOverrides(existing, { layout: null });
|
||||
expect("layout" in merged).toBe(false);
|
||||
expect(merged.frames).toEqual({ "03-1": "f01" });
|
||||
});
|
||||
|
||||
it("drops non-axis keys in partial (allowlist)", () => {
|
||||
// PUT payload may carry junk fields (typo, malicious key); allowlist
|
||||
// ensures only the 5 axes can be written to disk.
|
||||
const merged = mergeUserOverrides(
|
||||
{},
|
||||
{ layout: "x", random_key: "evil", __proto__: "x" } as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
);
|
||||
expect(merged.layout).toBe("x");
|
||||
expect("random_key" in merged).toBe(false);
|
||||
});
|
||||
|
||||
it("merges all 5 axes when present in partial", () => {
|
||||
const merged = mergeUserOverrides(
|
||||
{},
|
||||
{
|
||||
layout: "two_zone_split",
|
||||
frames: { "03-1+03-2": "frame_07" },
|
||||
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
|
||||
zone_sections: { top: ["03-1", "03-2"] },
|
||||
image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } },
|
||||
},
|
||||
);
|
||||
expect(Object.keys(merged).sort()).toEqual([
|
||||
"frames",
|
||||
"image_overrides",
|
||||
"layout",
|
||||
"zone_geometries",
|
||||
"zone_sections",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves image_overrides when absent from partial (5th axis IMP-51 #79 u2)", () => {
|
||||
// Sibling axis of layout/frames/zone_geometries/zone_sections: a PUT
|
||||
// that touches only layout must NOT erase the image_overrides map
|
||||
// already on disk. Mirrors the partial-merge invariant for the 4
|
||||
// pre-existing axes.
|
||||
const existing = {
|
||||
layout: "old",
|
||||
image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } },
|
||||
};
|
||||
const merged = mergeUserOverrides(existing, { layout: "new" });
|
||||
expect(merged.image_overrides).toEqual({
|
||||
"img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 },
|
||||
});
|
||||
expect(merged.layout).toBe("new");
|
||||
});
|
||||
|
||||
it("clears image_overrides when partial value is null (explicit clear)", () => {
|
||||
// Same null-sentinel contract as the 4 sibling axes — `null` removes
|
||||
// the axis from disk so the next render reverts to baseline (no
|
||||
// user image position/size override).
|
||||
const existing = {
|
||||
layout: "x",
|
||||
image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } },
|
||||
};
|
||||
const merged = mergeUserOverrides(existing, { image_overrides: null });
|
||||
expect("image_overrides" in merged).toBe(false);
|
||||
expect(merged.layout).toBe("x");
|
||||
});
|
||||
|
||||
it("does not mutate the existing input", () => {
|
||||
const existing = { layout: "old", frames: { a: "b" } };
|
||||
const snapshot = JSON.parse(JSON.stringify(existing));
|
||||
mergeUserOverrides(existing, { layout: "new", layout_evil: "x" } as Record<
|
||||
string,
|
||||
unknown
|
||||
>);
|
||||
expect(existing).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
describe("atomicWriteUserOverrides (IMP-52 u4)", () => {
|
||||
let tmpRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp52-u4-aw-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("creates parent dir if missing and writes JSON content", () => {
|
||||
const filePath = path.join(tmpRoot, "data", "user_overrides", "03.json");
|
||||
expect(fs.existsSync(path.dirname(filePath))).toBe(false);
|
||||
atomicWriteUserOverrides(filePath, { layout: "x" });
|
||||
expect(fs.existsSync(filePath)).toBe(true);
|
||||
expect(JSON.parse(fs.readFileSync(filePath, "utf-8"))).toEqual({
|
||||
layout: "x",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves no .tmp residue after a successful write", () => {
|
||||
const filePath = path.join(tmpRoot, "data", "user_overrides", "03.json");
|
||||
atomicWriteUserOverrides(filePath, { layout: "x" });
|
||||
const dirContents = fs.readdirSync(path.dirname(filePath));
|
||||
expect(dirContents).toEqual(["03.json"]);
|
||||
});
|
||||
|
||||
it("overwrites an existing file atomically", () => {
|
||||
const filePath = path.join(tmpRoot, "data", "user_overrides", "03.json");
|
||||
atomicWriteUserOverrides(filePath, { layout: "v1" });
|
||||
atomicWriteUserOverrides(filePath, { layout: "v2" });
|
||||
expect(JSON.parse(fs.readFileSync(filePath, "utf-8"))).toEqual({
|
||||
layout: "v2",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// req mock — EventEmitter with method/url + a `send(body)` helper that
|
||||
// emits the data chunk and then `end`, mirroring the node IncomingMessage
|
||||
// flow used by vite's dev middlewares.
|
||||
function makeMockReq(opts: {
|
||||
method?: string;
|
||||
url?: string;
|
||||
}): EventEmitter & { method?: string; url?: string; send: (body: string) => void } {
|
||||
const ee = new EventEmitter() as EventEmitter & {
|
||||
method?: string;
|
||||
url?: string;
|
||||
send: (body: string) => void;
|
||||
};
|
||||
ee.method = opts.method;
|
||||
ee.url = opts.url;
|
||||
ee.send = (body: string) => {
|
||||
if (body.length > 0) ee.emit("data", Buffer.from(body, "utf-8"));
|
||||
ee.emit("end");
|
||||
};
|
||||
return ee;
|
||||
}
|
||||
|
||||
describe("handlePutUserOverrides (IMP-52 u4)", () => {
|
||||
let tmpRoot: string;
|
||||
let overridesDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp52-u4-"));
|
||||
overridesDir = path.join(tmpRoot, "data", "user_overrides");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns false (next chained) when method != PUT", () => {
|
||||
const req = makeMockReq({ method: "GET", url: "/03" });
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handlePutUserOverrides(req, res, tmpRoot);
|
||||
expect(handled).toBe(false);
|
||||
expect(state.ended).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 400 on invalid key", () => {
|
||||
const req = makeMockReq({ method: "PUT", url: "/../escape" });
|
||||
const { res, state } = makeMockRes();
|
||||
const handled = handlePutUserOverrides(req, res, tmpRoot);
|
||||
expect(handled).toBe(true);
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body)).toEqual({ error: "invalid key" });
|
||||
});
|
||||
|
||||
it("returns 400 on invalid JSON body", () => {
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
req.send("{not json");
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body)).toEqual({ error: "invalid JSON" });
|
||||
// file MUST NOT have been created on parse failure
|
||||
expect(fs.existsSync(path.join(overridesDir, "03.json"))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 400 when JSON body is an array", () => {
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
req.send(JSON.stringify([1, 2, 3]));
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body)).toEqual({
|
||||
error: "body must be a JSON object",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 400 when JSON body is a primitive", () => {
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
req.send("42");
|
||||
expect(state.statusCode).toBe(400);
|
||||
expect(JSON.parse(state.body)).toEqual({
|
||||
error: "body must be a JSON object",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates the override file on first PUT and returns merged body", () => {
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
|
||||
const payload = { layout: "two_zone_split" };
|
||||
req.send(JSON.stringify(payload));
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(state.headers["Content-Type"]).toBe(
|
||||
"application/json; charset=utf-8",
|
||||
);
|
||||
expect(JSON.parse(state.body)).toEqual({ layout: "two_zone_split" });
|
||||
|
||||
const filePath = path.join(overridesDir, "03.json");
|
||||
expect(fs.existsSync(filePath)).toBe(true);
|
||||
expect(JSON.parse(fs.readFileSync(filePath, "utf-8"))).toEqual({
|
||||
layout: "two_zone_split",
|
||||
});
|
||||
});
|
||||
|
||||
it("partial-merges: axes absent from payload are preserved on disk", () => {
|
||||
fs.mkdirSync(overridesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(overridesDir, "03.json"),
|
||||
JSON.stringify({
|
||||
layout: "old",
|
||||
frames: { "03-1": "frame_01" },
|
||||
zone_sections: { top: ["03-1"] },
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
req.send(JSON.stringify({ layout: "new" }));
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
const onDisk = JSON.parse(
|
||||
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
|
||||
);
|
||||
expect(onDisk).toEqual({
|
||||
layout: "new",
|
||||
frames: { "03-1": "frame_01" },
|
||||
zone_sections: { top: ["03-1"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves foreign top-level keys on disk (forward-compat)", () => {
|
||||
// `image_overrides` is no longer a foreign key after IMP-51 #79 u2;
|
||||
// probe with axes that are still NOT in KNOWN_USER_OVERRIDES_AXES.
|
||||
fs.mkdirSync(overridesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(overridesDir, "future.json"),
|
||||
JSON.stringify({
|
||||
layout: "old",
|
||||
zone_sizes: { top: 0.42 },
|
||||
schema_version: 2,
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const req = makeMockReq({ method: "PUT", url: "/future" });
|
||||
const { res } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
req.send(JSON.stringify({ layout: "new" }));
|
||||
|
||||
const onDisk = JSON.parse(
|
||||
fs.readFileSync(path.join(overridesDir, "future.json"), "utf-8"),
|
||||
);
|
||||
expect(onDisk.zone_sizes).toEqual({ top: 0.42 });
|
||||
expect(onDisk.schema_version).toBe(2);
|
||||
expect(onDisk.layout).toBe("new");
|
||||
});
|
||||
|
||||
it("persists image_overrides partial-merge and preserves sibling axes (IMP-51 #79 u2)", () => {
|
||||
// 5th axis end-to-end PUT round-trip: writing only image_overrides
|
||||
// must NOT touch the 4 sibling axes already on disk. Mirrors the
|
||||
// existing partial-merge test for layout above.
|
||||
fs.mkdirSync(overridesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(overridesDir, "03.json"),
|
||||
JSON.stringify({
|
||||
layout: "two_zone_split",
|
||||
frames: { "03-1": "frame_01" },
|
||||
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
|
||||
zone_sections: { top: ["03-1"] },
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
req.send(
|
||||
JSON.stringify({
|
||||
image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
const onDisk = JSON.parse(
|
||||
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
|
||||
);
|
||||
expect(onDisk).toEqual({
|
||||
layout: "two_zone_split",
|
||||
frames: { "03-1": "frame_01" },
|
||||
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
|
||||
zone_sections: { top: ["03-1"] },
|
||||
image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } },
|
||||
});
|
||||
});
|
||||
|
||||
it("drops non-axis payload keys (allowlist enforced at write)", () => {
|
||||
fs.mkdirSync(overridesDir, { recursive: true });
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
|
||||
req.send(
|
||||
JSON.stringify({
|
||||
layout: "two_zone_split",
|
||||
random_evil_key: "should not persist",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
const onDisk = JSON.parse(
|
||||
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
|
||||
);
|
||||
expect(onDisk).toEqual({ layout: "two_zone_split" });
|
||||
expect("random_evil_key" in onDisk).toBe(false);
|
||||
});
|
||||
|
||||
it("clears an axis when payload sets it to null", () => {
|
||||
fs.mkdirSync(overridesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(overridesDir, "03.json"),
|
||||
JSON.stringify({ layout: "old", frames: { "03-1": "f01" } }),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
const { res } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
req.send(JSON.stringify({ layout: null }));
|
||||
|
||||
const onDisk = JSON.parse(
|
||||
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
|
||||
);
|
||||
expect("layout" in onDisk).toBe(false);
|
||||
expect(onDisk.frames).toEqual({ "03-1": "f01" });
|
||||
});
|
||||
|
||||
it("recovers from corrupt existing file (graceful degrade)", () => {
|
||||
fs.mkdirSync(overridesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(overridesDir, "03.json"),
|
||||
"{this is not JSON",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
req.send(JSON.stringify({ layout: "recovered" }));
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
const onDisk = JSON.parse(
|
||||
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
|
||||
);
|
||||
expect(onDisk).toEqual({ layout: "recovered" });
|
||||
});
|
||||
|
||||
it("treats array-rooted existing file as empty (graceful degrade)", () => {
|
||||
fs.mkdirSync(overridesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(overridesDir, "03.json"),
|
||||
JSON.stringify(["not", "an", "object"]),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
req.send(JSON.stringify({ layout: "recovered" }));
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
const onDisk = JSON.parse(
|
||||
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
|
||||
);
|
||||
expect(onDisk).toEqual({ layout: "recovered" });
|
||||
});
|
||||
|
||||
it("strips the leading slash and ignores query string when keying", () => {
|
||||
const req = makeMockReq({
|
||||
method: "PUT",
|
||||
url: "/03?ts=1747884800",
|
||||
});
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
req.send(JSON.stringify({ layout: "x" }));
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(fs.existsSync(path.join(overridesDir, "03.json"))).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts an empty body as a no-op partial (no axes mutated)", () => {
|
||||
fs.mkdirSync(overridesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(overridesDir, "03.json"),
|
||||
JSON.stringify({ layout: "kept" }),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
req.send("");
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
const onDisk = JSON.parse(
|
||||
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
|
||||
);
|
||||
expect(onDisk).toEqual({ layout: "kept" });
|
||||
});
|
||||
|
||||
it("accepts a chunked PUT body (concatenates data events)", () => {
|
||||
const req = makeMockReq({ method: "PUT", url: "/03" });
|
||||
const { res, state } = makeMockRes();
|
||||
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
||||
|
||||
const body = JSON.stringify({
|
||||
layout: "two_zone_split",
|
||||
frames: { "03-1": "frame_01" },
|
||||
});
|
||||
// Emit in two halves to simulate a fragmented HTTP body.
|
||||
const half = Math.floor(body.length / 2);
|
||||
req.emit("data", Buffer.from(body.slice(0, half), "utf-8"));
|
||||
req.emit("data", Buffer.from(body.slice(half), "utf-8"));
|
||||
req.emit("end");
|
||||
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(JSON.parse(state.body)).toEqual({
|
||||
layout: "two_zone_split",
|
||||
frames: { "03-1": "frame_01" },
|
||||
});
|
||||
});
|
||||
});
|
||||
706
Front/client/tests/user_overrides_restore.test.ts
Normal file
706
Front/client/tests/user_overrides_restore.test.ts
Normal file
@@ -0,0 +1,706 @@
|
||||
// IMP-52 u6 — vitest coverage for restore-on-reopen helpers used by
|
||||
// `Home.tsx` to layer persisted `user_overrides.json` payloads onto the
|
||||
// in-memory `UserSelection` and `slidePlan`.
|
||||
//
|
||||
// Scope (Stage 2 unit u6 contract):
|
||||
// 1) deriveUserOverridesKey(filename) — MDX-stem key derivation that
|
||||
// matches backend u2 fallback's `Path(args.mdx_path).stem`. Strips
|
||||
// `.mdx` case-insensitively; preserves everything else.
|
||||
// 2) applyPersistedNonFrameOverrides(selection, persisted) — layers
|
||||
// layout / zone_geometries / zone_sections onto an existing selection.
|
||||
// Frames are NOT layered here (unit_id key requires slidePlan).
|
||||
// Foreign / unrecognized payloads degrade silently (no throw, no
|
||||
// partial mutation).
|
||||
// 3) remapPersistedFramesToZoneFrames(slidePlan, framesByUnitId) —
|
||||
// remaps frames (unit_id → template_id) to zone_frames (region.id →
|
||||
// template_id). Stale unit_ids (no matching zone) drop silently;
|
||||
// zones without internal_regions[0] or without section_ids are
|
||||
// skipped without throwing.
|
||||
//
|
||||
// All helpers are pure; tests run in vitest's default node environment
|
||||
// without RTL / jsdom. Home.tsx wiring sites (handleFileUpload pre-Generate
|
||||
// seed + handleGenerate post-loadRun frame remap) are 1-line call sites that
|
||||
// these helpers cover end-to-end.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type {
|
||||
LayoutPresetId,
|
||||
SlidePlan,
|
||||
UserSelection,
|
||||
Zone,
|
||||
} from "../src/types/designAgent";
|
||||
import {
|
||||
applyPersistedNonFrameOverrides,
|
||||
createInitialUserSelection,
|
||||
deriveUserOverridesKey,
|
||||
remapPersistedFramesToZoneFrames,
|
||||
saveImageOverride,
|
||||
saveTextOverride,
|
||||
saveStructureOverride,
|
||||
} from "../src/utils/slidePlanUtils";
|
||||
|
||||
// ─── Fixtures ───────────────────────────────────────────────────────────────
|
||||
|
||||
function makeSelection(overrides?: Partial<UserSelection["overrides"]>): UserSelection {
|
||||
return {
|
||||
selectedSectionId: null,
|
||||
selectedZoneId: null,
|
||||
selectedRegionId: null,
|
||||
overrides: {
|
||||
layout_preset: undefined,
|
||||
zone_frames: {},
|
||||
zone_sections: {},
|
||||
zone_sizes: {},
|
||||
zone_geometries: {},
|
||||
// IMP-51 (#79) u11 — keep the fixture in sync with the 5th persisted
|
||||
// axis declared on `UserSelection.overrides`. Empty by default so the
|
||||
// existing IMP-52 cases remain unchanged in shape.
|
||||
image_overrides: {},
|
||||
// IMP-55 (#93) u3 — bool intent marker is REQUIRED on
|
||||
// `UserSelection.overrides` (not optional). Default to `false` so every
|
||||
// pre-existing fixture matches the `createInitialUserSelection` seed
|
||||
// and stays compile-clean after u3 widened the type.
|
||||
manual_section_assignment: false,
|
||||
// IMP-56 (#90) u15 — keep the fixture in sync with the two Step-22
|
||||
// persist axes declared on `UserSelection.overrides`. Empty by
|
||||
// default so pre-existing cases retain their shape.
|
||||
text_overrides: {},
|
||||
structure_overrides: {},
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeZone(
|
||||
partial: { id: string; zone_id: string; section_ids: string[]; region_id?: string },
|
||||
): Zone {
|
||||
return {
|
||||
id: partial.id,
|
||||
zone_id: partial.zone_id,
|
||||
section_ids: partial.section_ids,
|
||||
position: { x: 0, y: 0, width: 1, height: 1 },
|
||||
internal_regions: [
|
||||
{
|
||||
id: partial.region_id ?? `${partial.id}-r0`,
|
||||
region_id: "region-single",
|
||||
role: "primary",
|
||||
content_type: "text_block",
|
||||
ratio_estimate: 1,
|
||||
content_unit_ids: [],
|
||||
frame_match_strategy: {
|
||||
kind: "frame_match",
|
||||
frame_id: null,
|
||||
display_strategy: "inline_full",
|
||||
},
|
||||
frame_candidates: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function makeSlidePlan(zones: Zone[], layout: LayoutPresetId = "single"): SlidePlan {
|
||||
return {
|
||||
id: "plan-1",
|
||||
title: "test plan",
|
||||
layout_preset: layout,
|
||||
zones,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── deriveUserOverridesKey ─────────────────────────────────────────────────
|
||||
|
||||
describe("deriveUserOverridesKey (IMP-52 u6)", () => {
|
||||
it("strips trailing .mdx", () => {
|
||||
expect(deriveUserOverridesKey("03__DX_BIM_value_chain.mdx")).toBe(
|
||||
"03__DX_BIM_value_chain",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips .MDX case-insensitively", () => {
|
||||
expect(deriveUserOverridesKey("04_demo.MDX")).toBe("04_demo");
|
||||
expect(deriveUserOverridesKey("05_intro.Mdx")).toBe("05_intro");
|
||||
});
|
||||
|
||||
it("returns the filename unchanged when no .mdx suffix", () => {
|
||||
expect(deriveUserOverridesKey("03__DX_BIM_value_chain")).toBe(
|
||||
"03__DX_BIM_value_chain",
|
||||
);
|
||||
expect(deriveUserOverridesKey("notes.txt")).toBe("notes.txt");
|
||||
});
|
||||
|
||||
it("only strips the final .mdx, preserves dots inside the stem", () => {
|
||||
expect(deriveUserOverridesKey("05.2_layer.mdx")).toBe("05.2_layer");
|
||||
});
|
||||
|
||||
it("returns empty string for empty input", () => {
|
||||
expect(deriveUserOverridesKey("")).toBe("");
|
||||
});
|
||||
|
||||
it("matches backend Path(args.mdx_path).stem for the canonical demo MDXs", () => {
|
||||
// These are the three canonical samples loaded by /api/sample-mdx; the
|
||||
// key on both ends must agree so a write from frontend (PUT) is found
|
||||
// by backend (u2 fallback on next pipeline run).
|
||||
expect(deriveUserOverridesKey("03_demo.mdx")).toBe("03_demo");
|
||||
expect(deriveUserOverridesKey("04_demo.mdx")).toBe("04_demo");
|
||||
expect(deriveUserOverridesKey("05_demo.mdx")).toBe("05_demo");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── applyPersistedNonFrameOverrides ────────────────────────────────────────
|
||||
|
||||
describe("applyPersistedNonFrameOverrides (IMP-52 u6)", () => {
|
||||
it("layers layout / zone_geometries / zone_sections", () => {
|
||||
const sel = makeSelection();
|
||||
const persisted = {
|
||||
layout: "horizontal-2",
|
||||
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.4 } },
|
||||
zone_sections: { top: ["03-1"], bottom: ["03-2"] },
|
||||
} as const;
|
||||
const next = applyPersistedNonFrameOverrides(sel, persisted);
|
||||
expect(next.overrides.layout_preset).toBe("horizontal-2");
|
||||
expect(next.overrides.zone_geometries).toEqual({
|
||||
top: { x: 0, y: 0, w: 1, h: 0.4 },
|
||||
});
|
||||
expect(next.overrides.zone_sections).toEqual({
|
||||
top: ["03-1"],
|
||||
bottom: ["03-2"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT layer frames (frames need post-loadRun remap)", () => {
|
||||
const sel = makeSelection({ zone_frames: { "r-existing": "tpl-existing" } });
|
||||
const persisted = {
|
||||
frames: { "03-1+03-2": "tpl-persisted" },
|
||||
};
|
||||
const next = applyPersistedNonFrameOverrides(sel, persisted);
|
||||
// zone_frames is untouched here; the post-loadRun remap step owns it.
|
||||
expect(next.overrides.zone_frames).toEqual({ "r-existing": "tpl-existing" });
|
||||
});
|
||||
|
||||
it("rejects layout values outside the 8 known preset ids", () => {
|
||||
const sel = makeSelection({ layout_preset: "single" });
|
||||
const next = applyPersistedNonFrameOverrides(sel, {
|
||||
layout: "rogue-layout" as unknown as string,
|
||||
});
|
||||
// Stays at the original — preset whitelist guards against hand-edited
|
||||
// files or future schema drift.
|
||||
expect(next.overrides.layout_preset).toBe("single");
|
||||
});
|
||||
|
||||
it("ignores zone_geometries when the payload axis is an array", () => {
|
||||
const sel = makeSelection({ zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } } });
|
||||
const next = applyPersistedNonFrameOverrides(sel, {
|
||||
zone_geometries: [] as unknown as Record<string, { x: number; y: number; w: number; h: number }>,
|
||||
});
|
||||
expect(next.overrides.zone_geometries).toEqual({
|
||||
top: { x: 0, y: 0, w: 1, h: 0.5 },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the selection unchanged when persisted is null / undefined / non-object", () => {
|
||||
const sel = makeSelection({ layout_preset: "single" });
|
||||
expect(applyPersistedNonFrameOverrides(sel, null)).toEqual(sel);
|
||||
expect(applyPersistedNonFrameOverrides(sel, undefined)).toEqual(sel);
|
||||
});
|
||||
|
||||
it("returns the selection unchanged when persisted is empty {}", () => {
|
||||
const sel = makeSelection({ layout_preset: "single" });
|
||||
const next = applyPersistedNonFrameOverrides(sel, {});
|
||||
expect(next.overrides.layout_preset).toBe("single");
|
||||
expect(next.overrides.zone_geometries).toEqual({});
|
||||
expect(next.overrides.zone_sections).toEqual({});
|
||||
});
|
||||
|
||||
it("returns a NEW selection object (no mutation of input)", () => {
|
||||
const sel = makeSelection();
|
||||
const next = applyPersistedNonFrameOverrides(sel, { layout: "vertical-2" });
|
||||
expect(next).not.toBe(sel);
|
||||
expect(next.overrides).not.toBe(sel.overrides);
|
||||
// Input still pristine.
|
||||
expect(sel.overrides.layout_preset).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── remapPersistedFramesToZoneFrames ───────────────────────────────────────
|
||||
|
||||
describe("remapPersistedFramesToZoneFrames (IMP-52 u6)", () => {
|
||||
it("maps unit_id (section_ids joined by +) to region.id", () => {
|
||||
const plan = makeSlidePlan([
|
||||
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
|
||||
makeZone({ id: "z-bot", zone_id: "bottom", section_ids: ["03-2", "03-3"], region_id: "r-bot" }),
|
||||
]);
|
||||
const remapped = remapPersistedFramesToZoneFrames(plan, {
|
||||
"03-1": "tpl-a",
|
||||
"03-2+03-3": "tpl-b",
|
||||
});
|
||||
expect(remapped).toEqual({
|
||||
"r-top": "tpl-a",
|
||||
"r-bot": "tpl-b",
|
||||
});
|
||||
});
|
||||
|
||||
it("silently drops persisted entries whose unit_id matches no zone", () => {
|
||||
const plan = makeSlidePlan([
|
||||
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
|
||||
]);
|
||||
const remapped = remapPersistedFramesToZoneFrames(plan, {
|
||||
"03-1": "tpl-a",
|
||||
"stale-section-id": "tpl-stale", // user changed zone_sections between sessions
|
||||
});
|
||||
expect(remapped).toEqual({ "r-top": "tpl-a" });
|
||||
});
|
||||
|
||||
it("returns {} when slidePlan is null / undefined", () => {
|
||||
expect(remapPersistedFramesToZoneFrames(null, { "03-1": "tpl-a" })).toEqual({});
|
||||
expect(remapPersistedFramesToZoneFrames(undefined, { "03-1": "tpl-a" })).toEqual({});
|
||||
});
|
||||
|
||||
it("returns {} when framesByUnitId is null / undefined / {}", () => {
|
||||
const plan = makeSlidePlan([
|
||||
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
|
||||
]);
|
||||
expect(remapPersistedFramesToZoneFrames(plan, null)).toEqual({});
|
||||
expect(remapPersistedFramesToZoneFrames(plan, undefined)).toEqual({});
|
||||
expect(remapPersistedFramesToZoneFrames(plan, {})).toEqual({});
|
||||
});
|
||||
|
||||
it("skips zones with empty section_ids (no unit_id to derive)", () => {
|
||||
const plan = makeSlidePlan([
|
||||
makeZone({ id: "z-empty", zone_id: "empty", section_ids: [], region_id: "r-empty" }),
|
||||
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
|
||||
]);
|
||||
const remapped = remapPersistedFramesToZoneFrames(plan, {
|
||||
"": "tpl-should-not-match-empty-join",
|
||||
"03-1": "tpl-a",
|
||||
});
|
||||
expect(remapped).toEqual({ "r-top": "tpl-a" });
|
||||
});
|
||||
|
||||
it("skips zones without internal_regions[0]", () => {
|
||||
const plan: SlidePlan = {
|
||||
id: "plan-x",
|
||||
title: "no regions",
|
||||
layout_preset: "single",
|
||||
zones: [
|
||||
{
|
||||
id: "z-bare",
|
||||
zone_id: "bare",
|
||||
section_ids: ["03-1"],
|
||||
position: { x: 0, y: 0, width: 1, height: 1 },
|
||||
internal_regions: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(remapPersistedFramesToZoneFrames(plan, { "03-1": "tpl-a" })).toEqual({});
|
||||
});
|
||||
|
||||
it("ignores persisted entries with empty / non-string template_id", () => {
|
||||
const plan = makeSlidePlan([
|
||||
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
|
||||
]);
|
||||
const remapped = remapPersistedFramesToZoneFrames(plan, {
|
||||
"03-1": "" as unknown as string,
|
||||
});
|
||||
expect(remapped).toEqual({});
|
||||
});
|
||||
|
||||
it("preserves the user-selected template even when slidePlan layout would imply a different default", () => {
|
||||
// Backend u2 fallback should already have applied the user's frame
|
||||
// override via CLI args, but if the plan's default frame_match_strategy
|
||||
// disagrees, the post-loadRun remap still surfaces the user's choice
|
||||
// for the SlideCanvas override-vs-default preview indicator.
|
||||
const plan = makeSlidePlan([
|
||||
makeZone({ id: "z-top", zone_id: "top", section_ids: ["03-1"], region_id: "r-top" }),
|
||||
]);
|
||||
const remapped = remapPersistedFramesToZoneFrames(plan, {
|
||||
"03-1": "user-chosen-tpl",
|
||||
});
|
||||
expect(remapped["r-top"]).toBe("user-chosen-tpl");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── IMP-51 (#79) u11 — image_overrides axis ────────────────────────────────
|
||||
// New 5th persisted axis. The on-disk schema (KNOWN_AXES,
|
||||
// src/user_overrides_io.py u1), the typed client
|
||||
// (services/userOverridesApi.ts u3 ImageOverridesOverride), the Vite
|
||||
// allowlist (vite.config.ts u2), and the backend CLI flag (--override-image
|
||||
// in src/phase_z2_pipeline.py u5) all expect `image_id` → percent-of-slide
|
||||
// geometry. u11 owns the in-memory mirror on `UserSelection.overrides`
|
||||
// (declared in types/designAgent.ts) plus the three pure helpers that
|
||||
// Home.tsx (u10) wires:
|
||||
// • applyPersistedNonFrameOverrides — restore-on-reopen layer.
|
||||
// • createInitialUserSelection — fresh-slide initializer.
|
||||
// • saveImageOverride — single-image record helper invoked by the
|
||||
// SlideCanvas u8 drag/resize handler.
|
||||
|
||||
describe("image_overrides axis — applyPersistedNonFrameOverrides (IMP-51 u11)", () => {
|
||||
it("layers a flat image_overrides dict onto the selection", () => {
|
||||
const sel = makeSelection();
|
||||
const persisted = {
|
||||
image_overrides: {
|
||||
"img-abc1234567": { x: 10, y: 15, w: 30.5, h: 25 },
|
||||
"img-deadbeef00": { x: 50, y: 50, w: 40, h: 40 },
|
||||
},
|
||||
};
|
||||
const next = applyPersistedNonFrameOverrides(sel, persisted);
|
||||
expect(next.overrides.image_overrides).toEqual({
|
||||
"img-abc1234567": { x: 10, y: 15, w: 30.5, h: 25 },
|
||||
"img-deadbeef00": { x: 50, y: 50, w: 40, h: 40 },
|
||||
});
|
||||
// Untouched axes stay at their fixture defaults so the round-trip is
|
||||
// safe to interleave with the other four axes.
|
||||
expect(next.overrides.zone_geometries).toEqual({});
|
||||
expect(next.overrides.zone_sections).toEqual({});
|
||||
expect(next.overrides.layout_preset).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores image_overrides when the payload axis is an array", () => {
|
||||
const sel = makeSelection({
|
||||
image_overrides: { "img-existing00": { x: 1, y: 2, w: 30, h: 40 } },
|
||||
});
|
||||
const next = applyPersistedNonFrameOverrides(sel, {
|
||||
image_overrides: [] as unknown as Record<
|
||||
string,
|
||||
{ x: number; y: number; w: number; h: number }
|
||||
>,
|
||||
});
|
||||
// Same guard the zone_geometries branch uses — array payloads from a
|
||||
// hand-edited file are rejected and the prior in-memory value stays.
|
||||
expect(next.overrides.image_overrides).toEqual({
|
||||
"img-existing00": { x: 1, y: 2, w: 30, h: 40 },
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores image_overrides when the payload axis is null", () => {
|
||||
const sel = makeSelection({
|
||||
image_overrides: { "img-existing00": { x: 0, y: 0, w: 100, h: 100 } },
|
||||
});
|
||||
const next = applyPersistedNonFrameOverrides(sel, {
|
||||
image_overrides: null,
|
||||
});
|
||||
expect(next.overrides.image_overrides).toEqual({
|
||||
"img-existing00": { x: 0, y: 0, w: 100, h: 100 },
|
||||
});
|
||||
});
|
||||
|
||||
it("layers image_overrides alongside the four IMP-52 axes in one call", () => {
|
||||
const sel = makeSelection();
|
||||
const next = applyPersistedNonFrameOverrides(sel, {
|
||||
layout: "horizontal-2",
|
||||
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.4 } },
|
||||
zone_sections: { top: ["03-1"] },
|
||||
image_overrides: { "img-abc1234567": { x: 25, y: 25, w: 50, h: 50 } },
|
||||
});
|
||||
expect(next.overrides.layout_preset).toBe("horizontal-2");
|
||||
expect(next.overrides.zone_geometries).toEqual({
|
||||
top: { x: 0, y: 0, w: 1, h: 0.4 },
|
||||
});
|
||||
expect(next.overrides.zone_sections).toEqual({ top: ["03-1"] });
|
||||
expect(next.overrides.image_overrides).toEqual({
|
||||
"img-abc1234567": { x: 25, y: 25, w: 50, h: 50 },
|
||||
});
|
||||
});
|
||||
|
||||
it("seeds an empty image_overrides on a fresh selection (createInitialUserSelection)", () => {
|
||||
const sel = createInitialUserSelection();
|
||||
expect(sel.overrides.image_overrides).toEqual({});
|
||||
// Mirrors the shape Home.tsx receives before any user interaction —
|
||||
// SlideCanvas u8 expects the axis to exist (not undefined) so its
|
||||
// `Object.entries(measured + persisted)` merge never crashes.
|
||||
});
|
||||
});
|
||||
|
||||
describe("image_overrides axis — saveImageOverride (IMP-51 u11)", () => {
|
||||
const ID_A = "img-abc1234567";
|
||||
const ID_B = "img-deadbeef00";
|
||||
|
||||
it("adds a new image_id entry on an empty axis", () => {
|
||||
const sel = makeSelection();
|
||||
const next = saveImageOverride(sel, ID_A, { x: 10, y: 15, w: 30.5, h: 25 });
|
||||
expect(next.overrides.image_overrides).toEqual({
|
||||
[ID_A]: { x: 10, y: 15, w: 30.5, h: 25 },
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces an existing entry under the same image_id (most recent drag wins)", () => {
|
||||
const sel = makeSelection({
|
||||
image_overrides: { [ID_A]: { x: 0, y: 0, w: 20, h: 20 } },
|
||||
});
|
||||
const next = saveImageOverride(sel, ID_A, { x: 50, y: 50, w: 30, h: 30 });
|
||||
expect(next.overrides.image_overrides).toEqual({
|
||||
[ID_A]: { x: 50, y: 50, w: 30, h: 30 },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves sibling image_id entries when adding a new one", () => {
|
||||
const sel = makeSelection({
|
||||
image_overrides: { [ID_A]: { x: 10, y: 10, w: 20, h: 20 } },
|
||||
});
|
||||
const next = saveImageOverride(sel, ID_B, { x: 60, y: 60, w: 30, h: 30 });
|
||||
expect(next.overrides.image_overrides).toEqual({
|
||||
[ID_A]: { x: 10, y: 10, w: 20, h: 20 },
|
||||
[ID_B]: { x: 60, y: 60, w: 30, h: 30 },
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT touch the other four override axes", () => {
|
||||
const sel = makeSelection({
|
||||
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
|
||||
zone_sections: { top: ["03-1"] },
|
||||
zone_frames: { "r-top": "tpl-a" },
|
||||
layout_preset: "horizontal-2",
|
||||
});
|
||||
const next = saveImageOverride(sel, ID_A, { x: 10, y: 10, w: 20, h: 20 });
|
||||
expect(next.overrides.zone_geometries).toEqual({
|
||||
top: { x: 0, y: 0, w: 1, h: 0.5 },
|
||||
});
|
||||
expect(next.overrides.zone_sections).toEqual({ top: ["03-1"] });
|
||||
expect(next.overrides.zone_frames).toEqual({ "r-top": "tpl-a" });
|
||||
expect(next.overrides.layout_preset).toBe("horizontal-2");
|
||||
});
|
||||
|
||||
it("returns a NEW selection object (no input mutation)", () => {
|
||||
const sel = makeSelection({
|
||||
image_overrides: { [ID_A]: { x: 0, y: 0, w: 10, h: 10 } },
|
||||
});
|
||||
const before = { ...sel.overrides.image_overrides };
|
||||
const next = saveImageOverride(sel, ID_B, { x: 30, y: 30, w: 20, h: 20 });
|
||||
expect(next).not.toBe(sel);
|
||||
expect(next.overrides).not.toBe(sel.overrides);
|
||||
expect(next.overrides.image_overrides).not.toBe(sel.overrides.image_overrides);
|
||||
// Input still pristine.
|
||||
expect(sel.overrides.image_overrides).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── IMP-55 (#93) u3 — manual_section_assignment bool axis ──────────────────
|
||||
// Restore-on-reopen / seed coverage for the bool intent marker. Production
|
||||
// branch lives at `slidePlanUtils.ts` — `applyPersistedNonFrameOverrides`
|
||||
// guards with `typeof persisted.manual_section_assignment === "boolean"`,
|
||||
// and `createInitialUserSelection` seeds the axis to `false`. The marker
|
||||
// gates whether `handleGenerate` (u7) forwards `overrides.zoneSections`
|
||||
// to the backend; the pipeline (u9) consumes persisted `zone_sections`
|
||||
// only when the marker is exactly `true`, so any non-boolean payload MUST
|
||||
// end up `false` in memory (fail-closed).
|
||||
|
||||
describe("manual_section_assignment axis — applyPersistedNonFrameOverrides (IMP-55 #93 u3)", () => {
|
||||
it("restores literal true verbatim", () => {
|
||||
const sel = makeSelection();
|
||||
const next = applyPersistedNonFrameOverrides(sel, {
|
||||
manual_section_assignment: true,
|
||||
});
|
||||
expect(next.overrides.manual_section_assignment).toBe(true);
|
||||
});
|
||||
|
||||
it("restores literal false verbatim (u12 apply/cancel write must survive reopen)", () => {
|
||||
// Seed `true` so the assertion proves `false` overwrites; a truthiness
|
||||
// check instead of `typeof === \"boolean\"` would silently keep `true`
|
||||
// and resurrect stale auto-carry assignments as user intent.
|
||||
const sel = makeSelection({ manual_section_assignment: true });
|
||||
const next = applyPersistedNonFrameOverrides(sel, {
|
||||
manual_section_assignment: false,
|
||||
});
|
||||
expect(next.overrides.manual_section_assignment).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves the in-memory marker unchanged when the persisted axis is absent", () => {
|
||||
const sel = makeSelection({ manual_section_assignment: true });
|
||||
const next = applyPersistedNonFrameOverrides(sel, { layout: "horizontal-2" });
|
||||
expect(next.overrides.manual_section_assignment).toBe(true);
|
||||
expect(next.overrides.layout_preset).toBe("horizontal-2");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["null clear sentinel", null],
|
||||
['string "true"', "true"],
|
||||
['string "false"', "false"],
|
||||
["number 1", 1],
|
||||
["number 0", 0],
|
||||
["object {}", {}],
|
||||
["array []", []],
|
||||
])("ignores non-boolean payload (%s) — keeps prior in-memory value", (_label, payload) => {
|
||||
const sel = makeSelection({ manual_section_assignment: true });
|
||||
const next = applyPersistedNonFrameOverrides(sel, {
|
||||
manual_section_assignment: payload as unknown as boolean,
|
||||
});
|
||||
expect(next.overrides.manual_section_assignment).toBe(true);
|
||||
});
|
||||
|
||||
it("seeds an empty selection with manual_section_assignment=false (createInitialUserSelection)", () => {
|
||||
const sel = createInitialUserSelection();
|
||||
expect(sel.overrides.manual_section_assignment).toBe(false);
|
||||
});
|
||||
|
||||
it("returns a NEW selection object (no input mutation) when restoring the marker", () => {
|
||||
const sel = makeSelection({ manual_section_assignment: false });
|
||||
const next = applyPersistedNonFrameOverrides(sel, {
|
||||
manual_section_assignment: true,
|
||||
});
|
||||
expect(next).not.toBe(sel);
|
||||
expect(next.overrides).not.toBe(sel.overrides);
|
||||
// Input still pristine — proves the helper does not flip the fixture.
|
||||
expect(sel.overrides.manual_section_assignment).toBe(false);
|
||||
});
|
||||
|
||||
it("layers the bool axis alongside other persisted axes in a single call", () => {
|
||||
const sel = makeSelection();
|
||||
const next = applyPersistedNonFrameOverrides(sel, {
|
||||
layout: "vertical-2",
|
||||
zone_sections: { top: ["03-1"], bottom: ["03-2"] },
|
||||
manual_section_assignment: true,
|
||||
});
|
||||
expect(next.overrides.layout_preset).toBe("vertical-2");
|
||||
expect(next.overrides.zone_sections).toEqual({
|
||||
top: ["03-1"],
|
||||
bottom: ["03-2"],
|
||||
});
|
||||
expect(next.overrides.manual_section_assignment).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── IMP-56 (#90) u15 — text_overrides + structure_overrides axes ───────────
|
||||
// Pure helpers wired by Home.tsx into the SlideCanvas u13 focusout capture
|
||||
// (text) and u14 structure overlay emit (structure). Tests cover:
|
||||
// • saveTextOverride / saveStructureOverride immutability + merge semantics
|
||||
// • createInitialUserSelection seeding the two new axes empty
|
||||
// • applyPersistedNonFrameOverrides layering via the u10 extract helpers
|
||||
|
||||
describe("text_overrides axis — saveTextOverride (IMP-56 u15)", () => {
|
||||
it("records a fresh (zoneId, textPath, value) tuple", () => {
|
||||
const sel = makeSelection();
|
||||
const next = saveTextOverride(sel, "top", "row_1_left_body.0", "분석 결과");
|
||||
expect(next.overrides.text_overrides).toEqual({
|
||||
top: { "row_1_left_body.0": "분석 결과" },
|
||||
});
|
||||
});
|
||||
|
||||
it("merges within the same zone without erasing prior text_paths", () => {
|
||||
const sel = makeSelection({
|
||||
text_overrides: { top: { "row_1_left_body.0": "기존" } },
|
||||
});
|
||||
const next = saveTextOverride(sel, "top", "row_1_left_body.1", "신규");
|
||||
expect(next.overrides.text_overrides.top).toEqual({
|
||||
"row_1_left_body.0": "기존",
|
||||
"row_1_left_body.1": "신규",
|
||||
});
|
||||
});
|
||||
|
||||
it("overwrites the same textPath value within a zone", () => {
|
||||
const sel = makeSelection({
|
||||
text_overrides: { top: { "headline.0": "v1" } },
|
||||
});
|
||||
const next = saveTextOverride(sel, "top", "headline.0", "v2");
|
||||
expect(next.overrides.text_overrides.top).toEqual({ "headline.0": "v2" });
|
||||
});
|
||||
|
||||
it("does not mutate the input selection (immutable contract)", () => {
|
||||
const sel = makeSelection({
|
||||
text_overrides: { top: { "headline.0": "before" } },
|
||||
});
|
||||
saveTextOverride(sel, "top", "headline.0", "after");
|
||||
expect(sel.overrides.text_overrides).toEqual({
|
||||
top: { "headline.0": "before" },
|
||||
});
|
||||
});
|
||||
|
||||
it("seeds an empty text_overrides on a fresh selection", () => {
|
||||
const sel = createInitialUserSelection();
|
||||
expect(sel.overrides.text_overrides).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("structure_overrides axis — saveStructureOverride (IMP-56 u15)", () => {
|
||||
it("records a fresh (zoneId → {slot_order, hidden_slots}) tuple", () => {
|
||||
const sel = makeSelection();
|
||||
const next = saveStructureOverride(sel, "top", {
|
||||
slot_order: ["b", "a"],
|
||||
hidden_slots: ["c"],
|
||||
});
|
||||
expect(next.overrides.structure_overrides).toEqual({
|
||||
top: { slot_order: ["b", "a"], hidden_slots: ["c"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces an existing zone entry verbatim (no merge within zone)", () => {
|
||||
const sel = makeSelection({
|
||||
structure_overrides: { top: { slot_order: ["a", "b"], hidden_slots: [] } },
|
||||
});
|
||||
const next = saveStructureOverride(sel, "top", {
|
||||
slot_order: ["b", "a"],
|
||||
hidden_slots: ["a"],
|
||||
});
|
||||
expect(next.overrides.structure_overrides.top).toEqual({
|
||||
slot_order: ["b", "a"],
|
||||
hidden_slots: ["a"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps unrelated zones intact when updating one zone", () => {
|
||||
const sel = makeSelection({
|
||||
structure_overrides: {
|
||||
top: { slot_order: ["x"], hidden_slots: [] },
|
||||
bottom_l: { slot_order: ["y"], hidden_slots: ["z"] },
|
||||
},
|
||||
});
|
||||
const next = saveStructureOverride(sel, "top", {
|
||||
slot_order: ["x", "x2"],
|
||||
hidden_slots: [],
|
||||
});
|
||||
expect(next.overrides.structure_overrides.bottom_l).toEqual({
|
||||
slot_order: ["y"],
|
||||
hidden_slots: ["z"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mutate the input perZone object after save", () => {
|
||||
const sel = makeSelection();
|
||||
const perZone = { slot_order: ["a"], hidden_slots: ["b"] };
|
||||
const next = saveStructureOverride(sel, "top", perZone);
|
||||
perZone.slot_order.push("MUTATED");
|
||||
expect(next.overrides.structure_overrides.top.slot_order).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("seeds an empty structure_overrides on a fresh selection", () => {
|
||||
const sel = createInitialUserSelection();
|
||||
expect(sel.overrides.structure_overrides).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Step-22 axes — applyPersistedNonFrameOverrides restore (IMP-56 u15)", () => {
|
||||
it("layers persisted text_overrides through the u10 extract helper", () => {
|
||||
const sel = makeSelection();
|
||||
const next = applyPersistedNonFrameOverrides(sel, {
|
||||
text_overrides: {
|
||||
top: { "row_1_left_body.0": "복원" },
|
||||
},
|
||||
});
|
||||
expect(next.overrides.text_overrides).toEqual({
|
||||
top: { "row_1_left_body.0": "복원" },
|
||||
});
|
||||
});
|
||||
|
||||
it("layers persisted structure_overrides through the u10 extract helper", () => {
|
||||
const sel = makeSelection();
|
||||
const next = applyPersistedNonFrameOverrides(sel, {
|
||||
structure_overrides: {
|
||||
top: { slot_order: ["b", "a"], hidden_slots: ["c"] },
|
||||
},
|
||||
});
|
||||
expect(next.overrides.structure_overrides).toEqual({
|
||||
top: { slot_order: ["b", "a"], hidden_slots: ["c"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("drops non-object payloads silently (no throw, axis stays empty)", () => {
|
||||
const sel = makeSelection();
|
||||
const next = applyPersistedNonFrameOverrides(sel, {
|
||||
text_overrides: "garbage" as unknown as Record<string, Record<string, string>>,
|
||||
structure_overrides: ["bad"] as unknown as Record<
|
||||
string,
|
||||
{ slot_order?: string[]; hidden_slots?: string[] }
|
||||
>,
|
||||
});
|
||||
expect(next.overrides.text_overrides).toEqual({});
|
||||
expect(next.overrides.structure_overrides).toEqual({});
|
||||
});
|
||||
});
|
||||
625
Front/client/tests/user_overrides_service.test.ts
Normal file
625
Front/client/tests/user_overrides_service.test.ts
Normal file
@@ -0,0 +1,625 @@
|
||||
// IMP-52 u5 — vitest coverage for the typed frontend client at
|
||||
// `Front/client/src/services/userOverridesApi.ts`.
|
||||
//
|
||||
// Scope (Stage 2 unit u5 contract):
|
||||
// 1) getUserOverrides:
|
||||
// • 200 with object body → typed payload echoed.
|
||||
// • 200 with array / primitive / non-JSON body → {} (graceful).
|
||||
// • 4xx / 5xx → {}.
|
||||
// • fetch reject (network) → {} (no throw to caller).
|
||||
// 2) saveUserOverrides:
|
||||
// • Single call: PUT fires after exactly 300 ms with the mutated-axis
|
||||
// partial as body (NOT a full snapshot of UserOverrides).
|
||||
// • Rapid coalescing: N calls in <300 ms window collapse to ONE PUT
|
||||
// carrying the union of mutated axes.
|
||||
// • Per-axis later-wins: later call's value replaces earlier pending
|
||||
// value for the same axis; axes the user did not touch stay absent.
|
||||
// • null sentinel: forwarded verbatim so u4 mergeUserOverrides can
|
||||
// `delete` the axis on disk.
|
||||
// • Per-key isolation: rapid edits to "03" do not delay flush of "04".
|
||||
// • Promise resolves with the server-side merged document.
|
||||
// • Promise rejects on 4xx/5xx and on fetch reject.
|
||||
// 3) flushUserOverrides:
|
||||
// • No arg → flushes all pending buckets immediately (no 300 ms wait).
|
||||
// • Specific key → flushes only that bucket; other buckets stay
|
||||
// pending.
|
||||
// • No-op when no buckets are pending.
|
||||
//
|
||||
// All tests mock `fetch` and use `vi.useFakeTimers()` to make the 300 ms
|
||||
// debounce deterministic — no real wall-clock waits.
|
||||
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
type Mock,
|
||||
} from "vitest";
|
||||
import {
|
||||
__resetUserOverridesBuckets_FOR_TEST,
|
||||
flushUserOverrides,
|
||||
getUserOverrides,
|
||||
saveUserOverrides,
|
||||
type UserOverridesPartial,
|
||||
} from "../src/services/userOverridesApi";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fetch mock — minimal Response stub with the two methods the service uses
|
||||
// (.ok / .status / .json()). We track the call log so debounce + coalescing
|
||||
// can be asserted by counting PUTs and inspecting their bodies.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type MockResponse = {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
json: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
function mockResponse(body: unknown, ok = true, status = 200): MockResponse {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
};
|
||||
}
|
||||
|
||||
let fetchMock: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.useFakeTimers();
|
||||
__resetUserOverridesBuckets_FOR_TEST();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
__resetUserOverridesBuckets_FOR_TEST();
|
||||
});
|
||||
|
||||
// Microtask-flushing helper. vi.advanceTimersByTime fires timers, but the
|
||||
// promise chain inside flushBucket (await fetch → await res.json() → resolve
|
||||
// waiters) needs the microtask queue to drain before assertions run.
|
||||
async function drainMicrotasks(): Promise<void> {
|
||||
// Multiple ticks because each `await` in flushBucket adds another tick.
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
function lastPutBody(): unknown {
|
||||
const lastCall = fetchMock.mock.calls.at(-1);
|
||||
if (!lastCall) throw new Error("fetch was not called");
|
||||
const init = lastCall[1] as RequestInit | undefined;
|
||||
if (!init?.body) throw new Error("fetch was called without a body");
|
||||
return JSON.parse(String(init.body));
|
||||
}
|
||||
|
||||
function putCallsCount(): number {
|
||||
return fetchMock.mock.calls.filter(
|
||||
(call) => (call[1] as RequestInit | undefined)?.method === "PUT",
|
||||
).length;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// getUserOverrides
|
||||
// ============================================================================
|
||||
|
||||
describe("getUserOverrides (IMP-52 u5)", () => {
|
||||
it("issues GET against /api/user-overrides/<key>", async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockResponse({ layout: "x" }));
|
||||
await getUserOverrides("03");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("/api/user-overrides/03");
|
||||
expect((init as RequestInit).method).toBe("GET");
|
||||
});
|
||||
|
||||
it("returns the parsed object on 200 with object body", async () => {
|
||||
const payload = {
|
||||
layout: "two_zone_split",
|
||||
frames: { "03-1+03-2": "frame_07" },
|
||||
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
|
||||
zone_sections: { top: ["03-1", "03-2"] },
|
||||
};
|
||||
fetchMock.mockResolvedValueOnce(mockResponse(payload));
|
||||
const got = await getUserOverrides("03");
|
||||
expect(got).toEqual(payload);
|
||||
});
|
||||
|
||||
it("returns {} when JSON root is an array (mirrors u3 graceful degrade)", async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockResponse([1, 2, 3]));
|
||||
expect(await getUserOverrides("03")).toEqual({});
|
||||
});
|
||||
|
||||
it("returns {} when JSON root is a primitive", async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockResponse(42));
|
||||
expect(await getUserOverrides("03")).toEqual({});
|
||||
});
|
||||
|
||||
it("returns {} when JSON root is null", async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockResponse(null));
|
||||
expect(await getUserOverrides("03")).toEqual({});
|
||||
});
|
||||
|
||||
it("returns {} on 4xx (invalid key path from u3)", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockResponse({ error: "invalid key" }, false, 400),
|
||||
);
|
||||
expect(await getUserOverrides("..")).toEqual({});
|
||||
});
|
||||
|
||||
it("returns {} on 5xx", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockResponse({ error: "boom" }, false, 500),
|
||||
);
|
||||
expect(await getUserOverrides("03")).toEqual({});
|
||||
});
|
||||
|
||||
it("returns {} when response.json() throws (non-JSON body)", async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => {
|
||||
throw new SyntaxError("Unexpected token");
|
||||
},
|
||||
});
|
||||
expect(await getUserOverrides("03")).toEqual({});
|
||||
});
|
||||
|
||||
it("returns {} when fetch rejects (network error) — does NOT throw", async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error("network down"));
|
||||
await expect(getUserOverrides("03")).resolves.toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// saveUserOverrides — debounce + coalescing
|
||||
// ============================================================================
|
||||
|
||||
describe("saveUserOverrides (IMP-52 u5) — debounce", () => {
|
||||
it("does NOT fire fetch before 300 ms have elapsed", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({ layout: "two_zone_split" }));
|
||||
void saveUserOverrides("03", { layout: "two_zone_split" });
|
||||
|
||||
vi.advanceTimersByTime(299);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("fires exactly one PUT at the 300 ms boundary", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({ layout: "two_zone_split" }));
|
||||
void saveUserOverrides("03", { layout: "two_zone_split" });
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(1);
|
||||
|
||||
const lastCall = fetchMock.mock.calls.at(-1)!;
|
||||
expect(lastCall[0]).toBe("/api/user-overrides/03");
|
||||
expect((lastCall[1] as RequestInit).method).toBe("PUT");
|
||||
expect((lastCall[1] as RequestInit).headers).toMatchObject({
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
expect(lastPutBody()).toEqual({ layout: "two_zone_split" });
|
||||
});
|
||||
|
||||
it("PUT body contains ONLY the mutated axis (not a full snapshot)", async () => {
|
||||
// The frontend handler only knows the axis it just mutated; the server
|
||||
// is responsible for partial-merge against axes already on disk.
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", {
|
||||
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
|
||||
});
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
|
||||
const body = lastPutBody() as Record<string, unknown>;
|
||||
expect(Object.keys(body)).toEqual(["zone_geometries"]);
|
||||
expect("layout" in body).toBe(false);
|
||||
expect("frames" in body).toBe(false);
|
||||
expect("zone_sections" in body).toBe(false);
|
||||
});
|
||||
|
||||
it("coalesces N rapid calls into a SINGLE PUT after the debounce", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { layout: "old" });
|
||||
vi.advanceTimersByTime(100);
|
||||
void saveUserOverrides("03", { frames: { "03-1": "frame_01" } });
|
||||
vi.advanceTimersByTime(100);
|
||||
void saveUserOverrides("03", { zone_sections: { top: ["03-1"] } });
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
// After 300 ms total (but the timer was reset each call to start the
|
||||
// 300 ms window over), so we need one more 300 ms to fire.
|
||||
expect(putCallsCount()).toBe(0);
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(1);
|
||||
|
||||
// All three axes accumulated.
|
||||
const body = lastPutBody() as Record<string, unknown>;
|
||||
expect(body).toEqual({
|
||||
layout: "old",
|
||||
frames: { "03-1": "frame_01" },
|
||||
zone_sections: { top: ["03-1"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("per-axis later-wins: same axis mutated twice keeps the LAST value", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { layout: "first" });
|
||||
void saveUserOverrides("03", { layout: "second" });
|
||||
void saveUserOverrides("03", { layout: "final" });
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(1);
|
||||
expect(lastPutBody()).toEqual({ layout: "final" });
|
||||
});
|
||||
|
||||
it("forwards null sentinel verbatim (explicit clear)", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { layout: null });
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(lastPutBody()).toEqual({ layout: null });
|
||||
});
|
||||
|
||||
it("null can override a prior non-null pending value for the same axis", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { layout: "two_zone_split" });
|
||||
void saveUserOverrides("03", { layout: null });
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(lastPutBody()).toEqual({ layout: null });
|
||||
});
|
||||
|
||||
it("resolves the caller promise with the server-merged document", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockResponse({
|
||||
layout: "two_zone_split",
|
||||
// server's view includes axes preserved on disk that the partial
|
||||
// PUT did NOT carry — confirms we surface the full merged state.
|
||||
frames: { "03-1": "frame_01" },
|
||||
}),
|
||||
);
|
||||
const p = saveUserOverrides("03", { layout: "two_zone_split" });
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
|
||||
await expect(p).resolves.toEqual({
|
||||
layout: "two_zone_split",
|
||||
frames: { "03-1": "frame_01" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects all coalesced waiters on 5xx response", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockResponse({ error: "write failed" }, false, 500),
|
||||
);
|
||||
const p1 = saveUserOverrides("03", { layout: "x" });
|
||||
const p2 = saveUserOverrides("03", { frames: { "03-1": "f01" } });
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
|
||||
await expect(p1).rejects.toThrow(/500/);
|
||||
await expect(p2).rejects.toThrow(/500/);
|
||||
});
|
||||
|
||||
it("rejects waiters on fetch network error", async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error("ECONNRESET"));
|
||||
const p = saveUserOverrides("03", { layout: "x" });
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
|
||||
await expect(p).rejects.toThrow("ECONNRESET");
|
||||
});
|
||||
|
||||
it("after a successful flush, a new save starts a fresh debounce window", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { layout: "first" });
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(1);
|
||||
expect(lastPutBody()).toEqual({ layout: "first" });
|
||||
|
||||
void saveUserOverrides("03", { layout: "second" });
|
||||
vi.advanceTimersByTime(299);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(1); // not fired yet
|
||||
vi.advanceTimersByTime(1);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(2);
|
||||
expect(lastPutBody()).toEqual({ layout: "second" });
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// saveUserOverrides — per-key isolation
|
||||
// ============================================================================
|
||||
|
||||
describe("saveUserOverrides (IMP-52 u5) — per-key isolation", () => {
|
||||
it("rapid edits to key A do not delay key B's flush", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
// Schedule a save on "03"
|
||||
void saveUserOverrides("03", { layout: "x" });
|
||||
// Schedule a save on "04" at t=0
|
||||
void saveUserOverrides("04", { layout: "y" });
|
||||
|
||||
vi.advanceTimersByTime(150);
|
||||
// Keep extending "03"'s window
|
||||
void saveUserOverrides("03", { layout: "x2" });
|
||||
|
||||
// "04" should still fire at t=300 (untouched after first call)
|
||||
vi.advanceTimersByTime(150); // t=300
|
||||
await drainMicrotasks();
|
||||
|
||||
const puts = fetchMock.mock.calls.filter(
|
||||
(c) => (c[1] as RequestInit).method === "PUT",
|
||||
);
|
||||
expect(puts.length).toBe(1);
|
||||
expect(puts[0][0]).toBe("/api/user-overrides/04");
|
||||
expect(JSON.parse(String((puts[0][1] as RequestInit).body))).toEqual({
|
||||
layout: "y",
|
||||
});
|
||||
});
|
||||
|
||||
it("each key's PUT carries only that key's mutated axes", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { layout: "for-03" });
|
||||
void saveUserOverrides("04", { frames: { "04-1": "frame_05" } });
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
|
||||
const puts = fetchMock.mock.calls.filter(
|
||||
(c) => (c[1] as RequestInit).method === "PUT",
|
||||
);
|
||||
expect(puts.length).toBe(2);
|
||||
|
||||
const byUrl = new Map(
|
||||
puts.map((c) => [
|
||||
c[0],
|
||||
JSON.parse(String((c[1] as RequestInit).body)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
]),
|
||||
);
|
||||
expect(byUrl.get("/api/user-overrides/03")).toEqual({ layout: "for-03" });
|
||||
expect(byUrl.get("/api/user-overrides/04")).toEqual({
|
||||
frames: { "04-1": "frame_05" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// flushUserOverrides
|
||||
// ============================================================================
|
||||
|
||||
describe("flushUserOverrides (IMP-52 u5)", () => {
|
||||
it("with no arg, flushes ALL pending buckets immediately (no 300 ms wait)", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { layout: "x" });
|
||||
void saveUserOverrides("04", { layout: "y" });
|
||||
|
||||
expect(putCallsCount()).toBe(0);
|
||||
const flushP = flushUserOverrides();
|
||||
await drainMicrotasks();
|
||||
await flushP;
|
||||
|
||||
expect(putCallsCount()).toBe(2);
|
||||
});
|
||||
|
||||
it("with a key arg, flushes only that bucket; others stay pending", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { layout: "x" });
|
||||
void saveUserOverrides("04", { layout: "y" });
|
||||
|
||||
await flushUserOverrides("03");
|
||||
await drainMicrotasks();
|
||||
|
||||
const puts = fetchMock.mock.calls.filter(
|
||||
(c) => (c[1] as RequestInit).method === "PUT",
|
||||
);
|
||||
expect(puts.length).toBe(1);
|
||||
expect(puts[0][0]).toBe("/api/user-overrides/03");
|
||||
|
||||
// "04" should still fire at the regular 300 ms boundary.
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(2);
|
||||
});
|
||||
|
||||
it("is a no-op when no buckets are pending", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
await flushUserOverrides();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves the original saveUserOverrides promise via the in-flight PUT", async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockResponse({ layout: "flushed" }));
|
||||
const savePromise = saveUserOverrides("03", { layout: "flushed" });
|
||||
const flushPromise = flushUserOverrides();
|
||||
await drainMicrotasks();
|
||||
await flushPromise;
|
||||
await expect(savePromise).resolves.toEqual({ layout: "flushed" });
|
||||
});
|
||||
|
||||
it("propagates PUT failure as caller rejection (flush itself swallows)", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockResponse({ error: "boom" }, false, 500),
|
||||
);
|
||||
const savePromise = saveUserOverrides("03", { layout: "x" });
|
||||
// flush itself should not throw — the original waiter takes the rejection.
|
||||
const flushPromise = flushUserOverrides();
|
||||
await drainMicrotasks();
|
||||
await expect(flushPromise).resolves.toBeUndefined();
|
||||
await expect(savePromise).rejects.toThrow(/500/);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// type-level export sanity check (compile-time evidence; runtime no-op)
|
||||
// ============================================================================
|
||||
|
||||
describe("UserOverridesPartial type (IMP-52 u5)", () => {
|
||||
it("permits per-axis null sentinels and partial keys", () => {
|
||||
// Compile-time only — if any of these stops being a valid assignment,
|
||||
// the test suite fails at build with a TS error before this assertion
|
||||
// runs. The expect() is a placebo to keep vitest happy.
|
||||
const a: UserOverridesPartial = { layout: "x" };
|
||||
const b: UserOverridesPartial = { layout: null };
|
||||
const c: UserOverridesPartial = { frames: { unit: "tmpl" } };
|
||||
const d: UserOverridesPartial = {};
|
||||
const e: UserOverridesPartial = {
|
||||
image_overrides: { "img-1": { x: 10, y: 20, w: 30, h: 25 } },
|
||||
};
|
||||
const f: UserOverridesPartial = { image_overrides: null };
|
||||
expect([a, b, c, d, e, f]).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// IMP-51 #79 u3 — image_overrides axis (5th axis) parity coverage
|
||||
//
|
||||
// Same debounce / coalescing / clear / per-key isolation guarantees as the
|
||||
// 4 sibling axes (layout / frames / zone_geometries / zone_sections), but
|
||||
// asserted explicitly so a regression in the type or the runtime allowlist
|
||||
// fails here instead of in a downstream u8~u11 handler.
|
||||
// ============================================================================
|
||||
|
||||
describe("saveUserOverrides (IMP-51 #79 u3) — image_overrides axis", () => {
|
||||
it("PUT body carries only image_overrides when that is the sole mutated axis", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", {
|
||||
image_overrides: { "img-1": { x: 10, y: 20, w: 30, h: 25 } },
|
||||
});
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
|
||||
const body = lastPutBody() as Record<string, unknown>;
|
||||
expect(Object.keys(body)).toEqual(["image_overrides"]);
|
||||
expect(body.image_overrides).toEqual({
|
||||
"img-1": { x: 10, y: 20, w: 30, h: 25 },
|
||||
});
|
||||
expect("layout" in body).toBe(false);
|
||||
expect("frames" in body).toBe(false);
|
||||
expect("zone_geometries" in body).toBe(false);
|
||||
expect("zone_sections" in body).toBe(false);
|
||||
});
|
||||
|
||||
it("per-axis later-wins: same image_id mutated twice keeps the LAST value", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", {
|
||||
image_overrides: { "img-1": { x: 0, y: 0, w: 50, h: 50 } },
|
||||
});
|
||||
void saveUserOverrides("03", {
|
||||
image_overrides: { "img-1": { x: 25, y: 25, w: 30, h: 30 } },
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(1);
|
||||
expect(lastPutBody()).toEqual({
|
||||
image_overrides: { "img-1": { x: 25, y: 25, w: 30, h: 30 } },
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards null sentinel verbatim (clear all image_overrides on disk)", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { image_overrides: null });
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(lastPutBody()).toEqual({ image_overrides: null });
|
||||
});
|
||||
|
||||
it("coalesces with sibling axes in a single PUT", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { layout: "two_zone_split" });
|
||||
void saveUserOverrides("03", {
|
||||
image_overrides: { "img-1": { x: 10, y: 20, w: 30, h: 25 } },
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(1);
|
||||
expect(lastPutBody()).toEqual({
|
||||
layout: "two_zone_split",
|
||||
image_overrides: { "img-1": { x: 10, y: 20, w: 30, h: 25 } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// IMP-55 #93 u1 — manual_section_assignment axis (7th axis) parity coverage
|
||||
//
|
||||
// The bool intent marker rides on the same per-axis coalescing rails as the
|
||||
// 6 sibling axes. These tests lock the typed client behavior so a regression
|
||||
// in the boolean serialization (e.g., coercion to "true" string, dropped
|
||||
// `false` due to truthy filtering) fails here instead of in Home.tsx (u6/u7)
|
||||
// or the backend gate (u9~u11).
|
||||
// ============================================================================
|
||||
|
||||
describe("saveUserOverrides (IMP-55 #93 u1) — manual_section_assignment axis", () => {
|
||||
it("PUT body carries only manual_section_assignment when it is the sole mutated axis", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { manual_section_assignment: true });
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
|
||||
const body = lastPutBody() as Record<string, unknown>;
|
||||
expect(Object.keys(body)).toEqual(["manual_section_assignment"]);
|
||||
expect(body.manual_section_assignment).toBe(true);
|
||||
});
|
||||
|
||||
it("later-wins coalesces true → false within a single debounce window", async () => {
|
||||
// Drag-then-cancel inside 300 ms — server must see only the final
|
||||
// `false`, not a transient `true` that would re-enable backend
|
||||
// consumption of stale zone_sections.
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { manual_section_assignment: true });
|
||||
void saveUserOverrides("03", { manual_section_assignment: false });
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(1);
|
||||
expect(lastPutBody()).toEqual({ manual_section_assignment: false });
|
||||
});
|
||||
|
||||
it("forwards null sentinel verbatim (explicit clear)", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", { manual_section_assignment: null });
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(lastPutBody()).toEqual({ manual_section_assignment: null });
|
||||
});
|
||||
|
||||
it("coalesces with zone_sections sibling into a single PUT (drag-drop pair)", async () => {
|
||||
// Real-world drag flow (u6): one save() sets the bool + zone_sections
|
||||
// together. Asserts both axes survive coalescing as a single PUT body.
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03", {
|
||||
zone_sections: { left: ["03-2"], right: ["03-1"] },
|
||||
manual_section_assignment: true,
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(putCallsCount()).toBe(1);
|
||||
expect(lastPutBody()).toEqual({
|
||||
zone_sections: { left: ["03-2"], right: ["03-1"] },
|
||||
manual_section_assignment: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
802
Front/client/tests/user_overrides_write.test.ts
Normal file
802
Front/client/tests/user_overrides_write.test.ts
Normal file
@@ -0,0 +1,802 @@
|
||||
// IMP-52 u10 — Frontend write-side regression coverage.
|
||||
//
|
||||
// Stage 2 unit u10 contract:
|
||||
// 1) All 4 in-scope mutation handlers persist their axis.
|
||||
// 2) zone_sizes is NOT persisted (handleLayoutResize stays in-memory).
|
||||
// 3) Write-before-Generate ordering — flushUserOverrides forces pending
|
||||
// PUTs to commit before the pipeline run begins.
|
||||
// 4) Restore-on-reopen end-to-end — getUserOverrides → non-frame layering
|
||||
// and post-loadRun frame remap compose into a single restored state.
|
||||
//
|
||||
// React Testing Library is NOT installed in this repo (devDependencies has
|
||||
// vitest only). Home.tsx's mutation handlers live inside `useCallback`
|
||||
// closures so they cannot be invoked from a test without mounting the
|
||||
// component. We cover them with two complementary tactics:
|
||||
// • Source-pattern grep on Home.tsx that pins the exact wiring shape per
|
||||
// handler. A regression that drops or rewires a `saveUserOverrides`
|
||||
// call fails here loudly.
|
||||
// • End-to-end mocked-fetch tests on the `userOverridesApi` flow with the
|
||||
// payload shapes that Home.tsx produces — proves the contract the
|
||||
// handlers depend on still holds.
|
||||
//
|
||||
// File extension is `.ts` (no JSX). All tests run in vitest's default node
|
||||
// environment; fetch is stubbed with vi.stubGlobal and timers are faked so
|
||||
// the 300ms debounce in `saveUserOverrides` is deterministic.
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
type Mock,
|
||||
} from "vitest";
|
||||
import {
|
||||
__resetUserOverridesBuckets_FOR_TEST,
|
||||
flushUserOverrides,
|
||||
getUserOverrides,
|
||||
saveUserOverrides,
|
||||
type UserOverridesPartial,
|
||||
} from "../src/services/userOverridesApi";
|
||||
import {
|
||||
applyPersistedNonFrameOverrides,
|
||||
createInitialUserSelection,
|
||||
deriveUserOverridesKey,
|
||||
remapPersistedFramesToZoneFrames,
|
||||
} from "../src/utils/slidePlanUtils";
|
||||
import type { SlidePlan, Zone } from "../src/types/designAgent";
|
||||
|
||||
// ─── Source-pattern regression ─────────────────────────────────────────────
|
||||
// Without RTL we can't dispatch a click and read `fetch.mock.calls`. Instead
|
||||
// we read Home.tsx as text and assert each in-scope handler closure contains
|
||||
// the exact wiring that Stage 2 u7 specified. This is brittle in a good way:
|
||||
// if a handler is renamed or its `saveUserOverrides` call is moved/removed,
|
||||
// the assertion fires with a clear "X handler does not persist Y axis"
|
||||
// message instead of silently regressing in prod.
|
||||
|
||||
const HOME_TSX_PATH = path.resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"src",
|
||||
"pages",
|
||||
"Home.tsx",
|
||||
);
|
||||
const HOME_TSX = fs.readFileSync(HOME_TSX_PATH, "utf-8");
|
||||
|
||||
/**
|
||||
* Slice the `const <name> = useCallback(...)` block out of Home.tsx. The
|
||||
* handlers are well-formed and end either at the next `const handle...`
|
||||
* declaration or at the next top-level `const ` at 2-space indent.
|
||||
*/
|
||||
function sliceHandler(source: string, name: string): string {
|
||||
const start = source.indexOf(`const ${name} = useCallback(`);
|
||||
if (start === -1) {
|
||||
throw new Error(`handler "${name}" not found in Home.tsx`);
|
||||
}
|
||||
// Find the next handler / top-level const after `start`.
|
||||
const nextHandler = source.indexOf("\n const handle", start + 1);
|
||||
const nextConst = source.indexOf("\n const ", start + 1);
|
||||
const candidates = [nextHandler, nextConst].filter((i) => i > start);
|
||||
const end = candidates.length > 0 ? Math.min(...candidates) : source.length;
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* IMP-55 #93 u8 — strip JS/TS line + block comments so source-pattern
|
||||
* regex checks assert against LIVE code only. The u5 / u7 docblocks in
|
||||
* Home.tsx intentionally reference removed identifiers (e.g. `defaultByZone`,
|
||||
* `sameAsDefault`, `zoneSectionsDiff`) and the marker axis name in prose to
|
||||
* document the Stage 1 root cause for future readers — those references are
|
||||
* documentation, not behavior, and must not trigger negative-match guards.
|
||||
* Strips `// ...` to EOL and `/* ... */` (incl. multi-line) — keeps string
|
||||
* literals intact because we only consume the result for regex-match tests.
|
||||
*/
|
||||
function stripComments(source: string): string {
|
||||
return source
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/\/\/.*$/gm, "");
|
||||
}
|
||||
|
||||
describe("Home.tsx write-side wiring (IMP-52 u10) — source pattern", () => {
|
||||
it("handleSectionDrop persists zone_sections behind uploadedFile gate", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleSectionDrop");
|
||||
// gate
|
||||
expect(block).toMatch(/if\s*\(\s*p\.uploadedFile\s*\)/);
|
||||
// axis key + value source
|
||||
expect(block).toMatch(
|
||||
/saveUserOverrides\([\s\S]*?zone_sections:\s*finalSelection\.overrides\.zone_sections/,
|
||||
);
|
||||
// key derivation
|
||||
expect(block).toMatch(/deriveUserOverridesKey\(p\.uploadedFile\.name\)/);
|
||||
});
|
||||
|
||||
it("handleLayoutSelect persists `layout` axis behind uploadedFile gate", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleLayoutSelect");
|
||||
expect(block).toMatch(/if\s*\(\s*p\.uploadedFile\s*\)/);
|
||||
expect(block).toMatch(
|
||||
/saveUserOverrides\([\s\S]*?layout:\s*layoutId\s*\}/,
|
||||
);
|
||||
expect(block).toMatch(/deriveUserOverridesKey\(p\.uploadedFile\.name\)/);
|
||||
});
|
||||
|
||||
it("handleZoneResize persists merged zone_geometries behind uploadedFile gate", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleZoneResize");
|
||||
expect(block).toMatch(/if\s*\(\s*p\.uploadedFile\s*\)/);
|
||||
// merged geometry (not the partial delta) is persisted so the on-disk
|
||||
// axis is a complete snapshot of all currently-resized zones.
|
||||
expect(block).toMatch(
|
||||
/saveUserOverrides\([\s\S]*?zone_geometries:\s*mergedGeometries/,
|
||||
);
|
||||
expect(block).toMatch(/deriveUserOverridesKey\(p\.uploadedFile\.name\)/);
|
||||
});
|
||||
|
||||
it("handleFrameSelect persists frames-by-unit_id with default-frame gate", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleFrameSelect");
|
||||
expect(block).toMatch(/if\s*\(\s*p\.uploadedFile\s*&&\s*effectiveSlidePlan\s*\)/);
|
||||
// unit_id derivation matches handleGenerate's CLI-forwarding contract
|
||||
expect(block).toMatch(/z\.section_ids\.join\(\s*"\+"\s*\)/);
|
||||
// default-frame gate (rewind fix from Codex #17 / Claude #18)
|
||||
expect(block).toMatch(/overrideId\s*!==\s*defaultFrameId/);
|
||||
// axis key
|
||||
expect(block).toMatch(
|
||||
/saveUserOverrides\([\s\S]*?frames:\s*framesByUnitId/,
|
||||
);
|
||||
});
|
||||
|
||||
it("handleLayoutResize does NOT call saveUserOverrides (zone_sizes excluded)", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleLayoutResize");
|
||||
expect(block).not.toMatch(/saveUserOverrides/);
|
||||
// Sanity: handleLayoutResize still writes zone_sizes in-memory.
|
||||
expect(block).toMatch(/saveZoneSizes/);
|
||||
});
|
||||
|
||||
it("handleGenerate does NOT call saveUserOverrides (read-only re: persistence layer)", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleGenerate");
|
||||
// handleGenerate forwards overrides through runPipeline → /api/run, not
|
||||
// through /api/user-overrides. The persistence layer is owned by the
|
||||
// four mutation handlers; Generate must not introduce a competing
|
||||
// write path that could clobber a partially-edited bucket.
|
||||
expect(block).not.toMatch(/saveUserOverrides\(/);
|
||||
});
|
||||
|
||||
it("no handler in Home.tsx persists the zone_sizes axis", () => {
|
||||
// Top-level regression: searching the whole file rules out a future
|
||||
// accidental wiring inside a new handler we forgot to enumerate above.
|
||||
expect(HOME_TSX).not.toMatch(
|
||||
/saveUserOverrides\([\s\S]{0,200}?zone_sizes\s*:/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Payload-shape contract via mocked fetch ───────────────────────────────
|
||||
// Drive `saveUserOverrides` with the exact payload shapes each in-scope
|
||||
// handler produces in Home.tsx. Asserts that (a) the PUT body matches what
|
||||
// the on-disk schema (u1 / u4) accepts and (b) the partial-axis contract
|
||||
// holds — only the mutated axis is sent, never a full snapshot.
|
||||
|
||||
type MockResponse = {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
json: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
function mockResponse(body: unknown, ok = true, status = 200): MockResponse {
|
||||
return { ok, status, json: async () => body };
|
||||
}
|
||||
|
||||
let fetchMock: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.useFakeTimers();
|
||||
__resetUserOverridesBuckets_FOR_TEST();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
__resetUserOverridesBuckets_FOR_TEST();
|
||||
});
|
||||
|
||||
async function drainMicrotasks(): Promise<void> {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
function lastPutBody(): unknown {
|
||||
const lastCall = fetchMock.mock.calls.at(-1);
|
||||
if (!lastCall) throw new Error("fetch was not called");
|
||||
const init = lastCall[1] as RequestInit | undefined;
|
||||
if (!init?.body) throw new Error("fetch called without a body");
|
||||
return JSON.parse(String(init.body));
|
||||
}
|
||||
|
||||
describe("save payload contract per axis (IMP-52 u10)", () => {
|
||||
it("section-drop payload: PUT body carries only zone_sections", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
// Shape produced by handleSectionDrop after moveSectionToZone.
|
||||
const payload: UserOverridesPartial = {
|
||||
zone_sections: {
|
||||
top: ["03-1", "03-2"],
|
||||
bottom: ["03-3"],
|
||||
},
|
||||
};
|
||||
void saveUserOverrides("03_demo", payload);
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const body = lastPutBody() as Record<string, unknown>;
|
||||
expect(Object.keys(body)).toEqual(["zone_sections"]);
|
||||
expect(body.zone_sections).toEqual(payload.zone_sections);
|
||||
});
|
||||
|
||||
it("layout-select payload: PUT body carries only `layout` (string)", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03_demo", { layout: "two-column" });
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
const body = lastPutBody() as Record<string, unknown>;
|
||||
expect(Object.keys(body)).toEqual(["layout"]);
|
||||
expect(body.layout).toBe("two-column");
|
||||
});
|
||||
|
||||
it("zone-resize payload: PUT body carries only zone_geometries (merged snapshot)", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
const merged = {
|
||||
top: { x: 0, y: 0, w: 1, h: 0.42 },
|
||||
bottom_l: { x: 0, y: 0.42, w: 0.5, h: 0.58 },
|
||||
bottom_r: { x: 0.5, y: 0.42, w: 0.5, h: 0.58 },
|
||||
};
|
||||
void saveUserOverrides("03_demo", { zone_geometries: merged });
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
const body = lastPutBody() as Record<string, unknown>;
|
||||
expect(Object.keys(body)).toEqual(["zone_geometries"]);
|
||||
expect(body.zone_geometries).toEqual(merged);
|
||||
});
|
||||
|
||||
it("frame-select payload: PUT body carries only frames (unit_id → template_id)", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
// Shape produced by handleFrameSelect after the default-frame gate:
|
||||
// only zones the user explicitly chose a non-default frame for.
|
||||
const framesByUnitId = {
|
||||
"03-1": "process_product_two_way",
|
||||
"03-2+03-3": "three_parallel_requirements",
|
||||
};
|
||||
void saveUserOverrides("03_demo", { frames: framesByUnitId });
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
const body = lastPutBody() as Record<string, unknown>;
|
||||
expect(Object.keys(body)).toEqual(["frames"]);
|
||||
expect(body.frames).toEqual(framesByUnitId);
|
||||
});
|
||||
|
||||
it("frame-select payload with empty framesByUnitId still PUTs (replaces axis with {})", async () => {
|
||||
// When the user reverts the last frame override back to the backend
|
||||
// default, handleFrameSelect computes `framesByUnitId = {}`. The PUT
|
||||
// path still fires so the on-disk `frames` axis is cleared to the empty
|
||||
// object via u4's partial-merge replace semantics. Foreign axes
|
||||
// (layout / zone_geometries / zone_sections) remain on disk.
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03_demo", { frames: {} });
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(lastPutBody()).toEqual({ frames: {} });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── zone_sizes axis is not part of the on-disk schema ─────────────────────
|
||||
|
||||
describe("zone_sizes axis exclusion (IMP-52 u10)", () => {
|
||||
it("UserOverridesPartial type does not include zone_sizes at compile time", () => {
|
||||
// Compile-time check: this assignment must be a TS error. The runtime
|
||||
// assertion below is a placebo; the meaningful evidence is that the
|
||||
// suite *builds*. If a future schema bump adds zone_sizes to
|
||||
// UserOverrides, this comment serves as the migration touchpoint.
|
||||
// @ts-expect-error — zone_sizes is intentionally not part of UserOverridesPartial
|
||||
const _bad: UserOverridesPartial = { zone_sizes: { layout_group_1: [0.5, 0.5] } };
|
||||
void _bad;
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("Home.tsx never imports a write helper that would persist zone_sizes", () => {
|
||||
// handleLayoutResize delegates to saveZoneSizes (in-memory), not
|
||||
// saveUserOverrides. Cross-check the import line and the handler body.
|
||||
expect(HOME_TSX).toMatch(/import\s*\{[^}]*\bsaveZoneSizes\b[^}]*\}\s*from\s*"\.\.\/utils\/slidePlanUtils"/);
|
||||
const block = sliceHandler(HOME_TSX, "handleLayoutResize");
|
||||
expect(block).toMatch(/saveZoneSizes\(/);
|
||||
expect(block).not.toMatch(/saveUserOverrides/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Write-before-Generate ordering ────────────────────────────────────────
|
||||
// The four mutation handlers schedule debounced PUTs (300ms). If the user
|
||||
// hits Generate before the debounce fires, the persistence layer must not
|
||||
// drop the pending writes. `flushUserOverrides` is the contract: callers can
|
||||
// force-commit pending buckets before pipeline kickoff so the backend u2
|
||||
// fallback reads the latest file.
|
||||
|
||||
describe("write-before-Generate ordering (IMP-52 u10)", () => {
|
||||
// The service-level tests below prove the `flushUserOverrides` contract in
|
||||
// isolation. The two source-pattern checks here pin the *real* Generate
|
||||
// call site so a future refactor that drops the flush — re-exposing the
|
||||
// 300ms debounce race against `runPipeline` / the u2 backend fallback —
|
||||
// fails loudly. Without React Testing Library we cannot dispatch a click
|
||||
// on the Generate button, so we read Home.tsx as text and assert (a) the
|
||||
// import names `flushUserOverrides`, (b) the `handleGenerate` closure
|
||||
// awaits the flush before it awaits `runPipeline`.
|
||||
|
||||
it("Home.tsx imports flushUserOverrides from userOverridesApi", () => {
|
||||
expect(HOME_TSX).toMatch(
|
||||
/import\s*\{[^}]*\bflushUserOverrides\b[^}]*\}\s*from\s*"\.\.\/services\/userOverridesApi"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("handleGenerate awaits flushUserOverrides before awaiting runPipeline", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleGenerate");
|
||||
expect(block).toMatch(/await\s+flushUserOverrides\s*\(\s*\)/);
|
||||
expect(block).toMatch(/await\s+runPipeline\s*\(/);
|
||||
const flushIdx = block.search(/await\s+flushUserOverrides\s*\(/);
|
||||
const runIdx = block.search(/await\s+runPipeline\s*\(/);
|
||||
expect(flushIdx).toBeGreaterThan(-1);
|
||||
expect(runIdx).toBeGreaterThan(-1);
|
||||
expect(flushIdx).toBeLessThan(runIdx);
|
||||
});
|
||||
|
||||
it("flushUserOverrides commits a pending PUT before its 300ms debounce fires", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({ layout: "two-column" }));
|
||||
const savePromise = saveUserOverrides("03_demo", { layout: "two-column" });
|
||||
|
||||
// Without flush, the PUT would not fire for another 300ms.
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
const flushPromise = flushUserOverrides();
|
||||
await drainMicrotasks();
|
||||
await flushPromise;
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("/api/user-overrides/03_demo");
|
||||
expect((init as RequestInit).method).toBe("PUT");
|
||||
|
||||
// Caller's promise resolves with the server-merged document — so a
|
||||
// pre-Generate `await flushUserOverrides()` can be paired with
|
||||
// `await savePromise` for stronger ordering if needed.
|
||||
await expect(savePromise).resolves.toEqual({ layout: "two-column" });
|
||||
});
|
||||
|
||||
it("flushUserOverrides (no arg) flushes pending writes across multiple MDX keys", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03_demo", { layout: "two-column" });
|
||||
void saveUserOverrides("04_demo", { frames: { "04-1": "tpl_a" } });
|
||||
void saveUserOverrides("05_demo", {
|
||||
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
|
||||
});
|
||||
|
||||
await flushUserOverrides();
|
||||
await drainMicrotasks();
|
||||
|
||||
const putUrls = fetchMock.mock.calls
|
||||
.filter((c) => (c[1] as RequestInit).method === "PUT")
|
||||
.map((c) => c[0]);
|
||||
expect(putUrls).toEqual(
|
||||
expect.arrayContaining([
|
||||
"/api/user-overrides/03_demo",
|
||||
"/api/user-overrides/04_demo",
|
||||
"/api/user-overrides/05_demo",
|
||||
]),
|
||||
);
|
||||
expect(putUrls).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("flushUserOverrides is a no-op when no writes are pending", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
await flushUserOverrides();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("post-flush, a new save schedules a fresh 300ms debounce window", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03_demo", { layout: "two-column" });
|
||||
await flushUserOverrides();
|
||||
await drainMicrotasks();
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(
|
||||
(c) => (c[1] as RequestInit).method === "PUT",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
|
||||
// Second save after Generate completes — must not piggy-back on the
|
||||
// already-flushed bucket; must re-arm a fresh debounce.
|
||||
void saveUserOverrides("03_demo", { layout: "hero-detail" });
|
||||
vi.advanceTimersByTime(299);
|
||||
await drainMicrotasks();
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(
|
||||
(c) => (c[1] as RequestInit).method === "PUT",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
vi.advanceTimersByTime(1);
|
||||
await drainMicrotasks();
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(
|
||||
(c) => (c[1] as RequestInit).method === "PUT",
|
||||
),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Restore-on-reopen — end-to-end compose ────────────────────────────────
|
||||
// u6 covers the helpers in isolation. This test wires them together with a
|
||||
// mocked GET response in the order Home.tsx invokes them at file-upload
|
||||
// time (key derive → fetch persisted → layer non-frame axes pre-loadRun →
|
||||
// remap frames post-loadRun) to pin the integration contract.
|
||||
|
||||
function makeZone(partial: {
|
||||
id: string;
|
||||
zone_id: string;
|
||||
section_ids: string[];
|
||||
default_frame_id?: string | null;
|
||||
}): Zone {
|
||||
return {
|
||||
id: partial.id,
|
||||
zone_id: partial.zone_id,
|
||||
section_ids: partial.section_ids,
|
||||
position: { x: 0, y: 0, width: 1, height: 1 },
|
||||
internal_regions: [
|
||||
{
|
||||
id: `${partial.id}-r0`,
|
||||
region_id: "region-single",
|
||||
role: "primary",
|
||||
content_type: "text_block",
|
||||
ratio_estimate: 1,
|
||||
content_unit_ids: [],
|
||||
frame_match_strategy: {
|
||||
kind: "frame_match",
|
||||
frame_id: partial.default_frame_id ?? null,
|
||||
display_strategy: "inline_full",
|
||||
},
|
||||
frame_candidates: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("restore-on-reopen end-to-end (IMP-52 u10)", () => {
|
||||
it("getUserOverrides → non-frame layer + post-load frame remap composes a restored selection", async () => {
|
||||
// GET returns the persisted file for "03_demo". The `layout` value
|
||||
// must be a real LayoutPresetId — applyPersistedNonFrameOverrides
|
||||
// validates against the 8-preset whitelist (slidePlanUtils.ts:30).
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockResponse({
|
||||
layout: "horizontal-2",
|
||||
frames: { "03-1": "process_product_two_way" },
|
||||
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.42 } },
|
||||
zone_sections: { top: ["03-1"], bottom: ["03-2", "03-3"] },
|
||||
}),
|
||||
);
|
||||
|
||||
const key = deriveUserOverridesKey("03_demo.mdx");
|
||||
expect(key).toBe("03_demo");
|
||||
|
||||
// Step 1: Home.tsx fetches at handleFileUpload time.
|
||||
const persisted = await getUserOverrides(key);
|
||||
expect(persisted.layout).toBe("horizontal-2");
|
||||
|
||||
// Step 2: pre-loadRun layering applies layout / zone_geometries /
|
||||
// zone_sections onto a fresh selection. Frames are deferred because
|
||||
// the unit_id key cannot be remapped without a slidePlan yet.
|
||||
const seededSelection = applyPersistedNonFrameOverrides(
|
||||
createInitialUserSelection(null),
|
||||
persisted,
|
||||
);
|
||||
expect(seededSelection.overrides.layout_preset).toBe("horizontal-2");
|
||||
expect(seededSelection.overrides.zone_geometries).toEqual({
|
||||
top: { x: 0, y: 0, w: 1, h: 0.42 },
|
||||
});
|
||||
expect(seededSelection.overrides.zone_sections).toEqual({
|
||||
top: ["03-1"],
|
||||
bottom: ["03-2", "03-3"],
|
||||
});
|
||||
// Frames must NOT have been layered at this stage.
|
||||
expect(seededSelection.overrides.zone_frames).toEqual({});
|
||||
|
||||
// Step 3: post-loadRun, Home.tsx has a slidePlan. Remap unit_id-keyed
|
||||
// frames to region.id-keyed frames against the rebuilt plan.
|
||||
const plan: SlidePlan = {
|
||||
id: "plan-3",
|
||||
title: "demo",
|
||||
layout_preset: "horizontal-2",
|
||||
zones: [
|
||||
makeZone({
|
||||
id: "z-top",
|
||||
zone_id: "top",
|
||||
section_ids: ["03-1"],
|
||||
default_frame_id: "some_default_frame",
|
||||
}),
|
||||
makeZone({
|
||||
id: "z-bot",
|
||||
zone_id: "bottom",
|
||||
section_ids: ["03-2", "03-3"],
|
||||
default_frame_id: null,
|
||||
}),
|
||||
],
|
||||
};
|
||||
const remapped = remapPersistedFramesToZoneFrames(
|
||||
plan,
|
||||
persisted.frames,
|
||||
);
|
||||
expect(remapped).toEqual({
|
||||
"z-top-r0": "process_product_two_way",
|
||||
});
|
||||
|
||||
// Step 4: post-loadRun merge — Home.tsx layers `remapped` onto
|
||||
// `createInitialUserSelection(slidePlan)` so the SlideCanvas
|
||||
// override-vs-default preview indicator surfaces the restored choice.
|
||||
const finalSelection = {
|
||||
...applyPersistedNonFrameOverrides(
|
||||
createInitialUserSelection(plan),
|
||||
persisted,
|
||||
),
|
||||
};
|
||||
finalSelection.overrides = {
|
||||
...finalSelection.overrides,
|
||||
zone_frames: { ...finalSelection.overrides.zone_frames, ...remapped },
|
||||
};
|
||||
expect(finalSelection.overrides.zone_frames["z-top-r0"]).toBe(
|
||||
"process_product_two_way",
|
||||
);
|
||||
expect(finalSelection.overrides.layout_preset).toBe("horizontal-2");
|
||||
expect(finalSelection.overrides.zone_sections).toEqual({
|
||||
top: ["03-1"],
|
||||
bottom: ["03-2", "03-3"],
|
||||
});
|
||||
});
|
||||
|
||||
it("missing persisted file (GET returns {}) leaves the selection at backend defaults", async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockResponse({}));
|
||||
const persisted = await getUserOverrides(deriveUserOverridesKey("new_file.mdx"));
|
||||
expect(persisted).toEqual({});
|
||||
|
||||
const plan: SlidePlan = {
|
||||
id: "plan-x",
|
||||
title: "fresh",
|
||||
layout_preset: "single",
|
||||
zones: [
|
||||
makeZone({ id: "z-only", zone_id: "main", section_ids: ["x-1"] }),
|
||||
],
|
||||
};
|
||||
const seeded = applyPersistedNonFrameOverrides(
|
||||
createInitialUserSelection(plan),
|
||||
persisted,
|
||||
);
|
||||
// No override applied → layout_preset, geometries, sections all from
|
||||
// the slidePlan defaults; remap yields {} so no frames layered.
|
||||
expect(seeded.overrides.layout_preset).toBe("single");
|
||||
expect(seeded.overrides.zone_geometries).toEqual({});
|
||||
expect(remapPersistedFramesToZoneFrames(plan, persisted.frames)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── IMP-55 #93 u8 — manual_section_assignment intent marker contract ─────
|
||||
// Verifies four axes of the marker contract introduced in u3 (type) / u5
|
||||
// (apply reset) / u6 (drag flip + co-PUT) / u7 (generate gate):
|
||||
// 1) Drag dual-axis persistence — handleSectionDrop persists BOTH
|
||||
// `zone_sections` AND `manual_section_assignment: true` in the SAME
|
||||
// PUT body (co-PUT atomicity — disk never sees post-drop zone_sections
|
||||
// without the marker).
|
||||
// 2) Apply / cancel reset — handleApplyPendingLayout writes explicit
|
||||
// `manual_section_assignment: false` after the `...overrides` spread,
|
||||
// and handleCancelPendingLayout relies on createInitialUserSelection
|
||||
// (which u3 seeds to `false`) to drop a prior `true`.
|
||||
// 3) Marker-gated forwarding — handleGenerate gates `overrides.zoneSections`
|
||||
// forwarding strictly on `manualMarker === true` (NOT truthiness, NOT
|
||||
// `!= null`, NOT presence). u3-seeded `false` and absent values both
|
||||
// skip forwarding.
|
||||
// 4) sameAsDefault NOT required — the Stage 1 anti-pattern (defaultByZone
|
||||
// / sameAsDefault / zoneSectionsDiff self-compare loop) is gone from
|
||||
// `handleGenerate` entirely; the marker is the source of intent.
|
||||
|
||||
describe("IMP-55 #93 u8 — manual_section_assignment marker contract", () => {
|
||||
it("handleSectionDrop sets marker true in-memory before persistence", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleSectionDrop");
|
||||
// finalSelection literal (built from zoneSelected, then marker = true)
|
||||
// must occur BEFORE the saveUserOverrides call so the in-memory state
|
||||
// and the PUT body source from the same overrides shape.
|
||||
const markerIdx = block.search(/manual_section_assignment:\s*true/);
|
||||
const saveIdx = block.search(/saveUserOverrides\(/);
|
||||
expect(markerIdx).toBeGreaterThan(-1);
|
||||
expect(saveIdx).toBeGreaterThan(-1);
|
||||
expect(markerIdx).toBeLessThan(saveIdx);
|
||||
});
|
||||
|
||||
it("handleSectionDrop co-PUTs zone_sections + manual_section_assignment:true (single body)", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleSectionDrop");
|
||||
// Single saveUserOverrides call carrying BOTH axes. The regex spans the
|
||||
// call body to prove the two keys live in the same object literal — a
|
||||
// future split into two PUTs would race the 300ms debounce and re-open
|
||||
// the IMP-55 stale-disk window.
|
||||
expect(block).toMatch(
|
||||
/saveUserOverrides\([\s\S]*?zone_sections:[\s\S]*?manual_section_assignment:\s*true[\s\S]*?\)/,
|
||||
);
|
||||
// Exactly ONE saveUserOverrides call in the handler.
|
||||
const calls = block.match(/saveUserOverrides\(/g) ?? [];
|
||||
expect(calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it("handleApplyPendingLayout resets the marker to false in overrides literal", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleApplyPendingLayout");
|
||||
// After spreading `...p.userSelection.overrides`, the explicit
|
||||
// `manual_section_assignment: false` overrides any prior-drag `true`.
|
||||
// Without this the layout flip would carry the marker through, and u7
|
||||
// would forward auto-carried assignments as user overrides → the
|
||||
// PARTIAL_COVERAGE regression that motivated IMP-55.
|
||||
expect(block).toMatch(/\.\.\.p\.userSelection\.overrides[\s\S]*?manual_section_assignment:\s*false/);
|
||||
});
|
||||
|
||||
it("handleCancelPendingLayout uses createInitialUserSelection (u3 seeds false)", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleCancelPendingLayout");
|
||||
// Cancel discards all pending in-memory edits via the fresh-selection
|
||||
// helper — the seed (u3) is the single source of truth for the
|
||||
// in-memory marker on this path. u12 adds a separate disk-side
|
||||
// saveUserOverrides PUT (covered by the u12 describe block below);
|
||||
// the in-memory userSelection literal still has no explicit marker
|
||||
// field — the seed handles it.
|
||||
expect(block).toMatch(/createInitialUserSelection\(p\.slidePlan\)/);
|
||||
// In-memory contract: no `manual_section_assignment` property appears
|
||||
// inside the userSelection assignment. The only marker reference in
|
||||
// live code lives inside the u12 saveUserOverrides(...) call body.
|
||||
const codeOnly = stripComments(block);
|
||||
expect(codeOnly).not.toMatch(
|
||||
/userSelection:[\s\S]*?manual_section_assignment/,
|
||||
);
|
||||
});
|
||||
|
||||
it("handleGenerate gates overrides.zoneSections on manualMarker === true (strict bool)", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleGenerate");
|
||||
// Marker read AND strict-equality gate. `===` not `==`, not truthiness,
|
||||
// not presence — so `false` / absent both skip forwarding (fail-closed).
|
||||
expect(block).toMatch(/state\.userSelection\.overrides\.manual_section_assignment/);
|
||||
expect(block).toMatch(/manualMarker\s*===\s*true/);
|
||||
// The assignment to `overrides.zoneSections` must live INSIDE the
|
||||
// marker-true branch.
|
||||
const gateIdx = block.search(/if\s*\(\s*manualMarker\s*===\s*true\s*\)/);
|
||||
const assignIdx = block.search(/overrides\.zoneSections\s*=/);
|
||||
expect(gateIdx).toBeGreaterThan(-1);
|
||||
expect(assignIdx).toBeGreaterThan(gateIdx);
|
||||
});
|
||||
|
||||
it("handleGenerate filters forwarded zone_sections to valid zone_ids only (cross-layout safety)", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleGenerate");
|
||||
// A stale persisted layout could carry zone_ids that do not exist in
|
||||
// the current sourcePlan (e.g. horizontal-2 `top`/`bottom` while the
|
||||
// current layout is vertical-2 `left`/`right`). Those foreign keys
|
||||
// must be dropped before reaching the backend `--override-section-
|
||||
// assignment` so they cannot trigger PARTIAL_COVERAGE.
|
||||
expect(block).toMatch(/validZoneIds\s*=\s*new Set\(\s*sourcePlan\.zones\.map\(\(z\)\s*=>\s*z\.zone_id\)/);
|
||||
expect(block).toMatch(/if\s*\(!validZoneIds\.has\(zoneId\)\)\s*continue/);
|
||||
});
|
||||
|
||||
it("handleGenerate no longer contains the IMP-08 B-3 self-compare anti-pattern", () => {
|
||||
// Strip comments — the u7 docblock intentionally references the removed
|
||||
// identifiers (`defaultByZone` / `sameAsDefault` / `zoneSectionsDiff`)
|
||||
// in prose to explain the Stage 1 root cause for future readers; the
|
||||
// regression we guard against is the LIVE code re-emerging.
|
||||
const block = stripComments(sliceHandler(HOME_TSX, "handleGenerate"));
|
||||
// The Stage 1 root cause: these identifiers compared user input against
|
||||
// itself (sourcePlan === effectiveSlidePlan → zones === pendingZones,
|
||||
// both derived from the same overrides.zone_sections). u7 deleted the
|
||||
// entire block.
|
||||
expect(block).not.toMatch(/\bdefaultByZone\b/);
|
||||
expect(block).not.toMatch(/\bsameAsDefault\b/);
|
||||
expect(block).not.toMatch(/\bzoneSectionsDiff\b/);
|
||||
});
|
||||
|
||||
it("co-PUT payload contract: marker=true + zone_sections land in a single PUT body", async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
// Shape produced by handleSectionDrop after the u6 marker flip.
|
||||
void saveUserOverrides("03_demo", {
|
||||
zone_sections: { left: ["03-2"], right: ["03-1"] },
|
||||
manual_section_assignment: true,
|
||||
});
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const body = lastPutBody() as Record<string, unknown>;
|
||||
// Both axes in the same PUT body — exact equality, not arrayContaining,
|
||||
// because any extra axis would mean a foreign mutation leaked through.
|
||||
expect(Object.keys(body).sort()).toEqual(
|
||||
["manual_section_assignment", "zone_sections"].sort(),
|
||||
);
|
||||
expect(body.manual_section_assignment).toBe(true);
|
||||
expect(body.zone_sections).toEqual({ left: ["03-2"], right: ["03-1"] });
|
||||
});
|
||||
|
||||
it("co-PUT payload contract: marker=false carries explicitly through saveUserOverrides", async () => {
|
||||
// u12 will add the apply/cancel explicit `false` PUT; the typed client
|
||||
// must already propagate the literal `false` through the debounce
|
||||
// bucket. A truthiness-based coalesce in the bucket merge would drop
|
||||
// the value and re-open the stale-disk window. This locks the wire
|
||||
// contract independently of the u12 caller-site write.
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03_demo", { manual_section_assignment: false });
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
const body = lastPutBody() as Record<string, unknown>;
|
||||
expect(Object.keys(body)).toEqual(["manual_section_assignment"]);
|
||||
expect(body.manual_section_assignment).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── IMP-55 #93 u12 — stale-disk marker reset on apply / cancel ───────────
|
||||
// u5 resets the in-memory marker on layout apply, and u3's seed via
|
||||
// `createInitialUserSelection` resets it on cancel. But the disk persists
|
||||
// independently — a prior drag wrote `true` via u6's co-PUT, so after a
|
||||
// page reload the u3 restore branch would re-seed `true` and the u7 gate
|
||||
// would forward auto-carried section assignments → PARTIAL_COVERAGE
|
||||
// regression. u12 closes that window by writing `manual_section_assignment:
|
||||
// false` to disk via saveUserOverrides on both apply and cancel paths.
|
||||
describe("IMP-55 #93 u12 — stale-disk marker reset on layout apply/cancel", () => {
|
||||
it("handleApplyPendingLayout source contains a marker=false saveUserOverrides PUT", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleApplyPendingLayout");
|
||||
// Stripped-comment source so the u5 docblock prose doesn't satisfy the
|
||||
// assertion — must be a real call expression.
|
||||
const code = stripComments(block);
|
||||
// Uploaded-file gate (mirrors the u6 / other handler pattern — the
|
||||
// demo-mode initial render path must not PUT to an empty key).
|
||||
expect(code).toMatch(
|
||||
/if\s*\(\s*p\.uploadedFile\s*\)[\s\S]*?saveUserOverrides\([\s\S]*?manual_section_assignment:\s*false[\s\S]*?\)/,
|
||||
);
|
||||
expect(code).toMatch(/deriveUserOverridesKey\(p\.uploadedFile\.name\)/);
|
||||
});
|
||||
|
||||
it("handleCancelPendingLayout source contains a marker=false saveUserOverrides PUT", () => {
|
||||
const block = sliceHandler(HOME_TSX, "handleCancelPendingLayout");
|
||||
const code = stripComments(block);
|
||||
// Cancel handler converts from arrow-body to function-body for the
|
||||
// disk PUT; the in-memory reset still comes from createInitialUserSelection.
|
||||
expect(code).toMatch(
|
||||
/if\s*\(\s*p\.uploadedFile\s*\)[\s\S]*?saveUserOverrides\([\s\S]*?manual_section_assignment:\s*false[\s\S]*?\)/,
|
||||
);
|
||||
expect(code).toMatch(/createInitialUserSelection\(p\.slidePlan\)/);
|
||||
});
|
||||
|
||||
it("apply path PUT payload: marker=false carries alone (no auto-carry leakage)", async () => {
|
||||
// The apply handler issues a dedicated PUT for the marker reset that is
|
||||
// independent of the (conditional) zone_geometries PUT and of the
|
||||
// in-memory zone_sections rewrite. The wire contract for this PUT must
|
||||
// contain only the marker — if zone_sections leaked into the same body
|
||||
// it would re-arm the u9 backend fallback gate against u12's intent.
|
||||
fetchMock.mockResolvedValue(mockResponse({}));
|
||||
void saveUserOverrides("03_demo", { manual_section_assignment: false });
|
||||
vi.advanceTimersByTime(300);
|
||||
await drainMicrotasks();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const body = lastPutBody() as Record<string, unknown>;
|
||||
expect(Object.keys(body)).toEqual(["manual_section_assignment"]);
|
||||
expect(body.manual_section_assignment).toBe(false);
|
||||
});
|
||||
|
||||
it("apply path PUT is unconditional (does NOT gate on hadPriorGeoms)", () => {
|
||||
// The u4 zone_geometries PUT inside handleApplyPendingLayout is
|
||||
// conditional (`p.uploadedFile && hadPriorGeoms`). The u12 marker PUT
|
||||
// must NOT inherit that gate — a stale disk `true` can exist without
|
||||
// any prior zone_geometries, so the reset must always fire.
|
||||
const code = stripComments(sliceHandler(HOME_TSX, "handleApplyPendingLayout"));
|
||||
// Locate the marker PUT and verify its enclosing `if` clause is just
|
||||
// `p.uploadedFile`, not the compound `... && hadPriorGeoms` guard.
|
||||
const markerCallMatch = code.match(
|
||||
/if\s*\(([^)]*)\)\s*\{[^}]*saveUserOverrides\([^)]*manual_section_assignment:\s*false[^)]*\)/,
|
||||
);
|
||||
expect(markerCallMatch).not.toBeNull();
|
||||
if (markerCallMatch) {
|
||||
expect(markerCallMatch[1].trim()).toBe("p.uploadedFile");
|
||||
}
|
||||
});
|
||||
});
|
||||
222
Front/client/tests/zone_geometries_validation.test.ts
Normal file
222
Front/client/tests/zone_geometries_validation.test.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
// IMP-44 (#73) u3 — vitest coverage for `validateZoneGeometriesAgainstLayout`.
|
||||
//
|
||||
// Pairs with the backend [override-warning] guards added in u1 (1-D
|
||||
// horizontal-2 / vertical-2 branches of `build_layout_css`) and u2 (2-D
|
||||
// `_override_to_grid_tracks` call site). Same WARN+DROP unknown / KEEP known
|
||||
// contract; this helper lets handleGenerate (u4) validate against the active
|
||||
// layout before forwarding so the user sees a toast on dropped keys rather
|
||||
// than the backend silently even-splitting non-overridden zones with a false
|
||||
// `computation=user_override_geometry` signal.
|
||||
//
|
||||
// Cases (Stage 2 scope-lock):
|
||||
// 1) horizontal-2 → vertical-2 mismatch (all keys dropped)
|
||||
// 2) passthrough (all keys recognized)
|
||||
// 3) partial mix (some kept, some dropped)
|
||||
// 4) empty input ({} on a known layout)
|
||||
// 5) unknown-layout fail-safe (preset null / undefined / unknown string)
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { validateZoneGeometriesAgainstLayout } from "../src/utils/slidePlanUtils";
|
||||
|
||||
const g = (x: number, y: number, w: number, h: number) => ({ x, y, w, h });
|
||||
|
||||
describe("validateZoneGeometriesAgainstLayout (IMP-44 u3)", () => {
|
||||
// ── 1. mismatch ──────────────────────────────────────────────────────────
|
||||
it("drops horizontal-2 keys when the active layout is vertical-2", () => {
|
||||
const result = validateZoneGeometriesAgainstLayout(
|
||||
{ top: g(0, 0, 1, 0.4), bottom: g(0, 0.4, 1, 0.6) },
|
||||
"vertical-2",
|
||||
);
|
||||
expect(result.kept).toEqual({});
|
||||
expect(result.dropped).toEqual({
|
||||
top: g(0, 0, 1, 0.4),
|
||||
bottom: g(0, 0.4, 1, 0.6),
|
||||
});
|
||||
expect(result.expectedPositions).toEqual(["left", "right"]);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("drops vertical-2 keys when the active layout is horizontal-2", () => {
|
||||
const result = validateZoneGeometriesAgainstLayout(
|
||||
{ left: g(0, 0, 0.5, 1), right: g(0.5, 0, 0.5, 1) },
|
||||
"horizontal-2",
|
||||
);
|
||||
expect(result.kept).toEqual({});
|
||||
expect(Object.keys(result.dropped).sort()).toEqual(["left", "right"]);
|
||||
expect(result.expectedPositions).toEqual(["top", "bottom"]);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
// ── 2. passthrough ───────────────────────────────────────────────────────
|
||||
it("keeps all keys when every input key is in the active layout positions", () => {
|
||||
const input = {
|
||||
top: g(0, 0, 1, 0.4),
|
||||
bottom: g(0, 0.4, 1, 0.6),
|
||||
};
|
||||
const result = validateZoneGeometriesAgainstLayout(input, "horizontal-2");
|
||||
expect(result.kept).toEqual(input);
|
||||
expect(result.dropped).toEqual({});
|
||||
expect(result.expectedPositions).toEqual(["top", "bottom"]);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("passes a single 'primary' key through on the 'single' preset", () => {
|
||||
const result = validateZoneGeometriesAgainstLayout(
|
||||
{ primary: g(0, 0, 1, 1) },
|
||||
"single",
|
||||
);
|
||||
expect(result.kept).toEqual({ primary: g(0, 0, 1, 1) });
|
||||
expect(result.dropped).toEqual({});
|
||||
expect(result.expectedPositions).toEqual(["primary"]);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("recognizes the 2-D preset positions reported by computeZonePositions (top-1-bottom-2)", () => {
|
||||
const input = {
|
||||
top: g(0, 0, 1, 0.5),
|
||||
"bottom-left": g(0, 0.5, 0.5, 0.5),
|
||||
"bottom-right": g(0.5, 0.5, 0.5, 0.5),
|
||||
};
|
||||
const result = validateZoneGeometriesAgainstLayout(input, "top-1-bottom-2");
|
||||
expect(result.kept).toEqual(input);
|
||||
expect(result.dropped).toEqual({});
|
||||
expect(result.expectedPositions).toEqual([
|
||||
"top",
|
||||
"bottom-left",
|
||||
"bottom-right",
|
||||
]);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
// ── 3. partial mix ───────────────────────────────────────────────────────
|
||||
it("keeps known keys and drops unknown keys on a partial-mix input", () => {
|
||||
const result = validateZoneGeometriesAgainstLayout(
|
||||
{ top: g(0, 0, 1, 0.4), foo: g(0, 0, 1, 1) },
|
||||
"horizontal-2",
|
||||
);
|
||||
expect(result.kept).toEqual({ top: g(0, 0, 1, 0.4) });
|
||||
expect(result.dropped).toEqual({ foo: g(0, 0, 1, 1) });
|
||||
expect(result.expectedPositions).toEqual(["top", "bottom"]);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("on a 2-D preset, keeps known 2-D track keys and drops legacy 1-D keys", () => {
|
||||
// Simulates the user resizing under top-1-bottom-2, then flipping to
|
||||
// grid-2x2 — legacy `bottom-left` stays valid; `top` (no longer a 2x2
|
||||
// position) gets dropped.
|
||||
const result = validateZoneGeometriesAgainstLayout(
|
||||
{
|
||||
top: g(0, 0, 1, 0.5),
|
||||
"bottom-left": g(0, 0.5, 0.5, 0.5),
|
||||
"top-left": g(0, 0, 0.5, 0.5),
|
||||
},
|
||||
"grid-2x2",
|
||||
);
|
||||
expect(result.kept).toEqual({
|
||||
"bottom-left": g(0, 0.5, 0.5, 0.5),
|
||||
"top-left": g(0, 0, 0.5, 0.5),
|
||||
});
|
||||
expect(result.dropped).toEqual({ top: g(0, 0, 1, 0.5) });
|
||||
expect(result.expectedPositions).toEqual([
|
||||
"top-left",
|
||||
"top-right",
|
||||
"bottom-left",
|
||||
"bottom-right",
|
||||
]);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
// ── 4. empty input ───────────────────────────────────────────────────────
|
||||
it("returns empty kept/dropped and valid=true on an empty {} input", () => {
|
||||
const result = validateZoneGeometriesAgainstLayout({}, "horizontal-2");
|
||||
expect(result.kept).toEqual({});
|
||||
expect(result.dropped).toEqual({});
|
||||
expect(result.expectedPositions).toEqual(["top", "bottom"]);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("treats null / undefined geoms as empty input (no throw, valid=true on a known layout)", () => {
|
||||
const nullResult = validateZoneGeometriesAgainstLayout(null, "vertical-2");
|
||||
expect(nullResult.kept).toEqual({});
|
||||
expect(nullResult.dropped).toEqual({});
|
||||
expect(nullResult.expectedPositions).toEqual(["left", "right"]);
|
||||
expect(nullResult.valid).toBe(true);
|
||||
|
||||
const undefResult = validateZoneGeometriesAgainstLayout(
|
||||
undefined,
|
||||
"vertical-2",
|
||||
);
|
||||
expect(undefResult.kept).toEqual({});
|
||||
expect(undefResult.dropped).toEqual({});
|
||||
expect(undefResult.expectedPositions).toEqual(["left", "right"]);
|
||||
expect(undefResult.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores array payloads (defensive against hand-edited persisted files)", () => {
|
||||
const result = validateZoneGeometriesAgainstLayout(
|
||||
[] as unknown as Record<string, { x: number; y: number; w: number; h: number }>,
|
||||
"horizontal-2",
|
||||
);
|
||||
expect(result.kept).toEqual({});
|
||||
expect(result.dropped).toEqual({});
|
||||
expect(result.expectedPositions).toEqual(["top", "bottom"]);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
// ── 5. unknown-layout fail-safe ──────────────────────────────────────────
|
||||
it("drops every input key when layout is null (fail-safe)", () => {
|
||||
const result = validateZoneGeometriesAgainstLayout(
|
||||
{ top: g(0, 0, 1, 0.4), bottom: g(0, 0.4, 1, 0.6) },
|
||||
null,
|
||||
);
|
||||
expect(result.kept).toEqual({});
|
||||
expect(result.dropped).toEqual({
|
||||
top: g(0, 0, 1, 0.4),
|
||||
bottom: g(0, 0.4, 1, 0.6),
|
||||
});
|
||||
expect(result.expectedPositions).toEqual([]);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("drops every input key when layout is undefined (fail-safe)", () => {
|
||||
const result = validateZoneGeometriesAgainstLayout(
|
||||
{ primary: g(0, 0, 1, 1) },
|
||||
undefined,
|
||||
);
|
||||
expect(result.kept).toEqual({});
|
||||
expect(result.dropped).toEqual({ primary: g(0, 0, 1, 1) });
|
||||
expect(result.expectedPositions).toEqual([]);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("drops every input key when layout is an unknown preset string (fail-safe)", () => {
|
||||
const result = validateZoneGeometriesAgainstLayout(
|
||||
{ top: g(0, 0, 1, 0.4) },
|
||||
"rogue-preset" as unknown as string,
|
||||
);
|
||||
expect(result.kept).toEqual({});
|
||||
expect(result.dropped).toEqual({ top: g(0, 0, 1, 0.4) });
|
||||
expect(result.expectedPositions).toEqual([]);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("returns empty kept/dropped/expectedPositions when layout is unknown AND geoms is empty", () => {
|
||||
const result = validateZoneGeometriesAgainstLayout({}, null);
|
||||
expect(result.kept).toEqual({});
|
||||
expect(result.dropped).toEqual({});
|
||||
expect(result.expectedPositions).toEqual([]);
|
||||
// No keys to drop ⇒ vacuously valid; handleGenerate (u4) gates the toast
|
||||
// on `Object.keys(dropped).length > 0`, not `valid`, so this is safe.
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
// ── purity / mutation safety ─────────────────────────────────────────────
|
||||
it("does not mutate the input geometries object", () => {
|
||||
const input = { top: g(0, 0, 1, 0.4), foo: g(0, 0, 1, 1) };
|
||||
const inputKeysBefore = Object.keys(input).sort();
|
||||
validateZoneGeometriesAgainstLayout(input, "horizontal-2");
|
||||
expect(Object.keys(input).sort()).toEqual(inputKeysBefore);
|
||||
// Sample value still pristine.
|
||||
expect(input.top).toEqual(g(0, 0, 1, 0.4));
|
||||
});
|
||||
});
|
||||
@@ -204,20 +204,545 @@ function vitePluginStorageProxy(): Plugin {
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// IMP-52 u3/u4 — user_overrides.json persistence (MDX-stem keyed store).
|
||||
//
|
||||
// On-disk layout: <DESIGN_AGENT_ROOT>/data/user_overrides/<key>.json. Mirrors
|
||||
// the Python contract in src/user_overrides_io.py — same validate_key regex,
|
||||
// same graceful-degrade (corrupt → {}) so backend pipeline entry fallback
|
||||
// (u2) and the vite endpoints (u3 GET, u4 PUT) agree on every file.
|
||||
//
|
||||
// Helpers are named exports so vitest can drive handleGetUserOverrides /
|
||||
// handlePutUserOverrides with mock req/res without booting a real dev
|
||||
// server. vite still consumes the default `defineConfig` export below.
|
||||
// =============================================================================
|
||||
|
||||
export const USER_OVERRIDES_KEY_RE = /^[A-Za-z0-9_][A-Za-z0-9_.\-]*$/;
|
||||
|
||||
// The nine in-scope axes — full mirror of KNOWN_AXES in
|
||||
// src/user_overrides_io.py. Order matches the Python tuple verbatim so
|
||||
// a side-by-side audit reads as a no-op. Any payload key outside this
|
||||
// allowlist is silently dropped by the PUT handler (u4) so the on-disk
|
||||
// schema cannot drift from the backend pipeline (u2) contract. Foreign
|
||||
// top-level keys already on disk are preserved verbatim (see
|
||||
// mergeUserOverrides).
|
||||
// IMP-51 (#79) u2: added `image_overrides` (image_id → {x,y,w,h}
|
||||
// percent-of-slide coordinates).
|
||||
// IMP-55 (#93) u1: added `manual_section_assignment` (bool intent marker
|
||||
// — drag-drop sets true, layout apply/cancel sets false).
|
||||
// IMP-56 (#90) u3: allowlist sync — closes the prior `slide_css` gap
|
||||
// (IMP-45 #74; the Step-22 slide CSS edit path will write it from the
|
||||
// frontend) and pre-wires `text_overrides` (IMP-56 #90 u1, keyed by
|
||||
// {zone_id: {text_path: value}}) + `structure_overrides` (IMP-56 #90 u2,
|
||||
// keyed by {zone_id: {slot_order, hidden_slots}} — scope LOCKED to slot
|
||||
// reorder + hide; frame swap stays on the existing `frames` axis to
|
||||
// preserve Phase Z's no-AI-HTML-structure invariant) so the Step-22
|
||||
// capture path (u10~u17) can PUT either axis without a follow-on
|
||||
// allowlist edit.
|
||||
export const KNOWN_USER_OVERRIDES_AXES = [
|
||||
"layout",
|
||||
"zone_geometries",
|
||||
"zone_sections",
|
||||
"frames",
|
||||
"image_overrides",
|
||||
"slide_css",
|
||||
"manual_section_assignment",
|
||||
"text_overrides",
|
||||
"structure_overrides",
|
||||
] as const;
|
||||
export type KnownUserOverridesAxis = (typeof KNOWN_USER_OVERRIDES_AXES)[number];
|
||||
|
||||
// 1MB cap on PUT bodies. Override files in practice are < 10KB (5 axes,
|
||||
// each a small dict). The cap is a safety net against runaway client
|
||||
// loops, not a real schema constraint.
|
||||
const USER_OVERRIDES_PUT_MAX_BYTES = 1_000_000;
|
||||
|
||||
export function isValidUserOverridesKey(key: string): boolean {
|
||||
if (!key) return false;
|
||||
if (key.includes("..")) return false;
|
||||
if (key.includes("/") || key.includes("\\")) return false;
|
||||
return USER_OVERRIDES_KEY_RE.test(key);
|
||||
}
|
||||
|
||||
export function userOverridesPath(root: string, key: string): string {
|
||||
return path.join(root, "data", "user_overrides", `${key}.json`);
|
||||
}
|
||||
|
||||
// Minimal req/res shapes — node IncomingMessage / ServerResponse have many
|
||||
// fields the handler does not touch, so we accept a structural subset for
|
||||
// testability.
|
||||
type GetReqLike = { method?: string; url?: string };
|
||||
type PutReqLike = {
|
||||
method?: string;
|
||||
url?: string;
|
||||
on(event: "data" | "end" | "error", cb: (...args: any[]) => void): unknown;
|
||||
};
|
||||
type ResLike = {
|
||||
writeHead: (status: number, headers?: Record<string, string>) => void;
|
||||
end: (body?: string) => void;
|
||||
};
|
||||
|
||||
// IMP-52 u3 — GET /api/user-overrides/:key handler. Returns true when the
|
||||
// handler took over the response, false when the caller should `next()`.
|
||||
// Invariants:
|
||||
// • method != GET → false (chain continues; u4 PUT may handle)
|
||||
// • invalid key → 400 {"error":"invalid key"}
|
||||
// • file missing → 200 {}
|
||||
// • file unreadable/corrupt → 200 {} (graceful degrade, mirrors u1 load)
|
||||
// • non-object JSON root → 200 {} (mirrors u1 load)
|
||||
// • valid object JSON → 200 with parsed JSON body
|
||||
export function handleGetUserOverrides(
|
||||
req: GetReqLike,
|
||||
res: ResLike,
|
||||
root: string,
|
||||
): boolean {
|
||||
if (req.method !== "GET") return false;
|
||||
|
||||
const url = req.url || "";
|
||||
const key = url.split("?")[0].replace(/^\//, "");
|
||||
|
||||
if (!isValidUserOverridesKey(key)) {
|
||||
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "invalid key" }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const filePath = userOverridesPath(root, key);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end("{}");
|
||||
return true;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end("{}");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end("{}");
|
||||
return true;
|
||||
}
|
||||
|
||||
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify(parsed));
|
||||
return true;
|
||||
}
|
||||
|
||||
// IMP-52 u4 — pure merge function. Mirrors src/user_overrides_io.save():
|
||||
// • Only KNOWN_USER_OVERRIDES_AXES present in `partial` are mutated.
|
||||
// • Axes absent from `partial` are preserved verbatim from `existing`.
|
||||
// • Foreign top-level keys in `existing` (future axes like zone_sizes)
|
||||
// are preserved verbatim — allowlist guards what the PUT writes, NOT
|
||||
// what the file already holds.
|
||||
// • `partial[axis] = null` is the explicit clear sentinel (remove key).
|
||||
// • Any non-axis keys in `partial` are silently dropped (allowlist).
|
||||
export function mergeUserOverrides(
|
||||
existing: Record<string, unknown>,
|
||||
partial: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const merged: Record<string, unknown> = { ...existing };
|
||||
for (const axis of KNOWN_USER_OVERRIDES_AXES) {
|
||||
if (!(axis in partial)) continue;
|
||||
const value = partial[axis];
|
||||
if (value === null) {
|
||||
delete merged[axis];
|
||||
} else {
|
||||
merged[axis] = value;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
// IMP-52 u4 — atomic file write via tmp + rename. Mirrors the
|
||||
// `_atomic_write_json` semantics in src/user_overrides_io.py so a
|
||||
// crashed/interrupted PUT cannot leave a half-written .json on disk
|
||||
// (the next GET / pipeline-entry read would otherwise return {} via
|
||||
// graceful degrade, silently losing the user's prior overrides).
|
||||
export function atomicWriteUserOverrides(
|
||||
filePath: string,
|
||||
data: Record<string, unknown>,
|
||||
): void {
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const tmpName = path.join(
|
||||
dir,
|
||||
`.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`,
|
||||
);
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
tmpName,
|
||||
JSON.stringify(data, null, 2) + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
fs.renameSync(tmpName, filePath);
|
||||
} catch (err) {
|
||||
try {
|
||||
fs.unlinkSync(tmpName);
|
||||
} catch {
|
||||
// best-effort cleanup; the rename source may not exist on early failure
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// IMP-52 u4 — PUT /api/user-overrides/:key handler. Returns true when the
|
||||
// handler took over the response, false when the caller should `next()`.
|
||||
// Invariants:
|
||||
// • method != PUT → false (chain continues; GET runs first)
|
||||
// • invalid key → 400 {"error":"invalid key"}
|
||||
// • body > 1MB → 413 {"error":"payload too large"}
|
||||
// • invalid JSON → 400 {"error":"invalid JSON"}
|
||||
// • non-object JSON root → 400 {"error":"body must be a JSON object"}
|
||||
// • write failure → 500 {"error":"write failed: ..."}
|
||||
// • success → 200 with merged JSON body
|
||||
//
|
||||
// Existing-file read uses the same graceful-degrade rules as GET (corrupt
|
||||
// JSON / non-object root → treat as empty {}) so a PUT cannot fail solely
|
||||
// because a prior file is unparseable — the new payload replaces it.
|
||||
export function handlePutUserOverrides(
|
||||
req: PutReqLike,
|
||||
res: ResLike,
|
||||
root: string,
|
||||
): boolean {
|
||||
if (req.method !== "PUT") return false;
|
||||
|
||||
const url = req.url || "";
|
||||
const key = url.split("?")[0].replace(/^\//, "");
|
||||
|
||||
if (!isValidUserOverridesKey(key)) {
|
||||
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "invalid key" }));
|
||||
return true;
|
||||
}
|
||||
|
||||
let body = "";
|
||||
let aborted = false;
|
||||
|
||||
req.on("data", (chunk: Buffer | string) => {
|
||||
if (aborted) return;
|
||||
body += typeof chunk === "string" ? chunk : chunk.toString();
|
||||
if (body.length > USER_OVERRIDES_PUT_MAX_BYTES) {
|
||||
aborted = true;
|
||||
res.writeHead(413, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
});
|
||||
res.end(JSON.stringify({ error: "payload too large" }));
|
||||
}
|
||||
});
|
||||
|
||||
req.on("end", () => {
|
||||
if (aborted) return;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = body.length > 0 ? JSON.parse(body) : {};
|
||||
} catch {
|
||||
res.writeHead(400, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
});
|
||||
res.end(JSON.stringify({ error: "invalid JSON" }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof parsed !== "object" ||
|
||||
parsed === null ||
|
||||
Array.isArray(parsed)
|
||||
) {
|
||||
res.writeHead(400, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
});
|
||||
res.end(JSON.stringify({ error: "body must be a JSON object" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const partial = parsed as Record<string, unknown>;
|
||||
const filePath = userOverridesPath(root, key);
|
||||
|
||||
// Load existing — corrupt / non-object → {} so the PUT still succeeds
|
||||
// and recovers the file to a clean state. Mirrors u1 load() graceful
|
||||
// degrade.
|
||||
let existing: Record<string, unknown> = {};
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
const ex = JSON.parse(raw);
|
||||
if (
|
||||
typeof ex === "object" &&
|
||||
ex !== null &&
|
||||
!Array.isArray(ex)
|
||||
) {
|
||||
existing = ex as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// corrupt → treat as empty
|
||||
}
|
||||
}
|
||||
|
||||
const merged = mergeUserOverrides(existing, partial);
|
||||
|
||||
try {
|
||||
atomicWriteUserOverrides(filePath, merged);
|
||||
} catch (err) {
|
||||
res.writeHead(500, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
});
|
||||
res.end(JSON.stringify({ error: `write failed: ${String(err)}` }));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
});
|
||||
res.end(JSON.stringify(merged));
|
||||
});
|
||||
|
||||
req.on("error", () => {
|
||||
if (aborted) return;
|
||||
aborted = true;
|
||||
res.writeHead(500, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
});
|
||||
res.end(JSON.stringify({ error: "request error" }));
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// IMP-56 (#90) u18 — POST /api/connect : cel astro dev mirror copy.
|
||||
//
|
||||
// Body: {"run_id": "<id>", "slug": "<mdx-stem>"}.
|
||||
// • Copies <DESIGN_AGENT_ROOT>/data/runs/<run_id>/phase_z2/final.html →
|
||||
// <CEL_PROJECT_ROOT>/public/slides/<slug>.html (overwrite).
|
||||
// • If <run_dir>/phase_z2/assets/ exists, mirrors its contents into
|
||||
// <CEL_PROJECT_ROOT>/public/slides/assets/ (overwrite copy, recursive).
|
||||
// • run_id and slug are validated through the existing
|
||||
// isValidUserOverridesKey gate so path-traversal payloads are rejected.
|
||||
// =============================================================================
|
||||
|
||||
export function mirrorDirRecursive(srcDir: string, dstDir: string): number {
|
||||
if (!fs.existsSync(srcDir) || !fs.statSync(srcDir).isDirectory()) return 0;
|
||||
if (!fs.existsSync(dstDir)) fs.mkdirSync(dstDir, { recursive: true });
|
||||
let count = 0;
|
||||
for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
|
||||
const srcPath = path.join(srcDir, entry.name);
|
||||
const dstPath = path.join(dstDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
count += mirrorDirRecursive(srcPath, dstPath);
|
||||
} else if (entry.isFile()) {
|
||||
fs.copyFileSync(srcPath, dstPath);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export function handleConnectMirror(
|
||||
req: PutReqLike,
|
||||
res: ResLike,
|
||||
designAgentRoot: string,
|
||||
celRoot: string,
|
||||
): boolean {
|
||||
if (req.method !== "POST") return false;
|
||||
let body = "";
|
||||
req.on("data", (chunk: Buffer | string) => {
|
||||
body += typeof chunk === "string" ? chunk : chunk.toString();
|
||||
});
|
||||
req.on("end", () => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = body.length > 0 ? JSON.parse(body) : {};
|
||||
} catch {
|
||||
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "invalid JSON" }));
|
||||
return;
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "body must be a JSON object" }));
|
||||
return;
|
||||
}
|
||||
const { run_id, slug } = parsed as { run_id?: unknown; slug?: unknown };
|
||||
if (typeof run_id !== "string" || typeof slug !== "string") {
|
||||
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "missing run_id or slug" }));
|
||||
return;
|
||||
}
|
||||
if (!isValidUserOverridesKey(run_id) || !isValidUserOverridesKey(slug)) {
|
||||
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "invalid run_id or slug" }));
|
||||
return;
|
||||
}
|
||||
const runDir = path.join(designAgentRoot, "data", "runs", run_id, "phase_z2");
|
||||
const srcHtml = path.join(runDir, "final.html");
|
||||
if (!fs.existsSync(srcHtml)) {
|
||||
res.writeHead(404, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "final.html not found" }));
|
||||
return;
|
||||
}
|
||||
const dstSlidesDir = path.join(celRoot, "public", "slides");
|
||||
if (!fs.existsSync(dstSlidesDir)) fs.mkdirSync(dstSlidesDir, { recursive: true });
|
||||
const dstHtml = path.join(dstSlidesDir, `${slug}.html`);
|
||||
try {
|
||||
fs.copyFileSync(srcHtml, dstHtml);
|
||||
} catch (err) {
|
||||
res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: `copy failed: ${String(err)}` }));
|
||||
return;
|
||||
}
|
||||
const assetsCopied = mirrorDirRecursive(
|
||||
path.join(runDir, "assets"),
|
||||
path.join(dstSlidesDir, "assets"),
|
||||
);
|
||||
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ success: true, run_id, slug, html_target: dstHtml, assets_copied: assetsCopied }));
|
||||
});
|
||||
req.on("error", () => {
|
||||
res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "request error" }));
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// IMP-56 (#90) u19 — POST /api/export : standalone HTML download.
|
||||
//
|
||||
// Body: {"run_id": "<id>"}.
|
||||
// • Reads <DESIGN_AGENT_ROOT>/data/runs/<run_id>/phase_z2/final.html.
|
||||
// • Inlines every `url(assets/<frame>/<file>)` reference (the only
|
||||
// external dep emitted by the Phase Z2 render path — verified by grep
|
||||
// against templates/phase_z2/slide_base.html and a representative run)
|
||||
// as a base64 data URL so the emitted HTML is portable (file:// open
|
||||
// or any external host, no co-located assets/ dir required). Mirrors
|
||||
// u18 validation: isValidUserOverridesKey gate for path-traversal
|
||||
// rejection; final.html missing → 404.
|
||||
// • Response: 200 text/html with Content-Disposition: attachment so the
|
||||
// browser triggers a download with `<run_id>.html` filename. Raw HTML
|
||||
// body (NOT JSON-wrapped) — the BottomActions wiring (u20) will pipe
|
||||
// the response body straight into a Blob → a[download] click chain
|
||||
// mirroring the existing serializeSlidePlan JSON download flow.
|
||||
// =============================================================================
|
||||
|
||||
export function inlineAssetsAsDataUrls(html: string, assetsRoot: string): string {
|
||||
// Match `url(assets/<rel-path>)` (with optional single/double quotes,
|
||||
// optional surrounding whitespace). The Phase Z2 render path emits
|
||||
// `url(assets/<frame>/<file>.png)` verbatim into inline `style="..."`
|
||||
// custom-property declarations (see slide_base.html `--card-frame-bg`
|
||||
// etc.) — there is no `<link rel="stylesheet">` or `<img src>` external
|
||||
// ref to handle. Keeping the matcher narrow avoids accidentally
|
||||
// rewriting `data:` / `http(s):` / sibling-path URLs that the render
|
||||
// path does not produce.
|
||||
const URL_RE = /url\(\s*(['"]?)assets\/([^)'"]+)\1\s*\)/g;
|
||||
return html.replace(URL_RE, (match, _quote: string, rel: string) => {
|
||||
const filePath = path.join(assetsRoot, rel);
|
||||
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) return match;
|
||||
const ext = path.extname(filePath).toLowerCase().slice(1);
|
||||
const mime =
|
||||
ext === "png" ? "image/png" :
|
||||
ext === "jpg" || ext === "jpeg" ? "image/jpeg" :
|
||||
ext === "svg" ? "image/svg+xml" :
|
||||
ext === "webp" ? "image/webp" :
|
||||
ext === "gif" ? "image/gif" :
|
||||
"application/octet-stream";
|
||||
const buf = fs.readFileSync(filePath);
|
||||
return `url("data:${mime};base64,${buf.toString("base64")}")`;
|
||||
});
|
||||
}
|
||||
|
||||
export function handleExportStandalone(
|
||||
req: PutReqLike,
|
||||
res: ResLike,
|
||||
designAgentRoot: string,
|
||||
): boolean {
|
||||
if (req.method !== "POST") return false;
|
||||
let body = "";
|
||||
req.on("data", (chunk: Buffer | string) => {
|
||||
body += typeof chunk === "string" ? chunk : chunk.toString();
|
||||
});
|
||||
req.on("end", () => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = body.length > 0 ? JSON.parse(body) : {};
|
||||
} catch {
|
||||
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "invalid JSON" }));
|
||||
return;
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "body must be a JSON object" }));
|
||||
return;
|
||||
}
|
||||
const { run_id } = parsed as { run_id?: unknown };
|
||||
if (typeof run_id !== "string") {
|
||||
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "missing run_id" }));
|
||||
return;
|
||||
}
|
||||
if (!isValidUserOverridesKey(run_id)) {
|
||||
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "invalid run_id" }));
|
||||
return;
|
||||
}
|
||||
const runDir = path.join(designAgentRoot, "data", "runs", run_id, "phase_z2");
|
||||
const srcHtml = path.join(runDir, "final.html");
|
||||
if (!fs.existsSync(srcHtml)) {
|
||||
res.writeHead(404, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "final.html not found" }));
|
||||
return;
|
||||
}
|
||||
let html: string;
|
||||
try {
|
||||
html = fs.readFileSync(srcHtml, "utf-8");
|
||||
} catch (err) {
|
||||
res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: `read failed: ${String(err)}` }));
|
||||
return;
|
||||
}
|
||||
const inlined = inlineAssetsAsDataUrls(html, path.join(runDir, "assets"));
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${run_id}.html"`,
|
||||
});
|
||||
res.end(inlined);
|
||||
});
|
||||
req.on("error", () => {
|
||||
res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "request error" }));
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Phase Z API Plugin — MDX 업로드 → 파이프라인 실행 → 결과 노출
|
||||
//
|
||||
// Endpoints (vite dev middleware) :
|
||||
// POST /api/run multipart/JSON body {filename, content} → run_id
|
||||
// GET /data/runs/{run_id}/{path} → {DESIGN_AGENT_ROOT}/data/runs/{run_id}/phase_z2/{path}
|
||||
// GET /api/user-overrides/{key} → data/user_overrides/{key}.json (IMP-52 u3)
|
||||
// PUT /api/user-overrides/{key} → partial-merge save (IMP-52 u4)
|
||||
// POST /api/connect → cel mirror (IMP-56 #90 u18)
|
||||
// POST /api/export → standalone HTML download (IMP-56 #90 u19)
|
||||
//
|
||||
// 환경 변수 (선택) :
|
||||
// DESIGN_AGENT_ROOT python pipeline 실행 cwd. default = D:/ad-hoc/kei/design_agent
|
||||
// CEL_PROJECT_ROOT cel astro dev repo root. default = D:/ad-hoc/cel
|
||||
// =============================================================================
|
||||
|
||||
function vitePluginPhaseZApi(): Plugin {
|
||||
const DESIGN_AGENT_ROOT =
|
||||
process.env.DESIGN_AGENT_ROOT || "D:\\ad-hoc\\kei\\design_agent";
|
||||
const CEL_PROJECT_ROOT =
|
||||
process.env.CEL_PROJECT_ROOT || "D:\\ad-hoc\\cel";
|
||||
const UPLOADS_DIR = path.join(DESIGN_AGENT_ROOT, "samples", "uploads");
|
||||
const RUNS_DIR = path.join(DESIGN_AGENT_ROOT, "data", "runs");
|
||||
|
||||
@@ -241,7 +766,17 @@ function vitePluginPhaseZApi(): Plugin {
|
||||
layout?: string;
|
||||
frames?: Record<string, string>; // unit_id → template_id
|
||||
zoneGeometries?: Record<string, { x: number; y: number; w: number; h: number }>; // zone_id → bbox (slide-body 내부 0~1)
|
||||
// IMP-08 B-3 : zone_id -> list of canonical section_id assignments
|
||||
// (e.g., "top": ["03-1-sub-1"]). Forwarded as --override-section-assignment.
|
||||
zoneSections?: Record<string, string[]>;
|
||||
};
|
||||
// IMP-43 (#72) u6 — optional PREV_RUN_ID to reuse Step 0/1/2/5/6
|
||||
// artifacts from a prior run and resume execution at Step 7.
|
||||
// Lives at the payload root (NOT under `overrides`) because the
|
||||
// backend u1 post-merge guard rejects most override axes when
|
||||
// --reuse-from is supplied. Absent / empty = full pipeline
|
||||
// (byte-identical to pre-u6 spawn).
|
||||
reuseFromRunId?: string;
|
||||
};
|
||||
try {
|
||||
payload = JSON.parse(body);
|
||||
@@ -253,7 +788,7 @@ function vitePluginPhaseZApi(): Plugin {
|
||||
return;
|
||||
}
|
||||
|
||||
const { filename, content, overrides } = payload;
|
||||
const { filename, content, overrides, reuseFromRunId } = payload;
|
||||
if (!filename || typeof content !== "string") {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
@@ -322,14 +857,44 @@ function vitePluginPhaseZApi(): Plugin {
|
||||
}
|
||||
}
|
||||
}
|
||||
// IMP-08 B-3 — zoneSections override forward to CLI.
|
||||
// Each entry becomes `--override-section-assignment ZONE=sid[,sid]`.
|
||||
// Empty arrays and non-string sids are filtered out so the backend
|
||||
// never receives bogus assignments from a partially-built UI state.
|
||||
if (overrides?.zoneSections && typeof overrides.zoneSections === "object") {
|
||||
for (const [zoneId, sids] of Object.entries(overrides.zoneSections)) {
|
||||
if (!Array.isArray(sids)) continue;
|
||||
const cleaned = sids.filter((s) => typeof s === "string" && s.trim());
|
||||
if (cleaned.length === 0) continue;
|
||||
cliArgs.push(
|
||||
"--override-section-assignment",
|
||||
`${zoneId}=${cleaned.join(",")}`
|
||||
);
|
||||
}
|
||||
}
|
||||
// IMP-43 (#72) u6 — --reuse-from <PREV_RUN_ID> forward. Backend
|
||||
// (u1) parses this flag, validates the snapshot, copies Step
|
||||
// 0/1/2/5/6 artifacts from data/runs/<PREV_RUN_ID>/phase_z2 into
|
||||
// the new run_dir, and resumes execution at Step 7. The post-merge
|
||||
// guard at the same site rejects --override-layout /
|
||||
// --override-zone-geometry / --override-section-assignment /
|
||||
// --override-image with axis-named fail-closed exit; only
|
||||
// --override-frame (above) is preserved. Truthy check excludes
|
||||
// empty string + undefined so an invalid argument never reaches
|
||||
// argparse.
|
||||
if (reuseFromRunId && typeof reuseFromRunId === "string") {
|
||||
cliArgs.push("--reuse-from", reuseFromRunId);
|
||||
}
|
||||
console.log(
|
||||
`[phase-z-api] spawn pipeline: run_id=${runId}, mdx=${mdxPath}, args=${JSON.stringify(cliArgs.slice(2))}`
|
||||
);
|
||||
const pythonExe = process.platform === "win32" ? "python.exe" : "python";
|
||||
// 2026-05-14 — env toggle forward (보고용 일회성).
|
||||
// PHASE_Z_ALLOW_RESTRUCTURE / PHASE_Z_ALLOW_REJECT : status 통과
|
||||
// PHASE_Z_MAX_RANK=32 : V4 fallback chain 의 max_rank 확대 (등록 frame 까지 검색)
|
||||
// 04-1 (all reject) / 05-2 (rank 1~3 미등록) 등 자동 매칭 가능.
|
||||
// 2026-05-21 — IMP-38 retire PHASE_Z_MAX_RANK env (never read by backend).
|
||||
// v4 fallback chain max_rank 는 templates/phase_z2/catalog/v4_fallback_policy.yaml 의
|
||||
// 정식 정책 (dynamic_usable_count_based) 으로 결정 — backend src/phase_z2_pipeline.py
|
||||
// 의 lookup_v4_match_with_fallback() 가 load_v4_fallback_policy() 로 적용.
|
||||
const proc = spawn(pythonExe, cliArgs, {
|
||||
cwd: DESIGN_AGENT_ROOT,
|
||||
shell: false,
|
||||
@@ -337,7 +902,6 @@ function vitePluginPhaseZApi(): Plugin {
|
||||
...process.env,
|
||||
PHASE_Z_ALLOW_RESTRUCTURE: "1",
|
||||
PHASE_Z_ALLOW_REJECT: "1",
|
||||
PHASE_Z_MAX_RANK: "32",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -445,6 +1009,40 @@ function vitePluginPhaseZApi(): Plugin {
|
||||
fs.createReadStream(previewPath).pipe(res);
|
||||
});
|
||||
|
||||
// ── GET / PUT /api/user-overrides/{key} → data/user_overrides/{key}.json ──
|
||||
// IMP-52 u3 (GET) + u4 (PUT) — MDX-stem keyed user overrides. Logic
|
||||
// lives in the pure helpers (handleGetUserOverrides / handlePutUserOverrides)
|
||||
// so vitest can exercise them without booting vite. Both handlers
|
||||
// return false when the HTTP method does not match, so they chain
|
||||
// cleanly: GET first, then PUT, then next() for everything else
|
||||
// (e.g., OPTIONS / preflight handled by upstream middleware).
|
||||
server.middlewares.use("/api/user-overrides", (req, res, next) => {
|
||||
if (handleGetUserOverrides(req, res, DESIGN_AGENT_ROOT)) return;
|
||||
if (handlePutUserOverrides(req, res, DESIGN_AGENT_ROOT)) return;
|
||||
next();
|
||||
});
|
||||
|
||||
// ── POST /api/connect → cel astro public/slides mirror ──
|
||||
// IMP-56 (#90) u18 — see handleConnectMirror docblock for body shape +
|
||||
// copy semantics. Logic lives in the pure helper so vitest can drive
|
||||
// it without booting vite.
|
||||
server.middlewares.use("/api/connect", (req, res, next) => {
|
||||
if (handleConnectMirror(req, res, DESIGN_AGENT_ROOT, CEL_PROJECT_ROOT)) return;
|
||||
next();
|
||||
});
|
||||
|
||||
// ── POST /api/export → standalone HTML download ──
|
||||
// IMP-56 (#90) u19 — see handleExportStandalone docblock for body
|
||||
// shape + inline-asset semantics. Logic lives in the pure helper
|
||||
// (handleExportStandalone + inlineAssetsAsDataUrls) so vitest can
|
||||
// drive it without booting vite. The response is raw text/html
|
||||
// (Content-Disposition: attachment); the u20 BottomActions wiring
|
||||
// will turn the response body into a Blob → a[download] click.
|
||||
server.middlewares.use("/api/export", (req, res, next) => {
|
||||
if (handleExportStandalone(req, res, DESIGN_AGENT_ROOT)) return;
|
||||
next();
|
||||
});
|
||||
|
||||
// ── GET /data/runs/{run_id}/{path} → {RUNS_DIR}/{run_id}/phase_z2/{path} ──
|
||||
server.middlewares.use("/data/runs", (req, res, next) => {
|
||||
if (req.method !== "GET") return next();
|
||||
|
||||
135
docs/architecture/DORMANT-TRIGGERS.yaml
Normal file
135
docs/architecture/DORMANT-TRIGGERS.yaml
Normal file
@@ -0,0 +1,135 @@
|
||||
# Dormant trigger registry (L3 layer — machine-readable).
|
||||
#
|
||||
# Purpose :
|
||||
# Closed-but-binding dormant backlog rows ("documented:dormant" /
|
||||
# "documented (deferred)") carry implicit "trigger-on-X" contracts.
|
||||
# L1 (human memory) + L2 (periodic INTEGRATION-AUDIT) are fragile / late.
|
||||
# This file is the single source of truth that scripts/check_dormant_triggers.py
|
||||
# reads to flag activation candidates on every orchestrator run.
|
||||
#
|
||||
# Schema (per entry) :
|
||||
# - issue : int # closed Gitea issue id (the dormant axis)
|
||||
# - title : string
|
||||
# - doc : string # repo-relative path to the dormant reference doc
|
||||
# - doc_evidence_lines : string # "start-end" line range citing the activation-gate text
|
||||
# - status : enum # documented:dormant | documented:deferred | documented:no-runtime | followup-linked
|
||||
# - followup_issue : int|null # set when an open issue already tracks the watch (then no checker watch needed)
|
||||
# - trigger
|
||||
# description : string
|
||||
# file_patterns : [glob] # working-tree paths checked against changed files
|
||||
# content_patterns : [regex] # python re patterns matched against changed-file contents
|
||||
# manual_evidence_required : bool # true → checker skips (human-only gate; e.g. User GO, sign-off, runtime regression analysis)
|
||||
# - on_trigger
|
||||
# action : enum # create_runtime_issue | reactivate_dormant | manual_review | note_only
|
||||
# template : string # suggested follow-up issue title (if action ≠ note_only)
|
||||
#
|
||||
# Guardrails :
|
||||
# - Checker is informational only (exit 0 always; orchestrator never blocks Stage 5 on alerts).
|
||||
# - manual_evidence_required: true entries do NOT auto-fire — they are noted for human review.
|
||||
# - followup_issue is set: the registry entry is note-only; no checker watch (the open issue tracks the axis).
|
||||
# - Out of scope for this registry : IMP-07 (documented:no-runtime — policy decline, reactivation = policy reopen, not a code trigger).
|
||||
|
||||
- issue: 16
|
||||
title: "IMP-16 U2 wiring (Phase Q U1 → Phase Z runtime)"
|
||||
doc: docs/architecture/IMP-16-U2-WIRING-DESIGN.md
|
||||
doc_evidence_lines: "21-25"
|
||||
status: documented:dormant
|
||||
followup_issue: null
|
||||
trigger:
|
||||
description: >-
|
||||
IMP-07 reverse-path actually lands runtime — a non-test module under src/
|
||||
introduces the reverse-path adapter (html_to_slide_mdx / edited_html_to_mdx /
|
||||
reverse_path). At that point IMP-16 U2 wiring (Step 1/2/14 surface use)
|
||||
becomes a live integration axis, not a paper design.
|
||||
file_patterns:
|
||||
- "src/**/*.py"
|
||||
content_patterns:
|
||||
- "html_to_slide_mdx"
|
||||
- "edited_html_to_mdx"
|
||||
- "reverse_path"
|
||||
manual_evidence_required: false
|
||||
on_trigger:
|
||||
action: create_runtime_issue
|
||||
template: "[IMP-16][P5][WIRING] Activate U2 reverse-path wiring against new IMP-07 adapter"
|
||||
|
||||
- issue: 17
|
||||
title: "IMP-17 AI repair fallback carve-out"
|
||||
doc: docs/architecture/IMP-17-CARVE-OUT.md
|
||||
doc_evidence_lines: "25-31"
|
||||
status: documented:dormant
|
||||
followup_issue: null
|
||||
trigger:
|
||||
description: >-
|
||||
3-condition AND gate: (1) explicit User GO for axis activation,
|
||||
(2) B4 frame_selection evidence integration complete (Step 9 evidence trace
|
||||
stabilised), (3) IMP-04 (catalog expansion to 32 frames) + IMP-05 (V4
|
||||
rank-2/3 fallback) live. All three required before the carve-out exits
|
||||
design-only state.
|
||||
file_patterns: []
|
||||
content_patterns: []
|
||||
manual_evidence_required: true
|
||||
on_trigger:
|
||||
action: manual_review
|
||||
template: "[IMP-17][P5][CARVE-OUT] Activate ai_adaptation_required fallback (3-cond gate cleared)"
|
||||
|
||||
- issue: 18
|
||||
title: "IMP-18 SVG coordinate pipeline gap report"
|
||||
doc: docs/architecture/IMP-18-SVG-GAP-REPORT.md
|
||||
doc_evidence_lines: "38-43"
|
||||
status: documented:dormant
|
||||
followup_issue: null
|
||||
trigger:
|
||||
description: >-
|
||||
An SVG-bearing partial lands under templates/phase_z2/ (families or frames)
|
||||
AND the partial declares slots consuming items[*].cx/cy/r + outer_r +
|
||||
viewbox_* (the prepare_venn_data return contract). IMP-04 frame_partials
|
||||
registration is the natural upstream.
|
||||
file_patterns:
|
||||
- "templates/phase_z2/families/*.html"
|
||||
- "templates/phase_z2/frames/*.html"
|
||||
content_patterns:
|
||||
- "<svg"
|
||||
- "viewBox"
|
||||
manual_evidence_required: false
|
||||
on_trigger:
|
||||
action: create_runtime_issue
|
||||
template: "[IMP-18][P5][SVG] Activate SVG coordinate pipeline for new partial"
|
||||
|
||||
- issue: 19
|
||||
title: "IMP-19 zone ratio reference (Phase O role-container pattern)"
|
||||
doc: docs/architecture/IMP-19-ZONE-RATIO-REFERENCE.md
|
||||
doc_evidence_lines: "83-90"
|
||||
status: documented:dormant
|
||||
followup_issue: null
|
||||
trigger:
|
||||
description: >-
|
||||
Phase Z Step 8 solver (min_height_first + content_weight) produces a
|
||||
verifiable regression that the Phase O role-container pattern would have
|
||||
handled correctly, AND the IMP-09 owner confirms the case is not
|
||||
addressable inside the Phase Z solver (visual_hints.min_height_px /
|
||||
content_weight.score adjustments insufficient). Requires failing-case MDX
|
||||
+ frame_contract trace + observed vs expected geometry.
|
||||
file_patterns: []
|
||||
content_patterns: []
|
||||
manual_evidence_required: true
|
||||
on_trigger:
|
||||
action: manual_review
|
||||
template: "[IMP-19][P5][ZONE-RATIO] Re-activate Phase O role-container pattern (IMP-09 sign-off attached)"
|
||||
|
||||
- issue: 20
|
||||
title: "IMP-20 frame contract validation reference"
|
||||
doc: docs/architecture/IMP-20-FRAME-CONTRACT-VALIDATION-REFERENCE.md
|
||||
doc_evidence_lines: "85-91"
|
||||
status: followup-linked
|
||||
followup_issue: 55
|
||||
trigger:
|
||||
description: >-
|
||||
§A5 3-cond AND gate (Step 10 partial frame-contract emit insufficient +
|
||||
evidence + IMP-04 sign-off). Watch surface already owned by open issue
|
||||
#55 — no checker watch installed here to avoid double-tracking.
|
||||
file_patterns: []
|
||||
content_patterns: []
|
||||
manual_evidence_required: true
|
||||
on_trigger:
|
||||
action: note_only
|
||||
template: "Tracked under open issue #55 — no new watch needed."
|
||||
84
docs/architecture/IMP-16-U2-WIRING-DESIGN.md
Normal file
84
docs/architecture/IMP-16-U2-WIRING-DESIGN.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# IMP-16-U2 — Phase Z verification wiring design (design-only)
|
||||
> **⚠️ STATUS UPDATE (2026-05-20, INTEGRATION-AUDIT-02)** — IMP-16 is reclassified
|
||||
> as `documented:dormant` and IMP-07 as `documented:no-runtime`. The 3 "Open items
|
||||
> deferred until IMP-07 lands" below remain dormant until IMP-07 reverse-path
|
||||
> actually lands runtime (no current plan).
|
||||
>
|
||||
> Resolution evidence: see `INTEGRATION-AUDIT-02-REPORT.md` Sections 3, 4, and 7
|
||||
> (final decision: `NEEDS_DOC_SYNC_FOLLOWUP`).
|
||||
>
|
||||
> Do NOT treat this design contract as actionable in current Phase Z runtime.
|
||||
|
||||
**Status**: design-only contract. **No runtime wiring lands in this issue.** All wiring is gated behind IMP-07 reverse-path activation (B-2 main). When IMP-07 lands, this doc becomes the binding contract for the Step 1 / 2 / 14 / 21 / 22 changes that consume the IMP-16-U1 surface in `src/phase_z2_verification_utils.py`.
|
||||
|
||||
**Source anchors**
|
||||
- IMP-16 backlog row — [`docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md`](PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md):67 (priority ↓ low, hard link IMP-07, source §3 H3 Reference Only).
|
||||
- IMP-07 backlog row — same doc line 51 (status `pending`).
|
||||
- 22-step pipeline anchor — [`PHASE-Z-PIPELINE-OVERVIEW.md`](PHASE-Z-PIPELINE-OVERVIEW.md) Steps 1 / 2 / 14 / 21 / 22.
|
||||
- U1 module — `src/phase_z2_verification_utils.py` (u1~u10 ports).
|
||||
- Phase Q reference H3 (Reference Only — do not import) — `src/content_verifier.py`.
|
||||
|
||||
## Gate (hard block — do not merge wiring before this clears)
|
||||
|
||||
- IMP-07 status MUST be `implemented` and `verified` before any code change listed below lands.
|
||||
- Repo grep `html_to_slide_mdx | edited_html_to_mdx | reverse_path` MUST return at least one runtime hit in a non-test module under `src/`.
|
||||
- The reverse-path entry point MUST emit (a) a normalized re-entry MDX string and (b) the upstream generated HTML string, both as deterministic outputs accessible to Step 2 and Step 14 callers.
|
||||
|
||||
## Per-step wiring contract
|
||||
|
||||
### Step 1 — MDX upload (re-entered MDX validation)
|
||||
- Caller : the reverse-path adapter introduced by IMP-07, immediately after it produces a re-entry MDX.
|
||||
- Surface used : u6 `split_into_sentences` (validate that the reverse-path MDX yields at least one sentence after meta-strip + bullet-marker strip).
|
||||
- Behavior : if `split_into_sentences(reentry_mdx)` returns an empty list, the reverse-path adapter MUST raise a deterministic input error before Step 2 starts. No silent fallback. No AI call. No content rewrite.
|
||||
- Trace : `debug.json["step01"]["reentry_sentence_count"]` (additive integer field).
|
||||
|
||||
### Step 2 — MDX normalize (text preservation cross-check)
|
||||
- Caller : `parse_mdx` / `align_sections_to_v4_granularity` post-normalize hook (added only when the input came through the IMP-07 reverse path; original-upload path is unchanged).
|
||||
- Surface used : u8 `verify_text_preservation(reentry_mdx, upstream_generated_html, area_name="reentry_mdx_vs_upstream_html")`.
|
||||
- Threshold : the U1 module default (`_TEXT_PRESERVATION_DEFAULT_THRESHOLD = 0.70`, ported verbatim from Phase Q). Do not redesign in U2.
|
||||
- Behavior : `VerificationResult.passed == False` → adapter aborts the re-entry with the result's `errors` list surfaced; auto pipeline does NOT silently continue. Per `feedback_auto_pipeline_first`, no `review_required` / `review_queue` is inserted — adapter abort is the deterministic outcome.
|
||||
- Trace : `debug.json["step02"]["reentry_text_preservation"] = {passed, score, area_name, missing_count}` (additive; missing sentences themselves NOT serialised, per privacy-by-default).
|
||||
|
||||
### Step 14 — Selenium visual runtime check (invented-text guard)
|
||||
- Caller : the `run_overflow_check` post-render path, ONLY when the run was triggered from the reverse-path re-entry. Original-upload path keeps current Step 14 behavior unchanged (this is NOT an enhancement of Step 14 image/table coverage — that axis belongs to IMP-15).
|
||||
- Surface used : u9 `detect_invented_text(reentry_mdx, final_html)` against the just-rendered `final.html`.
|
||||
- Behavior : the returned `list[str]` is purely *telemetry*. It does NOT change render outcome and does NOT change `compute_slide_status` (Step 20). The reverse-path may consult the list to decide whether to surface a warning at Step 22 — but auto pipeline does not gate on it (per `feedback_auto_pipeline_first` + AI-isolation contract).
|
||||
- Trace : `debug.json["step14"]["reentry_invented_text_fragments"] = list[str]` (additive; already truncated by u9's `_INVENTED_TEXT_TRUNCATE_LEN = 80`).
|
||||
|
||||
### Step 21 — Debug / trace recording (additive only)
|
||||
- Surface used : none (Step 21 consumes the additive fields written by Step 1 / 2 / 14 above).
|
||||
- Behavior : `write_debug_json` MUST treat the new fields as additive — no rename, no removal, no schema regression of existing keys. Missing fields (original-upload path) MUST be absent rather than null, so downstream consumers can distinguish "original upload" from "reverse-path re-entry".
|
||||
- Trace contract : the three additive fields above + a single new flag `debug.json["pipeline"]["reverse_path_reentry"] = bool` (the only schema field that gates the existence of the other three).
|
||||
|
||||
### Step 22 — User confirmation / export (surface, no AI)
|
||||
- Surface used : none directly (Step 22 is UI scope, currently CLI-only — see PHASE-Z-PIPELINE-OVERVIEW Step 22).
|
||||
- Behavior contract for whoever lands Step 22 UI : Step 22 MAY render the additive Step 2 / Step 14 fields read-only. No write-back. No AI call. No content rewrite.
|
||||
|
||||
## Redesigned frame-contract pattern dict (reserved, NOT delivered in U2)
|
||||
|
||||
- Phase Q `REQUIRED_PATTERNS` (Phase Q reference: `src/content_verifier.py:382`) is `body_bg / core / sidebar / footer` — these are Phase Q *area* names, not Phase Z entities. **Values are NOT reused.**
|
||||
- Phase Z replacement will be keyed on (frame_id, frame_slot_id) per the canonical hierarchy `Slide → Zone → Internal Region → Frame → Frame Slot → Content` ([`PHASE-Z-PIPELINE-OVERVIEW.md`](PHASE-Z-PIPELINE-OVERVIEW.md) §Operating Principles), and will be sourced from `templates/phase_z2/catalog/frame_contracts.yaml` (Step 0 / Step 10).
|
||||
- **Out of scope for IMP-16-U2.** This belongs to IMP-20 (H2 frame contract validation — same backlog doc line 71). U2 must not ship a pattern dict; U2 must not import or wrap Phase Q `verify_structure` / `verify_area` / `verify_all_areas`.
|
||||
|
||||
## Guardrails (binding)
|
||||
|
||||
- **AI isolation contract** — all wiring above is deterministic. No LLM / Kei / httpx / SSE call on any path. (per `feedback_ai_isolation_contract` + `PZ-1: AI=0 normal`.)
|
||||
- **No-hardcoding** — U2 ports the algorithm. The only literal values reused are the Phase Q H3 thresholds already lifted to named constants in u7 / u8 / u9. No sample-specific value (MDX 03 / 04 / 05) enters U2.
|
||||
- **No `src.content_verifier` import** — under any condition. The U1 module is the sole Phase Z surface.
|
||||
- **No FORBIDDEN_KEI_MEMOS / `generate_with_retry` port** — these are H4 / H5 archive markers and remain out of scope.
|
||||
- **Schema additive only** — debug.json keys listed above are new; no existing key is renamed, removed, or repurposed. (per `feedback_artifact_status_naming` — final.html is not the same axis as preservation / invented-text telemetry.)
|
||||
- **Spacing direction** — N/A for this axis (this is verification, not layout). No common CSS / padding / tolerance shrinking is introduced.
|
||||
- **Status semantics** — Step 20 `compute_slide_status` is NOT changed by U2. Preservation / invented-text fields are *telemetry*; they do not flip `PASS` → `RENDERED_WITH_VISUAL_REGRESSION` on their own.
|
||||
|
||||
## Rollback
|
||||
|
||||
- All changes are additive: the Step 1 input-error path, the Step 2 post-normalize hook, the Step 14 telemetry call, the four new `debug.json` keys.
|
||||
- Rollback = revert the IMP-07 reverse-path entry's call sites; no schema migration needed because the four debug.json keys are gated on `pipeline.reverse_path_reentry`.
|
||||
|
||||
## Open items deferred until IMP-07 lands
|
||||
|
||||
- Exact module path of the IMP-07 reverse-path adapter (TBD by IMP-07).
|
||||
- Whether Step 2's preservation cross-check needs a per-section variant or only a whole-MDX variant — depends on whether IMP-07 emits a single re-entry MDX or per-section MDX fragments.
|
||||
- Whether Step 14's invented-text telemetry should be emitted per `area_name` or only once globally — depends on whether IMP-07's reverse-path produces area-tagged HTML.
|
||||
|
||||
These are NOT resolved here. They are resolved at IMP-07 land time, in a follow-up update to this doc.
|
||||
55
docs/architecture/IMP-17-CARVE-OUT.md
Normal file
55
docs/architecture/IMP-17-CARVE-OUT.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# IMP-17 — AI repair fallback infrastructure (carve-out)
|
||||
|
||||
**Status**: carve-out infra **scaffolded under IMP-33** (issue #61, Stage 3 u1~u11). Normal-path AI calls = 0 (PZ-1) — `ai_fallback_enabled` flag default `False` in `src/config.py`. Runtime AI is reachable only via fallback path entry points; Step 12 entry is provisional-gated, Step 17 entry is structurally blocked behind IMP-34 + IMP-35.
|
||||
|
||||
**Source anchors**
|
||||
- IMP-17 backlog row — [`PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md`](PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md):68 (carve-out — normal path 밖, soft link IMP-04 + IMP-05).
|
||||
- INSIGHT-MAP §3 — [`PHASE-Q-INSIGHT-TO-22STEP-MAP.md`](PHASE-Q-INSIGHT-TO-22STEP-MAP.md) (G3 AI repair fallback infra registry row, normal path = no).
|
||||
- 22-step pipeline — [`PHASE-Z-PIPELINE-OVERVIEW.md`](PHASE-Z-PIPELINE-OVERVIEW.md) Step 12 (lines 280-287), Step 16 (lines 318-325), Step 17 (lines 326-333).
|
||||
- Pattern shape reference (Phase Q Archive — link-only, do not port) — `src/content_editor.py:21,318` (httpx + retry shape, imports `sse_utils`) + `src/sse_utils.py:16-50` (SSE token parser).
|
||||
- Route hint surface (current anchors) — `src/phase_z2_pipeline.py:570` (conceptual comment), `:572` (`_IMP05_ROUTE_HINTS` table), `:575` (`restructure` → `ai_adaptation_required`), `:580` (`_imp05_route_hint`), `:664` (candidate_evidence emission). Deterministic emission today; AI consumer deferred to IMP-17 (this carve-out). Anchor pin: `tests/orchestrator_unit/test_imp17_comment_anchor.py`.
|
||||
|
||||
## Carve-out boundary
|
||||
|
||||
### Allowed (fallback path only)
|
||||
- **Step 12**: when V4 emits `restructure` (route hint `ai_adaptation_required`) AND deterministic mapping cannot satisfy the frame contract, an AI proposal MAY be invoked to map `content_object` → `Internal Region` / `Frame Slot`. Output = placement proposal at content-object granularity. Frame selection, layout selection, zone topology remain deterministic.
|
||||
- **Step 16 / 17**: when retry router exhausts deterministic actions (zone_ratio_retry / layout_adjust / frame_reselect / details_popup_escalation / image_fit_candidate / frame_internal_fit_candidate) AND user-approved fallback budget remains, an AI proposal MAY be invoked. Output scope identical to Step 12 — content-object placement only.
|
||||
|
||||
### Forbidden (any path)
|
||||
- Normal-path AI calls (Step 12 deterministic mapper, all other steps).
|
||||
- MDX 원문 요약·삭제·재작성 (Phase Z spacing direction guardrail: never compress text).
|
||||
- HTML / CSS 직접 생성, frame contract 신설, layout / zone topology 결정 (Layer-A / Layer-B planning은 코드 영역).
|
||||
- 공통 padding / spacing / tolerance 축소 (PZ-4 — no silent shrink).
|
||||
- 신규 IMP ID 발급 (이 carve-out 은 IMP-17 슬롯에 영구 귀속).
|
||||
|
||||
## Activation gate (3-condition AND — all three required)
|
||||
|
||||
1. **User GO** — 명시적 axis activation 요청. carve-out 자체로는 코드 작성 트리거 안 됨.
|
||||
2. **B4 frame_selection evidence integration complete** — Step 9 frame_selection 의 evidence trace 가 안정화되어야 fallback proposal 이 어떤 frame contract 안에서 동작해야 하는지 식별 가능.
|
||||
3. **IMP-04 (catalog 확장) + IMP-05 (V4 fallback) live** — 카탈로그가 32 frame 으로 확장되고 V4 rank-2/3 fallback 이 활성화돼야 `ai_adaptation_required` 라우트가 실제 의미를 가짐 (현재는 dead-end route hint).
|
||||
|
||||
세 조건 중 하나라도 미충족이면 본 carve-out 은 design-only 상태로 잠겨 있다.
|
||||
|
||||
## Pattern shape reference (link-only, do not import)
|
||||
|
||||
Phase Q `content_editor.py` 는 **Archive Candidate** ([`PHASE-Q-AUDIT.md`](PHASE-Q-AUDIT.md):660-673) — 포팅 대상 아님. 모양만 참조한다:
|
||||
- httpx async streaming + retry 구조 — `src/content_editor.py:21,318` 라인 부근 (import + `stream_sse_tokens` 호출 site).
|
||||
- SSE token 파서 분리 모듈 — `src/sse_utils.py:16-50` (`stream_sse_tokens` 본체).
|
||||
- `EDITOR_PROMPT` (Kei persona) 및 Kei-API endpoint 는 **영구 단절**. 재사용 금지.
|
||||
|
||||
## AI 격리 + Kei persona 단절 contract
|
||||
|
||||
- AI 호출은 normal path 에 없다 (Phase Z 원칙, [memory `feedback_ai_isolation_contract`](../../README.md)).
|
||||
- 출력 단위는 항상 content_object / Internal Region / Frame Slot 또는 restructuring proposal — HTML 구조 / 레이아웃 / 프리셋 결정 X.
|
||||
- Phase Q 자산 (Kei persona prompts, Kei-API endpoint, persona retry semantics) 과 단절. Phase Z 의 fallback runtime 은 별도 prompt / endpoint 설계로 출발한다 (본 carve-out 활성 시).
|
||||
|
||||
## Runtime module surface (IMP-33 u1~u11 binding)
|
||||
|
||||
| Axis | Binding |
|
||||
|---|---|
|
||||
| Module path | `src/phase_z2_ai_fallback/` (locked by [`IMP-31-GATE-AUDIT.md`](IMP-31-GATE-AUDIT.md):31,50,56). |
|
||||
| Step 12 entry | `src.phase_z2_ai_fallback.step12.gather_step12_ai_repair_proposals` — IMP-30 provisional gate (`not_provisional` skip) AND reject gate (`design_reference_only_no_ai` skip) AND non-AI route catch-all run BEFORE `route_ai_fallback`. |
|
||||
| Step 17 entry | `src.phase_z2_ai_fallback.step17.gather_step17_ai_repair_proposals` — STRUCTURALLY BLOCKED. Every unit returns `skip_reason="step17_ai_blocked_imp_34_35_prerequisites_missing"`. Module does NOT import `route_ai_fallback` / `AiFallbackClient` / `anthropic`. |
|
||||
| Cascade order | `src.phase_z2_ai_fallback.step17.OVERFLOW_CASCADE_ORDER = (DETERMINISTIC, POPUP, AI_REPAIR, USER_OVERRIDE)` — single source of truth for Step 17 consumers. Aligns with line 16 of this doc. |
|
||||
| IMP-46 cache gate | `src.phase_z2_ai_fallback.cache.save_proposal(..., visual_check_passed, user_approved, auto_cache=False)` raises `AiFallbackCacheGateError` unless `visual_check_passed=True` AND (`user_approved=True` OR `auto_cache=True`). Persistent JSON backend at `data/frame_cache/{frame_id}/{signature_hash}.json` (u2); cache key = structural signature over 8 axes (u1+u4); read-side fingerprint invalidation via `read_proposal(..., fingerprints=...)` strict equality (u3); `--auto-cache` CLI flag + `settings.ai_fallback_auto_cache` (default `False`) bypasses ONLY the `user_approved` gate (u5); repo root tracked via `data/frame_cache/.gitkeep` with cached payloads git-ignored (u6). `read_proposal` returns `None` on missing / corrupt / fingerprint-mismatched entries — cache is a hint, never a hard dependency. |
|
||||
| AST isolation | `tests/phase_z2_ai_fallback/test_ast_isolation.py` parses every `*.py` under `src/phase_z2_ai_fallback/` and forbids Phase Q runtime / Kei client / `src.phase_z2_*` (non-fallback) imports. Whitelist = `src.config` + intra-package + stdlib + `anthropic` + `pydantic`. |
|
||||
64
docs/architecture/IMP-18-SVG-GAP-REPORT.md
Normal file
64
docs/architecture/IMP-18-SVG-GAP-REPORT.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# IMP-18 — Phase Z SVG Coordinate Pre-compute Gap Report
|
||||
|
||||
**Status**: documented (reference-only, dormant)
|
||||
**Scope**: doc-only. No runtime surface modified.
|
||||
**Related issue**: https://gitea.hmac.kr/Kyeongmin/C.E.L_Slide_test2/issues/18
|
||||
**Soft dependency**: IMP-04 (frame_partials registration) — IMP-18 activates only when a SVG-bearing partial lands under `templates/phase_z2/`.
|
||||
|
||||
---
|
||||
|
||||
## A1 — Phase R' source pattern (read-only reference)
|
||||
|
||||
Phase R' implements SVG coordinate pre-compute as a renderer hook. References (do **not** modify):
|
||||
|
||||
- `src/renderer.py:169-207` — `_preprocess_svg_data(block_type, block_data)` — mutates `block_data` with computed coordinates when `block_type` ∈ `SVG_BLOCKS`; warns and falls back on exception.
|
||||
- `src/renderer.py:175` — `SVG_BLOCKS = {"venn-diagram", "relationship"}` — exhaustive type allow-list.
|
||||
- `src/renderer.py:321` — call site inside `render_multi_page()` (`block_data = _preprocess_svg_data(block_type, block_data)`), right before `_resolve_template_path` lookup.
|
||||
- `src/svg_calculator.py:15-156` — five helpers:
|
||||
- L15 `calc_circle_positions(n, center_x, center_y, radius)` — 12 o'clock clockwise N-element layout.
|
||||
- L47 `calc_item_radius(n, base_radius=75.0)` — auto-shrink small-circle radius for crowding.
|
||||
- L59 `calc_orbit_radius(n, base_orbit=120.0)` — auto-expand orbit for crowding.
|
||||
- L70 `calc_outer_radius(n, orbit_radius, item_radius)` — outer enclosing circle, 40 px margin.
|
||||
- L77 `prepare_venn_data(items, center_label, center_sub, description, viewbox_width=600.0, viewbox_height=550.0)` — top-level entry; mutates `items[*].cx/cy/r` and returns `outer_r`/`center_x`/`center_y`/`viewbox_width`/`viewbox_height`.
|
||||
|
||||
## A2 — Phase Z partial SVG inventory (gap)
|
||||
|
||||
Phase Z active partials surface:
|
||||
|
||||
- `templates/phase_z2/families/*.html` — **11 contracted + 2 WIP untracked = 13 on disk** (contracted set = `templates/phase_z2/catalog/frame_contracts.yaml` top-level keys; WIP allowlist = [`templates/phase_z2/families/_WIP_FILES.md`](../../templates/phase_z2/families/_WIP_FILES.md), gated on Gitea #42 / #52 F-2 option (c)).
|
||||
- `templates/phase_z2/frames/*.html` — **2** files.
|
||||
- Total surface = **13 active partials (11 contracted families + 2 frames) + 2 WIP untracked families** (15 on disk; runtime matcher consumes the contracted set only).
|
||||
|
||||
SVG usage scan (evidence): `rg "<svg|viewBox" templates/phase_z2/` → **0 matches** (exit 1).
|
||||
|
||||
Closest geometric candidate is `templates/phase_z2/families/construction_goals_three_circle_intersection.html` (frame_id `1171281189`, "cycle-3way-intersection" intent), but it renders three intersecting circles via HTML/CSS — `border-radius:50%` + `linear-gradient` + `::before` outer ring — **not** SVG. The Figma source's six accent kanji circles, six side labels, three decorative rects, and three arcs are explicitly **NOT PROMOTED** at the partial header (compact MDX-mapped focus). No partial currently demands the pre-computed `items[*].cx/cy/r` contract.
|
||||
|
||||
## A3 — IMP-04 activation gate (soft dependency)
|
||||
|
||||
IMP-18 has no Phase Z runtime consumer today. Re-activation triggers:
|
||||
|
||||
1. IMP-04 (frame_partials registration) lands an SVG-bearing partial under `templates/phase_z2/` (e.g., a venn-diagram or relationship frame promoted from Figma).
|
||||
2. The partial declares slots that consume `items[*].cx/cy/r` + `outer_r` + `viewbox_*` (the `prepare_venn_data` return contract).
|
||||
|
||||
Until both conditions hold, IMP-18 stays dormant and this gap report is the sole deliverable.
|
||||
|
||||
## A4 — Phase R' guardrail (read-only lock)
|
||||
|
||||
Per `CLAUDE.md` Phase R' regression prevention rules and the Stage 1/2 exit reports:
|
||||
|
||||
- `src/renderer.py` — read-only. No edit to `_preprocess_svg_data` body, `SVG_BLOCKS` set, or `render_multi_page` call site.
|
||||
- `src/svg_calculator.py` — read-only. No edit to the five helpers or their public signatures.
|
||||
- `templates/phase_z2/families/*.html` (11 contracted + 2 WIP untracked = 13 on disk; WIP set = [`_WIP_FILES.md`](../../templates/phase_z2/families/_WIP_FILES.md)) + `templates/phase_z2/frames/*.html` (2) — no `<svg>` / `viewBox` insertion in IMP-18 scope. SVG-bearing partial onboarding is owned by IMP-04. The 2 WIP family templates are gated on Gitea #42 (promote-or-remove) and remain outside the runtime matcher set per #52 F-2 option (c).
|
||||
- F12 `construction_goals_three_circle_intersection.html` HTML/CSS → SVG migration is **out of scope** (separate post-IMP-04 issue).
|
||||
- No hardcoded SVG coordinates in Phase Z templates — when IMP-18 re-activates, coordinates must be derived from `svg_calculator` helpers (or equivalent forward-port into `phase_z2_renderer`), not hand-copied.
|
||||
|
||||
---
|
||||
|
||||
## Re-activation checklist (future)
|
||||
|
||||
When IMP-04 introduces the first SVG-bearing Phase Z partial:
|
||||
|
||||
- [ ] Identify partial(s) consuming `items[*].cx/cy/r` + `outer_r` + `viewbox_*`.
|
||||
- [ ] Decide port target — extend `phase_z2_renderer` with a `_preprocess_svg_data` analog, or reuse `src/svg_calculator.py` directly.
|
||||
- [ ] Keep Phase R' references untouched.
|
||||
- [ ] Add anchor SHA bump only if runtime source surface changes.
|
||||
97
docs/architecture/IMP-19-ZONE-RATIO-REFERENCE.md
Normal file
97
docs/architecture/IMP-19-ZONE-RATIO-REFERENCE.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# IMP-19 — Phase O/Q Zone Ratio Container Pattern Reference
|
||||
|
||||
**Status**: documented (reference-only, dormant)
|
||||
**Scope**: doc-only. No runtime surface modified.
|
||||
**Related issue**: https://gitea.hmac.kr/Kyeongmin/C.E.L_Slide_test2/issues/19
|
||||
**Soft dependency**: IMP-09 (Phase Z Step 8 zone-ratio solver) — IMP-19 stays dormant; activates only via the A5 gate.
|
||||
**Source axis**: INSIGHT-MAP §3 / §2.8 I4 — `renderer._group_blocks_by_area` pattern reference.
|
||||
|
||||
---
|
||||
|
||||
## A1 — Phase O/Q consumer pattern (read-only reference)
|
||||
|
||||
Phase O/Q implements role-based block grouping inside body-side zones at the renderer layer. References (do **not** modify):
|
||||
|
||||
- `src/renderer.py:210-295` — `_group_blocks_by_area(blocks, container_specs=None)` — `OrderedDict` grouping by `block["area"]`; when `container_specs` is supplied and `area ∈ {"body","left","right","hero","detail"}` enters the role-container branch (L230).
|
||||
- `src/renderer.py:234` — hardcoded `role_order = ["배경", "본심"]` — two-role role-loop axis (block-level container, **not** zone geometry).
|
||||
- `src/renderer.py:240-253` — topic_id-first match against `spec.topic_ids`, then fallback positional fill when topic_id match yields empty (L248-253).
|
||||
- `src/renderer.py:261-274` — inline-style injection: `height:{spec.height_px}px; overflow:visible; display:flex; flex-direction:column; gap:8px; font-size:{font_size}px; --spacing-inner:{padding}px; --font-body:{font_size/16}rem;`. `font_size` / `padding` are read at `:262-263` via `spec.block_constraints.get("font_size_px", 15.2)` / `.get("padding_px", 20)` — **renderer-side defaults**, not producer-emitted (see A2).
|
||||
- `src/renderer.py:277-279` — leftover (unassigned) blocks appended after role containers.
|
||||
- `src/renderer.py:283-291` — non-container branch: `len(block_list)==1` → single html, else `flex-direction:column` wrapper with `gap:var(--spacing-block); height:100%`.
|
||||
|
||||
Call sites:
|
||||
|
||||
- `src/renderer.py:352-353` — `render_multi_page()` — passes `layout_concept.get("_container_specs")` as `container_specs` argument (Phase O activation path).
|
||||
- `src/renderer.py:426` — `render_slide()` — invokes `_group_blocks_by_area(blocks_raw)` with **no** `container_specs` (legacy fallback / unit-test path).
|
||||
|
||||
Classification: block/role-level container injection at render time. **Not** Phase Z zone geometry.
|
||||
|
||||
## A2 — Phase O upstream producer (read-only reference)
|
||||
|
||||
`ContainerSpec` payloads consumed by A1 are produced upstream. References (do **not** modify):
|
||||
|
||||
- `src/space_allocator.py:445-586` — `build_containers_type_b(page_structure, slide_width=1280, slide_height=720, image_sizes=None)` — Phase X-B 유형 B container builder.
|
||||
- `src/space_allocator.py:462-468` — token load (`_load_design_tokens`) + `pad`, `header_h`, `gap_block`, `gap_small`, `inner_w` derivation.
|
||||
- `src/space_allocator.py:470-484` — role classification into `top_roles` / `bottom_roles` / `footer_role` by `info["zone"] ∈ {"top","bottom","bottom_left","bottom_right","footer"}`.
|
||||
- `src/space_allocator.py:486-503` — usable height calculation against `slide_body_top=65` + `slide_body_h=590` with optional `footer_role` carve-out.
|
||||
- `src/space_allocator.py:505-510` — `zone_overhead = zone_count * zone_title_h(28) + (zone_count-1) * zone_gap(16)`.
|
||||
- `src/space_allocator.py:512-520` — `top_h` / `bottom_h` split by `weight` ratio over `usable_h`.
|
||||
- `src/space_allocator.py:522-537` — image-aware top-zone width split (`img_w = min(top_h*ratio, inner_w*0.45)`).
|
||||
- `src/space_allocator.py:541-556` — top-role `ContainerSpec` emission: `block_constraints = {"img_width_px": img_w, "img_height_px": top_h if img_w>0 else 0, "has_image": img_w>0}` — image-aware keys only.
|
||||
- `src/space_allocator.py:562-574` — bottom-role `ContainerSpec` emission: `block_constraints = {}` (empty; no producer keys).
|
||||
- `src/space_allocator.py:577-588` — footer-role `ContainerSpec` emission: `block_constraints = {}` (empty; `max_height_cost="low"` literal).
|
||||
|
||||
Producer classification: block-level role container with `height_px` + `width_px` + `block_constraints` containing **only** image-aware keys (`img_width_px`, `img_height_px`, `has_image`) on top role, **empty** on bottom/footer roles. **Not** zone-level ratio geometry. `font_size_px` / `padding_px` are **renderer-side defaults** (consumed via `.get(..., 15.2)` / `.get(..., 20)` at `src/renderer.py:262-263`), **not** producer output.
|
||||
|
||||
## A3 — Phase Z Step 8 solver delta (IMP-09 owned)
|
||||
|
||||
The active Phase Z zone-ratio solver lives in `src/phase_z2_pipeline.py` and is **IMP-09 owned**. IMP-19 does **not** absorb, replace, or amend this surface. References (do **not** modify):
|
||||
|
||||
- `src/phase_z2_pipeline.py:794-853` — `compute_zone_layout(zones_data, total_height=SLIDE_BODY_HEIGHT, gap=GRID_GAP)` — row-axis solver. Algorithm = `min_height_first + content_weight_distribution`: Step 1 reserves per-zone `min_height_px` from frame_contract `visual_hints` (with proportional scale-down on overflow), Step 2 distributes the remaining vertical budget by `content_weight.score`, Step 3 absorbs rounding residual into the last zone. Returns `heights_px` + `ratios` + reasoning trace.
|
||||
- `src/phase_z2_pipeline.py:924-972` — `compute_zone_layout_cols(zones_data, total_width=SLIDE_BODY_WIDTH, gap=GRID_GAP)` — col-axis solver. Algorithm = `content_weight_distribution_cols` (weight-only; no `min_width_px` contract exists in `frame_contracts.yaml` per IMP-09 verification). Zero-weight guard splits evenly across `n` zones. Returns `widths_px` + `width_ratios`.
|
||||
- `src/phase_z2_pipeline.py:1125-1452` — topology dispatch surface:
|
||||
- `:1125-1152` `_build_rows_dynamic` — `topology=="rows"` (horizontal-2): dynamic row heights via `compute_zone_layout`, static fr column widths via `_parse_fr_string`.
|
||||
- `:1155+` `_build_grid_dynamic_2d` — `topology ∈ {T, inverted-T, side-T-left, side-T-right, 2x2}`: per-row + per-col virtual-zone aggregation → row solver + col solver → `2d_dynamic_aggregated` computation.
|
||||
- `:1444-1452` dynamic-branch dispatcher: `rows` / `cols` / 2-D / default fr.
|
||||
- `:1380-1434` user-override geometry branch (`computation == "user_override_geometry"`) — preserves raw override percentages without invoking the weight solver.
|
||||
|
||||
Delta vs Phase O/Q (A1+A2):
|
||||
|
||||
| Axis | Phase O/Q (`renderer._group_blocks_by_area`) | Phase Z Step 8 (`compute_zone_layout` + cols) |
|
||||
|---|---|---|
|
||||
| Geometry level | block/role inside one zone | zone-level row/col tracks across slide_body |
|
||||
| Width source | role x-anchor + `top_h` image carve-out | content_weight share (cols) / fr-string (rows) |
|
||||
| Height source | producer `ContainerSpec.height_px` injection | min_height_first + content_weight remainder |
|
||||
| Role axis | hardcoded `["배경","본심"]` (L234) | no role concept — zone position + frame contract |
|
||||
| Min-height source | none (producer-emitted absolute px) | frame_contract `visual_hints.min_height_px` |
|
||||
| Topology dispatch | none (single role-loop) | rows / cols / T / inverted-T / side-T-* / 2x2 / single |
|
||||
| Inline-style injection | yes (height + font_size + spacing-inner) | no (geometry-only; styling handled downstream) |
|
||||
|
||||
Conclusion: Phase O role-container pattern and Phase Z zone-ratio solver operate at **different abstraction layers** (block-in-zone vs zone-in-slide). They are **not** drop-in interchangeable; IMP-19 surfaces this delta only for design-pattern comparison.
|
||||
|
||||
## A4 — IMP-09 boundary statement (soft-link)
|
||||
|
||||
IMP-19 is `soft link: IMP-09`. Ownership separation:
|
||||
|
||||
- **IMP-09 owns**: every algorithmic change to `compute_zone_layout`, `compute_zone_layout_cols`, the topology dispatch surface (`_build_rows_dynamic` / `_build_cols_dynamic` / `_build_grid_dynamic_2d` / `_build_fr_default`), and the frame_contract `visual_hints.min_height_px` contract.
|
||||
- **IMP-19 owns**: reference-only documentation of the Phase O/Q `_group_blocks_by_area` + `build_containers_type_b` pattern (A1 + A2) and the Phase Z solver delta narrative (A3).
|
||||
- **No bidirectional code flow**: IMP-19 does not move Phase O code into Phase Z, and IMP-09 does not consume Phase O `ContainerSpec` payloads. The two solvers remain isolated.
|
||||
- **Reference direction is one-way**: this document points read-only at `src/renderer.py`, `src/space_allocator.py`, and `src/phase_z2_pipeline.py`. No reverse pointer is required in those source files.
|
||||
|
||||
If IMP-09 alters the Phase Z solver signature, A3 must be re-verified (file:line refs); the boundary statement itself does not change.
|
||||
|
||||
## A5 — Re-activation gate + guardrails
|
||||
|
||||
IMP-19 is `documented` (dormant). Re-activation requires **all** of the following gate conditions:
|
||||
|
||||
1. **Trigger**: Phase Z Step 8 produces a verifiable case where the active solver (`min_height_first + content_weight`) yields geometry that the Phase O role-container pattern would have handled correctly — i.e., a regression that maps cleanly to the block-level role abstraction, not the zone-level abstraction.
|
||||
2. **Evidence requirement**: failing-case MDX + frame_contract trace + observed geometry vs expected geometry, attached to a new issue or this issue's reopened state.
|
||||
3. **IMP-09 sign-off**: the IMP-09 owner confirms the failing case is **not** addressable inside the Phase Z solver (e.g., adding `visual_hints.min_height_px` or adjusting `content_weight.score` does not resolve it).
|
||||
4. **Scope re-lock**: the new axis is scope-locked under a fresh implementation issue (not silently reopened in IMP-19) so the soft-link contract is preserved.
|
||||
|
||||
Guardrails (preserved from Stage 1 + Stage 2):
|
||||
|
||||
- **GR1 — No runtime integration**: this document does not authorize merging Phase O role-container code into the Phase Z runtime. Any such integration requires a new scope-locked issue with its own Stage 1/2 review.
|
||||
- **GR2 — Phase O no-regression**: Phase O containers (`render_multi_page` path with `_container_specs`) must not re-enter the Phase Z render path; the `render_slide` legacy fallback at `src/renderer.py:426` (no `container_specs`) remains the unit-test entry.
|
||||
- **GR3 — Reference extract stays in `docs/architecture/`**: never under `src/`. No code body copying; file:line refs only.
|
||||
- **GR4 — Soft-link integrity**: IMP-19 status remains `documented` until the A5 gate fires. The IMP-09 backlog entry carries a back-reference (see u3); IMP-19 carries the forward reference here.
|
||||
109
docs/architecture/IMP-20-FRAME-CONTRACT-VALIDATION-REFERENCE.md
Normal file
109
docs/architecture/IMP-20-FRAME-CONTRACT-VALIDATION-REFERENCE.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# IMP-20 — Phase Q `content_verifier` Frame Contract Validation Pattern Reference
|
||||
|
||||
**Status**: documented (reference-only, dormant)
|
||||
**Scope**: doc-only. No runtime surface modified.
|
||||
**Related issue**: https://gitea.hmac.kr/Kyeongmin/C.E.L_Slide_test2/issues/20
|
||||
**Soft dependency**: IMP-04 (extended catalog application) — IMP-20 stays dormant; activates only via the A5 gate.
|
||||
**Source axis**: INSIGHT-MAP §3 / §2.7 H2 — `content_verifier.verify_structure` pattern reference.
|
||||
|
||||
---
|
||||
|
||||
## A1 — Phase Q consumer pattern (read-only reference)
|
||||
|
||||
Phase Q implements area-level required-pattern validation at the content-verifier layer. References (do **not** modify):
|
||||
|
||||
- `src/content_verifier.py:382-392` — `REQUIRED_PATTERNS: dict[str, list[str]]` — top-level pattern dictionary keyed by area name (`body_bg`, `body_core`, `sidebar`, `footer`). Values verified: `body_bg=[]`, `body_core=["key-msg"]`, `sidebar=["padding-left", "text-indent"]`, `footer=[]`. Phase T (`L379-381` comment) removed the `overflow:hidden` requirement to reconcile with the Phase T prompt's "overflow:hidden 금지" directive — that no-regression boundary is preserved.
|
||||
- `src/content_verifier.py:395-448` — `verify_structure(generated_html, area_name, has_image=False, font_hierarchy=None) → VerificationResult` — the substring-check + OR + tolerance core logic.
|
||||
- `:405-412` — substring presence loop. Each pattern string is split on `|` (`pattern.split("|")` at L410) and treated as an OR alternation: any alternative present passes the pattern. Missing alternatives are appended to a `missing` list.
|
||||
- `:414-416` — `has_image` branch. When `has_image=True` and `area_name == "body_core"`, an additional implicit requirement is enforced: `"slide-img-"` must appear in `generated_html`. Missing image marker is reported as `"slide-img-* (이미지 태그)"` in `missing`.
|
||||
- `:418-436` — `font_hierarchy` branch. When supplied, area-name → max-font lookup uses a fixed `role_font_map = {"body_bg":bg/11, "body_core":core/12, "sidebar":sidebar/10, "footer":core/12}`. HTML `font-size:\s*(\d+(?:\.\d+)?)\s*px` matches are extracted via regex (L430); each measured size > `max_font + 1` (1px tolerance at L433) emits a `font_warnings` entry. Warnings do **not** flip `passed`.
|
||||
- `:438-447` — result construction. `passed = (len(missing) == 0)`. `score = 1.0` on pass else `1.0 - len(missing) / max(1, len(patterns))` (continuous degradation; `max(1, …)` guards empty-pattern division by zero). Errors prefixed `"필수 패턴 누락: "`. Warnings carry font hierarchy violations only.
|
||||
- `src/content_verifier.py:455-487` — `verify_area(original_text, generated_html, area_name, has_image=False) → VerificationResult` — composes L1 (`verify_text_preservation`) + L2 (`verify_no_forbidden_content`) + L3 (`verify_structure`) at L462-466. `verify_structure` call at L465 passes `has_image` but **not** `font_hierarchy` (font_hierarchy is unused inside `verify_area`).
|
||||
- `src/content_verifier.py:490-529` — `verify_all_areas(generated, area_texts, has_image_areas=None)` — area dispatch fan-out. `body_html` is split into `body_bg` + `body_core` (L510-519); `body_core` is the **only** branch that propagates `has_image=("body_core" in has_image_areas)` to `verify_area` (L518). `sidebar_html` (L521-525) and `footer_html` (L527-531) call `verify_area` with default `has_image=False`.
|
||||
|
||||
Classification: area-level (Phase Q HTML area axis) required-pattern validation at content-verifier time. **Not** Phase Z frame_id × sub_zone contract validation.
|
||||
|
||||
## A2 — Phase Q `REQUIRED_PATTERNS` shape (read-only reference)
|
||||
|
||||
The Phase Q pattern-dict shape — **values are Phase Q-specific and excluded from reuse; only the shape is Phase Z design input.**
|
||||
|
||||
| Axis | Phase Q shape | Where observed |
|
||||
|---|---|---|
|
||||
| Key axis | area name (string) | `src/content_verifier.py:382` keys: `body_bg` / `body_core` / `sidebar` / `footer` |
|
||||
| Value type | `list[str]` of substring patterns | `src/content_verifier.py:383-391` |
|
||||
| Alternation semantics | `"a\|b"` → OR (any alt passes) via `pattern.split("|")` | `src/content_verifier.py:410` |
|
||||
| Image-conditional branch | `has_image=True` ∧ `area_name=="body_core"` → implicit `"slide-img-"` requirement | `src/content_verifier.py:414-416` |
|
||||
| Font hierarchy tolerance | 1px (`fs > max_font + 1`); area-name → max-font fixed lookup | `src/content_verifier.py:433`, `:421-426` |
|
||||
| Pass/score rule | `passed = (missing == [])`; score = continuous degradation `1.0 - len(missing)/max(1, len(patterns))` | `src/content_verifier.py:438`, `:445` |
|
||||
| Empty-pattern handling | `max(1, len(patterns))` guards divide-by-zero; empty pattern list always passes | `src/content_verifier.py:445`, `:382-383` (`body_bg=[]`) |
|
||||
|
||||
Shape-only carry-over candidates for Phase Z design (see A3 in u2):
|
||||
|
||||
- `dict[key]→list[pattern]` indirection.
|
||||
- OR via in-string `|` separator (low-ceremony alternation).
|
||||
- Conditional implicit requirement injected by external context flag (here `has_image`; in Phase Z potentially `accepted_content_types` per sub_zone).
|
||||
- Continuous score degradation rather than binary pass/fail (downstream consumers can threshold).
|
||||
- Separate `errors` (block) vs `warnings` (advisory) lanes — font hierarchy lives in warnings, not errors.
|
||||
|
||||
Values that **must not** carry into Phase Z: the literal strings `"key-msg"`, `"padding-left"`, `"text-indent"`, `"slide-img-"`, and the area names `body_bg` / `body_core` / `sidebar` / `footer` themselves — these are Phase Q area-HTML idioms, not Phase Z frame/slot idioms.
|
||||
|
||||
## A3 — Phase Z target pattern dict (design input, not yet active)
|
||||
|
||||
The Phase Z-native target axis = **frame_id × sub_zone** pattern dict, aligned with `templates/phase_z2/catalog/frame_contracts.yaml`. References (do **not** modify):
|
||||
|
||||
- `templates/phase_z2/catalog/frame_contracts.yaml:21` `three_parallel_requirements` (F13, 3 sub_zones), `:77` `process_product_two_way` (F29, 2 sub_zones × strict 3 cardinality), `:128` `bim_issues_quadrant_four` (F16, 4 sub_zones), `:189` `three_persona_benefits` (F14, 3 sub_zones), `:253` `construction_goals_three_circle_intersection` (F12, 3+1 sub_zones — `intersection` is `min:0,max:1`), `:323` `construction_bim_three_usage` (F11, 3 sub_zones), `:391` `bim_dx_comparison_table` (F18, 2 header + 1 `rows` with `min:1,max:12`), `:456` `dx_sw_necessity_three_perspectives` (F20, 3 sub_zones), `:520` `info_management_what_how_when` (F8, 3 sub_zones), `:580` `sw_reality_three_emphasis` (F28, 3 sub_zones), `:637` `bim_current_problems_paired` (F17, 8 sub_zones — row × side 2-axis).
|
||||
- All 11 contracts carry `accepted_content_types` + `sub_zones`; field `density_envelope` is absent across the catalog (verified `grep -c "density_envelope" templates/phase_z2/catalog/frame_contracts.yaml` = 0).
|
||||
- `src/phase_z2_mapper.py:49-57` `load_frame_contracts` / `get_contract` — direct dict lookup against the 11 entries above.
|
||||
- `src/phase_z2_pipeline.py:3776-3805` Step 10 emit — currently surfaces `frame_id` / `family` / `source_shape` / `cardinality` / `visual_hints` / `accepted_content_types` / `sub_zones` / `payload_builder` / `payload_builder_options` to `step10_frame_contract.json` with `step_status="partial"`. No pattern-dict assertion runs against this payload yet.
|
||||
|
||||
Abstraction-mismatch table (Phase Q area-level vs Phase Z frame/slot-level):
|
||||
|
||||
| Axis | Phase Q (A1+A2) | Phase Z target (A3) |
|
||||
|---|---|---|
|
||||
| Key | area name (`body_bg`/`body_core`/`sidebar`/`footer`) | `(frame_id, sub_zone_id)` tuple — e.g. `(1171281190, "pillar_1")` |
|
||||
| Cardinality of keys | 4 fixed area names | open over 11 contracts × N sub_zones (3+2+4+3+4+3+3+3+3+3+8 = 39 sub_zones in current catalog) |
|
||||
| Value semantics | substring presence (HTML-string match) | candidates: substring presence and/or contract-field assertion (`cardinality.strict` / `accepts` membership / `partial_target_path` resolution) |
|
||||
| Conditional branch input | `has_image` external flag | `accepted_content_types` per sub_zone (catalog-driven, not external flag) |
|
||||
| Tolerance | 1px on font-size (single axis) | candidates: font-size 1px tolerance carried over **or** replaced by `visual_hints.min_height_px` envelope check |
|
||||
| Validation timing | post-render HTML (`generated_html` string) | post Step 18 final.html (mirrors Phase Q timing) — Step 12 light_edit/restructure proposal is excluded (proposal is upstream of render) |
|
||||
| Result lanes | `errors` (block) + `warnings` (advisory) | preserved as-is from Phase Q shape (continuous score; separate font-hierarchy warnings) |
|
||||
|
||||
Classification: Phase Q area axis ⇄ Phase Z frame/slot axis are **not** drop-in compatible. The shape (dict indirection + OR alternation + tolerance + conditional implicit-requirement + continuous score) is the only portable element; every value (key strings, area names, literal patterns) is Phase Q-local.
|
||||
|
||||
## A4 — IMP-04 soft-link boundary (catalog vs validation ownership)
|
||||
|
||||
IMP-20 is `soft link: IMP-04` per the backlog (`docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md:71`). Ownership separation:
|
||||
|
||||
- **IMP-04 owns**: every `frame_contracts.yaml` entry — addition / removal / `accepted_content_types` change / `sub_zones` schema change / `cardinality` change / `visual_hints` change. `templates/phase_z2/catalog/frame_contracts.yaml` is the IMP-04 source of truth.
|
||||
- **IMP-20 owns**: reference-only documentation of the Phase Q pattern-dict shape (A1 + A2) and the Phase Z target axis design narrative (A3). No catalog edits, no Step 10 promotion.
|
||||
- **Coupling direction**: **one-way** read. A Phase Z pattern dict (if/when activated through the A5 gate) consumes `frame_contracts.yaml` as input. It does **not** publish back into the catalog. IMP-04 is unaware of IMP-20.
|
||||
- **No bidirectional code flow**: IMP-20 does not move Phase Q `content_verifier.py` code into Phase Z, and IMP-04 does not consume `REQUIRED_PATTERNS`. The two surfaces remain isolated.
|
||||
- **Reference direction is one-way**: this document points read-only at `src/content_verifier.py`, `src/phase_z2_mapper.py`, `src/phase_z2_pipeline.py`, and `templates/phase_z2/catalog/frame_contracts.yaml`. No reverse pointer is required in those source files.
|
||||
|
||||
If IMP-04 alters the catalog schema (e.g. adds `density_envelope` or renames `sub_zones`), A3 must be re-verified (key axis and conditional-branch row in particular). The boundary statement itself does not change.
|
||||
|
||||
## A5 — Re-activation gate + guardrails
|
||||
|
||||
IMP-20 is `documented` (dormant). Re-activation requires **all** of the following gate conditions (3-cond AND):
|
||||
|
||||
1. **Trigger**: Phase Z Step 10 produces a verifiable case where the partial frame-contract emit alone is insufficient — i.e., a final.html regression that a frame_id × sub_zone pattern dict would have caught (missing slot marker, contract field violation, font-hierarchy breach against a sub_zone-resolved max). The trigger must be a regression that maps cleanly to the frame/slot axis, **not** to a higher layer (composition planning, content adapter, render-time CSS).
|
||||
2. **Evidence requirement**: failing-case MDX + `step10_frame_contract.json` trace + final.html excerpt with the slot path that should have asserted, attached to a new issue or this issue's reopened state.
|
||||
3. **IMP-04 sign-off**: the IMP-04 owner confirms the failing case is **not** addressable inside the catalog (e.g. tightening `cardinality` or `accepted_content_types` does not resolve it) — only then is a Phase Z-native pattern dict justified.
|
||||
|
||||
Design questions resolved in this document (revisit if the gate fires):
|
||||
|
||||
- **Q1 — Key granularity**: `(frame_id, sub_zone_id)`. Frame-only granularity is insufficient because contracts with `sub_zones` of differing `accepts` (e.g. F29 `process_column` accepts `[text_block, transform_table]` vs `product_column` accepts `[text_block]`) require slot-level differentiation.
|
||||
- **Q2 — Value type**: hybrid — substring patterns (Phase Q parity) **plus** contract-field assertions (`cardinality.strict` / `accepts` membership / `partial_target_path` resolved in DOM) **plus** numeric tolerance (carried from font-hierarchy 1px). Three lanes preserved separately so each can fail/pass independently.
|
||||
- **Q3 — Validation timing**: post Step 18 final.html **only**. Step 12 light_edit/restructure proposal is upstream of render and exposes no HTML for substring assertion; running the dict there would either fire false negatives (no DOM yet) or duplicate Step 18 work.
|
||||
- **Q4 — Font-hierarchy carry-over**: replaced — Phase Q's `role_font_map` fixed dict (area → max-font) is Phase Q-local. The Phase Z equivalent reads from `frame_contracts.yaml` `visual_hints` (`min_height_px` already present; a future `max_font_px` field would live in `visual_hints` and is IMP-04-owned). 1px tolerance shape is portable; the lookup source is replaced.
|
||||
|
||||
Guardrails (preserved from Stage 1 + Stage 2):
|
||||
|
||||
- **GR1 — Shape-only reference**: no Phase Q `REQUIRED_PATTERNS` value (`"key-msg"`, `"padding-left"`, `"text-indent"`, `"slide-img-"`) or area name (`body_bg`/`body_core`/`sidebar`/`footer`) may appear in any Phase Z pattern dict activation.
|
||||
- **GR2 — Phase Q no-regression**: `src/content_verifier.py:382-392` `REQUIRED_PATTERNS` is no-touch. The Phase T `L379-381` comment (overflow:hidden removed) remains the no-regression boundary; any Phase Z dict design must not re-introduce removed patterns into Phase Q's surface.
|
||||
- **GR3 — Phase Z dict is Phase Z-owned**: no `import` of `content_verifier.REQUIRED_PATTERNS` from Phase Z code. The two pattern dicts coexist without symbol sharing.
|
||||
- **GR4 — IMP-04 soft-link one-way**: per § A4. Activating IMP-20 must not block on or modify IMP-04; the catalog is read-only input.
|
||||
- **PZ-1 — AI isolation contract**: pattern dict is code/spec, not AI-generated content. No Kei rewrite, no LLM proposal of pattern values (`feedback_ai_isolation_contract`).
|
||||
- **RULE 13 — Anchor sync**: any future activation must update backlog (`PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md`), status board (`PHASE-Z-PIPELINE-STATUS-BOARD.md`), and INSIGHT-MAP (`PHASE-Q-INSIGHT-TO-22STEP-MAP.md`) in the same commit.
|
||||
|
||||
If IMP-04 alters the catalog schema or `src/content_verifier.py` is rewritten upstream, A1–A3 must be re-verified (file:line refs); the A5 gate itself does not change.
|
||||
59
docs/architecture/IMP-31-GATE-AUDIT.md
Normal file
59
docs/architecture/IMP-31-GATE-AUDIT.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# IMP-31 — AI-assisted frame-aware adaptation activation gate audit
|
||||
|
||||
**Status**: design-only audit. IMP-31 (#40) = IMP-17 carve-out activation tracking issue. No new design slot. No runtime AI code lands until the 3-condition AND gate clears.
|
||||
|
||||
**Source**
|
||||
- Gitea issue [#40](https://gitea.hmac.kr/Kyeongmin/C.E.L_Slide_test2/issues/40) IMP-31 — AI-assisted frame-aware adaptation (restructure / reject routes).
|
||||
- Carve-out boundary spec: [`IMP-17-CARVE-OUT.md`](IMP-17-CARVE-OUT.md) (allowed / forbidden / activation gate).
|
||||
- Backlog row: [`PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md`](PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md):68 (IMP-17 — carve-out, normal path 밖, soft link IMP-04 + IMP-05).
|
||||
- Stage 1 / Stage 2 exit reports: `.orchestrator/issues/40_stage_problem-review_exit.md` (Stage 1 binding contract).
|
||||
|
||||
## Issue-body anchor drift (axis C1)
|
||||
|
||||
Issue body cites `src/phase_z2_pipeline.py:452` for IMP-05 L5 `_imp05_route_hint()`. Current anchor surface (commit `1efbf67`):
|
||||
|
||||
- `:570` — conceptual comment ("restructure → AI-assisted frame-aware adaptation (deferred to IMP-17 …)").
|
||||
- `:572` — `_IMP05_ROUTE_HINTS: dict[str, str] = {` declaration.
|
||||
- `:575` — `"restructure": "ai_adaptation_required"` entry.
|
||||
- `:580` — `def _imp05_route_hint(label: Optional[str]) -> Optional[str]:`.
|
||||
- `:664` — `"route_hint": _imp05_route_hint(match.label)` candidate_evidence emission.
|
||||
|
||||
Anchor pin: `tests/orchestrator_unit/test_imp17_comment_anchor.py`. Synced in [`IMP-17-CARVE-OUT.md`](IMP-17-CARVE-OUT.md):10 (Stage 3 u1).
|
||||
|
||||
## 3-condition AND gate state (this cycle)
|
||||
|
||||
| # | Condition | State | Evidence |
|
||||
|---|---|---|---|
|
||||
| 1 | User GO — explicit activation request | **NOT CLEAR** | No axis activation directive in #40. Stage 1 root_cause: runtime consumer = 0. |
|
||||
| 2 | B4 frame_selection evidence integration complete | **NOT CLEAR** (⚠ partial) | [`PHASE-Z-PIPELINE-STATUS-BOARD.md`](PHASE-Z-PIPELINE-STATUS-BOARD.md):48 Step 9 ⚠ partial; :82 "B4 frame_selection 의 V4 evidence 미통합"; :126 (j) ❌ pending. |
|
||||
| 3 | IMP-04 catalog expansion + IMP-05 V4 fallback live | **AMBIGUOUS** | `templates/phase_z2/catalog/frame_contracts.yaml` = 11 `template_id:` entries vs 32 target. IMP-05 V4 rank-2/3 fallback selector logic live, but catalog coverage gates real semantics. |
|
||||
|
||||
**Verdict**: gate **NOT CLEAR**. Runtime AI adaptation remains gated. `src/phase_z2_ai_fallback/` = **scaffolded under IMP-33** (#61, Stage 3 u1~u11); module created, but `settings.ai_fallback_enabled` defaults to `False` (u1) so normal-path AI call count remains 0 (PZ-1). Runtime engagement still requires the 3-condition AND gate above.
|
||||
|
||||
## Issue-body axis verdict
|
||||
|
||||
| Axis | Issue-body line | Verdict | Binding boundary |
|
||||
|---|---|---|---|
|
||||
| A1 | restructure → ai_adaptation_required actual adaptation route | **gate-blocked** | Allowed only inside [`IMP-17-CARVE-OUT.md`](IMP-17-CARVE-OUT.md) Step 12 fallback path; runtime AI consumer not added this cycle. |
|
||||
| A2 | reject → design_reference_only | **gate-blocked + frontend ownership** | Reject route = design reference only. Frontend zone-level override remains IMP-29 scope ([`PHASE-Z-PIPELINE-OVERVIEW.md`](PHASE-Z-PIPELINE-OVERVIEW.md) Step 12). |
|
||||
| A3 | AI call provider | **Anthropic API only** | Kei API / `EDITOR_PROMPT` / Kei-API endpoint forbidden (Phase Q Kei persona 영구 단절 — [`IMP-17-CARVE-OUT.md`](IMP-17-CARVE-OUT.md) §"AI 격리 + Kei persona 단절 contract"). |
|
||||
| A4 | candidate_evidence[].route_hint | **live (deterministic emission)** | Emission anchored at `src/phase_z2_pipeline.py:570/:572/:575/:580/:664`; AI consumer deferred. Anchor pin: `tests/orchestrator_unit/test_imp17_comment_anchor.py`. |
|
||||
| A5 | MDX content preservation = strict | **locked** | No invent / rewrite / compress / summarize ([`IMP-17-CARVE-OUT.md`](IMP-17-CARVE-OUT.md) §Forbidden; memory `feedback_phase_z_spacing_direction`). |
|
||||
| A6 | AI prompt = frame-aware placement only, not "rewrite content" | **locked** | Output = content_object → Internal Region / Frame Slot placement proposal at content-object granularity ([`IMP-17-CARVE-OUT.md`](IMP-17-CARVE-OUT.md) §Allowed). HTML / CSS / layout / zone topology / frame selection X. |
|
||||
| A7 | popup / details / zone-resize routing when content cannot fit | **deferred to Step 17 fallback** | Deterministic actions exhausted (zone_ratio_retry / layout_adjust / frame_reselect / details_popup_escalation / image_fit_candidate / frame_internal_fit_candidate) before AI proposal ([`IMP-17-CARVE-OUT.md`](IMP-17-CARVE-OUT.md) §Allowed Step 16/17). |
|
||||
| A8 | no `calculate_fit` migration | **locked** | IMP-05 selector uses V4 labels + frame-contract presence + Phase Z capacity precheck only (`src/phase_z2_pipeline.py:587` `lookup_v4_match_with_fallback` declaration; :599 docstring "it does not call calculate_fit"; secondary anchors :3093 / :4871). |
|
||||
| C1 | Anchor drift `:452` → current | **synced** | Stage 3 u1 — [`IMP-17-CARVE-OUT.md`](IMP-17-CARVE-OUT.md):10. |
|
||||
| C2 | Backlog + status-board cross-ref | **planned (u3)** | Cross-ref discoverability surfaces only; no verdict duplication. |
|
||||
|
||||
## Out of scope (this cycle)
|
||||
|
||||
Runtime AI consumer enablement (flag default OFF), `candidate_evidence` schema change, Phase Q file mutation, Kei API reuse, frontend zone override (IMP-29 scope), IMP-30 invariant change, `calculate_fit` migration. Note: `src/phase_z2_ai_fallback/` directory scaffold itself was created under IMP-33 (#61, Stage 3 u1~u11) — see [`IMP-17-CARVE-OUT.md`](IMP-17-CARVE-OUT.md) §"Runtime module surface".
|
||||
|
||||
## Future activation path
|
||||
|
||||
When the 3-condition AND gate clears (User GO ∧ B4 V4 evidence integrated ∧ catalog 32/32 + IMP-05 V4 fallback live):
|
||||
|
||||
- Runtime AI module path = `src/phase_z2_ai_fallback/` (scaffolded under IMP-33; flag default OFF until gate clears).
|
||||
- Provider = Anthropic API only. Prompt design starts fresh (no Phase Q `EDITOR_PROMPT` import).
|
||||
- Output granularity = content_object → Internal Region / Frame Slot placement proposal. Frame / layout / zone topology selection remains deterministic.
|
||||
- Activation tracker = this issue (#40, IMP-31). No new IMP ID issued.
|
||||
162
docs/architecture/INTEGRATION-AUDIT-01-MATRIX.md
Normal file
162
docs/architecture/INTEGRATION-AUDIT-01-MATRIX.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# INTEGRATION-AUDIT-01 -- Axis 2 pipeline map (22 issues x 22 steps)
|
||||
|
||||
**Anchor (Stage 1 lock)** :
|
||||
> This audit verifies pipeline contracts. It does not optimize any single MDX sample.
|
||||
|
||||
**Companion file** : `docs/architecture/INTEGRATION-AUDIT-01-REPORT.md` -- this MATRIX is the spin-off body of REPORT Section 4 (Axis 2). Combined REPORT exceeded the 10 KB readability threshold (REPORT u1 size = 21,070 bytes) at u1 completion, so the grid is housed here per the Stage 2 split rule. REPORT Section 4 carries a back-pointer to this file.
|
||||
|
||||
**Pipeline reference** : `docs/architecture/PHASE-Z-PIPELINE-OVERVIEW.md` (22-step master). Block A (Steps 0-12) = pre-render planning; Block B (Step 13) = render; Block C (Steps 14-22) = post-render telemetry / exception handling.
|
||||
|
||||
**Closed issues under audit (22 total)** : `#2 #3 #4 #5 #6 #7 #8 #9 #10 #11 #12 #13 #14 #15 #16 #17 #18 #45 #46 #47 #48 #49`. `#15` = parent; `#45-#49` = execution children. Parent/child de-dup convention (Stage 1 lock) -- `#15` row records integration glue only, no `P` (primary) cells; real code attribution lives in `#45-#48` rows. `#49` = verification-only, no new SHA, re-uses `#48` evidence.
|
||||
|
||||
---
|
||||
|
||||
## Step 0 precondition NOTE (NOT an axis, recorded above the grid)
|
||||
|
||||
Step 0 = `docs/architecture/PHASE-Z-PIPELINE-OVERVIEW.md` precondition block (catalog / contract / matching data / template / asset). Per Stage 2 plan, Step 0 is NOT a grid column; it is recorded here as a precondition note. Closed issues that touched Step 0 :
|
||||
|
||||
| issue | Step 0 touch | scope summary | evidence path |
|
||||
|---|---|---|---|
|
||||
| `#4` | catalog + contract expansion (16 frame_partials + F17 paired_rows_4x2 + frame_contracts.yaml schema) | adds frame DB rows + contract schema fields | `templates/phase_z2/catalog/frame_contracts.yaml` ; `templates/phase_z2/families/*.html` |
|
||||
| `#11` | contract field `min_height_px` exposure | additive contract payload field | `templates/phase_z2/catalog/frame_contracts.yaml` ; `src/phase_z2_pipeline.py` (commit `a79bd8b`) |
|
||||
| `#13` | build-time frame preview generator (salvage of `capture_slide_screenshot`) | precondition asset only (lives in `scripts/`, NOT runtime pipeline) | `scripts/generate_frame_previews.py` (commit `7d5639a`) |
|
||||
| `#14` | slide-base template contract bit (embedded vs standalone) | precondition template surface | `templates/phase_z2/slide_base.html` (commit `7a52ceb`) |
|
||||
| `#18` | doc-only carve-out (no Step 0 code change) | SVG gap report + 1-line backlog status flip | `docs/architecture/IMP-18-SVG-GAP-REPORT.md` (commit `cbbc163`) |
|
||||
|
||||
Step 0 touches above are precondition data / template / contract; they do not flow runtime decisions in Steps 1-22 directly, except via consumers already accounted for as Step 5 / 9 / 10 / 12 / 13 / 22 cells in the grid below.
|
||||
|
||||
---
|
||||
|
||||
## Cell legend
|
||||
|
||||
- `P` = primary touch (the issue's own declared scope per body / closing commit)
|
||||
- `A` = adjacent contract (consumer / producer / cross-step dependency surface, not the primary scope)
|
||||
- `.` = not touched (blank-equivalent; dot used for column alignment in monospace renderers)
|
||||
|
||||
Rule applied : if an issue's body or closing commit explicitly names a step or its code file, that is `P`. If the change shape forces the issue to read from or write into another step's contract without being the primary scope, that is `A`. Otherwise `.`.
|
||||
|
||||
Parent `#15` row carries no `P` cells per the Stage 1 de-dup convention; its child rows (`#45-#48`) carry the actual `P` cells.
|
||||
|
||||
---
|
||||
|
||||
## 22 x 22 grid (Step 1 columns -> Step 22 columns)
|
||||
|
||||
Column header shorthand : `S1 = MDX upload | S2 = MDX normalize | S3 = content_object | S4 = section internal composition planning | S5 = V4 evidence | S6 = composition planning | S7 = layout vocabulary | S8 = zone+region ratio | S9 = region-level frame/display | S10 = frame contract | S11 = region-to-slot mapping | S12 = slot payload | S13 = render | S14 = visual_check | S15 = fit_classification | S16 = router | S17 = action | S18 = failure_classify | S19 = next_action | S20 = slide_status | S21 = debug.json | S22 = user UI/export`.
|
||||
|
||||
| issue | S1 | S2 | S3 | S4 | S5 | S6 | S7 | S8 | S9 | S10 | S11 | S12 | S13 | S14 | S15 | S16 | S17 | S18 | S19 | S20 | S21 | S22 | row total |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| `#2` | . | P | A | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | A | . | 3 |
|
||||
| `#3` | . | A | P | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | A | . | 3 |
|
||||
| `#4` | . | . | . | . | A | . | . | . | A | P | . | A | A | . | . | . | . | . | . | . | . | . | 5 |
|
||||
| `#5` | . | . | . | . | A | A | . | . | P | . | . | . | . | . | . | A | A | . | . | P | . | . | 6 |
|
||||
| `#6` | A | . | . | . | . | P | A | A | A | . | . | . | A | . | . | . | . | . | . | . | . | A | 7 |
|
||||
| `#7` | A | A | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | P | 3 |
|
||||
| `#8` | . | . | P | . | A | A | . | . | A | . | . | . | A | . | . | . | . | . | . | . | . | A | 6 |
|
||||
| `#9` | . | . | . | . | . | . | A | P | A | . | . | . | A | . | . | . | A | . | . | . | . | . | 5 |
|
||||
| `#10` | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | A | . | P | 2 |
|
||||
| `#11` | . | . | . | . | . | . | . | . | A | . | . | . | . | . | . | . | . | . | . | . | . | P | 2 |
|
||||
| `#12` | . | . | . | . | . | . | . | . | . | . | . | . | . | A | . | P | P | P | A | A | . | . | 6 |
|
||||
| `#13` | . | . | . | . | . | . | . | . | . | . | . | . | . | A | . | . | . | . | . | . | . | . | 1 |
|
||||
| `#14` | . | . | . | . | . | . | . | . | . | . | . | . | P | . | . | . | . | . | . | . | . | A | 2 |
|
||||
| `#15` | . | . | . | . | . | . | . | . | . | . | . | . | . | A | A | . | . | . | . | . | A | . | 3 |
|
||||
| `#16` | A | A | . | . | . | . | . | . | . | . | . | . | . | A | . | . | . | . | . | . | A | A | 5 |
|
||||
| `#17` | . | . | . | . | . | . | . | . | . | . | . | P | . | . | . | A | A | . | . | . | . | . | 3 |
|
||||
| `#18` | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | 0 |
|
||||
| `#45` | . | . | . | . | . | . | . | . | . | . | . | . | . | P | A | . | . | . | . | . | A | . | 3 |
|
||||
| `#46` | . | . | . | . | . | . | . | . | . | . | . | . | . | P | A | . | . | . | . | . | A | . | 3 |
|
||||
| `#47` | . | . | . | . | . | . | . | . | . | . | . | . | . | A | P | A | . | . | . | . | . | . | 3 |
|
||||
| `#48` | . | . | . | . | . | . | . | . | . | . | . | . | . | A | A | . | . | . | . | . | P | . | 3 |
|
||||
| `#49` | . | . | . | . | . | . | . | . | . | . | . | . | . | A | A | . | . | . | . | . | A | . | 3 |
|
||||
| **col total** | 3 | 4 | 3 | 0 | 3 | 3 | 2 | 2 | 6 | 1 | 0 | 2 | 5 | 9 | 6 | 4 | 4 | 1 | 1 | 3 | 8 | 7 | -- |
|
||||
| **HOTSPOT (>= 4)** | . | H | . | . | . | . | . | . | H | . | . | . | H | H | H | H | H | . | . | . | H | H | -- |
|
||||
|
||||
Cell-count totals : sum of row totals = 77 ; sum of column totals = 77 (cross-check matches; 22 rows x 22 cols = 484 grid positions, of which 77 are non-blank).
|
||||
|
||||
---
|
||||
|
||||
## HOTSPOT enumeration (column total >= 4)
|
||||
|
||||
9 of the 22 steps are HOTSPOT (touched by 4 or more closed issues). Listed in pipeline order :
|
||||
|
||||
| step | col total | touching issues | hotspot meaning |
|
||||
|---|---|---|---|
|
||||
| `S2 MDX normalize` | 4 | `#2 P`, `#3 A`, `#7 A`, `#16 A` | Step 2 is the entry surface for both the Stage 0 chained adapter (`#2`) and downstream content-object trace (`#3`), with reverse-path (`#7`) and verification utility (`#16`) as adjacent consumers. Cross-issue contract = `parse_mdx` output shape stays compatible with `extract_*` semantics. |
|
||||
| `S9 region-level frame/display` | 6 | `#4 A`, `#5 P`, `#6 A`, `#8 A`, `#9 A`, `#11 A` | Step 9 is the heaviest pre-render hotspot. `#5` is primary (V4 fallback / application_plan). `#4 #8 #11` extend the contract / schema feeding Step 9. `#6 #9` exercise the consumer of zone-region geometry. Cross-issue invariant : V4 candidates list + min_height contract + sub_section alias + region ratio must all agree at the Step 9 application_plan boundary. |
|
||||
| `S13 render` | 5 | `#4 A`, `#6 A`, `#8 A`, `#9 A`, `#14 P` | Step 13 is the Jinja2 render surface. `#14` (slide-base iframe mode) is primary. `#4 #6 #8 #9` flow new payload / layout css into the same renderer. Cross-issue invariant : `build_layout_css` + frame_partial + slide_base remain deterministic with no AI in path. |
|
||||
| `S14 visual_check` | 9 | `#12 A`, `#13 A`, `#15 A`, `#16 A`, `#45 P`, `#46 P`, `#47 A`, `#48 A`, `#49 A` | Highest column total. `#15` parent + 5 children (`#45-#49`) all converge here. `#12 #13 #16` are adjacent. Cross-issue invariant : detector producers (`#45 #46`) emit canonical event shape; classifier consumer (`#47`) reads the same shape; debug.json surfaces (`#48`) match -- to be re-verified by Axis 3 (REPORT Section 5). |
|
||||
| `S15 fit_classification` | 6 | `#15 A`, `#45 A`, `#46 A`, `#47 P`, `#48 A`, `#49 A` | `#47` primary (classifier consumes image + table events). All `#15` family is adjacent. Cross-issue invariant : Step 14 producer event keys agree with Step 15 `CONTENT_TYPE_PATTERNS`. |
|
||||
| `S16 router` | 4 | `#5 A`, `#12 P`, `#17 A`, `#47 A` | `#12` primary (3-stage salvage cascade). `#5` bridge fallback adjacent. `#17` gated carve-out adjacent. `#47` classifier output flows into router. Cross-issue invariant : router action map remains deterministic / no AI in normal path. |
|
||||
| `S17 action` | 4 | `#5 A`, `#9 A`, `#12 P`, `#17 A` | `#12` primary (zone_ratio_retry expansion + cross-zone donor + 3-stage cascade). `#9` zone-geometry feeds the same retry surface. `#5` V4 fallback shares `PASS_WITH_FALLBACK` status enum. `#17` is gated. Cross-issue invariant : no common-CSS shrink (per `feedback_phase_z_spacing_direction`). |
|
||||
| `S21 debug.json` | 8 | `#2 A`, `#3 A`, `#15 A`, `#16 A`, `#45 A`, `#46 A`, `#48 P`, `#49 A` | Second-highest column total. `#48` primary (debug.json event surfacing). 7 issues adjacent. Cross-issue invariant : debug.json schema additive only; no key type / semantic conflict (Axis 3 re-verifies this category). |
|
||||
| `S22 user UI/export` | 7 | `#6 A`, `#7 P`, `#8 A`, `#10 P`, `#11 P`, `#14 A`, `#16 A` | Frontend / CLI exit surface. 3 primary (`#7 #10 #11`). 4 adjacent. Cross-issue invariant : `Front/` consumes backend artifacts as read-only payload; backend never reads from frontend except via the reverse path (`#7`). |
|
||||
|
||||
`S2 S9 S13 S14 S15 S16 S17 S21 S22` = 9 distinct hotspot steps (col total >= 4). The col-total HOTSPOT row in the grid carries 9 `H` marks ; counting check matches.
|
||||
|
||||
---
|
||||
|
||||
## Row total HOTSPOT (issues touching the most steps)
|
||||
|
||||
For information only -- this dimension is not an issue-body requirement, but is useful for scope-myopia cross-check with REPORT Section 3 :
|
||||
|
||||
| issue | row total | finding (per REPORT Section 3) |
|
||||
|---|---|---|
|
||||
| `#6` | 7 | Warning -- wide override blast radius (4 commits + Stage 4 blocker-fix `52ccb7f`) -- matrix row total agrees |
|
||||
| `#5` | 6 | OK -- pre-render bridge ; rank-1 path unchanged |
|
||||
| `#8` | 6 | OK -- additive schema with explicit backward-compat alias resolver |
|
||||
| `#12` | 6 | Warning -- large blast radius (4 src + 5 test modules in `56619a0`) -- matrix row total agrees |
|
||||
| `#4` | 5 | OK -- pre-render planning only ; catalog read-only for V4 |
|
||||
| `#9` | 5 | OK -- 8-vocabulary build_layout_css with fixtures |
|
||||
| `#16` | 5 | OK -- utility + design doc only ; gated by `#7` activation |
|
||||
|
||||
The two `Warning` rows in Section 3 (`#6` row total 7 and `#12` row total 6) sit at the top of the row-total ranking -- this is consistent with "wide blast radius" findings in Section 3. The other high-row-total issues (`#5 #8 #4 #9 #16`) are all `OK` per Section 3 because each ships with explicit backward-compat guards / fixtures / gating.
|
||||
|
||||
---
|
||||
|
||||
## Cross-check vs REPORT Section 3 adjacency list
|
||||
|
||||
REPORT Section 3 flagged 9 adjacent-contract pairs for Axis 3 re-verification. Each pair maps onto cells in this grid :
|
||||
|
||||
| Section 3 adjacency pair | matrix evidence |
|
||||
|---|---|
|
||||
| `#2` Step 2 normalize -> `#3` Step 3 content_object | `#2` S2 `P` + `#3` S2 `A` (producer/consumer same column) |
|
||||
| `#3` content_object -> `#8` sub_sections | `#3` S3 `P` + `#8` S3 `P` (both primary on same step -- schema extension) |
|
||||
| `#4` catalog -> `#5` V4 fallback | `#4` S5 `A` + `#5` S5 `A` (both adjacent on same step -- candidate pool dedup) |
|
||||
| `#4` catalog -> `#10 #11` min_height | `#11` S0 (NOTE) ; `#11` S9 `A` (Step 9 consumer of min_height) -- direct adjacency |
|
||||
| `#9` layout vocabulary -> `#12` retry zone-ratio | `#9` S17 `A` + `#12` S17 `P` (consumer/producer same step) |
|
||||
| `#9` -> `#11` Step 9 min_height test | `#9` S9 `A` + `#11` S9 `A` (both adjacent on same step) |
|
||||
| `#45 + #46` Step 14 -> `#47` Step 15 | `#45 #46` S14 `P` + `#47` S15 `P` ; `#47` S14 `A` (cross-step producer/consumer) |
|
||||
| `#48` debug.json -> open `#21` consumer | `#48` S21 `P` ; `#21` is out-of-scope (open) -- no grid row |
|
||||
| `#17` AI carve-out -> `#5 + #4` activation gate | `#17` S12 `P` ; `#17` S16 `A` ; `#17` S17 `A` (gated cells) |
|
||||
|
||||
All 9 adjacency pairs map onto provable cells. Axis 3 (REPORT Section 5) will verify each pair's producer-line / consumer-line on live code.
|
||||
|
||||
---
|
||||
|
||||
## Empty columns (col total = 0)
|
||||
|
||||
- `S4 section internal composition planning` -- 0 touches. Consistent with PHASE-Z-PIPELINE-OVERVIEW Step 4 status `missing` (no closed issue implemented Step 4 yet; it remains in the open backlog).
|
||||
- `S11 content unit / child group -> internal region -> frame slot mapping` -- 0 touches. Consistent with PHASE-Z-PIPELINE-OVERVIEW Step 11 status `missing` (Layer A / Layer B 2-stage placement algorithm not implemented).
|
||||
|
||||
Step 4 and Step 11 are the two `missing` steps in Block A that no closed issue in the audit window addressed. This is expected per the master pipeline status; the audit records absence without claiming a gap (an implementation gap would require an OPEN issue to claim it, which is out of audit scope).
|
||||
|
||||
---
|
||||
|
||||
## Low-touch columns (col total = 1)
|
||||
|
||||
- `S10 frame contract` (1) -- `#4` only ; consistent with `#4` being the catalog/contract owner.
|
||||
- `S18 failure_classify` (1) -- `#12` only ; consistent with `#12` being the retry cascade owner.
|
||||
- `S19 next_action` (1) -- `#12` only ; same.
|
||||
|
||||
---
|
||||
|
||||
## Notes on parent / child row separation
|
||||
|
||||
- `#15` row carries 3 adjacencies (S14 / S15 / S21) and zero `P` cells per the Stage 1 de-dup convention.
|
||||
- `#45 #46 #47 #48` carry the corresponding `P` cells (S14 for `#45 #46` ; S15 for `#47` ; S21 for `#48`).
|
||||
- `#49` (verification-only, no new SHA) mirrors the `#48` adjacency pattern with all-`A` cells -- this is intentional and consistent with the Stage 1 lock that `#49` re-uses `#48` evidence (commit `614c533`). No double-count.
|
||||
|
||||
Sum cross-check : `#15` 3 + `#45` 3 + `#46` 3 + `#47` 3 + `#48` 3 + `#49` 3 = 18 row-total cells across the `#15` family. None of these duplicate code attribution -- only `#45 #46 #47 #48` carry the four `P` cells (one each), totaling 4 primary cells for the family. `#15 #49` carry zero primaries.
|
||||
|
||||
---
|
||||
|
||||
*End of MATRIX. Back to REPORT Section 4 for narrative integration.*
|
||||
547
docs/architecture/INTEGRATION-AUDIT-01-REPORT.md
Normal file
547
docs/architecture/INTEGRATION-AUDIT-01-REPORT.md
Normal file
@@ -0,0 +1,547 @@
|
||||
# INTEGRATION-AUDIT-01 -- Phase Z closed-issue cumulative consistency review
|
||||
|
||||
## Section 1. Audit anchor
|
||||
|
||||
**Anchor (cited verbatim per Stage 1 exit report)** :
|
||||
> This audit verifies pipeline contracts. It does not optimize any single MDX sample.
|
||||
|
||||
**Scope** : 22 closed Gitea issues `#2-#18 + #45-#49` on `Kyeongmin/C.E.L_Slide_test2` against the 22-step Phase Z pipeline (`docs/architecture/PHASE-Z-PIPELINE-OVERVIEW.md`, Steps 1-22 plus Step 0 precondition).
|
||||
|
||||
**Mode** : audit-only -- no source code changes. Report-only file changes under `docs/architecture/INTEGRATION-AUDIT-*.md` and one row in `docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md` (u7).
|
||||
|
||||
**Parent / child relationship** : Gitea `#15` = parent (IMP-15 Step 14 visual_check reinforcement). Execution children = `#45 / #46 / #47 / #48 / #49`. Locked child SHAs (Stage 1 exit report) :
|
||||
- `#45` -> `e9b3d2e` (execution-1, image_aspect_mismatch detection)
|
||||
- `#46` -> `2827622` (execution-2, table_self_overflow detection; commit message label says `IMP-16` but the closed Gitea issue is `#46`; flagged in Section 3)
|
||||
- `#47` -> `535c484` (execution-3, classifier consumes image+table events)
|
||||
- `#48` -> `614c533` (execution-4, debug.json event surfacing + spec taxonomy)
|
||||
- `#49` -> no new SHA (verification-only per `#15` body; re-uses `614c533` evidence)
|
||||
|
||||
**Close timestamp anomaly** (Stage 1 lock, recorded; NOT reopened) :
|
||||
- `#15` closed `2026-05-19T02:35:05+09:00`
|
||||
- `#45 / #46 / #47 / #48` all closed BEFORE `#15` (correct ordering)
|
||||
- `#49` closed `2026-05-19T02:49:56+09:00` -- about 15 minutes AFTER `#15` close (anomaly)
|
||||
- Disposition : record-only in Section 3 / Section 6 finding column; no remediation row in backlog beyond the existing audit completion row (u7).
|
||||
|
||||
**Excluded (open / not in audit)** : `#1, #19, #20, #21, #22, #23, #24, #25, #26, #27, #28, #38, #39, #40, #41, #42, #43, #44`.
|
||||
|
||||
**Sample budget** : `samples/mdx_batch/03.mdx` (smoke) plus `samples/mdx_batch/04.mdx` (details + images). Pipeline runs captured in Section 7.
|
||||
|
||||
---
|
||||
|
||||
## Section 2. Baseline pytest
|
||||
|
||||
**Method** : `pytest -q tests` is the project regression suite. The audit captures it twice -- once before any u5 / u6 / u7 edits, once after Section 7 / 8 grep + render evidence is collected. Equality of both runs proves the audit-only work surface (`docs/architecture/INTEGRATION-AUDIT-*.md` + backlog row in u7) did not perturb production code.
|
||||
|
||||
**Command** : `pytest -q tests` (working dir = repo root `D:\ad-hoc\kei\design_agent\`).
|
||||
|
||||
**Pytest BEFORE audit u5 edits (audit date 2026-05-19)** :
|
||||
- Result : `303 passed in 40.80s`
|
||||
- Last 5 progress dots aggregated to `[100%]` then `Running teardown with pytest sessionfinish...` -- expected suite teardown banner.
|
||||
|
||||
**Pytest AFTER audit u5 edits (post §7 / §8 evidence collection, same audit date)** :
|
||||
- Result : `303 passed in 40.54s`
|
||||
- 303 == 303 ; 0 new failures, 0 skipped, 0 errored. Test count parity proves no test discovery side-effect from new audit docs.
|
||||
|
||||
**Verdict** : OK. Audit-only edits under `docs/architecture/INTEGRATION-AUDIT-*.md` introduce no regression. Baseline stable across u5 assembly.
|
||||
|
||||
---
|
||||
|
||||
## Section 3. Axis 1 -- Scope myopia (22 issues x adjacent-contract cross-reference)
|
||||
|
||||
**Method** : per closed issue, list (a) its own scope as declared in body / backlog row / closing commits, (b) adjacent pipeline contracts the change could have leaked into, (c) downstream consumers of its outputs, (d) finding label `OK` / `Warning` / `Blocker`. Each row cites `src/`, `tests/`, `docs/`, or `templates/` paths.
|
||||
|
||||
**De-dup convention** : `#15` is treated as the *integration parent*; the actual code/test changes are owned by execution children `#45-#49`. `#15` row records integration glue only (parent close evidence + cross-child reconciliation). No change is double-counted across parent + child.
|
||||
|
||||
**Pipeline step shorthand (per `PHASE-Z-PIPELINE-OVERVIEW.md`, full 22-step list)** :
|
||||
- Step 0 precondition / 1 MDX upload / 2 normalize / 3 content_object / 4 internal composition planning / 5 V4 evidence / 6 composition planning / 7 layout vocabulary / 8 zone+region ratio / 9 region-level frame/display / 10 frame contract / 11 region-to-slot mapping / 12 slot payload / 13 render / 14 visual_check / 15 fit_classification / 16 router / 17 action / 18 failure_classify / 19 next_action / 20 slide_status / 21 debug.json / 22 user UI.
|
||||
|
||||
### Section 3 table -- 22 rows
|
||||
|
||||
| # | issue (title) | declared own_scope | adjacent contracts (potential leak surface) | downstream consumers | finding | evidence path |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | `#2` IMP-02 A-1 Stage 0 normalize chained adapter | Step 2 -- chained `normalize_mdx_content` + `extract_major_sections` + `extract_conclusion_text` with dual-write, preserve raw MDX | Step 3 content_object input shape (raw chunk handoff); Step 21 debug.json schema (`step02_*` keys) | Step 3 (IMP-03 ContentObject extractor); Step 21 trace writer; Step 7/8 layout planner (consumes normalized section list) | OK -- additive; preserves prior `extract_*` semantics via dual-write; no AI in path | `src/phase_z2_pipeline.py` (commit `bac13c0`, +165/-3) |
|
||||
| 2 | `#3` IMP-03 A-1 popup/image/table trace | Step 3 -- normalize popups/images/tables into ContentObject (B1 v0 extension); slide-level rich ContentObject trace | Step 2 normalize output shape (consumer); Step 4 internal composition planning (Step 4 itself still not implemented, so this row only emits trace); Step 21 debug.json schema | Step 4 (not yet implemented; receives data only via trace); Step 21 debug.json (`content_objects` field) | OK -- emits trace without coupling to downstream Step 4 (Step 4 still pending); raw content preserved (no AI summarization; satisfies `feedback_ai_isolation_contract`) | `src/phase_z2_content_extractor.py` + `src/phase_z2_pipeline.py` (commit `fc3f7d8`) |
|
||||
| 3 | `#4` IMP-04 A-2 catalog expansion | Step 0 + Step 9 -- register/expand 16 frame_partials + `frame_contracts.yaml` schema; F17 paired_rows_4x2 + pill alternation + theme | Step 5 V4 evidence (catalog size affects evidence pool); Step 10 frame contract validator (consumes new contracts); Step 12 mapper PAYLOAD_BUILDERS (consumes new schema); Step 13 render template surface (16 new `templates/phase_z2/families/*.html`) | Step 5/9/10/12/13; smoke tests `scripts/smoke_frame_render.py` | OK -- pre-render planning only; catalog is read-only data for V4; frame DB extension matches Step 0 contract; commit `73a98b8` corrected F17 schema after first land (factual_verification path active) | `templates/phase_z2/catalog/frame_contracts.yaml`; `templates/phase_z2/families/*.html`; `src/phase_z2_mapper.py`; `docs/architecture/IMP-04-FRAME-SUITABILITY-MATRIX.md` |
|
||||
| 4 | `#5` IMP-05 A-5 V4 fallback | Step 9 + Step 16/17 -- deterministic V4 candidate bridge (pre-render rank-2/3 fallback); trace schema; dedup invariant test; new `PASS_WITH_FALLBACK` status semantics in Step 20 | Step 5 evidence (candidate dedup must agree with rank-1 path); Step 6 composition (candidates[0] backward-compat); Step 9 application_plan; Step 20 status enum; debug.json trace | Step 9/16/17/20; `tests/test_phase_z2_v4_fallback.py`; `tests/test_catalog_invariant.py` | OK -- pre-render bridge (Block A); deterministic (no AI); rank-1 path unchanged (backward compat per backlog guardrail); dedup invariant test guards collision with `#4` catalog expansion | `src/phase_z2_pipeline.py` + `src/phase_z2_composition.py` + `src/phase_z2_router.py` (commits `15c5b9a`, `21476ae`, `23d1b25`) |
|
||||
| 5 | `#6` IMP-06 B-1 zone-section override | Step 6 + Step 1/22 input -- CLI arg + composition planner override (`replaced_auto_unit`, `render_records`, plan-aware traces, units rebuild, empty zone) | Step 1 CLI surface; Step 6 `plan_composition` schema (CompositionUnit); Step 7/8/9 downstream (units rebuild forces re-planning); Step 13 render (Catch K render-path) | Step 7/8/9/13; debug.json render_records; `Front/` (later wired via `#8` U3) | Warning -- wide blast radius (4 commits + Stage 4 blocker-fix `52ccb7f`); units-rebuild touches Step 7/8/9 implicitly; verified by `tests/test_phase_z2_section_assignment_override.py` (285 + 42 + 228 lines). No AI; deterministic. Risk = override path widens Step 6 surface where Step 4 is still pending | `src/phase_z2_pipeline.py` (commits `d596fab` `b81e564` `1f15495` `52ccb7f`) |
|
||||
| 6 | `#7` IMP-07 B-2 edited HTML to MDX reverse path | Step 22 + Step 1/2 input -- Vite/React `Front/` plus reverse path glue; pipeline re-entry | Step 1 MDX upload; Step 2 normalize (must accept reverse-path MDX); CLI plus service API; `feedback_ai_isolation_contract` (reverse must not invoke AI rewrite) | Step 2 (reverse-path consumer); `Front/client/src/services/designAgentApi.ts`; pipeline CLI | OK -- frontend-shipped (`0f0d3fa`); reverse path schema aligned with `#2` Stage 0 normalize via hard-link declared in backlog. AI isolation preserved (no normal-path LLM in reverse). | `Front/`; `src/phase_z2_pipeline.py`; backlog row IMP-07 |
|
||||
| 7 | `#8` IMP-08 B-3 sub-section drag-drop | Step 3 schema -- sub_sections schema + V4 alias resolver + aligner canonical sub-id + decimal alias guard (N-R5) + frontend wire | Step 3 ContentObject schema (extends `#3`); Step 5 V4 alias surface; Step 6 composition planner (consumer); Step 9 application_plan; `Front/` zoneSections override (U3) | Step 5/6/9/13; `tests/test_phase_z2_subsection_schema.py` (82+100+61 lines) | OK -- additive schema with explicit backward-compat guard (alias resolver at 4 lookup sites); Stage 5 R2 blocker-fix `8f6cffc` force-drills aligner only on override targets (scope contained) | `src/phase_z2_pipeline.py` + `src/phase_z2_composition.py` (commits `a422d72` `5191aca` `ab2764c` `8f6cffc`) |
|
||||
| 8 | `#9` IMP-09 B-4 non-default layout zone-geometry | Step 8 -- col-axis solver + per-zone geometry mapper + retry gate; 2-D dynamic dispatch for 5 preset families (single + horizontal-2 + vertical-2 + top-1-bottom-2 + top-2-bottom-1 + left-1-right-2 + left-2-right-1 + grid-2x2) | Step 7 layout vocabulary (consumer); Step 9 region-level (zone geometry feeds region ratios; Step 9 region-level still warning); Step 17 zone_ratio_retry (`#12` IMP-12 retry path); Step 13 render `build_layout_css` | Step 9/13/17; `tests/phase_z2/fixtures/build_layout_css/*.yaml` (16 fixtures); `tests/phase_z2/fixtures/retry_gate/*.yaml` | OK -- all 8 vocabulary entries enabled in build_layout_css; fixtures supply provable diff per preset; no Kei/Phase R' regression (existing `build_containers_type_b` untouched) | `src/phase_z2_pipeline.py` (commits `201099e` PR1, `1fb9732` PR2) |
|
||||
| 9 | `#10` IMP-10 D-1 filtered_section_reasons UI | Step 20/22 -- frontend read-only display of `filtered_section_reasons` artifact | Step 20 slide_status enum (read-only consumer); `Front/` service API; no backend mutation | `Front/client/src/pages/Home.tsx`; `Front/client/src/services/designAgentApi.ts` | OK -- frontend-only; backend artifact strictly read-only per backlog guardrail | `Front/` (commit `0fb168b`, +45 lines) |
|
||||
| 10 | `#11` IMP-11 D-2 Frame min_height display | Step 22 -- `min_height_px` hint exposed backend to UI; resize hint read-only; Step 9 v4 all-judgments min_height test | Step 0 frame contract (`min_height_px` field); Step 9 region-level (consumer); `Front/` SlideCanvas | Step 9; `Front/client/src/components/SlideCanvas.tsx`; `tests/test_phase_z2_step9_v4_all_judgments_min_height.py` | OK -- contract read-only; backend exposure is additive payload field (`src/phase_z2_pipeline.py` +32/-12 in `a79bd8b`) | `src/phase_z2_pipeline.py` + `Front/client/src/components/SlideCanvas.tsx`; `tests/test_phase_z2_step9_v4_all_judgments_min_height.py` |
|
||||
| 11 | `#12` IMP-12 Step 16/17 retry refinement | Step 16 + Step 17 -- multi-donor + 3-stage salvage cascade; `redistribute` + glue + font compression; new router action; new failure_router taxonomy | Step 14 visual_check (donor selection consumes overflow events); Step 18 failure_classify (cascade adds new failure types); Step 19 next_action (downstream router consumer); Step 20 status semantics; `feedback_phase_z_spacing_direction` (cross-zone redistribute is grant-changing, not common-shrink) | Step 18/19/20; `tests/phase_z2/test_phase_z2_*` (cross_zone, font_step, glue, multi_donor, step17_salvage_chain -- 5 new test modules) | Warning -- large blast radius (4 src files + 5 test modules in `56619a0`); multi-donor introduces cross-zone state in Step 17; verified by 5 dedicated test modules. Risk = cascade may interact with `#5` V4 fallback path in Step 20 status enum (mitigated by separate status enums per `#5` exit report) | `src/phase_z2_failure_router.py` + `src/phase_z2_pipeline.py` + `src/phase_z2_retry.py` + `src/phase_z2_router.py` (commit `56619a0`) |
|
||||
| 12 | `#13` IMP-13 A-3 frame preview consistency | Step 0 + Step 14/21 -- build-time frame preview generator (salvage of `capture_slide_screenshot`) | Step 0 catalog frame_partials (consumer for snapshot); Step 14 visual_check (uses preview for sanity, read-only); no Phase R' regression | `scripts/generate_frame_previews.py`; `tests/test_generate_frame_previews.py` | OK -- build-time only (not in runtime pipeline); deterministic; no Phase R' coupling (script lives in `scripts/`) | `scripts/generate_frame_previews.py` (commit `7d5639a`, 239 LOC + 50 LOC test) |
|
||||
| 13 | `#14` IMP-14 A-4 slide-base iframe mode | Step 13 render -- `slide-base.html` conditional CSS (embedded vs standalone); Step 0 contract bit | Step 0 slide_base template; Step 13 Jinja2 deterministic render; `Front/` SlideCanvas (consumer) | Step 13; `Front/client/src/components/SlideCanvas.tsx`; `tests/phase_z2/test_slide_base_embedded_mode.py` | OK -- render-time contract only; Jinja2 deterministic; embedded mode reduces SlideCanvas friction (34 LOC simplified) | `templates/phase_z2/slide_base.html` + `src/phase_z2_pipeline.py` (commit `7a52ceb`) |
|
||||
| 14 | `#15` IMP-15 Step 14 visual_check reinforcement (PARENT -- execution children `#45-#49`) | Integration glue only -- *no direct code* under #15; closure depends on `#45-#49` SHAs. De-duped against children (real change attribution = #45-#49 rows below) | Step 14 (parent contract); Step 15 fit_classification consumer; Step 21 debug.json trace; `PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md` (taxonomy row added by `#48`) | Step 15/21; spec doc | Warning -- close-timestamp anomaly only : `#49` closed at `2026-05-19T02:49:56+09:00`, about 15 minutes AFTER `#15` close `02:35:05+09:00`. All other children (#45-#48) close BEFORE #15. `#49` body declares verification-only path (no new SHA; re-uses `614c533`), so post-close `#49` close does not leak code into `#15`. Disposition : record-only, no reopen. | `docs/architecture/PHASE-Z-PIPELINE-OVERVIEW.md` Step 14/15; child rows below |
|
||||
| 15 | `#16` IMP-16 B-2 verification helper axis | Step 1/2/14/21/22 -- `phase_z2_verification_utils.py` port + 8 verification test modules + U2 wiring design doc | Step 22 reverse path verification (consumer is `#7` IMP-07 once activated); no normal-path coupling | Step 14/21 trace consumers (utility); future `#7` reverse-path verification | OK -- utility module plus design doc only; no normal-path coupling (gated by `#7` activation); commit `23ba8b6` is design + utility port (335 LOC utility + 8 test modules + wiring doc) | `src/phase_z2_verification_utils.py`; `docs/architecture/IMP-16-U2-WIRING-DESIGN.md` (commit `23ba8b6`) |
|
||||
| 16 | `#17` IMP-17 AI repair fallback infra (carve-out -- outside normal path) | Design-only boundary + 3-cond AND gate (User GO AND B4 frame_selection evidence AND IMP-04/05 live); `httpx` + SSE + retry + JSON parse pattern reference | Step 12 (AI position contract; carve-out body asserts normal path AI = 0); Step 16/17 fallback path (gated activation); `feedback_ai_isolation_contract` (foundational rule); backlog row + INSIGHT-MAP cross-ref | Step 12 (design boundary); future activation gated by 3-cond AND | OK -- design-only carve-out; `src/phase_z2_pipeline.py` change = 1 line (comment anchor for orchestrator test); no runtime AI added | `docs/architecture/IMP-17-CARVE-OUT.md` + `tests/orchestrator_unit/test_imp17_comment_anchor.py` (commit `e10ec36`) |
|
||||
| 17 | `#18` IMP-18 I3 SVG coordinate reinforcement | Doc-only carve-out -- SVG gap report; `renderer._preprocess_svg_data` pattern reference | Step 0 frame_partials SVG geometry (reference); Phase R' (renderer.py) read-only | doc consumers; backlog row | OK -- doc-only (`docs/architecture/IMP-18-SVG-GAP-REPORT.md` + 1-line backlog status flip from `pending` to `documented`); no code touched | `docs/architecture/IMP-18-SVG-GAP-REPORT.md` (commit `cbbc163`) |
|
||||
| 18 | `#45` (`#15` execution-1) image_aspect_mismatch detection + runtime test | Step 14 -- `image_aspect_mismatch` detection in visual_check; runtime test `test_phase_z2_step14_image_check.py` | Step 15 fit_classification consumer; Step 21 debug.json event surfacing (delegated to `#48`); `#15` parent close evidence | Step 15 (consumer via classifier event); `#47` (classifier integration) | OK -- Step 14 detection only (no Step 15 wiring yet; delegated to `#47`). Test scope local. | `src/phase_z2_pipeline.py` + `tests/phase_z2/test_phase_z2_step14_image_check.py` (commit `e9b3d2e`) |
|
||||
| 19 | `#46` (`#15` execution-2) table overflow + element-identity dedup + Selenium test | Step 14 -- `table_self_overflow` detection; element-identity dedup; Selenium integration test | Step 14 dedup logic (must agree with image events from `#45`); Step 15 consumer (delegated to `#47`); `#15` parent | Step 15 (consumer); `#47` | Warning -- commit-message label drift only (Step 14 scope-discipline pattern itself matches `#45`). Commit `2827622` message reads `feat(IMP-16): ...`, which mis-labels the closing Gitea issue (actually closes `#46` = `#15` execution-2; IMP-16 backlog row is the verification utility carved out separately). Audit attribution corrected here; SHA `2827622` is the authoritative anchor. No code/contract leak; risk is record-keeping only. | `src/phase_z2_pipeline.py` + `tests/phase_z2/test_phase_z2_step14_table_check.py` (commit `2827622`) |
|
||||
| 20 | `#47` (`#15` execution-3) classifier consumer (image + table) + pure-dict test | Step 15 -- classifier consumes image+table events from Step 14; pure-dict test (no Selenium) | Step 14 producers (`#45` + `#46`); Step 15 `CONTENT_TYPE_PATTERNS` taxonomy; Step 16 router (consumer) | Step 16; `tests/phase_z2/test_phase_z2_visual_classifier.py` | OK -- classifier wiring with pure-dict tests isolates Step 15 from Selenium dependency; aligns Step 14 producer to Step 15 consumer (Axis 3 invariant -- to be re-verified in Section 5) | `src/phase_z2_classifier.py` (commit `535c484`) |
|
||||
| 21 | `#48` (`#15` execution-4) debug.json event surfacing + spec doc trace + regression | Step 21 debug.json event surfacing + `PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md` taxonomy row + regression test | Step 21 trace schema (additive); spec doc; regression guard | spec doc consumers; debug.json consumers (Front/, audit tooling) | OK -- 3-line pipeline change + 2 test modules + 1-line spec doc row; smallest blast radius of #15 children | `src/phase_z2_pipeline.py` + `docs/architecture/PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md` (commit `614c533`) |
|
||||
| 22 | `#49` (`#15` execution-5) final integration + parent close | Verification-only -- re-uses `#48` `614c533` evidence; no new SHA per `#15` body | `#15` parent close; integration-only | `#15` parent | Warning -- close-timestamp anomaly (closed `2026-05-19T02:49:56+09:00`, about 15 minutes AFTER `#15` close `02:35:05+09:00`). Verification-only path has no code change, so anomaly is administrative only; no contract leak. | `#15` body + `614c533` (re-used) |
|
||||
|
||||
### Section 3 finding summary
|
||||
|
||||
- **OK** rows : `#2 #3 #4 #5 #7 #8 #9 #10 #11 #13 #14 #16 #17 #18 #45 #47 #48` (17)
|
||||
- **Warning** rows : `#6` (wide override blast radius, contained by tests); `#12` (multi-donor + cascade, contained by 5 test modules); `#15` (close-timestamp anomaly via `#49`); `#46` (commit-message label drift only, SHA correct); `#49` (close-timestamp anomaly, verification-only) -- 5 rows
|
||||
- **Blocker** rows : 0
|
||||
- **Total** : 17 OK + 5 Warning + 0 Blocker = 22 rows (matches 22 closed issues under audit).
|
||||
- **De-dup audit** : `#15` row carries no code attribution; all code/test work attributed to `#45-#48` (and `#49` = verification-only). No double-count.
|
||||
|
||||
### Section 3 cross-issue scope-myopia adjacency check
|
||||
|
||||
Adjacent-contract pairs flagged for Section 5 Axis 3 re-verification (producer to consumer continuity) :
|
||||
- `#2` Step 2 normalize output -> `#3` Step 3 content_object input
|
||||
- `#3` content_object schema -> `#8` sub_sections schema extension
|
||||
- `#4` catalog expansion -> `#5` V4 fallback candidate pool dedup
|
||||
- `#4` catalog expansion -> `#10`-`#11` `min_height_px` exposure
|
||||
- `#9` layout vocabulary -> `#12` retry zone-ratio donor selection
|
||||
- `#9` layout vocabulary -> `#11` Step 9 min_height v4-all-judgments test
|
||||
- `#45 + #46` Step 14 events -> `#47` Step 15 classifier -> Step 16 router
|
||||
- `#48` debug.json event surfacing -> `#21` (Step 21 debug consumer; open, excluded)
|
||||
- `#17` AI carve-out -> `#5 + #4` activation 3-cond AND gate (gated, not active)
|
||||
|
||||
Axis 3 (Section 5) will verify each pair has agreeing producer-line / consumer-line on the live code.
|
||||
|
||||
---
|
||||
|
||||
## Section 4. Axis 2 -- 22 issues x 22 steps pipeline matrix
|
||||
|
||||
**Split rationale** : at u1 completion the combined REPORT was 21,070 bytes / 136 lines -- over the 10 KB readability threshold defined in the Stage 2 plan. Per the split rule (`combined REPORT >= 10 KB grid moves to MATRIX.md + back-pointer`), the 22 x 22 grid lives in the companion file :
|
||||
|
||||
- `docs/architecture/INTEGRATION-AUDIT-01-MATRIX.md`
|
||||
|
||||
**What MATRIX.md contains** :
|
||||
- Step 0 precondition NOTE (NOT a grid column) -- 5 issues (`#4 #11 #13 #14 #18`) recorded with scope summary + evidence path.
|
||||
- 22 x 22 grid (Step 1 through Step 22 columns x 22 issue rows). Cell legend : `P` = primary touch, `A` = adjacent contract, `.` = no touch. ASCII-only.
|
||||
- Row footer (touched-step count) and column footer (touching-issue count + `H` HOTSPOT marker for col total >= 4).
|
||||
- HOTSPOT enumeration (9 steps : `S2 S9 S13 S14 S15 S16 S17 S21 S22`).
|
||||
- Cross-check against the 9 adjacent-contract pairs flagged in Section 3.
|
||||
- Empty / low-touch column notes (Step 4 and Step 11 are `missing` per PHASE-Z-PIPELINE-OVERVIEW -- 0 touches expected).
|
||||
- Parent/child de-dup sum check : `#15` row carries 3 adjacencies and zero `P` cells ; only `#45 #46 #47 #48` carry the 4 primary cells for the `#15` family ; `#49` is verification-only with all-`A`.
|
||||
|
||||
**Section 4 summary (for readers staying in REPORT)** :
|
||||
- 9 hotspot steps (col total >= 4) : Step 2 (4), Step 9 (6), Step 13 (5), Step 14 (9 highest), Step 15 (6), Step 16 (4), Step 17 (4), Step 21 (8), Step 22 (7).
|
||||
- 2 empty columns : Step 4 + Step 11. Consistent with master pipeline `missing` status -- no audit gap.
|
||||
- Total grid cells filled = 77 (row sum = col sum, cross-checked).
|
||||
- Top row-total issues : `#6` (7), `#5` (6), `#8` (6), `#12` (6) -- the 2 `Warning` rows (`#6 #12`) sit at the top, consistent with Section 3 wide-blast-radius finding.
|
||||
|
||||
---
|
||||
|
||||
## Section 5. Axis 3 -- Cross-issue conflict per invariant category
|
||||
|
||||
**Method** : 6 invariant categories listed in the issue body. Per category, identify producer file:line, consumer file:line, the named state key / contract, the closed issues that touch it, agree-or-conflict verdict, plus grep evidence path. Categories are evaluated against the live tracked code at audit time, not against historical snapshots.
|
||||
|
||||
### 5.1 Invariant category roster (from issue body)
|
||||
|
||||
| # | category | issue-body wording |
|
||||
|---|---|---|
|
||||
| C1 | `debug.json` schema | phase_z2 debug payload paths; no conflicting key type / semantics |
|
||||
| C2 | `visual_check_passed` | `src/phase_z2_pipeline.py` Step 14 / 17; set-site <-> read-site agree |
|
||||
| C3 | `fit_classification` / router | `src/phase_z2_mapper.py` + consumers; labels consistent producer -> consumer (charter mis-cite; live producer = `src/phase_z2_classifier.py` -- see §10 F-1) |
|
||||
| C4 | Step 14 / 17 / 21 interactions | expected state values stay aligned across the trio |
|
||||
| C5 | Phase R vs Phase Z boundary | no R regression, Z additions don't leak into R |
|
||||
| C6 | template / catalog / frame count | all docs / code use same numbers (family = 13) |
|
||||
|
||||
### 5.2 Producer / consumer / agreement table
|
||||
|
||||
| C# | invariant key | producer (file : line) | consumer(s) (file : line) | touching closed issues | verdict | grep evidence |
|
||||
|---|---|---|---|---|---|---|
|
||||
| C1 | per-step JSON schema = `step_num`, `step_name`, `step_status`, `pipeline_path_connected`, `input`, `output`, `note`, `data` (locked) | `src/phase_z2_pipeline.py:2593` `_write_step_artifact` definition; locked schema docstring at `2605-2611` (Locked schema lines `2607-2610`) | every step writer in `src/phase_z2_pipeline.py` -- 24 call sites at lines `2782, 2812, 2857, 2934, 3184, 3619, 3652, 3674, 3793, 3804, 3826, 3881, 4056, 4308, 4481, 4507, 4527, 4549, 4658, 4677, 4688, 4706, 4761, 4780`; `Front/` reads `data/runs/.../steps/*.json`; audit tooling | `#2 step02_*`; `#3 content_objects`; `#5 v4_fallback_summary` + `selection_paths` + `fallback_selection_count`; `#6 render_records`; `#11 min_height_px` payload; `#48 image_events` / `table_events` event surfacing | AGREE -- all step writers go through the single `_write_step_artifact` site with the locked field set; additive `data` payload only; no conflicting key types observed | `Grep _write_step_artifact src/phase_z2_pipeline.py` = 1 definition (line 2593) + 24 call sites = 25 total occurrences (all 24 call sites enumerated in consumer column); all share the same `_write_step_artifact(run_dir, step_num, name, data, *, step_status, pipeline_path_connected, inputs, outputs, note)` kwargs surface |
|
||||
| C2 | `visual_check_passed: bool` set at Step 14 / read at Step 17 | `src/phase_z2_classifier.py:495` `visual_check_passed = bool(overflow.get("passed", False)) and not classifications` returned at `497` | `src/phase_z2_router.py:128` `if fit_classification.get("visual_check_passed", True): ... router_active = False`; `src/phase_z2_pipeline.py:2560` sets `slide_status["visual_check_passed"] = visual_passed`; pipeline summary reads at `4800`, `4804`, `4830` | `#15` parent; `#45` (image_events flip the flag); `#46` (table_events flip the flag); `#47` (classifier widens semantic to `passed AND no classifications`) | AGREE -- single set-site (classifier.py:495) + slide_status mirror (pipeline.py:2560); router.py:128 + pipeline.py:4800/4804/4830 read the same key. Default `.get(..., True)` at router.py:128 is safe because absent key = no classification = pass | `Grep visual_check_passed src` = 14 hits across `classifier.py` + `router.py` + `pipeline.py` -- producer / consumer line set matches |
|
||||
| C3 | `fit_classification` dict keys = `visual_check_passed`, `classifications`, `summary`, `categories_seen`, `unclassified_signals`, `placement_diagnostics`; classifier <-> router consumer | `src/phase_z2_classifier.py:496-506` `classify_visual_runtime_check` return dict | `src/phase_z2_router.py:109` `route_fit_classification(fit_classification)`; `src/phase_z2_pipeline.py:4524` `fit_classification = classify_visual_runtime_check(overflow, debug_zones)`; pipeline re-classify after retry at `4582 / 4643`; router decision call at `4540 / 4583 / 4644`; retry consumer `src/phase_z2_retry.py:47` reads `fit_classification` | `#5` (V4 fallback PASS_WITH_FALLBACK semantics); `#12` (retry router multi-donor + cascade); `#15` parent; `#47` (classifier feed); `#48` (debug surfacing) | AGREE -- producer key set is the exact set consumed downstream. NOTE : the issue body says `src/phase_z2_mapper.py` for invariant C3, but the live producer is `src/phase_z2_classifier.py` (`mapper.py` owns slot payload, not fit classification). This is a record-keeping mismatch in the issue body, not a code conflict. Recorded as Section 10 follow-up candidate F-1 | `Grep fit_classification src` = 30 total occurrences across 4 files (`classifier.py` 3 hits incl. docstring/comments; `pipeline.py` 20 hits; `router.py` 5 hits; `retry.py` 2 hits). Active code use sites = producer at `classifier.py:497`; consumers at `router.py:128 / 139` + `pipeline.py 2732 / 4524 / 4540 / 4571 / 4582 / 4583 / 4643 / 4644 / 4754 / 4804 / 4805` + `retry.py:47 / 67`. Remaining occurrences are imports / function-parameter declarations / docstring references |
|
||||
| C4 | Step 14 visual_check overflow events (`image_events`, `table_events`, `passed`) -> Step 15/16 (fit + router) -> Step 17 retry action -> Step 21 debug surface | Step 14 emit sites `src/phase_z2_pipeline.py:2236` (`image_events`), `2282` (`table_events`), `2367 / 2386` (aggregation); Step 15 classifier consumes both event lists at `src/phase_z2_classifier.py:429 / 453`; Step 16 router at `src/phase_z2_router.py:142`; Step 17 retry orchestration at `src/phase_z2_pipeline.py:4571 / 4583 / 4644`; Step 21 trace producer at `src/phase_z2_pipeline.py:4762-4777` (`step21_debug_index.json` + `debug.json` outputs) | Step 21 `debug.json` index reader (`Front/` + audit tooling); pipeline summary 4791-4841 | `#12` (retry cascade Step 17 multi-donor + glue + font compression); `#15 / #45 / #46 / #47 / #48` (Step 14 producer / Step 15 classifier consumer / Step 21 surface); `#10` filtered_section_reasons (Step 22 read-only, Step 21 source) | AGREE with one DOCUMENTED PARTIAL -- Step 21 writer at `pipeline.py:4772` is `step_status="partial"` with note `region marker partial 미주입 -- Step 21 ⚠ partial`. This is an *acknowledged* partial state recorded in trace, not a contract conflict between issues. Recorded as Section 6 status row | `Grep step_num.*=.*21\|outputs.*debug\.json src/phase_z2_pipeline.py` = single producer at line 4762-4777 |
|
||||
| C5 | Phase R' (`src/renderer.py`, `src/content_editor.py`, `src/html_validator.py`, `src/block_selector.py`) <-> Phase Z (`src/phase_z2_*.py`) module boundary; no cross-import | `src/phase_z2_pipeline.py` (Phase Z entry) has zero imports of Phase R' modules; verified via `Grep "from renderer\|import renderer\|from phase_q\|from src\.renderer" src/phase_z2_pipeline.py` = `No matches found` | inverse direction `src/renderer.py` and `src/block_selector.py` have zero references to `phase_z2`; verified via `Grep phase_z2 src/renderer.py` = 0 and `Grep phase_z2 src/block_selector.py` = 0 | `#13` (build-time frame preview generator, scripts/ only); `#14` (slide-base iframe mode -- Phase Z only); `#16` (verification utility for Phase Z, no Phase R coupling); `#17` (AI carve-out, design-only no R coupling); `#18` (SVG gap report doc-only) | AGREE -- boundary clean both directions for the closed-issue scope. No Phase R' regression observed; Phase Z additions stay in `phase_z2_*.py` modules | `Grep` results above |
|
||||
| C6 | family templates count vs frame_contracts.yaml count (= 11 in tracked baseline); docs cite "family = 13" including 2 in-progress untracked files | `templates/phase_z2/families/*.html` tracked = 11 (`git ls-files templates/phase_z2/families/` produces 11 entries); `templates/phase_z2/catalog/frame_contracts.yaml` top-level entries = 11 (`grep -cE "^[a-z_]+:$"` = 11) | `src/phase_z2_mapper.py` PAYLOAD_BUILDERS / ITEM_PARSERS / COLUMN_BODY_PARSERS registries (mapper.py:10-16 docstring + 262 / 306 / 332 / 369 / 414 / 424 / 471 registry sites); render surface `templates/phase_z2/families/*.html` | `#4` (16 frame_partials + F17 paired_rows_4x2 schema + theme); `#5` (V4 fallback candidate pool dedup); `#13` (frame preview generator); `#18` (SVG gap report cites `families/*.html (13)`) | AGREE FOR TRACKED BASELINE -- 11 tracked family templates <-> 11 frame_contracts entries. SURFACE NOTE : 2 untracked WIP family templates (`app_sw_package_vs_solution.html`, `pre_construction_model_info_stacked.html`) exist on disk but are NOT in any closed issue and NOT yet contracted. IMP-18 doc "families/*.html (13)" is forward-looking, includes the 2 WIP files. No closed-issue contract is broken; documentation drift is recorded as Section 10 follow-up candidate F-2 | `git ls-files templates/phase_z2/families/` = 11; `ls templates/phase_z2/families/*.html` = 13 (2 untracked); `grep -cE "^[a-z_]+:$" frame_contracts.yaml` = 11 |
|
||||
|
||||
### 5.3 Cross-issue adjacency continuity (Section 3 pairs re-verified)
|
||||
|
||||
| Section 3 adjacent pair | invariant carrying the contract | live continuity verdict |
|
||||
|---|---|---|
|
||||
| `#2` Step 2 normalize -> `#3` Step 3 content_object input | C1 (debug.json `step02_*` + content_objects) | OK -- additive payload, schema preserved via `_write_step_artifact` |
|
||||
| `#3` content_object schema -> `#8` sub_sections schema | C1 + C4 (alias resolver state) | OK -- alias resolver covers 4 lookup sites (REPORT Section 3 row #8 evidence) |
|
||||
| `#4` catalog -> `#5` V4 fallback dedup | C3 + C6 (frame count + classifier consumer) | OK -- candidates[0] backward-compat verified by `tests/test_catalog_invariant.py` (REPORT Section 3 row #5) |
|
||||
| `#4` catalog -> `#10 / #11` `min_height_px` exposure | C1 + C6 | OK -- `min_height_px` is additive read-only field |
|
||||
| `#9` layout vocabulary -> `#12` retry donor selection | C3 + C4 (Step 17 cascade) | OK -- multi-donor cross-zone state lives inside Step 17 retry; spacing direction matches `feedback_phase_z_spacing_direction` (no common-shrink) |
|
||||
| `#9` layout vocabulary -> `#11` Step 9 min_height v4-all-judgments | C6 | OK -- guarded by `tests/test_phase_z2_step9_v4_all_judgments_min_height.py` |
|
||||
| `#45 + #46` Step 14 events -> `#47` Step 15 classifier -> Step 16 router | C2 + C3 + C4 | OK -- live trace `image_events` / `table_events` enter classifier at `classifier.py:429 / 453`, flow into router at `router.py:142` |
|
||||
| `#48` debug.json event surfacing -> `#21` (open, excluded) | C1 | OK for closed scope -- open consumer `#21` is outside audit window |
|
||||
| `#17` AI carve-out -> `#5 / #4` activation 3-cond AND gate | C5 (boundary not yet crossed) | OK -- gate is *closed* (`User GO AND B4 frame_selection evidence AND IMP-04/05 live`); no normal-path AI active |
|
||||
|
||||
### 5.4 Axis 3 summary
|
||||
|
||||
- 6 invariant categories evaluated. All AGREE for the closed-issue audit scope.
|
||||
- 2 surface notes recorded as Section 10 follow-up candidates :
|
||||
- **F-1** : issue body cites `src/phase_z2_mapper.py` for invariant C3 (`fit_classification`), but the live producer is `src/phase_z2_classifier.py`. Record-keeping correction needed in any future audit charter, not a code conflict. RESOLVED via IMP-53 (2026-05-19)
|
||||
- **F-2** : 2 untracked family templates exist on disk without `frame_contracts.yaml` entries; IMP-18 doc cites "families/*.html (13)" forward-looking. Tracked baseline (11 / 11) is consistent. Contract drift is *not* present for any closed issue; the WIP delta belongs to open work. RESOLVED via #52 option (c) (2026-05-19) -- WIP allowlist captured in `templates/phase_z2/families/_WIP_FILES.md`; tracked + contracted baseline unchanged at 11/11; promote / remove gated on #42.
|
||||
- 1 documented partial recorded :
|
||||
- Step 21 `_write_step_artifact` at `pipeline.py:4772` carries `step_status="partial"` with note `region marker partial 미주입 -- Step 21 ⚠ partial`. This is *self-honest acknowledged* per `feedback_artifact_status_naming`; no cross-issue conflict.
|
||||
- Phase R' <-> Phase Z boundary clean both directions for the 22 closed issues.
|
||||
- 0 Blocker findings in Axis 3.
|
||||
|
||||
### 5.5 Live-grep re-verification stamp (audit date 2026-05-19)
|
||||
|
||||
All numerical claims in Section 5.2 re-verified against live source on the audit date. Commands and results :
|
||||
|
||||
| Claim | Command | Live result | Status |
|
||||
|---|---|---|---|
|
||||
| C1 producer + consumer count | `Grep _write_step_artifact src/phase_z2_pipeline.py -n` | 1 definition (`pipeline.py:2593`) + 24 call sites at lines `2782, 2812, 2857, 2934, 3184, 3619, 3652, 3674, 3793, 3804, 3826, 3881, 4056, 4308, 4481, 4507, 4527, 4549, 4658, 4677, 4688, 4706, 4761, 4780` = 25 total occurrences | MATCH (Section 5.2 C1 row already lists all 24 call sites) |
|
||||
| C2 consumer scan | `Grep visual_check_passed src` | 14 hits across 3 files (`classifier.py:5`, `pipeline.py:6`, `router.py:3`) | MATCH (Section 5.2 C2 row says "14 hits across `classifier.py` + `router.py` + `pipeline.py`") |
|
||||
| C3 consumer scan | `Grep fit_classification src` | 30 hits across 4 files (`classifier.py:3`, `pipeline.py:20`, `retry.py:2`, `router.py:5`) | MATCH (Section 5.2 C3 row says "30 total occurrences across 4 files") |
|
||||
| C6 family templates -- tracked | `git ls-files templates/phase_z2/families/` | 11 entries | MATCH (Section 5.2 C6 row says "tracked = 11") |
|
||||
| C6 family templates -- on disk | `ls templates/phase_z2/families/*.html | wc -l` | 13 files (11 tracked + 2 WIP untracked : `app_sw_package_vs_solution.html`, `pre_construction_model_info_stacked.html`) | MATCH (Section 5.2 C6 row + F-2 follow-up candidate) |
|
||||
| C6 frame_contracts entries | `grep -cE "^[a-z_]+:$" templates/phase_z2/catalog/frame_contracts.yaml` | 11 | MATCH (Section 5.2 C6 row says "= 11") |
|
||||
|
||||
No discrepancy between Section 5.2 grep evidence and live code. Re-verification re-confirms u3 Axis 3 conclusion : 6 invariant categories all AGREE; 2 record-keeping follow-up candidates (F-1, F-2); 1 documented partial (Step 21); 0 Blocker findings.
|
||||
|
||||
---
|
||||
|
||||
## Section 6. Axis 4 -- Backlog vs code reality status matrix
|
||||
|
||||
**Method** : per closed issue, compare (a) `docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md` status column at audit time (live read 2026-05-19), (b) live src/ + templates/ + tests/ + docs/ evidence (grep hits + file existence), (c) the audit-allowed status enum `implemented | documented (deferred) | pending`, (d) mismatch flag.
|
||||
|
||||
**Per issue-body rule set** :
|
||||
- `implemented` -> live grep on `src/**` MUST show wired call site(s); not just a single declaration with no consumer.
|
||||
- `documented (deferred)` -> live grep on `src/**` MUST NOT show a production code path that assumes the feature is active (carve-out only).
|
||||
- `pending` -> live grep on `src/**` MUST NOT show wired implementation (or evidence shows incomplete).
|
||||
- `pending -> documented` flip -> reason cited in backlog row must match what `src/**` actually contains.
|
||||
|
||||
### 6.1 Backlog status legend (live read on audit date)
|
||||
|
||||
| backlog status | IMP rows under audit | meaning |
|
||||
|---|---|---|
|
||||
| `documented` | `IMP-18` (1 row) | doc-only carve-out, no production path |
|
||||
| `pending` | `IMP-02` through `IMP-17` (16 rows) | backlog status column has NOT been flipped, despite Gitea issue being closed |
|
||||
| (no backlog row) | `#45 / #46 / #47 / #48 / #49` (5 rows) | execution children of `#15`; backlog tracks the parent `IMP-15` only -- and `IMP-15` is itself still marked `pending` in §2 row |
|
||||
|
||||
**Headline Axis 4 finding** : `PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md` status column is **stale across the entire closed-issue audit scope** -- 16 of 22 audited issues are flagged `BACKLOG_STALE` (backlog `pending` vs Gitea closed + live code wired); additionally 5 of 22 carry `NO_BACKLOG_ROW` for the `#15` execution children (`#45-#49`), and only 1 of 22 (`#18`) is `AGREE`. Reconciliation: 16 `BACKLOG_STALE` + 5 `NO_BACKLOG_ROW` + 1 `AGREE` = 22 (matches Section 6.3 summary and the 15+1=16 flip plan in §6.3 follow-up reference). This is documentation drift, not a code-side contract conflict; recorded as Section 10 follow-up candidate `F-3`.
|
||||
|
||||
### 6.2 Axis 4 -- 22 row backlog vs code reality matrix
|
||||
|
||||
Status meaning (audit verdict column) :
|
||||
- `implemented_live` = backlog should be flipped to `implemented`; live src/ wiring proves it (grep evidence below).
|
||||
- `documented_live` = backlog `documented` matches code reality (doc-only carve-out; no prod path).
|
||||
- `child_of_parent` = no backlog row by design (execution child of parent IMP-15); status tracked via parent row.
|
||||
|
||||
Mismatch flag :
|
||||
- `BACKLOG_STALE` = backlog says `pending` but code is wired live. Documentation drift only; no code conflict.
|
||||
- `AGREE` = backlog status matches live code reality.
|
||||
- `NO_BACKLOG_ROW` = execution child, child not represented in backlog; not an error, but parent `IMP-15` row is itself stale.
|
||||
|
||||
| # | issue (title) | backlog status (live read) | audit verdict | mismatch flag | live grep evidence |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | `#2` IMP-02 A-1 Stage 0 normalize chained adapter | `pending` (§1 row 2) | `implemented_live` | BACKLOG_STALE | `Grep "normalize_mdx_content\|extract_major_sections\|extract_conclusion_text" src/` = 24 hits across 6 files (`mdx_normalizer.py`, `phase_z2_content_extractor.py`, `phase_z2_pipeline.py` 9 hits, `pipeline.py`, `pipeline_v2.py`, `section_parser.py`); commit `bac13c0` +165/-3 |
|
||||
| 2 | `#3` IMP-03 A-1 popup/image/table trace | `pending` (§1 row 3) | `implemented_live` | BACKLOG_STALE | `src/phase_z2_content_extractor.py` file exists (Glob hit); commit `fc3f7d8` |
|
||||
| 3 | `#4` IMP-04 A-2 catalog expansion | `pending` (§1 row 4) | `implemented_live` | BACKLOG_STALE | `git ls-files templates/phase_z2/families/` = 11 tracked; `frame_contracts.yaml` top-level entries = 11; commit `73a98b8` corrects F17 schema; matches Axis 3 C6 |
|
||||
| 4 | `#5` IMP-05 A-5 V4 fallback | `pending` (§1 row 5) | `implemented_live` | BACKLOG_STALE | `Grep "PASS_WITH_FALLBACK\|v4_fallback\|fallback_selection" src/` = 28 hits in `phase_z2_pipeline.py`; commits `15c5b9a`, `21476ae`, `23d1b25` |
|
||||
| 5 | `#6` IMP-06 B-1 Zone-section override | `pending` (§1 row 6) | `implemented_live` | BACKLOG_STALE | `Grep "replaced_auto_unit\|render_records\|zone_section_override" src/` = 33 hits in `phase_z2_pipeline.py`; commits `d596fab` / `b81e564` / `1f15495` / `52ccb7f` |
|
||||
| 6 | `#7` IMP-07 B-2 edited HTML to MDX reverse path | `pending` (§1 row 7) | `implemented_live` | BACKLOG_STALE | `Front/client/src/services/designAgentApi.ts` file exists (Glob hit); commit `0f0d3fa` |
|
||||
| 7 | `#8` IMP-08 B-3 sub-section drag-drop | `pending` (§1 row 8) | `implemented_live` | BACKLOG_STALE | `Grep "sub_sections\|sub_section_id\|subsection_alias" src/` = 14 hits across `block_assembler.py` (12) + `phase_z2_pipeline.py` (2); commits `a422d72` / `5191aca` / `ab2764c` / `8f6cffc` |
|
||||
| 8 | `#9` IMP-09 B-4 non-default layout zone-geometry | `pending` (§1 row 9) | `implemented_live` | BACKLOG_STALE | `Grep "build_layout_css\|preset_layout\|zone_geometry" src/` = 11 hits in `phase_z2_pipeline.py`; commits `201099e` / `1fb9732` |
|
||||
| 9 | `#10` IMP-10 D-1 filtered_section_reasons UI | `pending` (§1 row 10) | `implemented_live` | BACKLOG_STALE | `Grep "filtered_section_reasons" Front/` = 4 hits (`Home.tsx`, `designAgentApi.ts`); + `src/phase_z2_pipeline.py` 6 hits (read-only consumer); commit `0fb168b` +45 lines |
|
||||
| 10 | `#11` IMP-11 D-2 Frame min_height display | `pending` (§1 row 11) | `implemented_live` | BACKLOG_STALE | `Grep "min_height_px" src/` = 50 hits across 6 files (`block_reference.py`, `block_selector.py`, `fit_verifier.py`, `phase_z2_pipeline.py` 21 hits, `phase_z2_retry.py`, `space_allocator.py`); + Front/ 21 hits across 7 files including `SlideCanvas.tsx` (8); commit `a79bd8b` |
|
||||
| 11 | `#12` IMP-12 Step 16/17 retry refinement | `pending` (§2 row 12 IMP-12) | `implemented_live` | BACKLOG_STALE | `Grep "phase_z2_failure_router\|phase_z2_retry\|redistribute\|font_compression" src/` = 63 hits across 7 files (incl. `phase_z2_failure_router.py` 17, `phase_z2_retry.py` 16, `phase_z2_router.py` 6, `phase_z2_pipeline.py` 17); commit `56619a0` |
|
||||
| 12 | `#13` IMP-13 A-3 frame preview consistency | `pending` (§2 row 13) | `implemented_live` | BACKLOG_STALE | `scripts/generate_frame_previews.py` file exists (Glob hit); build-time only (scripts/, not runtime src/) -- matches `documented (deferred)` semantics for *runtime* path but verdict here = implemented_live because the script is the deliverable per issue body; commit `7d5639a` |
|
||||
| 13 | `#14` IMP-14 A-4 slide-base iframe mode | `pending` (§2 row 14) | `implemented_live` | BACKLOG_STALE | `templates/phase_z2/slide_base.html` file exists (Glob hit); `Grep "slide_base\|embedded_mode\|standalone_mode" src/` = 25 hits across 5 files (incl. `block_assembler.py` 8, `phase_z2_pipeline.py` 11); commit `7a52ceb` |
|
||||
| 14 | `#15` IMP-15 Step 14 visual_check reinforcement (PARENT) | `pending` (§2 row 15) | `implemented_live` (via children `#45-#49`) | BACKLOG_STALE | parent integration only; live code attribution belongs to child rows below (Stage 1 de-dup rule). All 4 child SHAs present in repo (`e9b3d2e` / `2827622` / `535c484` / `614c533`) |
|
||||
| 15 | `#16` IMP-16 B-2 verification helper axis | `pending` (§2 row 16) | `implemented_live` | BACKLOG_STALE | `src/phase_z2_verification_utils.py` file exists (Glob hit); `docs/architecture/IMP-16-U2-WIRING-DESIGN.md` exists; commit `23ba8b6` |
|
||||
| 16 | `#17` IMP-17 AI repair fallback infra (carve-out) | `pending` (§2 row 17) | `documented_live` | BACKLOG_STALE (status semantics) | `docs/architecture/IMP-17-CARVE-OUT.md` file exists (Glob hit); src/ runtime AI = 0 (verified Axis 3 C5 boundary); 3-cond AND gate closed; commit `e10ec36` -- 1 line in `src/phase_z2_pipeline.py` is comment anchor only, not a runtime path. Mismatch FLAG semantics : backlog says `pending`, but reality = `documented (deferred)`. The flag is BACKLOG_STALE *with status-class shift*, distinguished from rows above. |
|
||||
| 17 | `#18` IMP-18 I3 SVG coordinate reinforcement | `documented` (§2 row 18) | `documented_live` | AGREE | `docs/architecture/IMP-18-SVG-GAP-REPORT.md` file exists (Glob hit); pure doc carve-out; no `src/**` touched; commit `cbbc163` -- the ONLY closed audited issue whose backlog status already reflects code reality |
|
||||
| 18 | `#45` (`#15` execution-1) image_aspect_mismatch detection | no backlog row | `child_of_parent` | NO_BACKLOG_ROW | `tests/phase_z2/test_phase_z2_step14_image_check.py` file exists (Glob hit); `Grep "image_aspect_mismatch" src/` = 6 hits across `phase_z2_classifier.py` (2 : lines 426, 435) + `phase_z2_pipeline.py` (4 : lines 131, 2236, 2367, 4517); commit `e9b3d2e` |
|
||||
| 19 | `#46` (`#15` execution-2) table_self_overflow detection | no backlog row | `child_of_parent` | NO_BACKLOG_ROW | `tests/phase_z2/test_phase_z2_step14_table_check.py` file exists (Glob hit); `Grep "table_self_overflow" src/` = 3 hits all in `phase_z2_pipeline.py` (lines 136, 2282, 2386); commit `2827622` (commit-message label drift `feat(IMP-16)` flagged in Section 3 row 19) |
|
||||
| 20 | `#47` (`#15` execution-3) classifier consumer (image + table) | no backlog row | `child_of_parent` | NO_BACKLOG_ROW | `tests/phase_z2/test_phase_z2_visual_classifier.py` file exists (Glob hit); `Grep "classify_visual_runtime_check\|CONTENT_TYPE_PATTERNS" src/` = 8 hits across `phase_z2_classifier.py` (4) + `phase_z2_pipeline.py` (4); commit `535c484` |
|
||||
| 21 | `#48` (`#15` execution-4) debug.json event surfacing + spec doc + regression | no backlog row | `child_of_parent` | NO_BACKLOG_ROW | `Grep "step21_debug_index\|step21_debug" src/` = 1 hit (`phase_z2_pipeline.py`); `docs/architecture/PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md` has taxonomy row (Section 3 row 21 evidence); commit `614c533`; Axis 3 C4 confirms `image_events` / `table_events` end-to-end |
|
||||
| 22 | `#49` (`#15` execution-5) final integration + parent close | no backlog row | `child_of_parent` (verification-only) | NO_BACKLOG_ROW + close-timestamp anomaly (recorded Section 3 row 22) | verification-only per `#15` body; no new SHA; re-uses `614c533` evidence; no fresh grep needed |
|
||||
|
||||
### 6.3 Axis 4 summary
|
||||
|
||||
- **BACKLOG_STALE** rows : `#2 #3 #4 #5 #6 #7 #8 #9 #10 #11 #12 #13 #14 #15 #16 #17` = 16 rows (status column reads `pending` but live code is wired; for `#17` the right target status is `documented (deferred)` while for the other 15 it is `implemented`).
|
||||
- **AGREE** rows : `#18` = 1 row (the only issue whose backlog status truthfully reflects code reality).
|
||||
- **NO_BACKLOG_ROW** rows : `#45 #46 #47 #48 #49` = 5 rows (execution children, by-design no backlog row; parent `IMP-15` row exists but is itself BACKLOG_STALE).
|
||||
- **Total** : 16 + 1 + 5 = 22 rows (matches 22 closed issues under audit).
|
||||
- **Implementation-vs-documented split** (audit verdict, ignoring backlog wording) :
|
||||
- `implemented_live` (runtime path wired) : `#2 #3 #4 #5 #6 #7 #8 #9 #10 #11 #12 #13 #14 #15(via children) #16` = 15 rows
|
||||
- `documented_live` (doc-only / design-only carve-out, no runtime path) : `#17 #18` = 2 rows
|
||||
- `child_of_parent` (no backlog row, attribution via parent) : `#45-#49` = 5 rows
|
||||
- **0 Blocker findings in Axis 4.** No closed issue is `pending` *and* unimplemented; the only mismatches are documentation drift in the backlog status column.
|
||||
- **Cross-axis consistency** :
|
||||
- Axis 3 C6 frame count `11 tracked / 11 contract entries / 13 on disk (2 WIP)` matches the IMP-04 evidence in Axis 4 row 3 (BACKLOG_STALE but live code present).
|
||||
- Axis 3 C5 boundary (Phase R' <-> Phase Z) clean both ways re-confirms `#17 #18` as documented_live (no R' leak).
|
||||
- Axis 1 (Section 3) `Warning` rows `#6 #12 #15 #46 #49` are all still `implemented_live` in Axis 4 -- the warnings are about *blast radius* and *administrative drift*, not implementation absence.
|
||||
- **Follow-up candidate F-3** (Section 10) : `PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md` status column needs a sweep to flip 15 rows `pending` -> `implemented`, 1 row `pending` -> `documented (deferred)` for `IMP-17`, and either add child-row stubs for `#45-#49` or add a footnote on the `IMP-15` row pointing at the 5 execution children. This is a single-file documentation-only edit; orthogonal to source-code Stage 3 work; safe under audit-only scope (deferred to a separate follow-up issue, NOT this audit's u7 backlog row).
|
||||
|
||||
---
|
||||
|
||||
## Section 7. Representative pipeline runs
|
||||
|
||||
**Method** : run the Phase Z runtime entry (`python -m src.phase_z2_pipeline <mdx_path> <run_id>`) on the two locked samples (`samples/mdx_batch/03.mdx` smoke + `samples/mdx_batch/04.mdx` details+images). Per run capture (from `data/runs/<run_id>/phase_z2/debug.json`) : top-level keys, `slide_status.visual_check_passed`, `slide_status.overall`, zone count, per-zone frame template + slot keys + slot key count, `slide_status.visual_fail_reasons`, `slide_status.filtered_section_reasons`, `selection_paths`, `image_events` / `table_events` count. Compare invariants across both runs.
|
||||
|
||||
**Audit date** : 2026-05-19. Both runs are fresh on this audit pass (run_ids `audit50_run_03_smoke` + `audit50_run_04_details`).
|
||||
|
||||
### 7.1 Run #1 -- `samples/mdx_batch/03.mdx` (smoke baseline)
|
||||
|
||||
| field | value |
|
||||
|---|---|
|
||||
| `run_id` | `audit50_run_03_smoke` |
|
||||
| MDX title parsed | `DX 실행 체계 구축 방안` |
|
||||
| sections parsed | 2 (`03-1`, `03-2`) |
|
||||
| layout preset | `horizontal-2` (composition v0 count-based) |
|
||||
| mode | `composition_v0_layout_8preset` |
|
||||
| debug.json top-level keys | `composition_planner_debug`, `fit_classification`, `image_events`, `layout_css`, `layout_preset`, `mode`, `mode_note`, `mvp1_allowed_statuses`, `retry_trace`, `router_decision`, `slide_status`, `table_events`, `v4_label_to_phase_z_status`, `v4_source`, `visual_runtime_check`, `zone_geometries_px`, `zones` (17 keys) |
|
||||
| `slide_status.visual_check_passed` | `True` |
|
||||
| `slide_status.full_mdx_coverage` | `True` |
|
||||
| `slide_status.rendered` | `True` |
|
||||
| `slide_status.overall` | `PASS` |
|
||||
| `slide_status.visual_fail_reasons` | `[]` (empty) |
|
||||
| `slide_status.filtered_section_reasons` | `[]` (empty) |
|
||||
| `slide_status.fallback_selection_count` | `0` |
|
||||
| `fit_classification.visual_check_passed` | `True` (mirrors slide_status) |
|
||||
| `fit_classification.classifications` | `[]` |
|
||||
| `fit_classification.categories_seen` | `[]` |
|
||||
| `router_decision.action` | `None` (no retry path triggered) |
|
||||
| `image_events` count | `0` |
|
||||
| `table_events` count | `0` |
|
||||
| zone count | `2` |
|
||||
| zone[0] (top) | template `three_parallel_requirements` (frame 13), contract `three_parallel_requirements`, label `use_as_is`, slot keys `['pillars', 'title']` (2), sections `['03-1']`, `height_px=228`, `width_px=1180` |
|
||||
| zone[1] (bottom) | template `process_product_two_way` (frame 29), contract `process_product_two_way`, label `use_as_is`, slot keys `['banner_left', 'banner_right', 'process', 'product', 'title']` (5), sections `['03-2']`, `height_px=343`, `width_px=1180` |
|
||||
| `selection_paths` | both `rank_1` (no fallback) |
|
||||
| fail / overflow events | none |
|
||||
|
||||
### 7.2 Run #2 -- `samples/mdx_batch/04.mdx` (details + images)
|
||||
|
||||
| field | value |
|
||||
|---|---|
|
||||
| `run_id` | `audit50_run_04_details` |
|
||||
| MDX title parsed | `DX 지연 요인` |
|
||||
| sections parsed | 2 (`04-1`, `04-2`) |
|
||||
| sections aligned | 3 (`04-1`, `04-2-sub-1`, `04-2-sub-2`) -- IMP-08 sub_section schema active |
|
||||
| layout preset | `single` (composition v0 count-based; only 1 unit survived filtering) |
|
||||
| mode | `composition_v0_layout_8preset` |
|
||||
| debug.json top-level keys | identical 17 keys as Run #1 (`composition_planner_debug`, `fit_classification`, `image_events`, `layout_css`, `layout_preset`, `mode`, `mode_note`, `mvp1_allowed_statuses`, `retry_trace`, `router_decision`, `slide_status`, `table_events`, `v4_label_to_phase_z_status`, `v4_source`, `visual_runtime_check`, `zone_geometries_px`, `zones`) |
|
||||
| `slide_status.visual_check_passed` | `True` |
|
||||
| `slide_status.full_mdx_coverage` | `False` |
|
||||
| `slide_status.rendered` | `True` (partial artifact -- viable units only) |
|
||||
| `slide_status.overall` | `PARTIAL_COVERAGE` |
|
||||
| `slide_status.visual_fail_reasons` | `[]` (visual side OK; coverage failure is upstream of visual_check) |
|
||||
| `slide_status.filtered_section_reasons` | `[]` (filtering recorded via `selection_paths` chain_exhausted / no_v4_candidate, not via `filtered_section_reasons`) |
|
||||
| `slide_status.fallback_selection_count` | `0` |
|
||||
| `fit_classification.visual_check_passed` | `True` |
|
||||
| `fit_classification.classifications` | `[]` |
|
||||
| `fit_classification.categories_seen` | `[]` |
|
||||
| `router_decision.action` | `None` |
|
||||
| `image_events` count | `0` |
|
||||
| `table_events` count | `0` |
|
||||
| zone count | `1` (single preset) |
|
||||
| zone[0] (primary) | template `bim_issues_quadrant_four` (frame 16), contract `bim_issues_quadrant_four`, label `light_edit`, slot keys `['quadrant_1_body', 'quadrant_1_label', 'quadrant_2_body', 'quadrant_2_label', 'quadrant_3_body', 'quadrant_3_label', 'quadrant_4_body', 'quadrant_4_label', 'title']` (9), sections `['04-2-sub-2']`, `height_px=585`, `width_px=1180` |
|
||||
| `selection_paths` | `04-1=chain_exhausted`, `04-2-sub-1=chain_exhausted`, `04-2-sub-2=rank_1`, `04-2=no_v4_candidate` |
|
||||
| fail / overflow events | none |
|
||||
|
||||
### 7.3 Cross-run invariants
|
||||
|
||||
| invariant | run #1 (03.mdx) | run #2 (04.mdx) | verdict |
|
||||
|---|---|---|---|
|
||||
| debug.json top-level key set | 17 keys (above) | identical 17 keys | AGREE -- Step 21 schema stable across both runs (Axis 3 C1) |
|
||||
| `slide_status` schema keys | 19 keys (`visual_check_passed`, `full_mdx_coverage`, `rendered`, `overall`, `visual_fail_reasons`, `filtered_section_ids`, `filtered_section_reasons`, `aligned_section_ids`, `covered_section_ids`, `adapter_needed_count`, `adapter_needed_units`, `content_truncated_count`, `content_truncated_units`, `fallback_selection_count`, `fallback_selections`, `fallback_used`, `selection_path`, `selection_paths`, `note`) | identical 19 keys | AGREE -- slide_status surface stable (Axis 3 C2 + C4) |
|
||||
| `fit_classification` shape | `{visual_check_passed, classifications, summary, categories_seen, ...}` -- matches Axis 3 C3 row | identical shape | AGREE -- classifier output schema invariant |
|
||||
| `visual_check_passed` semantic | `True` AND `classifications=[]` -> overall `PASS` | `True` AND `classifications=[]` AND `full_mdx_coverage=False` -> overall `PARTIAL_COVERAGE` | AGREE -- visual side passing under both runs; `PARTIAL_COVERAGE` is composition-planner side (upstream of Step 14), so visual_check_passed does NOT contradict overall status (Axis 3 C2 verdict re-confirmed) |
|
||||
| zone count vs layout preset | `horizontal-2` -> 2 zones (top + bottom) | `single` -> 1 zone (primary) | AGREE -- preset-to-zone arity matches IMP-09 B-4 vocabulary (Axis 1 row 8) |
|
||||
| frame contract resolution | both zones resolved to a contract id (rank_1 path) | only 1 of 4 selection paths resolved (3 `chain_exhausted` / `no_v4_candidate`) | DIFF EXPECTED -- 04.mdx exhibits v4 candidate gap; this is the composition-planner maturity gap (not in any closed-issue scope). Not a contract conflict. |
|
||||
| `image_events` / `table_events` arity | both = 0 | both = 0 | AGREE -- neither sample triggers Step 14 image/table self-overflow; `#45` `image_aspect_mismatch` and `#46` `table_self_overflow` event-arrays exist in the schema and are *correctly empty* when no overflow is detected |
|
||||
| router action triggered | `None` | `None` | AGREE -- Step 16 router is dormant when classifications=[] (Axis 3 C3 verdict re-confirmed; `#12` retry cascade not exercised by these samples) |
|
||||
| pipeline final banner | `PASS` (full MDX coverage + visual OK) | `PARTIAL_COVERAGE` (visual OK + composition-planner filter) | self-honest status naming per [[feedback_artifact_status_naming]] |
|
||||
|
||||
### 7.4 Run-level findings
|
||||
|
||||
- Both runs pass the visual_check axis (Axis 3 C2 contract). `visual_check_passed=True` agrees between `fit_classification` and `slide_status` mirror in both runs.
|
||||
- 04.mdx `PARTIAL_COVERAGE` is a composition-planner side filter (3 sections drop to `chain_exhausted` / `no_v4_candidate` before reaching Step 14). This is NOT an audit Blocker because (a) no closed issue under audit targets composition-planner coverage, (b) the status field is self-honestly named `PARTIAL_COVERAGE` rather than misnamed `PASS` (matches [[feedback_artifact_status_naming]]).
|
||||
- Step 21 `debug.json` writer surfaces a stable 17-key top-level surface across both runs; Axis 3 C1 invariant re-confirmed at runtime.
|
||||
- Zero Blocker findings in Section 7.
|
||||
|
||||
---
|
||||
|
||||
## Section 8. Anti-hardcoding grep checklist
|
||||
|
||||
**Method** : run the 6 anti-hardcoding patterns enumerated in the Issue #50 body. For each, capture the live hit set, classify hits (Phase Z scope vs. legacy Phase R'/Q out-of-scope vs. docstring/comment vs. test fixture), then return a verdict. Raw output preserved at `D:\ad-hoc\kei\design_agent\.orchestrator\tmp\50_grep_checklist_raw.txt` (evidence-only, not staged for commit per Stage 3 directive).
|
||||
|
||||
**Audit date** : 2026-05-19. Searched against tracked source on this date.
|
||||
|
||||
### 8.1 Checklist
|
||||
|
||||
| # | pattern (issue body) | expected | live hit count (src/) | hit classification | verdict |
|
||||
|---|---|---|---|---|---|
|
||||
| G1 | `grep -E 'if .* == ["'\\''].*\.mdx' src/` | 0 hits | 0 | none | PASS |
|
||||
| G2 | `grep -E 'OVERRIDES\s*=\s*\{' src/` | each match sample-agnostic | 0 | none | PASS (vacuously sample-agnostic) |
|
||||
| G3 | `grep -E '재구성\|건설산업 DX\|BIM' src/` -- sample text leak | 0 hits | 31 source hits across 14 `.py` files (binary `.pyc` matches ignored) | (a) 20 hits in legacy Phase R'/Q files (`block_assembler_b2.py` 1, `block_matcher_tfidf.py` 1, `block_reference.py` 3, `content_editor.py` 3, `design_director.py` 2, `design_tokens.py` 1, `fit_verifier.py` 1, `frame_extractor.py` 1, `kei_client.py` 4, `pipeline.py` 3) -- pre-Phase-Z; not in audit window; (b) 11 hits in Phase Z files (`phase_z2_content_extractor.py` 7 -- all inside `if __name__ == "__main__"` self-test data blocks at lines 466/493/511/556/565/573/591; `phase_z2_failure_router.py:123` 1 -- internal taxonomy string `"topology 부터 재구성. frame_reselect 는 그 다음 단계"`; `phase_z2_mapper.py:519/529` 2 -- docstring examples; `phase_z2_retry.py:59` 1 -- docstring). Per-file count sum = 20 + 11 = 31, matching the live total. | PASS for the audit scope -- **0 closed-issue (#2-#18 + #45-#49)** introduces new sample-specific hardcoded BIM/재구성/건설산업 string literals into runtime code paths. All 11 Phase Z hits are docstring/taxonomy/self-test fixtures, none injected into runtime contracts. Legacy 20 hits are out of audit window. Recorded as Section 10 follow-up candidate `F-4` for future cleanup (doc-only, optional). |
|
||||
| G4 | `grep -E 'height\s*=\s*720\|aspect\s*=\s*0\.5' src/` -- magic literal pinning | 0 hits | 0 | none | PASS |
|
||||
| G5 | sample paths come from CLI args / config, not hardcoded | sample-agnostic | 4 occurrences across 2 files (`src/block_assembler.py:1390/1393` + `src/image_utils.py:62/65`) | all 4 hits use `samples/mdx_batch` as one of several **generic asset search directories** alongside `samples/images` (image asset discovery fallback). The directory is treated as a discovery namespace, not as a path to a specific MDX file. CLI entry (`src/phase_z2_pipeline.py:4861`) takes `mdx_path` as positional arg -- no hardcoded MDX path on the runtime entry. | PASS -- sample-agnostic asset discovery default; not a per-sample pin. |
|
||||
| G6 | `tests/` : sample-specific fixtures only under `tests/fixtures/`, not in production pipeline | fixtures isolated | `tests/fixtures/` directory does not exist; closest hits = `tests/phase_z2/test_pz2_vu_integration.py:6, 82` referencing `samples/mdx_batch/02.mdx` as smoke-coverage MDX | the references in `test_pz2_vu_integration.py` are inside a verification-utility integration test (`#16` IMP-16 scope). The test file is named with the test prefix and lives in `tests/phase_z2/`, so pytest discovery treats it as a test, not as a production module. No production pipeline file imports a sample MDX path literal. | PASS WITH NOTE -- no `tests/fixtures/` directory exists today; the existing integration tests already keep sample references inside `tests/phase_z2/test_*.py`, which discharges the spirit of the rule. Optional follow-up: formalize a `tests/fixtures/` directory if sample inventory grows. Recorded as Section 10 follow-up candidate `F-5` (low priority, doc-only). |
|
||||
|
||||
### 8.2 Anti-hardcoding verdict
|
||||
|
||||
- 4 patterns PASS cleanly with 0 hits (G1, G2, G4) and 1 PASS with sample-agnostic hits (G5).
|
||||
- 1 pattern PASS-for-audit-scope with classification (G3) : 11 Phase Z hits are all docstrings/taxonomy/self-test fixtures; 20 legacy hits are out of the 22-closed-issue audit window. Per-file counts sum to 31, matching the live grep total. No closed issue introduces new hardcoded sample text into a runtime code path.
|
||||
- 1 pattern PASS WITH NOTE (G6) : `tests/fixtures/` directory not yet established; existing integration test references stay inside `tests/phase_z2/`. Already aligned with the spirit of the rule.
|
||||
- **0 Blocker findings in Section 8.**
|
||||
- Cross-axis : the F-4 / F-5 follow-up candidates are doc-only optional cleanup; they do not alter any closed-issue contract.
|
||||
|
||||
---
|
||||
|
||||
## Section 9. Final decision
|
||||
|
||||
**Decision** : **CONDITIONAL GO for #19**.
|
||||
|
||||
### 9.1 Summary across all 4 audit axes + supporting sections
|
||||
|
||||
| section | axis | Blocker | Warning | OK | follow-up candidates |
|
||||
|---|---|---|---|---|---|
|
||||
| §3 | Axis 1 -- scope myopia | 0 | 5 (`#6 #12 #15 #46 #49`) | 17 | none Blocker; warnings are blast-radius + administrative drift |
|
||||
| §4 + MATRIX.md | Axis 2 -- 22 x 22 pipeline matrix | 0 | (9 hotspot steps, 2 expected-empty cols) | 22 issues mapped | none Blocker; hotspots match expected Step 14 / 21 attention |
|
||||
| §5 | Axis 3 -- cross-issue conflict (6 invariants) | 0 | 0 | 6 categories AGREE | F-1 (body cites mapper.py; live producer is classifier.py for `fit_classification`); F-2 (13 family templates on disk vs. 11 tracked / contracted -- 2 WIP outside any closed issue) |
|
||||
| §6 | Axis 4 -- backlog vs code reality | 0 | -- | 1 AGREE, 16 BACKLOG_STALE (doc drift), 5 NO_BACKLOG_ROW (by design for `#45-#49`) | F-3 (backlog status sweep : flip 15 rows `pending` -> `implemented`, 1 row `pending` -> `documented (deferred)` for IMP-17, footnote `IMP-15` row with 5 children) |
|
||||
| §7 | representative runs (03.mdx + 04.mdx) | 0 | -- | both runs visual_check_passed = True; debug.json schema stable; 04.mdx PARTIAL_COVERAGE is composition-planner side (no audit-window contract conflict) | none new |
|
||||
| §8 | grep checklist (6 patterns from issue body) | 0 | -- | G1/G2/G4/G5 PASS; G3/G6 PASS WITH NOTE | F-4 (legacy Phase R'/Q BIM literals -- optional cleanup); F-5 (formalize `tests/fixtures/` -- optional) |
|
||||
| §2 | baseline pytest | -- | -- | 303 passed BEFORE + 303 passed AFTER audit | none |
|
||||
|
||||
### 9.2 Blocker tally
|
||||
|
||||
- **0 Blocker** findings across all four axes and all supporting sections.
|
||||
- 5 Warning rows in §3 are about blast radius (`#6 #12`), administrative commit-label drift (`#46`), and parent/child close-timestamp anomaly (`#15 #49`). None of them indicate broken code contracts.
|
||||
- All BACKLOG_STALE rows in §6 are documentation drift, not implementation absence. Live grep on `src/**` confirms each closed issue is wired (or carved-out as designed for `#17 #18`).
|
||||
- 5 follow-up candidates (F-1 .. F-5) are all doc-only. None require source code changes.
|
||||
|
||||
### 9.3 Why CONDITIONAL GO, not unconditional GO
|
||||
|
||||
Audit found zero Blocker, but the conditions for upgrading to unconditional GO are not met because:
|
||||
|
||||
1. **F-3 (backlog sweep)** is the largest doc-drift surface (16 of 22 audited rows mislabeled). Issue #19 will read the backlog when scoping next-step coverage; running #19 against a stale backlog risks a planner who treats already-implemented features as still pending. The F-3 follow-up should be filed and merged before -- or at minimum in parallel with -- #19 Stage 2 planning.
|
||||
2. **F-2 (family template count drift)** matters if #19 touches the catalog / `frame_contracts.yaml` (likely). The audit confirms 11 tracked entries are consistent today, but #19 should reconcile the 2 WIP files (`app_sw_package_vs_solution.html`, `pre_construction_model_info_stacked.html`) before adding any new family templates.
|
||||
3. **F-1 (record-keeping for invariant C3 producer file path)** -- a small but real mismatch between the issue body wording (`src/phase_z2_mapper.py`) and the live producer (`src/phase_z2_classifier.py`). Should be fixed in the audit charter / spec doc before the next integration audit so future audits do not repeat the same drift check.
|
||||
|
||||
F-4 / F-5 are optional and do not gate #19.
|
||||
|
||||
### 9.4 Conditions to satisfy for #19 progression
|
||||
|
||||
- File F-1 / F-2 / F-3 as Section 10 follow-up issues (text-only drafts produced in u6).
|
||||
- F-3 backlog sweep should land before #19 Stage 2 (so #19 plans against accurate status).
|
||||
- F-2 family template reconciliation should land before #19 introduces new family templates (whichever comes first).
|
||||
- F-1 is a one-line spec-doc edit, can land any time before the next INTEGRATION-AUDIT issue is opened.
|
||||
|
||||
### 9.5 Decision sentence
|
||||
|
||||
> **Issue #19 is approved for entry under CONDITIONAL GO**, with the explicit dependency that follow-up F-3 (backlog status sweep) must land before #19 Stage 2 planning consumes the backlog, and F-2 (family template reconciliation) must land before any #19 work that extends the catalog. No production source code change is required from this audit. Pytest baseline stable (303 passed BEFORE + AFTER).
|
||||
|
||||
---
|
||||
|
||||
## Section 10. Follow-up issue drafts (text-only, not auto-posted)
|
||||
|
||||
**Scope rule (Stage 2 u6 contract)** : per-draft fields = `title` + `source_axis` (1-4) + `scope` (what files / what change) + `evidence_link` (REPORT section that produced the finding). **No Gitea post.** Final disposition of each candidate is the orchestrator / human triage decision after #50 closes; this REPORT only records the audit-side text.
|
||||
|
||||
Five candidates were produced by Axes 1-4. F-3 + F-2 + F-1 are blocking conditions for upgrading §9 CONDITIONAL GO -> unconditional GO for #19; F-4 + F-5 are optional housekeeping. None require source-code changes inside this audit.
|
||||
|
||||
### 10.1 F-1 -- audit charter record-keeping : invariant C3 producer file path -- RESOLVED via IMP-53 (2026-05-19)
|
||||
|
||||
- **title** : `[AUDIT-CHARTER-FIX] invariant C3 (fit_classification) producer cited as src/phase_z2_mapper.py; live producer is src/phase_z2_classifier.py`
|
||||
- **source_axis** : Axis 3 (cross-issue conflict, invariant category C3) -- recorded in §5.2 C3 row + §5.4 follow-up bullet F-1.
|
||||
- **scope** :
|
||||
- one-line fix in any future INTEGRATION-AUDIT-* issue body or in `docs/architecture/PHASE-Z-PIPELINE-OVERVIEW.md` if it cites the wrong file for `fit_classification` producer.
|
||||
- replace text `src/phase_z2_mapper.py` -> `src/phase_z2_classifier.py` *only in the context of `fit_classification` invariant* (mapper.py legitimately owns slot payload and registries, so do not blanket-rename).
|
||||
- update audit charter template (if one exists) so the next integration audit does not repeat the drift check.
|
||||
- **scope-lock** : **doc-only**, zero `src/**` / `templates/**` / `tests/**` edits.
|
||||
- **evidence_link** :
|
||||
- REPORT §5.2 row C3 (issue body wording vs. live producer).
|
||||
- REPORT §5.4 follow-up bullet F-1.
|
||||
- Live producer site : `src/phase_z2_classifier.py:495-497` (return dict with `visual_check_passed`, `classifications`, `summary`, `categories_seen`, `unclassified_signals`, `placement_diagnostics`).
|
||||
- **priority / gating** : low priority on its own; required for charter cleanliness; **not** a blocker for #19 Stage 2.
|
||||
|
||||
### 10.2 F-2 -- family template count reconciliation : 11 tracked / 11 contracted / 13 on disk -- RESOLVED via #52 (option c, 2026-05-19)
|
||||
|
||||
- **title** : `[FAMILY-TEMPLATE-RECONCILE] templates/phase_z2/families/ has 13 .html files on disk but 11 tracked + 11 frame_contracts entries; 2 WIP files (app_sw_package_vs_solution.html, pre_construction_model_info_stacked.html) untracked`
|
||||
- **source_axis** : Axis 3 (invariant category C6 template / catalog / frame count) -- recorded in §5.2 C6 row + §5.4 follow-up bullet F-2 + §6.3 Axis 4 cross-axis consistency bullet.
|
||||
- **scope** :
|
||||
- decide whether the 2 untracked WIP family templates (`app_sw_package_vs_solution.html`, `pre_construction_model_info_stacked.html`) should be (a) tracked + contracted (add to `frame_contracts.yaml`, add to `git ls-files`), (b) removed if abandoned, or (c) explicitly noted as in-progress with a parent issue.
|
||||
- reconcile the IMP-18 SVG-gap report doc citation `families/*.html (13)` against whichever decision is chosen (so the doc count matches code reality).
|
||||
- **scope-lock** : touches `templates/phase_z2/families/*.html`, `templates/phase_z2/catalog/frame_contracts.yaml`, `docs/architecture/IMP-18-SVG-GAP-REPORT.md`. **Must NOT be folded into #19 silently**: any catalog growth needs a dedicated issue per [[feedback_workflow_atomicity_rules]] (one commit = one decision).
|
||||
- **evidence_link** :
|
||||
- REPORT §5.2 row C6 ("AGREE FOR TRACKED BASELINE -- 11 tracked family templates <-> 11 frame_contracts entries").
|
||||
- REPORT §5.5 row "C6 family templates -- on disk" (`ls templates/phase_z2/families/*.html` = 13).
|
||||
- REPORT §6.3 Axis 4 cross-axis consistency bullet (matches IMP-04 evidence).
|
||||
- **priority / gating** : **must land before #19 introduces any new family template** (per §9.3 condition 2). Until #19's catalog touch surface is known, this can be filed independently.
|
||||
- **resolution** : option (c) -- 2 WIP family templates explicitly noted as in-progress and tracked outside `frame_contracts.yaml` (RESOLVED via Gitea #52, 2026-05-19) :
|
||||
- WIP allowlist : `templates/phase_z2/families/_WIP_FILES.md` (added by #52 u1) -- names both files with Figma frame IDs (`app_sw_package_vs_solution.html` -> frame 23 / `1171281203`; `pre_construction_model_info_stacked.html` -> frame 9 / `1171281180`) and explicit "not in `frame_contracts.yaml`, not in runtime matcher set" status; promote / remove gated on Gitea #42.
|
||||
- IMP-18 doc reconciled : `docs/architecture/IMP-18-SVG-GAP-REPORT.md` L28 + L30 + L51 corrected from disk-only "13 files" / "15 partials" wording to "11 contracted + 2 WIP untracked = 13 on disk" (#52 u2) -- runtime matcher consumes the contracted set only; doc / tracked / contracted surfaces agree at 11 active.
|
||||
- baseline guard (planned by #52 u4) : `tests/test_family_contract_baseline.py` will enforce tracked families <-> `frame_contracts.yaml` 1:1 set-equality modulo WIP allowlist parsed from `_WIP_FILES.md`; future drift (#42 or otherwise) fails CI.
|
||||
- tracked baseline (11 contracted families <-> 11 `frame_contracts.yaml` entries) unchanged; no contract entries added or removed; no runtime matcher mutation; **C6 invariant remains AGREE** for the closed-issue audit scope.
|
||||
- **F-2 closed-by-#52** under [[feedback_workflow_atomicity_rules]] (one commit = one decision unit), without re-opening any §5 C-invariant or §6.3 Axis 4 conclusion. #19 catalog-touch gate (per §9.3 condition 2) is now satisfied for the current 11/11 baseline; any #19 / #42 catalog growth must reconcile the WIP allowlist before merge.
|
||||
|
||||
### 10.3 F-3 -- backlog status sweep : 15 rows pending->implemented + 1 row pending->documented(deferred) + IMP-15 children footnote
|
||||
|
||||
- **title** : `[BACKLOG-STATUS-SWEEP] PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md has 16 of 22 audited rows mislabeled as pending; flip 15 to implemented, 1 (IMP-17) to documented (deferred), footnote IMP-15 with 5 execution children`
|
||||
- **source_axis** : Axis 4 (backlog vs code reality) -- recorded in §6.1 headline finding + §6.2 22-row matrix + §6.3 follow-up candidate F-3 + §9.1 §6 row + §9.3 condition 1.
|
||||
- **scope** :
|
||||
- **15 rows** to flip `pending` -> `implemented` : IMP-02, IMP-03, IMP-04, IMP-05, IMP-06, IMP-07, IMP-08, IMP-09, IMP-10, IMP-11, IMP-12, IMP-13, IMP-14, IMP-15 (parent), IMP-16.
|
||||
- **1 row** to flip `pending` -> `documented (deferred)` : IMP-17 (status-class shift; runtime AI = 0, 3-cond AND gate closed; matches §6.2 row 16).
|
||||
- **IMP-15 row** : add inline footnote citing the 5 execution children commits `#45 (e9b3d2e)`, `#46 (2827622)`, `#47 (535c484)`, `#48 (614c533)`, `#49 (verification-only, re-uses 614c533)`. Either as a footnote on the IMP-15 row or as 5 child stub rows -- pick one and apply consistently.
|
||||
- **IMP-18 row** : leave as `documented` (already AGREE per §6.2 row 17).
|
||||
- **scope-lock** : single-file edit to `docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md`. **Doc-only**, zero `src/**` / `templates/**` / `tests/**` edits. Must be filed as a separate Gitea issue with its own Stage 5 commit (not merged into #19 or any other improvement issue).
|
||||
- **evidence_link** :
|
||||
- REPORT §6.1 headline finding (16 BACKLOG_STALE + 5 NO_BACKLOG_ROW + 1 AGREE = 22).
|
||||
- REPORT §6.2 22-row matrix (per-row grep evidence + commit SHAs).
|
||||
- REPORT §6.3 follow-up reference.
|
||||
- REPORT §9.1 / §9.3 condition 1 ("F-3 backlog sweep should land before #19 Stage 2 planning consumes the backlog").
|
||||
- **priority / gating** : **highest of the 5 candidates**. **Must land before #19 Stage 2 planning** (per §9.3 condition 1) so that #19's planner reads accurate `implemented` / `documented (deferred)` status and does not treat already-wired features as still pending.
|
||||
|
||||
### 10.4 F-4 -- legacy Phase R' / Q sample-literal cleanup (OPTIONAL)
|
||||
|
||||
- **title** : `[LEGACY-LITERAL-CLEANUP] 20 hits of 재구성 / 건설산업 DX / BIM across 10 legacy Phase R'/Q files (block_assembler_b2.py, block_matcher_tfidf.py, block_reference.py, content_editor.py, design_director.py, design_tokens.py, fit_verifier.py, frame_extractor.py, kei_client.py, pipeline.py)`
|
||||
- **source_axis** : Axis-supporting Section 8 (anti-hardcoding grep checklist) -- recorded in §8.1 row G3 + §8.2 third bullet + §9.1 §8 row.
|
||||
- **scope** :
|
||||
- per-file review of the 20 legacy hits to determine which are docstrings / comments (keep), legacy taxonomy (keep with annotation), or true sample-literal pins (remove or generalize).
|
||||
- per-file counts to triage : `block_assembler_b2.py` 1, `block_matcher_tfidf.py` 1, `block_reference.py` 3, `content_editor.py` 3, `design_director.py` 2, `design_tokens.py` 1, `fit_verifier.py` 1, `frame_extractor.py` 1, `kei_client.py` 4, `pipeline.py` 3.
|
||||
- **NOT** touching the 11 Phase Z hits (`phase_z2_content_extractor.py` 7 self-test data, `phase_z2_failure_router.py:123` taxonomy, `phase_z2_mapper.py:519/529` docstring examples, `phase_z2_retry.py:59` docstring) -- those passed audit verdict G3.
|
||||
- **scope-lock** : potentially touches legacy `src/**` files NOT in the Phase Z 22-step pipeline. Must be filed as a deliberate cleanup issue with its own scope-lock. If any file flagged here turns out to be on a live Phase Z code path on review, demote the candidate or split it.
|
||||
- **evidence_link** :
|
||||
- REPORT §8.1 row G3 (per-file count breakdown, audit date 2026-05-19).
|
||||
- REPORT §8.2 third bullet (20 legacy + 11 Phase Z = 31, reconciliation).
|
||||
- Raw grep output : `D:\ad-hoc\kei\design_agent\.orchestrator\tmp\50_grep_checklist_raw.txt`.
|
||||
- **priority / gating** : **optional, low priority, doc-only follow-up note**. Does NOT gate #19; §8 verdict already PASS for audit scope. Recorded for completeness so future audits do not re-discover the same 20-hit baseline.
|
||||
|
||||
### 10.5 F-5 -- formalize tests/fixtures/ directory (OPTIONAL)
|
||||
|
||||
- **title** : `[TESTS-FIXTURES-FORMALIZE] tests/fixtures/ directory does not exist; sample MDX references currently live in tests/phase_z2/test_pz2_vu_integration.py`
|
||||
- **source_axis** : Axis-supporting Section 8 (anti-hardcoding grep checklist G6) -- recorded in §8.1 row G6 + §8.2 fourth bullet.
|
||||
- **scope** :
|
||||
- if-and-only-if sample inventory grows beyond what fits inside `tests/phase_z2/test_*.py` files, formalize a `tests/fixtures/` directory holding sample-specific fixtures.
|
||||
- migrate existing `samples/mdx_batch/02.mdx` references in `tests/phase_z2/test_pz2_vu_integration.py:6, 82` only if migration is part of a broader test-fixture refactor (otherwise leave them as integration smoke).
|
||||
- update the issue-body rule wording to acknowledge that `tests/phase_z2/test_*.py` already discharges the spirit of "no sample-specific fixtures in production pipeline".
|
||||
- **scope-lock** : touches `tests/fixtures/` (new directory if filed) + the cited test files. Must NOT be folded into any unrelated test refactor.
|
||||
- **evidence_link** :
|
||||
- REPORT §8.1 row G6 verdict "PASS WITH NOTE".
|
||||
- REPORT §8.2 fourth bullet (`tests/fixtures/` not yet established).
|
||||
- **priority / gating** : **optional, very low priority**. Filing is only justified when sample inventory grows; the current state is already aligned with the spirit of the rule.
|
||||
|
||||
#### 10.5.1 F-5 docs-only resolution addendum (#54 Stage 3 u5, 2026-05-19)
|
||||
|
||||
Per issue #54 Stage 2 plan, F-5 is closed as **docs-only**; no root `tests/fixtures/` directory is created in this work. The current fixture inventory does not justify migration, and the existing convention is sufficient. The convention is recorded here so future anti-hardcoding audits can distinguish fixture / test-only paths from production paths without re-discovering the §8 G6 PASS-WITH-NOTE baseline.
|
||||
|
||||
- **Existing convention (DO NOT CHANGE)** : `tests/phase_z2/fixtures/` exists as a YAML regression fixture root (loaded by `tests/phase_z2/test_fixtures_loader.py`). Subdirectories present at audit time : `tests/phase_z2/fixtures/build_layout_css/`, `tests/phase_z2/fixtures/retry_gate/`. This is the canonical home for Phase Z regression fixtures.
|
||||
- **Root `tests/fixtures/` (ABSENT)** : not created in #54. If a future change requires a non-Phase-Z, non-YAML fixture corpus (for example, multi-file MDX golden inputs that grow beyond what `tests/phase_z2/test_*.py` can hold inline), the migration must be filed as its own Gitea issue with its own scope-lock per §10.5.
|
||||
- **Allowed sample references** : `samples/mdx_batch/**` and `samples/mdx/**` may be referenced from `tests/**` (test-only paths) for integration smoke -- e.g. the existing `samples/mdx_batch/02.mdx` references in `tests/phase_z2/test_pz2_vu_integration.py`. These do not violate the §8 anti-hardcoding rule because the spirit of the rule targets production pipeline code, not test runners.
|
||||
- **Forbidden sample references** : production pipeline code (`src/**` runtime path) must NOT hardcode sample-specific MDX filenames or content (e.g. `02.mdx`, `03.mdx`, frame-specific labels keyed to a sample). The 20 legacy Phase R'/Q hits annotated under F-4 (#54 Stage 3 u1-u4) are intentional documented examples in docstrings / comments / glossary regex / sample-data dicts, not runtime input pins; they are out of scope for this rule by §10.4 verdict.
|
||||
- **AI-isolation contract** : this addendum is text-only. No production behavior change, no runtime sample-path mutation, no new fixture file. Compatible with PZ-1 (AI = 0 on normal path) and [[feedback_ai_isolation_contract]].
|
||||
- **Cross-reference** : `tests/CLAUDE.md` fixture convention note (#54 Stage 3 u5) mirrors the test-only / production rule split documented here.
|
||||
|
||||
### 10.6 Follow-up summary
|
||||
|
||||
| candidate | source axis | doc-only? | gates #19? | priority |
|
||||
|---|---|---|---|---|
|
||||
| F-1 audit-charter producer file path | Axis 3 (§5) | YES | NO | low (charter cleanup) |
|
||||
| F-2 family template count reconcile | Axis 3 (§5) | NO -- touches templates / catalog / docs | gate IF #19 extends catalog | medium |
|
||||
| F-3 backlog status sweep | Axis 4 (§6) | YES | YES -- must land before #19 Stage 2 plan | **highest** |
|
||||
| F-4 legacy R'/Q literal cleanup | §8 (anti-hardcoding) | NO -- legacy src/ touch surface | NO | low (optional) |
|
||||
| F-5 tests/fixtures/ formalize | §8 (anti-hardcoding) | NO -- tests/ migration | NO | very low (optional) |
|
||||
|
||||
- **Counts** : 5 candidates total. 3 are blocking conditions for upgrading §9 CONDITIONAL GO to unconditional GO for #19 (F-3 hard-gates, F-2 conditional-gates on catalog touch, F-1 nice-to-have before next audit). 2 are optional housekeeping (F-4, F-5).
|
||||
- **Compliance with Stage 2 u6 contract** : per-draft fields (title / source_axis / scope / evidence_link) populated for each of F-1 .. F-5. **Zero auto-posts** -- this section is text-only. Filing decisions = orchestrator / human after #50 closes.
|
||||
- **AI-isolation contract** : none of the 5 follow-up candidates require AI on a normal path. F-2 / F-4 / F-5 are scope decisions to be made by a human reviewer. Compatible with [[feedback_ai_isolation_contract]] and PZ-1 (AI = 0 on normal path).
|
||||
197
docs/architecture/INTEGRATION-AUDIT-02-REPORT.md
Normal file
197
docs/architecture/INTEGRATION-AUDIT-02-REPORT.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# INTEGRATION-AUDIT-02 — IMP-07 reverse-path ↔ backlog ↔ IMP-16-U2 deferred items
|
||||
|
||||
**Issue**: Gitea #56 ([`Kyeongmin/C.E.L_Slide_test2/issues/56`](https://gitea.hmac.kr/Kyeongmin/C.E.L_Slide_test2/issues/56))
|
||||
**Mode**: audit-only (orchestrator P4/P4a) — no runtime code; reverse-path NOT implemented in this audit.
|
||||
**HEAD at audit**: `47f072e` (`docs: PROJECT-INTENT-AND-GOVERNANCE master doc`)
|
||||
**Binding evidence artifact**: `.orchestrator/tmp/issue7_comments_r3.json` (102144 B, mtime_utc `2026-05-19T17:11:58Z`, 13 comments)
|
||||
**Live Gitea API calls during audit**: 0 (artifact is binding per Stage 1)
|
||||
**Fallback exit-report check**: `ls .orchestrator/issues/ | grep '^7_stage' | wc -l = 0` (no local stage-exit fallback)
|
||||
|
||||
**Scope-lock (u1 binding)**
|
||||
- Forbidden writes (4 surfaces): `src/**`, `templates/**`, `tests/**`, `docs/architecture/IMP-16-U2-WIRING-DESIGN.md`.
|
||||
- Allowed writes (2 surfaces): CREATE this report; line-scoped EDIT to `docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md` L51 + L67 status cells only.
|
||||
|
||||
**Cross-links**
|
||||
- Project governance: [`PROJECT-INTENT-AND-GOVERNANCE.md`](PROJECT-INTENT-AND-GOVERNANCE.md)
|
||||
- Pipeline anchors: [`PHASE-Z-PIPELINE-OVERVIEW.md`](PHASE-Z-PIPELINE-OVERVIEW.md), [`PHASE-Z-PIPELINE-STATUS-BOARD.md`](PHASE-Z-PIPELINE-STATUS-BOARD.md)
|
||||
- Backlog: [`PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md`](PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md)
|
||||
- Wiring-design (read-only, not edited here): [`IMP-16-U2-WIRING-DESIGN.md`](IMP-16-U2-WIRING-DESIGN.md)
|
||||
- Prior audit: [`INTEGRATION-AUDIT-01-REPORT.md`](INTEGRATION-AUDIT-01-REPORT.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive decision
|
||||
|
||||
| Q | question | verdict | evidence anchor |
|
||||
|---|---|---|---|
|
||||
| Q1 | IMP-07 actual implementation status | **closed-as-no-runtime** (policy close; no backend adapter; no FE trigger) | u2 close-trio c.17970 / c.19226 / c.19240; u3 BE grep 1 hit (docstring) + FE grep 0 hits |
|
||||
| Q2 | Backlog accuracy for IMP-07 (and dependent IMP-16) | **divergent — correct to `documented:no-runtime` (IMP-07) + `documented:dormant` (IMP-16)** | Backlog L51 / L67 currently both `implemented`; status-vocabulary precedent at L68–L71 (`documented`, `documented (deferred)`) |
|
||||
| Q3 | IMP-16-U2 3 deferred items resolution | **all three DORMANT pending reverse-path reactivation** (no runtime substrate to resolve any of the three) | u4 §3 (a/b/c each cite u2 + u3 + IMP-16-U2-WIRING-DESIGN.md L14–16 gate clauses, all NOT CLEARED) |
|
||||
| Q4 | Follow-up needs | **1 backlog correction (applied in u7) + 1 doc-sync follow-up (drafted in §6, NOT posted)**; no runtime follow-up needed under current policy | §5 (backlog patch) + §6 (doc-sync banner draft) |
|
||||
|
||||
**Final decision**: see §7 below.
|
||||
|
||||
---
|
||||
|
||||
## 2. Evidence table (4-axis convergence)
|
||||
|
||||
| axis | claim | observed state | source / anchor |
|
||||
|---|---|---|---|
|
||||
| Gitea #7 close text | reverse-path closed-as-no-runtime (policy) | c.17970 `<< 해당 기능 필요 없음 >>`; c.19226 §5 `"코드 변경 없이 close … '구현 완료'가 아니라 '기능 불필요 / 현 정책상 reverse path 미진행'"`; c.19240 `"이 이슈는 코드 변경 없이 정책 판단으로 close했다."` | `.orchestrator/tmp/issue7_comments_r3.json` (binding artifact); cited verbatim in `.orchestrator/drafts/56_close_evidence.md` §3 / §4 / §5 |
|
||||
| Live BE code grep (`src/`) | no reverse-path adapter exists | pattern P `html_to_slide_mdx\|edited_html_to_mdx\|reverse_path\|reverse-path\|reversePath\|html-to-mdx` → 1 hit at `src/phase_z2_verification_utils.py:68`, classified **docstring-only** inside `extract_text_from_html()` (docstring says `Deterministic, pure: no I/O, no LLM, no network.`) | `src/phase_z2_verification_utils.py:64-73`; `.orchestrator/drafts/56_code_grep.md` §3 |
|
||||
| Live FE code grep (`Front/client/src/`) | no reverse-path payload trigger exists | same pattern P → **0 hits** across populated tree (`App.tsx`, `components/`, `contexts/`, `data/`, `hooks/`, `lib/`, `pages/`, `services/`, `types/`, `utils/`); 0-hit is true absence, not missing-dir false negative | `.orchestrator/drafts/56_code_grep.md` §4 |
|
||||
| Backlog status (`PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md` L51) | currently labels IMP-07 `implemented` — divergent from #7 close text + grep | L51 final cell = `implemented`; row preserves hard link to IMP-02 (normalize schema). Correct token under audit verdict = `documented:no-runtime`. | `docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md:51`; proposed diff in `.orchestrator/drafts/56_backlog_diff.md` §2 |
|
||||
| Backlog status (L67) | currently labels IMP-16 `implemented` — gated to closed IMP-07, so dormant | L67 final cell = `implemented`; row carries `hard link: IMP-07 (B-2 main 활성 시점 의미)`. Correct token under audit verdict = `documented:dormant`. | `docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md:67`; proposed diff in `.orchestrator/drafts/56_backlog_diff.md` §3 |
|
||||
| IMP-16-U2 deferred items (`IMP-16-U2-WIRING-DESIGN.md` L71–L73) | three items deferred "until IMP-07 lands" | (a) adapter module path TBD, (b) Step 2 per-section vs whole-MDX undecided, (c) Step 14 telemetry granularity undecided. None can be resolved while IMP-07 remains closed-as-no-runtime. | `docs/architecture/IMP-16-U2-WIRING-DESIGN.md:69-75`; gate at L14–L16 (all 3 clauses NOT CLEARED — `.orchestrator/drafts/56_imp16_deferred.md` §2) |
|
||||
| Fallback orchestrator exit report | absent — binding artifact is sole source | `ls .orchestrator/issues/ \| grep '^7_stage' \| wc -l = 0` | `.orchestrator/drafts/56_close_evidence.md` §1 |
|
||||
| Convergence | zero contradicting evidence across 37 independent passes (Stage 1 → Stage 3) | all 4 evidence axes (close-text / BE grep / FE grep / dependent doc gate) point to **policy-closed, no runtime, dependent doc dormant** | Stage 1 + Stage 2 exit reports; u2–u5 drafts; u4 §4 cross-axis check |
|
||||
|
||||
**Commit SHA at audit time**: `47f072e` (HEAD before u7's backlog patch).
|
||||
|
||||
---
|
||||
|
||||
## 3. IMP-07 verdict (with evidence)
|
||||
|
||||
**Verdict**: `documented:no-runtime` — reverse-path (B-2 Edited HTML → MDX) was closed by user policy decision on 2026-05-15 (c.17970) and re-affirmed by structured close-audit on 2026-05-18 (c.19226 + c.19240). **No backend adapter, no frontend trigger, no `html_to_slide_mdx` port exists in this repository.**
|
||||
|
||||
### Evidence chain (compact form — full verbatim in drafts)
|
||||
|
||||
1. **Initial close decision** — c.17970 (2026-05-15T18:28:22+09:00):
|
||||
> `<< 해당 기능 필요 없음 >>`
|
||||
> `(*) mdx → html 변환 이후 html 수기 수정된 것은 html에서만 적용.`
|
||||
2. **Structured close-audit (v1)** — c.19226 (2026-05-18T08:31:05+09:00). Section 3 enumerates the *absence* of every required runtime surface: SlideCanvas outerHTML capture absent; backend POST absent; `/api/edit | /api/html_to_mdx | /api/save` endpoints absent; glubeot `html_to_slide_mdx` not ported. Section 5 verdict: `"코드 변경 없이 close … '구현 완료'가 아니라 '기능 불필요 / 현 정책상 reverse path 미진행'"`.
|
||||
3. **Structured close-audit (v2 restatement)** — c.19240 (2026-05-18T08:41:19+09:00):
|
||||
> `"이 이슈는 코드 변경 없이 정책 판단으로 close했다."`
|
||||
4. **Live BE grep** (`src/`, pattern P): 1 hit at `src/phase_z2_verification_utils.py:68` inside the docstring of `extract_text_from_html()`. Function body is a deterministic, pure text extractor (`no I/O, no LLM, no network`) — **not** a reverse-path adapter, **not** an HTML→MDX converter, **not** a pipeline re-entry call site.
|
||||
5. **Live FE grep** (`Front/client/src/`, pattern P): 0 hits across populated React/TS tree — true absence, not missing-dir false negative.
|
||||
|
||||
### Why not `implemented:partial`
|
||||
|
||||
c.19226 §3 enumerates the absence of **every** required runtime surface (frontend, backend, converter, endpoint). `implemented:partial` would imply at least one runtime substrate is present; none is.
|
||||
|
||||
### Why not plain `documented` / `documented (deferred)`
|
||||
|
||||
IMP-17/18/19/20 use `documented` / `documented (deferred)` to mean "design captured, runtime deferred pending an explicit activation gate (IMP-17 carve-out, IMP-18 gap report, etc.)". IMP-07 is a stronger statement — **closed by explicit policy decision, no runtime, reactivation requires reopening the policy in a separate issue**. The `:no-runtime` suffix encodes that distinction so future readers can tell IMP-07 apart from the IMP-17/18/19/20 `documented` family.
|
||||
|
||||
### Reactivation contract (informational, NOT a doc edit)
|
||||
|
||||
Per c.19226 §5 and c.19240 closing line, reverse-path reactivation requires reopening IMP-07 policy in a **separate** issue covering: endpoint design, marker coverage, re-entry validation. This audit does NOT reopen that policy.
|
||||
|
||||
---
|
||||
|
||||
## 4. IMP-16-U2 deferred items resolution (with evidence)
|
||||
|
||||
**Source**: `docs/architecture/IMP-16-U2-WIRING-DESIGN.md` lines 69–75 (read-only; this doc is FORBIDDEN to edit in this audit per u1).
|
||||
|
||||
**Governing gate** (doc L12–L16): three clauses MUST be cleared before any IMP-16-U2 wiring lands.
|
||||
|
||||
| gate clause | required state | observed | gate status |
|
||||
|---|---|---|---|
|
||||
| `IMP-07 implemented + verified` | runtime adapter in `src/`, verified | Gitea #7 closed as policy / no-runtime (c.17970 / c.19226 §5 / c.19240) | **NOT CLEARED** |
|
||||
| Repo grep returns runtime hit in non-test `src/` module | ≥1 non-docstring runtime hit for pattern P | u3 hits=1, **docstring only** at `src/phase_z2_verification_utils.py:68` (pure text extractor) | **NOT CLEARED** |
|
||||
| Reverse-path entry emits (a) re-entry MDX + (b) upstream HTML | both as deterministic outputs | c.19226 §3 enumerates absence of every required surface; u3 FE grep hits=0 | **NOT CLEARED** |
|
||||
|
||||
All three gate clauses NOT CLEARED → resolution policy from issue body Q3 branches: "If Q1 confirms no-runtime / dormant → reclassify item as dormant pending reverse-path reactivation."
|
||||
|
||||
### Per-item resolution
|
||||
|
||||
| item | text (verbatim, doc L71–L73) | classification | reason | evidence anchor |
|
||||
|---|---|---|---|---|
|
||||
| (a) | Exact module path of the IMP-07 reverse-path adapter (TBD by IMP-07). | **DORMANT** | No reverse-path adapter exists in `src/`. The TBD slot stays TBD — not answered with a placeholder path. | u3 §3 (single docstring hit at `src/phase_z2_verification_utils.py:68`); c.19226 §3 absent-surface enumeration |
|
||||
| (b) | Step 2 preservation cross-check: per-section variant vs whole-MDX variant. | **DORMANT (gate closed)** | Step 2 surface = `verify_text_preservation(reentry_mdx, upstream_generated_html, area_name=...)` (doc L29). With no emitter producing `reentry_mdx`, the per-section vs whole-MDX choice is unanswerable from runtime evidence. | doc L29; u3 §3; c.19226 §3 (`html_to_slide_mdx` not in repo); c.19226 §5 |
|
||||
| (c) | Step 14 invented-text telemetry: per `area_name` vs global. | **DORMANT (gate closed)** | Step 14 surface = `detect_invented_text(reentry_mdx, final_html)` (doc L35). With no FE producer of area-tagged HTML (u3 FE grep hits=0), the granularity question has no runtime substrate. The current Step 14 `run_overflow_check` path is unchanged because no reverse-path re-entry sets `debug.json["pipeline"]["reverse_path_reentry"] = True` (doc L42 schema gate). | doc L35; doc L42; u3 §4 (FE 0-hits); c.19240 closing line |
|
||||
|
||||
### Axis disambiguation (why DORMANT, not no-runtime)
|
||||
|
||||
IMP-07 is **policy-closed** (active decline). IMP-16's verification helpers are **code-present** in `src/phase_z2_verification_utils.py` (u6 `split_into_sentences`, u8 `verify_text_preservation`, u9 `detect_invented_text` ports). The wiring they would land is **gated by IMP-07** (doc L12–L16). Because the gate is closed, the helpers are runtime-inert — they have no upstream caller. `:dormant` captures "code-shape present, runtime entry-point absent"; `:no-runtime` would imply the helpers themselves are absent (they are not).
|
||||
|
||||
---
|
||||
|
||||
## 5. Backlog status correction proposal
|
||||
|
||||
Target file: `docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md` — line-scoped edit to L51 and L67 status cells only. **Exactly 2 line changes**; surrounding cells (id / title / step / source / priority / scope / guardrail / dependency) byte-for-byte unchanged on both rows. Adjacent rows (L50 IMP-06, L52 IMP-08, L66 IMP-15, L68 IMP-17) untouched.
|
||||
|
||||
### L51 — IMP-07: `implemented` → `documented:no-runtime`
|
||||
|
||||
```diff
|
||||
-| ... | hard link: IMP-02 (A-1 normalize schema 와 reverse path schema 정합 필요) | implemented |
|
||||
+| ... | hard link: IMP-02 (A-1 normalize schema 와 reverse path schema 정합 필요) | documented:no-runtime |
|
||||
```
|
||||
|
||||
Justification: §3 verdict + close-trio (c.17970 / c.19226 / c.19240) + BE grep (docstring only) + FE grep (0 hits).
|
||||
|
||||
### L67 — IMP-16: `implemented` → `documented:dormant`
|
||||
|
||||
```diff
|
||||
-| ... | hard link: IMP-07 (B-2 main 활성 시점 의미) | implemented |
|
||||
+| ... | hard link: IMP-07 (B-2 main 활성 시점 의미) | documented:dormant |
|
||||
```
|
||||
|
||||
Justification: §4 — all three deferred items DORMANT under the IMP-07 no-runtime gate. The row's own `hard link: IMP-07` declares its meaning is conditioned on IMP-07 activation.
|
||||
|
||||
### Status-vocabulary precedent
|
||||
|
||||
Existing tokens in the file: `pending` (L45), `implemented` (L46–L66 majority), `documented (deferred)` (L68 IMP-17), `documented` (L69 IMP-18 / L70 IMP-19 / L71 IMP-20). The proposed `documented:<qualifier>` form is a minimal suffix extension of an already-present family — and is **explicitly enumerated by the issue body's Q2**: "propose corrected status (`implemented` / `implemented:partial` / `documented:dormant` / `documented:no-runtime` / etc.)".
|
||||
|
||||
---
|
||||
|
||||
## 6. Follow-up issue recommendations (drafts, NOT posted)
|
||||
|
||||
Auto-posting follow-ups is out-of-scope per u1. The drafts below are recommended text only; this audit does **not** post them.
|
||||
|
||||
### Recommended follow-up #1 — doc-sync banner for `IMP-16-U2-WIRING-DESIGN.md`
|
||||
|
||||
- **Draft title**: `[DOC-SYNC] IMP-16-U2-WIRING-DESIGN.md — add cross-reference banner to INTEGRATION-AUDIT-02-REPORT.md (IMP-07 closed-as-no-runtime context)`
|
||||
- **Scope sketch**:
|
||||
- Add a one-paragraph banner near the top of `IMP-16-U2-WIRING-DESIGN.md` (post-§1 "Status" paragraph) cross-referencing this audit report.
|
||||
- Banner content: IMP-07 was closed-as-no-runtime per Gitea #7 (c.17970 / c.19226 / c.19240). The L12–L16 gate clauses remain unchanged but are currently NOT CLEARED; the 3 deferred items (L71–L73) are DORMANT pending a future reverse-path reactivation issue.
|
||||
- **Do NOT** modify the gate clauses, the per-step wiring contract, or the deferred items themselves — preserve them verbatim as the binding contract for any future IMP-07 reactivation.
|
||||
- **Allowed file changes**: `docs/architecture/IMP-16-U2-WIRING-DESIGN.md` (banner add only); optionally a one-line back-link in `INTEGRATION-AUDIT-02-REPORT.md`.
|
||||
- **Forbidden**: any change to the doc's gate clauses, per-step contract, or deferred items list; any change to `src/**`, `templates/**`, `tests/**`.
|
||||
- **Acceptance**: banner contains explicit cross-link to `INTEGRATION-AUDIT-02-REPORT.md`, cites c.17970 / c.19226 / c.19240, and states the 3 deferred items are DORMANT (not resolved, not closed).
|
||||
- **Rationale for separating from this audit**: per u1 scope-lock, `IMP-16-U2-WIRING-DESIGN.md` is a forbidden write surface in INTEGRATION-AUDIT-02 (issue #56). The banner addition is a separate doc-sync axis.
|
||||
|
||||
### No runtime follow-up needed under current policy
|
||||
|
||||
Reverse-path runtime activation is **out-of-scope under current user policy** (c.17970 / c.19226 §5 / c.19240). A runtime follow-up would require reopening IMP-07 policy in a separate issue — that decision lies with the user, not with this audit. This audit does NOT recommend a runtime follow-up at this time.
|
||||
|
||||
### Pre-existing follow-up linkage (informational)
|
||||
|
||||
Per the issue body's "Sequence note", the next planned issue #57 ([P5][DORMANT-TRIGGER-GUARD]) will register IMP-17 / IMP-18 / IMP-19 + (per #56 outcome) IMP-16 / IMP-07 + IMP-20 as followup-linked to #55. This audit's verdict feeds #57's dormant-trigger registry input: IMP-07 enters as `documented:no-runtime`; IMP-16 enters as `documented:dormant`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Final decision
|
||||
|
||||
**`NEEDS_DOC_SYNC_FOLLOWUP`**
|
||||
|
||||
Rationale: the in-scope reconciliation (backlog L51 + L67 status corrections) is performed in u7. However, `IMP-16-U2-WIRING-DESIGN.md` opens with `**Status**: design-only contract. **No runtime wiring lands in this issue.** All wiring is gated behind IMP-07 reverse-path activation (B-2 main). When IMP-07 lands, this doc becomes the binding contract …` (L3) — written under the original assumption that IMP-07 would eventually land as runtime. With IMP-07 now classified `documented:no-runtime` (policy decline, not deferred-pending-future), this framing is stale without a cross-reference banner pointing readers to the present audit. Because u1 forbids direct edits to that doc, the banner addition must be a separate follow-up issue (drafted in §6, NOT posted by this audit).
|
||||
|
||||
Why not `BACKLOG_PATCH_ONLY`: the backlog patch alone leaves `IMP-16-U2-WIRING-DESIGN.md` reading as a future-binding contract without acknowledging the IMP-07 close. A reader landing on that doc would not know to consult this audit.
|
||||
|
||||
Why not `NEEDS_RUNTIME_FOLLOWUP`: reverse-path runtime is out-of-scope under current user policy (c.17970 / c.19226 §5 / c.19240); recommending a runtime follow-up would contradict the binding close-decision.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria checklist (issue body)
|
||||
|
||||
| AC | requirement | status |
|
||||
|---|---|---|
|
||||
| 1 | No production source code (`src/**`, `templates/**`, `tests/**`) changes | ✅ — u1 forbids; u2–u6 verified empty tracked diff on these surfaces; u7 scoped to BACKLOG.md only |
|
||||
| 2 | No direct modification of `IMP-16-U2-WIRING-DESIGN.md` | ✅ — u1 forbids; banner addition deferred to follow-up #1 in §6 |
|
||||
| 3 | Each of Q1~Q4 has evidence-backed answer | ✅ — §1 table cites u2/u3/u4 drafts; §3, §4, §5, §6 expand each answer |
|
||||
| 4 | Evidence table includes concrete `file:line`, comment IDs, commit SHAs | ✅ — §2 cites `src/phase_z2_verification_utils.py:68`, c.17970 / c.19226 / c.19240, SHA `47f072e`, `BACKLOG.md:51` / `:67`, `IMP-16-U2-WIRING-DESIGN.md:69-75` / `:12-16` |
|
||||
| 5 | Final decision ∈ {BACKLOG_PATCH_ONLY, NEEDS_DOC_SYNC_FOLLOWUP, NEEDS_RUNTIME_FOLLOWUP} | ✅ — §7 = `NEEDS_DOC_SYNC_FOLLOWUP` |
|
||||
| 6 | Body size budget: each Gitea comment ≤ 8000 chars | ✅ — Stage 3 comments split large evidence into `.orchestrator/drafts/56_*.md` + this report; report body itself is not a comment |
|
||||
|
||||
---
|
||||
|
||||
## Evidence drafts (RULE-6 evidence-only; NOT staged for commit)
|
||||
|
||||
- u1: `.orchestrator/drafts/56_scope_lock.md` — scope binding + forbidden / allowed writes.
|
||||
- u2: `.orchestrator/drafts/56_close_evidence.md` — c.17970 / c.19226 / c.19240 verbatim.
|
||||
- u3: `.orchestrator/drafts/56_code_grep.md` — `src/` 1 hit (docstring) + `Front/client/src/` 0 hits.
|
||||
- u4: `.orchestrator/drafts/56_imp16_deferred.md` — 3 deferred items DORMANT (per-item table).
|
||||
- u5: `.orchestrator/drafts/56_backlog_diff.md` — L51 + L67 status-cell diff proposal.
|
||||
|
||||
These drafts are evidence-only per RULE 6 and remain untracked. The committed deliverables of INTEGRATION-AUDIT-02 are: (i) this report (`INTEGRATION-AUDIT-02-REPORT.md`), and (ii) the 2 line-scoped status-cell edits applied in u7 (`PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md` L51 + L67).
|
||||
@@ -96,13 +96,13 @@ Phase Z 는 본체이고, Phase Q 는 부품 창고 / 참고 자산이다. Phase
|
||||
| id | 보완 항목 | 목적 | input | output | Phase Q 후보 파일 | 우선순위 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **A-1** | Stage 0 normalize 통합 | HTML-heavy / 비정형 raw MDX 를 Phase Z canonical input 으로 변환 | raw MDX text | `{clean_text, title, images, popups, tables, sections}` (frontmatter / 코드블록 보호 / list/table HTML 변환 / AST 구조 추출) | `mdx_normalizer.py`, `section_parser.py` | 높음 |
|
||||
| **A-2** | Catalog 확장 (frame_contracts + frame_partials) | V4 32 후보 중 backend 적용 가능한 frame 수 증가 (현재 3 → 32 목표) | `figma_to_html_agent/blocks/{frame_id}/` 의 index.html / assets / analysis.md | `templates/phase_z2/catalog/frame_contracts.yaml` entry + `templates/phase_z2/frames/{template_id}.html` partial | `block_reference.py`, `block_selector.py` | 높음 |
|
||||
| **A-3** | Frame preview png 일관성 | 모든 catalog frame 의 일관된 preview.png 자동 생성 (현재 figma_previews 우회) | frame partial HTML + assets | `figma_to_html_agent/blocks/{frame_id}/preview.png` | `renderer.py`, `html_generator.py` (selenium 캡처 흔적 추정) | 중 |
|
||||
| **A-4** | slide-base.html iframe-friendly mode | iframe embed 시 body padding / centering / min-height 미적용 (frontend CSS injection 제거) | slide-base.html template + query string `?embedded=1` 같은 시그널 | conditional CSS (standalone vs embedded) | `html_generator.py` | 중 |
|
||||
| **A-2** | Catalog 확장 (frame_contracts + frame_partials) | V4 32 후보 중 backend 적용 가능한 frame 수 증가 (현재 3 → 32 목표) | `figma_to_html_agent/blocks/{frame_id}/` 의 index.html / assets / analysis.md | `templates/phase_z2/catalog/frame_contracts.yaml` entry + `templates/phase_z2/frames/{template_id}.html` partial | `block_reference.py`, `block_selector.py` (간접 — catalog 로딩 / block 검색 패턴 reference; A-2 main = frame_contracts.yaml + frame_partials 신규 구축, Phase Q catalog schema ≠ Phase Z) | 높음 |
|
||||
| **A-3** | Frame preview png 일관성 | 모든 catalog frame 의 일관된 preview.png 자동 생성 (현재 figma_previews 우회) | frame partial HTML + assets | `figma_to_html_agent/blocks/{frame_id}/preview.png` | `slide_measurer.capture_slide_screenshot` (main), `renderer.py` (간접 — render-path 자료) | 중 |
|
||||
| **A-4** | slide-base.html iframe-friendly mode | iframe embed 시 body padding / centering / min-height 미적용 (frontend CSS injection 제거) | slide-base.html template + query string `?embedded=1` 같은 시그널 | conditional CSS (standalone vs embedded) | `renderer.py` (legacy `slide-base.html` 호출 지점 보유, embedded/standalone CSS 분기 미구현) | 중 |
|
||||
| **A-5** | V4 후보 자동 fallback | rank-1 capacity / cardinality / structure mismatch 시 자동 rank-2/3 시도 | V4 후보 list + 각 frame contract 의 cardinality + 추출된 content items | 통과한 frame template_id (모두 fail 시 filtered_capacity) | `fit_verifier.py` | 높음 |
|
||||
| **A-6** | Zone DOM 좌표 export | backend 가 zone 절대 px 좌표를 step08 / 별도 step 에 export (frontend 측정 우회) | layout_css + slide-base 좌표 | `zone_geometries_px: [{position, x, y, w, h}]` | `slide_measurer.py` | 중 |
|
||||
| **B-1** | Zone-section assignment override | 사용자 drag drop 결과를 backend 가 받아 composition planner 의 자동 결정 강제 변경 | `--override-section-assignment ZONE_ID=section_id,section_id` (CLI multi) | units 배치가 사용자 매핑 따름 | `pipeline.py`, `content_editor.py` | 중 |
|
||||
| **B-2** | Edited HTML → MDX 역변환 | frontend 편집 모드의 텍스트 변경이 새 final.html 에 반영 | edited HTML (iframe contentDocument outerHTML) | 새 MDX text 또는 patched mapper input | 글벗 `fmt_slide.py html_to_slide_mdx`, `content_editor.py` | 중 |
|
||||
| **B-1** | Zone-section assignment override | 사용자 drag drop 결과를 backend 가 받아 composition planner 의 자동 결정 강제 변경 | `--override-section-assignment ZONE_ID=section_id,section_id` (CLI multi) | units 배치가 사용자 매핑 따름 | `pipeline.py` (간접 — orchestration entry, Stage Y page_structure 생성 흐름 보유) | 중 |
|
||||
| **B-2** | Edited HTML → MDX 역변환 | frontend 편집 모드의 텍스트 변경이 새 final.html 에 반영 | edited HTML (iframe contentDocument outerHTML) | 새 MDX text 또는 patched mapper input | 글벗 `fmt_slide.py html_to_slide_mdx` | 중 |
|
||||
| **B-3** | Sub-section (### 단위) drag drop backend 처리 | backend 가 sub-section id 를 인식해서 zone 에 sub-section 단위로 매핑 | sub-section id (e.g., "03-1-sub-2") + zone_id | 그 sub-content 단위로 unit 분할 | `section_parser.py` | 낮 |
|
||||
| **B-4** | 다른 layout 의 zone-geometry override 확장 | top-1-bottom-2 / top-2-bottom-1 / left-1-right-2 / left-2-right-1 / grid-2x2 도 사용자 ratio override 적용 (현재 horizontal-2 / vertical-2 만) | `--override-zone-geometry` 인자 + 새 layout_preset 분기 | build_layout_css 의 grid 표현 (areas / cols / rows) | `space_allocator.py` | 낮 |
|
||||
| **D-1** | filtered_section_reasons 노출 UI | 사용자가 어떤 섹션이 왜 빠졌는지 즉시 인지 (Step 8 coverage UI) | `step20_slide_status.json.data.filtered_section_reasons` | frontend header / 패널 UI | N/A (frontend 만) — Phase Q audit 외 | 중 |
|
||||
@@ -122,7 +122,7 @@ Phase Z 는 본체이고, Phase Q 는 부품 창고 / 참고 자산이다. Phase
|
||||
3. `slide_measurer.py` (A-6)
|
||||
4. `fit_verifier.py` (A-5, D-2 간접)
|
||||
5. `space_allocator.py` (B-4)
|
||||
6. `content_editor.py` (B-1, B-2)
|
||||
6. `content_editor.py`
|
||||
7. `content_verifier.py` (검증 — B-2 후속)
|
||||
8. `renderer.py` (A-3, A-4)
|
||||
9. `html_generator.py` (A-3, A-4)
|
||||
|
||||
@@ -120,10 +120,10 @@
|
||||
| A-4 slide-base iframe mode | Step 13 | §2.8 I2 (renderer.py slide-base 사용 호출 지점) | pending | yes (UI/backend) |
|
||||
| Step 14 visual_check 보강 | Step 14, 21 | §2.7 H1 (`content_verifier` utilities Reference Only) | pending | yes (deterministic) |
|
||||
| B-2 verification 보조 | Step 1, 2, 14, 21, 22 | §2.7 H3 (text 추출 / 정규화 / 비교 utility) | pending | yes (UI/backend) |
|
||||
| AI repair fallback infra | Step 12, 16, 17 | §2.6 G3 (`httpx` + SSE streaming + retry + JSON parse pattern) | pending | no (AI fallback only) |
|
||||
| IMP-17 AI repair fallback infra (carve-out — see [`IMP-17-CARVE-OUT.md`](IMP-17-CARVE-OUT.md)) | Step 12, 16, 17 | §2.6 G3 (`httpx` + SSE streaming + retry + JSON parse pattern) | pending | no (AI fallback only) |
|
||||
| I3 SVG 좌표 보강 | Step 0, 9 | §2.8 I3 (`renderer._preprocess_svg_data`) | pending | yes (deterministic) |
|
||||
| I4 zone 비중 분배 | Step 8 | §2.8 I4 (`renderer._group_blocks_by_area`) | pending | yes (deterministic) |
|
||||
| H2 frame contract validation | Step 10 | §2.7 H2 (`content_verifier.verify_structure` pattern) | pending | yes (deterministic) |
|
||||
| IMP-19 I4 zone 비중 분배 (reference — see [`IMP-19-ZONE-RATIO-REFERENCE.md`](IMP-19-ZONE-RATIO-REFERENCE.md)) | Step 8 | §2.8 I4 (`renderer._group_blocks_by_area`) | pending | yes (deterministic) |
|
||||
| IMP-20 H2 frame contract validation (reference — see [`IMP-20-FRAME-CONTRACT-VALIDATION-REFERENCE.md`](IMP-20-FRAME-CONTRACT-VALIDATION-REFERENCE.md)) | Step 10 | §2.7 H2 (`content_verifier.verify_structure` pattern) | pending | yes (deterministic) |
|
||||
|
||||
---
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
|
||||
| candidate ID | 출처 | cleanup 대상 | trigger axis |
|
||||
|---|---|---|---|
|
||||
| J3 | §2.9 `html_generator` | utility 중복 — `normalize_mdx` / `_slice_mdx_sections` / `_get_definitions` / `_get_conclusion` (vs §2.1 / §2.2 SoT) | Phase R' cleanup axis 활성 시 |
|
||||
| J3 | §2.9 `html_generator` | utility 중복 — `normalize_mdx` / `_slice_mdx_sections` / `_get_definitions` / `_get_conclusion` (vs §2.1 / §2.2 SoT) | Phase R' archive trigger AND §2.1/§2.2 SoT signature unification (both preconditions required to keep guardrail = code-removal-only) |
|
||||
| K5 | §2.10 `block_reference` + `block_selector` + §2.8 `renderer` | catalog 로드 + `_get_block_by_id` 중복 (3 module) | Phase R' cleanup 또는 Phase Z catalog 확장 axis 활성 시 |
|
||||
| L4 | §2.11 `pipeline` + §2.6 `content_editor` + §2.9 `html_generator` | `_parse_json` 중복 (3 module) | Phase R' cleanup 또는 Phase Z utility 통합 axis 활성 시 |
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ action :
|
||||
| `moderate_overflow` | content_type ∈ {`text_flow`, `frame_label`} AND `line_equivalent` ∈ (1.5, 4] |
|
||||
| `minor_overflow` | content_type ∈ {`text_flow`, `frame_label`} AND `line_equivalent` ≤ 1.5 |
|
||||
| `hard_visual_fail` | 위 어디에도 매핑 안 됨 OR retry budget 소진 |
|
||||
| `image_aspect_mismatch` | Post-render `fail_reasons` signal — Step 14 visual_runtime_check 가 이미지 frame slot 의 rendered aspect ratio 와 declared aspect ratio 불일치를 감지 (router-routed fit_classifier 출력 아님; 별도 image_events stream 으로 표면화) |
|
||||
|
||||
### 3.2 분류 우선순위 (위에서 아래로)
|
||||
|
||||
|
||||
@@ -43,16 +43,16 @@
|
||||
| ID | title | related step | source | priority | scope | guardrail / validation | dependency | status |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| IMP-01 | A-6 Zone DOM 좌표 export | Step 14, 21 | §2 A-6 Salvage | ↑ high (small) | `_MEASURE_SCRIPT` JS extension `getBoundingClientRect()` + artifact field 추가 | AI/Kei/V4/frame 선택 변경 X / DOM bbox trace / 기존 debug.json schema 보존 (additive) | none | pending |
|
||||
| IMP-02 | A-1 Stage 0 normalize chained adapter | Step 2 | §2 A-1 Salvage chained | ↑ high (medium) | `normalize_mdx_content` + `extract_major_sections` + `extract_conclusion_text` chained adapter + dual-write | AI/Kei normalize 회귀 X / step02 sections / sub_sections trace 설명 가능 | none | pending |
|
||||
| IMP-03 | A-1 popup/image/table trace | Step 3 | §2 A-1 chained 보강 | medium | normalized popups / images / tables → ContentObject 변환 (B1 v0 보강) | AI/Kei content extraction 회귀 X / popup/image/table 추출 trace 설명 가능 | hard link: IMP-02 (Stage 0 normalize output 의 popup/image/table list 의존) | pending |
|
||||
| IMP-04 | A-2 Catalog 확장 | Step 0, 9 | §2 A-2 새로 만들기 (핵심 unblocker) | medium (large) | `frame_contracts.yaml` + frame_partials 32 frame 등록/확장 | Phase R' frame catalog 회귀 X / V4 logic 변경 X / catalog 확장 후 PASS/FAIL 변화와 frame 선택 trace 설명 가능 | none | pending |
|
||||
| IMP-05 | A-5 V4 fallback | Step 9, 16, 17, 20 | §2 A-5 새로 만들기 | medium | Step 9 / Step 16 router 확장 (rank-1 fail 시 rank-2/3 fallback) + step20 status semantics | `calculate_fit` 통째 Migrate X (dual path 위험) / 신설 status (`PASS_WITH_FALLBACK` 등) 일관성 / frame 변경 허용 trace 설명 | hard link: IMP-04 (catalog 확장 후 fallback path 의미 있음) | pending |
|
||||
| IMP-06 | B-1 Zone-section override | Step 6 + input Step 1, 22 | §2 B-1 새로 만들기 (backend path) | medium | CLI 인자 + composition planner override path 신설 | Kei composition / Phase R' frame 보조 회귀 X / override 적용 시 composition_unit schema 정합 + trace | soft link: IMP-04 (frame 후보 ↑ 시 override 의미 ↑) | pending |
|
||||
| IMP-07 | B-2 Edited HTML → MDX reverse path | Step 22 + Step 1, 2 | §2 B-2 새로 만들기 (backend path) | medium | frontend edited HTML → backend → MDX 변환 → pipeline 재진입 (글벗 `html_to_slide_mdx` 참조) | AI/Kei reverse 회귀 X / 재진입 후 step02 정합 + visual_check 통과 | hard link: IMP-02 (A-1 normalize schema 와 reverse path schema 정합 필요) | pending |
|
||||
| IMP-08 | B-3 Sub-section drag drop | Step 3 | §2 B-3 새로 만들기 (backend schema) | ↓ low | Phase Z `section_id` schema 확장 (sub_sections 단위 매핑) | AI/Kei schema 회귀 X / backward compatible / step03 trace | hard link: IMP-02 (A-1 normalize sub_sections schema 의존) | pending |
|
||||
| IMP-09 | B-4 다른 layout zone-geometry | Step 8 | §2 B-4 새로 만들기 (backend layout) | ↓ low | `build_layout_css` 분기 확장 (top-1-bottom-2 / left-1-right-2 / grid-2x2 등) | Kei `build_containers_type_b` 회귀 X / step08 trace | none | pending |
|
||||
| IMP-10 | D-1 filtered_section_reasons UI | Step 20, 22 | §2 D-1 frontend 신규 | ↓ low | frontend UI — backend artifact read-only 표시 | AI/Kei UI 회귀 X / backend artifact read-only | none | pending |
|
||||
| IMP-11 | D-2 Frame min_height 표시 | Step 22 | §2 D-2 새로 만들기 (frontend hint + catalog 참조) | ↓ low | frontend UI — frame contract `min_height_px` read-only + resize hint | AI/Kei UI 회귀 X / catalog 참조 + resize limit | none | pending |
|
||||
| IMP-02 | A-1 Stage 0 normalize chained adapter | Step 2 | §2 A-1 Salvage chained | ↑ high (medium) | `normalize_mdx_content` + `extract_major_sections` + `extract_conclusion_text` chained adapter + dual-write | AI/Kei normalize 회귀 X / step02 sections / sub_sections trace 설명 가능 | none | implemented |
|
||||
| IMP-03 | A-1 popup/image/table trace | Step 3 | §2 A-1 chained 보강 | medium | normalized popups / images / tables → ContentObject 변환 (B1 v0 보강) | AI/Kei content extraction 회귀 X / popup/image/table 추출 trace 설명 가능 | hard link: IMP-02 (Stage 0 normalize output 의 popup/image/table list 의존) | implemented |
|
||||
| IMP-04 | A-2 Catalog 확장 | Step 0, 9 | §2 A-2 새로 만들기 (핵심 unblocker) | medium (large) | `frame_contracts.yaml` + frame_partials 32 frame 등록/확장 | Phase R' frame catalog 회귀 X / V4 logic 변경 X / catalog 확장 후 PASS/FAIL 변화와 frame 선택 trace 설명 가능 | none | implemented |
|
||||
| IMP-05 | A-5 V4 fallback | Step 9, 16, 17, 20 | §2 A-5 새로 만들기 | medium | Step 9 / Step 16 router 확장 (rank-1 fail 시 rank-2/3 fallback) + step20 status semantics | `calculate_fit` 통째 Migrate X (dual path 위험) / 신설 status (`PASS_WITH_FALLBACK` 등) 일관성 / frame 변경 허용 trace 설명 | hard link: IMP-04 (catalog 확장 후 fallback path 의미 있음) | implemented |
|
||||
| IMP-06 | B-1 Zone-section override | Step 6 + input Step 1, 22 | §2 B-1 새로 만들기 (backend path) | medium | CLI 인자 + composition planner override path 신설 | Kei composition / Phase R' frame 보조 회귀 X / override 적용 시 composition_unit schema 정합 + trace | soft link: IMP-04 (frame 후보 ↑ 시 override 의미 ↑) | implemented |
|
||||
| IMP-07 | B-2 Edited HTML → MDX reverse path | Step 22 + Step 1, 2 | §2 B-2 새로 만들기 (backend path) | medium | frontend edited HTML → backend → MDX 변환 → pipeline 재진입 (글벗 `html_to_slide_mdx` 참조) | AI/Kei reverse 회귀 X / 재진입 후 step02 정합 + visual_check 통과 | hard link: IMP-02 (A-1 normalize schema 와 reverse path schema 정합 필요) | documented:no-runtime |
|
||||
| IMP-08 | B-3 Sub-section drag drop | Step 3 | §2 B-3 새로 만들기 (backend schema) | ↓ low | Phase Z `section_id` schema 확장 (sub_sections 단위 매핑) | AI/Kei schema 회귀 X / backward compatible / step03 trace | hard link: IMP-02 (A-1 normalize sub_sections schema 의존) | implemented |
|
||||
| IMP-09 | B-4 다른 layout zone-geometry | Step 8 | §2 B-4 새로 만들기 (backend layout) | ↓ low | `build_layout_css` 분기 확장 (top-1-bottom-2 / left-1-right-2 / grid-2x2 등) | Kei `build_containers_type_b` 회귀 X / step08 trace | soft back-link: IMP-19 ([reference doc](IMP-19-ZONE-RATIO-REFERENCE.md) — Phase O block-level pattern reference, no runtime integration) | implemented |
|
||||
| IMP-10 | D-1 filtered_section_reasons UI | Step 20, 22 | §2 D-1 frontend 신규 | ↓ low | frontend UI — backend artifact read-only 표시 | AI/Kei UI 회귀 X / backend artifact read-only | none | implemented |
|
||||
| IMP-11 | D-2 Frame min_height 표시 | Step 22 | §2 D-2 새로 만들기 (frontend hint + catalog 참조) | ↓ low | frontend UI — frame contract `min_height_px` read-only + resize hint | AI/Kei UI 회귀 X / catalog 참조 + resize limit | none | implemented |
|
||||
|
||||
---
|
||||
|
||||
@@ -60,15 +60,17 @@
|
||||
|
||||
| ID | title | related step | source | priority | scope | guardrail / validation | dependency | status |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| IMP-12 | Step 16/17 retry 정밀화 | Step 16, 17 | §3 group B (Salvage deterministic) | medium | `redistribute` + glue + font compression — Step 16 router action 신설 + Step 17 action 실행 | AI fallback X / Kei retry loop (H5) 회귀 X / status semantics 일관 | soft link: IMP-05 (Step 16 router 영역 공유, 병렬 가능) | pending |
|
||||
| IMP-13 | A-3 frame preview 일관성 | Step 0, 14, 21 | §3 Salvage 후보 | ↓ low | `capture_slide_screenshot` Salvage — preview.png 자동 생성 path | Phase R' reference path 회귀 X / preview artifact trace | soft link: IMP-04 (catalog frame_partial 확장 시 의미 ↑) | pending |
|
||||
| IMP-14 | A-4 slide-base iframe mode | Step 13 | §3 새로 만들기 | ↓ low | `slide-base.html` conditional CSS (embedded vs standalone) | Claude / Phase R' HTML generation 회귀 X / Jinja2 deterministic | none | pending |
|
||||
| IMP-15 | Step 14 visual_check 보강 | Step 14, 21 | §3 H1 Reference Only | medium | image_aspect_mismatch / tabular_overflow 검사 추가 | AI/Kei classification 회귀 X / deterministic 검사 + trace | soft link: IMP-01 (Step 14 측정/trace layer 공유) | pending |
|
||||
| IMP-16 | B-2 verification 보조 axis | Step 1, 2, 14, 21, 22 | §3 H3 Reference Only | ↓ low | B-2 reverse path 의 verification 보조. main reverse path 는 IMP-07, 본 issue 는 text/visual/trace 검증 layer | AI/Kei verification 회귀 X / utility deterministic | hard link: IMP-07 (B-2 main 활성 시점 의미) | pending |
|
||||
| **IMP-17** | **AI repair fallback infra** (**carve-out — normal path 밖**) | Step 12, 16, 17 | §3 G3 | (별 axis priority — pending) | `httpx` + SSE streaming + retry + JSON parse pattern reference — light_edit / restructure proposal | **normal path AI 호출 0 — 본 axis = fallback only, normal path 와 분리 설계** / Kei persona 단절 (Phase Q 자산과 단절) | soft link: IMP-04 + IMP-05 (catalog 확장 + V4 fallback 활성 시 의미) | pending |
|
||||
| IMP-18 | I3 SVG 좌표 보강 | Step 0, 9 | §3 Reference Only | ↓ low | `renderer._preprocess_svg_data` 패턴 reference — frame_partials SVG 좌표 사전 박힘 | Phase R' (renderer.py) 회귀 X | soft link: IMP-04 (frame_partials 등록 후 의미 ↑) | pending |
|
||||
| IMP-19 | I4 zone 비중 분배 | Step 8 | §3 Reference Only | ↓ low | `renderer._group_blocks_by_area` 패턴 reference — zone-level ratio 분배 | Phase O 컨테이너 회귀 X / 직접 통합 X | soft link: IMP-09 (zone 비중 분배 영역 공유) | pending |
|
||||
| IMP-20 | H2 frame contract validation | Step 10 | §3 Reference Only | ↓ low | `content_verifier.verify_structure` pattern reference — Phase Z frame contract 검증 pattern | Phase Q `REQUIRED_PATTERNS` 값 회귀 X / Phase Z 자체 pattern dict 설계 | soft link: IMP-04 (확장 catalog 적용 시 검증 범위 확대) | pending |
|
||||
| IMP-12 | Step 16/17 retry 정밀화 | Step 16, 17 | §3 group B (Salvage deterministic) | medium | `redistribute` + glue + font compression — Step 16 router action 신설 + Step 17 action 실행 | AI fallback X / Kei retry loop (H5) 회귀 X / status semantics 일관 | soft link: IMP-05 (Step 16 router 영역 공유, 병렬 가능) | implemented |
|
||||
| IMP-13 | A-3 frame preview 일관성 | Step 0, 14, 21 | §3 Salvage 후보 | ↓ low | `capture_slide_screenshot` Salvage — preview.png 자동 생성 path | Phase R' reference path 회귀 X / preview artifact trace | soft link: IMP-04 (catalog frame_partial 확장 시 의미 ↑) | implemented |
|
||||
| IMP-14 | A-4 slide-base iframe mode | Step 13 | §3 새로 만들기 | ↓ low | `slide-base.html` conditional CSS (embedded vs standalone) | Claude / Phase R' HTML generation 회귀 X / Jinja2 deterministic | none | implemented |
|
||||
| IMP-15 | Step 14 visual_check 보강 | Step 14, 21 | §3 H1 Reference Only | medium | image_aspect_mismatch / tabular_overflow 검사 추가 | AI/Kei classification 회귀 X / deterministic 검사 + trace | soft link: IMP-01 (Step 14 측정/trace layer 공유) | implemented |
|
||||
| IMP-16 | B-2 verification 보조 axis | Step 1, 2, 14, 21, 22 | §3 H3 Reference Only | ↓ low | B-2 reverse path 의 verification 보조. main reverse path 는 IMP-07, 본 issue 는 text/visual/trace 검증 layer | AI/Kei verification 회귀 X / utility deterministic | hard link: IMP-07 (B-2 main 활성 시점 의미) | documented:dormant |
|
||||
| **IMP-17** | **AI repair fallback infra** (**carve-out — normal path 밖**) | Step 12, 16, 17 | §3 G3 | (별 axis priority — pending) | [carve-out boundary + activation gate](IMP-17-CARVE-OUT.md) (3-cond AND: User GO ∧ B4 frame_selection evidence ∧ IMP-04/05 live — full def in u2 doc) — `httpx` + SSE streaming + retry + JSON parse pattern reference — light_edit / restructure proposal. Activation tracker = IMP-31 (#40); current gate state in [`IMP-31-GATE-AUDIT.md`](IMP-31-GATE-AUDIT.md) | **normal path AI 호출 0 — 본 axis = fallback only, normal path 와 분리 설계** / Kei persona 단절 (Phase Q 자산과 단절) | soft link: IMP-04 + IMP-05 (catalog 확장 + V4 fallback 활성 시 의미) | documented (deferred) |
|
||||
| IMP-18 | I3 SVG 좌표 보강 | Step 0, 9 | §3 Reference Only | ↓ low | `renderer._preprocess_svg_data` 패턴 reference — frame_partials SVG 좌표 사전 박힘 — [gap report](IMP-18-SVG-GAP-REPORT.md) | Phase R' (renderer.py) 회귀 X | soft link: IMP-04 (frame_partials 등록 후 의미 ↑) | documented |
|
||||
| IMP-19 | I4 zone 비중 분배 | Step 8 | §3 Reference Only | ↓ low | `renderer._group_blocks_by_area` 패턴 reference — zone-level ratio 분배 — [reference doc](IMP-19-ZONE-RATIO-REFERENCE.md) | Phase O 컨테이너 회귀 X / 직접 통합 X | soft link: IMP-09 (zone 비중 분배 영역 공유) | documented |
|
||||
| IMP-20 | H2 frame contract validation | Step 10 | §3 Reference Only | ↓ low | `content_verifier.verify_structure` pattern reference — Phase Z frame contract 검증 pattern — [reference doc](IMP-20-FRAME-CONTRACT-VALIDATION-REFERENCE.md) | Phase Q `REQUIRED_PATTERNS` 값 회귀 X / Phase Z 자체 pattern dict 설계 | soft link: IMP-04 (확장 catalog 적용 시 검증 범위 확대) | documented |
|
||||
|
||||
> **IMP-15 child issues note (#45–#49)** — IMP-15 (Step 14 visual_check 보강) is the parent row; child sub-axes were tracked as separate Gitea issues and are not given standalone backlog rows. Children: #45 (e9b3d2e), #46 (2827622), #47 (535c484), #48 (614c533), #49 (verification-only). Per INTEGRATION-AUDIT-01 §10.3 footnote option to avoid double-counting under IMP-15.
|
||||
|
||||
---
|
||||
|
||||
@@ -88,7 +90,7 @@
|
||||
|
||||
| ID | title | related module | source | priority | scope | guardrail / validation | trigger axis | status |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| IMP-26 | J3 — html_generator utility 중복 cleanup | §2.9 html_generator | §5 J3 | ↓ low (future) | `normalize_mdx` / `_slice_mdx_sections` / `_get_definitions` / `_get_conclusion` 중복 제거 (vs §2.1/§2.2 SoT) | Phase R' 영역 — 코드 제거만 | Phase R' cleanup axis 활성 시 | pending |
|
||||
| IMP-26 | J3 — html_generator utility 중복 cleanup | §2.9 html_generator | §5 J3 | ↓ low (future) | `normalize_mdx` / `_slice_mdx_sections` / `_get_definitions` / `_get_conclusion` 중복 제거 (vs §2.1/§2.2 SoT) | Phase R' 영역 — 코드 제거만 | Phase R' archive trigger AND §2.1/§2.2 SoT signature unification (both preconditions required to keep guardrail = code-removal-only) | deferred |
|
||||
| IMP-27 | K5 — catalog 로드 + `_get_block_by_id` 중복 cleanup | §2.10 + §2.8 (3 module) | §5 K5 | ↓ low (future) | block_reference / block_selector / renderer 의 catalog 로드 중복 제거 | Phase R' 영역 또는 Phase Z catalog 확장 axis | Phase Z catalog 확장 axis 활성 시 (soft link: IMP-04) | pending |
|
||||
| IMP-28 | L4 — `_parse_json` 중복 cleanup | §2.11 + §2.6 + §2.9 (3 module) | §5 L4 | ↓ low (future) | pipeline / content_editor / html_generator 의 `_parse_json` 중복 제거 | Phase R' 영역 또는 Phase Z utility 통합 axis | Phase R' cleanup 또는 Phase Z utility 통합 axis 활성 시 | pending |
|
||||
|
||||
@@ -132,3 +134,5 @@ Gitea Issues 활성 sanity check 별 GO ─┐
|
||||
(Codex 1차 → Claude 재검토 → Codex 재검증
|
||||
→ 100% 합의 → 구현 → 검증 → close)
|
||||
```
|
||||
|
||||
- **IMP-50 audit (2026-05-19)** — [INTEGRATION-AUDIT-01-REPORT.md](INTEGRATION-AUDIT-01-REPORT.md) — Decision: **CONDITIONAL GO for #19** (F-3 backlog status sweep + F-2 family template reconciliation required before #19 Stage 2) — Stage 5 commit SHA: 8c7d693
|
||||
|
||||
@@ -46,7 +46,7 @@ Step 0 은 본체가 아닌 *준비 조건*. Step 1 (MDX 업로드) 부터가 ru
|
||||
| A | 7 | Slide-Level Layout Planning | ⚠ partial (count-based / 7-A catalog + 7-B candidate fn 추가, runtime 호출처 X) |
|
||||
| A | 8 | Zone + Internal Region Ratio Planning | ⚠ partial (zone-level horizontal-2 만 dynamic / 8-A region+display catalog + 8-B-1/2 candidate fn 추가, runtime 호출처 X / region-level 은 B2 안 partial) |
|
||||
| A | 9 | Region-Level Frame / Display Selection | ⚠ partial (B4 가 catalog cover + declaration order 로 frame 선택 분담 / V4 evidence 미통합 / Step 5 와 conflate 잔존) |
|
||||
| A | 10 | Frame Contract 확인 | ⚠ partial (B3 의 accepted_content_types + sub_zones 선언 추가 — B4 만 읽음, mapper 미읽음 / density envelope 별 axis) |
|
||||
| A | 10 | Frame Contract 확인 | ⚠ partial (B3 의 accepted_content_types + sub_zones 선언 추가 — B4 만 읽음, mapper 미읽음 / density envelope 별 axis) — IMP-20 ref: [reference doc](IMP-20-FRAME-CONTRACT-VALIDATION-REFERENCE.md) |
|
||||
| A | 11 | Content Unit / Child Group → Internal Region → Frame Slot Mapping | ⚠ partial (B4 v0 dormant 2-stage + region 1:1 sub_zone + narrowest first + trace-only runtime 호출, render path 미연결) |
|
||||
| A | 12 | Slot Payload 생성 | ✅ (deterministic) |
|
||||
| B | 13 | Render | ✅ |
|
||||
@@ -157,6 +157,8 @@ Step 0 (사전 준비) 의 Figma → HTML 변환은 *precondition phase 의 작
|
||||
|
||||
다른 step 에서의 AI 호출은 본 도면 안에 *없음*.
|
||||
|
||||
> **Activation status reference** : runtime AI fallback (Step 12 light_edit / restructure) 는 IMP-17 carve-out infra + IMP-31 activation tracker (#40) 로 관리. carve-out boundary = [`IMP-17-CARVE-OUT.md`](IMP-17-CARVE-OUT.md). current 3-condition AND gate state + issue-body axis verdict = [`IMP-31-GATE-AUDIT.md`](IMP-31-GATE-AUDIT.md). 본 board 는 verdict 중복 X — gate / axis 판정은 audit doc 따름.
|
||||
|
||||
---
|
||||
|
||||
## 6. 현재 병목 (한 줄)
|
||||
@@ -165,6 +167,66 @@ Step 0 (사전 준비) 의 Figma → HTML 변환은 *precondition phase 의 작
|
||||
|
||||
---
|
||||
|
||||
## 7. Multi-MDX regression markers (IMP-91)
|
||||
|
||||
> CI workflow `.github/workflows/multi-mdx-regression.yml` rewrites these via `scripts/update_status_board.py` after each push / PR. Initial value `?` = not yet observed. `PASS` / `FAIL` / `ERR` / `SKIP` = last CI run outcome per axis × mdx. Untouched markers remain `?` so collection failures are loud, not silent.
|
||||
|
||||
| axis | mdx 01 | mdx 02 | mdx 03 | mdx 04 | mdx 05 |
|
||||
|---|---|---|---|---|---|
|
||||
| F0 normalize | <!-- IMP-91:F0:01 -->?<!-- /IMP-91 --> | <!-- IMP-91:F0:02 -->?<!-- /IMP-91 --> | <!-- IMP-91:F0:03 -->?<!-- /IMP-91 --> | <!-- IMP-91:F0:04 -->?<!-- /IMP-91 --> | <!-- IMP-91:F0:05 -->?<!-- /IMP-91 --> |
|
||||
| F1 V4 ranking | <!-- IMP-91:F1:01 -->?<!-- /IMP-91 --> | <!-- IMP-91:F1:02 -->?<!-- /IMP-91 --> | <!-- IMP-91:F1:03 -->?<!-- /IMP-91 --> | <!-- IMP-91:F1:04 -->?<!-- /IMP-91 --> | <!-- IMP-91:F1:05 -->?<!-- /IMP-91 --> |
|
||||
| F2 slot_payload | <!-- IMP-91:F2:01 -->?<!-- /IMP-91 --> | <!-- IMP-91:F2:02 -->?<!-- /IMP-91 --> | <!-- IMP-91:F2:03 -->?<!-- /IMP-91 --> | <!-- IMP-91:F2:04 -->?<!-- /IMP-91 --> | <!-- IMP-91:F2:05 -->?<!-- /IMP-91 --> |
|
||||
| F3 classifier-only AI | <!-- IMP-91:F3:01 -->?<!-- /IMP-91 --> | <!-- IMP-91:F3:02 -->?<!-- /IMP-91 --> | <!-- IMP-91:F3:03 -->?<!-- /IMP-91 --> | <!-- IMP-91:F3:04 -->?<!-- /IMP-91 --> | <!-- IMP-91:F3:05 -->?<!-- /IMP-91 --> |
|
||||
| F4 layout | <!-- IMP-91:F4:01 -->?<!-- /IMP-91 --> | <!-- IMP-91:F4:02 -->?<!-- /IMP-91 --> | <!-- IMP-91:F4:03 -->?<!-- /IMP-91 --> | <!-- IMP-91:F4:04 -->?<!-- /IMP-91 --> | <!-- IMP-91:F4:05 -->?<!-- /IMP-91 --> |
|
||||
| F5 final.html | <!-- IMP-91:F5:01 -->?<!-- /IMP-91 --> | <!-- IMP-91:F5:02 -->?<!-- /IMP-91 --> | <!-- IMP-91:F5:03 -->?<!-- /IMP-91 --> | <!-- IMP-91:F5:04 -->?<!-- /IMP-91 --> | <!-- IMP-91:F5:05 -->?<!-- /IMP-91 --> |
|
||||
|
||||
---
|
||||
|
||||
## 8. IMP-43 (#72) `--reuse-from` measured savings
|
||||
|
||||
> Stage 2 §u8 binding contract: the issue-body 50–70% / 10–20s → 3–8s claim is **unverified** and is **not** mirrored here. Numbers below come from `scripts/measure_reuse_savings.py` on the project reference host; until that script is run and the values committed, every cell stays `TBD`.
|
||||
|
||||
| axis | value |
|
||||
|---|---|
|
||||
| measurement script | `scripts/measure_reuse_savings.py` |
|
||||
| reuse boundary (Stage 1 lock) | Step 0 / 1 / 2 / 5 / 6 only; Step 7+ re-executes |
|
||||
| full rerun seconds (p50) | TBD |
|
||||
| full rerun seconds (p95) | TBD |
|
||||
| reuse seconds (p50) | TBD |
|
||||
| reuse seconds (p95) | TBD |
|
||||
| reuse / full ratio (p50) | TBD |
|
||||
| last measured | TBD (date / host / mdx / iterations) |
|
||||
|
||||
Run protocol (per iteration): `(A)` seed → `(B)` full rerun with one self-discovered `--override-frame` pin → `(C)` `--reuse-from <seed>` with the same pin. The `(A)` seed time is reported separately and **not** included in the B-vs-C comparison — the reuse path's whole point is that the seed already exists from a prior interactive run.
|
||||
|
||||
Invocation: `python -m scripts.measure_reuse_savings samples/mdx_batch/02.mdx --iterations 5` (mdx is argv-driven; the script does not pin a sample internally).
|
||||
|
||||
---
|
||||
|
||||
## 9. IMP-95 (V4 evidence → B4 `_select_frame` integration) sub-axis markers
|
||||
|
||||
> Sub-axis carve-out of section 3 item (j) for IMP-95. Pair-comment markers
|
||||
> `<!-- IMP-95:<axis> -->VALUE<!-- /IMP-95 -->`. Closing tag `<!-- /IMP-95 -->`
|
||||
> is intentionally distinct from IMP-91's `<!-- /IMP-91 -->` so the IMP-91
|
||||
> updater (`scripts/update_status_board.py`) cannot rewrite IMP-95 cells.
|
||||
> Allowed values: `pending` (not implemented), `trace-only` (default-OFF flag
|
||||
> `PHASE_Z_B4_V4_EVIDENCE`, additive telemetry only — no render-path change),
|
||||
> `guarded` (default-OFF regression harness landed and runs locally), `active`
|
||||
> (default-ON — not the current IMP-95 target).
|
||||
|
||||
| sub-axis | status |
|
||||
|---|---|
|
||||
| j1 V4-aware selector under `accepted_content_types ⊇` (u2) | <!-- IMP-95:j1 -->trace-only<!-- /IMP-95 --> |
|
||||
| j2 `plan_placement` v4_candidates kwarg + selection_trace (u3) | <!-- IMP-95:j2 -->trace-only<!-- /IMP-95 --> |
|
||||
| j3 Step 11 `placement_trace` hoist (u4) | <!-- IMP-95:j3 -->trace-only<!-- /IMP-95 --> |
|
||||
| j4 Gatekeeper `v4_short_circuit` telemetry (u5) | <!-- IMP-95:j4 -->trace-only<!-- /IMP-95 --> |
|
||||
| j5 `partial_exists` precheck (u6) | <!-- IMP-95:j5 -->trace-only<!-- /IMP-95 --> |
|
||||
| j6 Flag-OFF SHA parity regression on mdx 01/02/04/05 (u8) | <!-- IMP-95:j6 -->guarded<!-- /IMP-95 --> |
|
||||
| j7 Flag-ON adapter_needed monotone regression (u9) | <!-- IMP-95:j7 -->guarded<!-- /IMP-95 --> |
|
||||
| j8 Flag-ON `placement_trace` field presence regression (u10) | <!-- IMP-95:j8 -->guarded<!-- /IMP-95 --> |
|
||||
|
||||
---
|
||||
|
||||
## 사용 방법
|
||||
|
||||
- 새 작업 들어오면 → 본 board 의 *어느 step* 의 status 를 바꾸는 작업인지 식별
|
||||
|
||||
182
docs/architecture/PROJECT-INTENT-AND-GOVERNANCE.md
Normal file
182
docs/architecture/PROJECT-INTENT-AND-GOVERNANCE.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# 프로젝트의 목적과 거버넌스
|
||||
|
||||
> 이 문서는 **왜** 이 프로젝트를 하는지, **무엇을 위해** 이슈와 audit 을 도는지, 그리고 **그 구조가 어떻게 짜여있는지** 기록한다. 매번 처음부터 설명하지 않기 위함.
|
||||
>
|
||||
> 작성: 2026-05-20.
|
||||
|
||||
---
|
||||
|
||||
## 1. Destination (도착점)
|
||||
|
||||
**Phase Z 가 다음 두 가지까지 작동하면 프로젝트 목표 달성**:
|
||||
|
||||
1. **22-step pipeline** end-to-end 작동
|
||||
- 참조: [`PHASE-Z-PIPELINE-OVERVIEW.md`](PHASE-Z-PIPELINE-OVERVIEW.md)
|
||||
- 현재 status: [`PHASE-Z-PIPELINE-STATUS-BOARD.md`](PHASE-Z-PIPELINE-STATUS-BOARD.md)
|
||||
2. **AI 가 zone fit 평가 → 안 맞는 frame reject → zone 에 맞는 frame 생성**
|
||||
- frame 이 zone 안에 들어가지 않으면 AI 가 reject
|
||||
- reject 후 zone 에 맞춰 frame 을 생성하는 것까지가 destination
|
||||
|
||||
이 두 가지가 작동하면 끝. 그 이상은 별도 결정.
|
||||
|
||||
---
|
||||
|
||||
## 2. Q~Y 검토 = 이미 끝났음 (과거형)
|
||||
|
||||
Phase Z 구현 갭을 메우기 위해 Phase Q~Y 의 코드/기능을 **이미 다 검토했고**, 참고할 만한 것들을 22-step 에 매칭해서 **이슈로 다 정리해놓은 상태**.
|
||||
|
||||
- Q~Y 새로 다시 보지 않음 — 작업은 끝남
|
||||
- 결과물 = INSIGHT-MAP 문서 + 28 개 초기 IMP 이슈 (#1~#28)
|
||||
- 회귀 금지선 4 항목 (Q/R'/T 의 폐기된 path 로 돌아가지 않음) 도 [`PHASE-Q-INSIGHT-TO-22STEP-MAP.md §0`](PHASE-Q-INSIGHT-TO-22STEP-MAP.md) 에 같이 박혀있음
|
||||
|
||||
이제 남은 일 = **정리된 이슈를 orchestrator 로 처리해서 Phase Z 에 반영하는 것**.
|
||||
|
||||
---
|
||||
|
||||
## 3. 그 검토 결과 = INSIGHT-MAP 문서
|
||||
|
||||
**문서**: [`PHASE-Q-INSIGHT-TO-22STEP-MAP.md`](PHASE-Q-INSIGHT-TO-22STEP-MAP.md)
|
||||
|
||||
Q~Y 검토 결과를 22-step 의 어느 step 에 어떤 부품을 가져올지 매핑해서 정리한 catalog. 섹션 구성:
|
||||
|
||||
- §0: 목적 + 회귀 금지 4 항목 + Archive marker inventory (9 개)
|
||||
- §1: SoT read result + 22 Step status snapshot
|
||||
- §2: Salvage chained + new-make backend axes
|
||||
- §3: Reference / carve-out
|
||||
- §4: audit §1 lens column 정정
|
||||
- §5: Module duplication cleanup
|
||||
|
||||
각 § cell 이 IMP 이슈로 1-to-1 분해됨.
|
||||
|
||||
---
|
||||
|
||||
## 4. IMP 이슈 = INSIGHT-MAP § cell 의 execution unit
|
||||
|
||||
**초기 28 개 (2026-05-12 한 번에 생성, #1~#28)**:
|
||||
|
||||
| INSIGHT-MAP § | 이슈 |
|
||||
|---|---|
|
||||
| §2 (Salvage chained + new-make backend) | #1~#11 (IMP-01~11: A-1~A-6, B-1~B-4, D-1, D-2) |
|
||||
| §3 (Reference / carve-out) | #12~#20 (IMP-12~20: A-3/A-4, B-2, AI fallback, frame contract 등) |
|
||||
| §4 (audit §1 lens column 정정) | #21~#25 (IMP-21~25: G2, I6, J5, K6, L5) |
|
||||
| §5 (Module duplication cleanup) | #26~#28 (IMP-26~28: J3, K5, L4) |
|
||||
|
||||
**모든 IMP 이슈 본문에 표준 anchor**:
|
||||
```
|
||||
**관련 step**: Phase Z 22-step 좌표
|
||||
**source**: INSIGHT-MAP §X (Q~Y 부품 출처)
|
||||
**priority**: ↑ high / medium / ↓ low
|
||||
**scope**: 구체 작업
|
||||
**guardrails**: 깨면 안 되는 contract
|
||||
```
|
||||
|
||||
**이후 추가된 이슈** (모두 source 명시):
|
||||
|
||||
| 이슈 | source | 의미 |
|
||||
|---|---|---|
|
||||
| #38~#41 (IMP-29~32) | IMP-05 §5 defer + Codex 분석 | V4 fallback 후 frontend bridge / AI adaptation 등 |
|
||||
| #42 (IMP-04b) | IMP-04 milestone close 후 잔여 | Catalog 32 frames 확장 |
|
||||
| #43, #44 | MDX 03/04/05 작업 중 발견 | 프론트 작업에서 발견된 새 axis |
|
||||
| #45~#49 | #15 (Step 14 visual_check) decomposition | parent → 5 execution children |
|
||||
| #50 | governance audit | 초반 28 다수 close 후 INTEGRATION-AUDIT-01 |
|
||||
| #51~#54 | #50 audit 의 발견 (F-1~F-5) | follow-up 분리 처리 |
|
||||
| #55 | #20 closed 후 runtime defer | doc-axis closed, runtime 별도 |
|
||||
|
||||
→ 추가 이슈도 모두 (관련 step, source, priority) 좌표로 anchor.
|
||||
|
||||
---
|
||||
|
||||
## 5. orchestrator 의 역할
|
||||
|
||||
이슈 처리의 **disciplined executor**.
|
||||
|
||||
**파일**: [`orchestrator.py`](../../orchestrator.py) (현재 line 수: ~1500)
|
||||
**테스트**: [`tests/orchestrator_unit/`](../../tests/orchestrator_unit/) (현재 94 케이스)
|
||||
|
||||
**6 stage workflow**:
|
||||
1. problem-review — 문제 검토
|
||||
2. simulation-plan — 시뮬 기반 계획 수립 (IMPLEMENTATION_UNITS YAML 강제)
|
||||
3. code-edit — 코드 수정 / 이슈 분기
|
||||
4. test-verify — 테스트 및 검증
|
||||
5. commit-push — 커밋 및 푸쉬
|
||||
6. final-close — 최종 확인 / close
|
||||
|
||||
**원칙**:
|
||||
- Claude (executor) + Codex (verifier) 양쪽 합의 + evidence required
|
||||
- 단일 LLM 의견 X
|
||||
- 매 stage 마다 dual-write (local draft + Gitea comment)
|
||||
- exit report = stage 완료의 binding contract
|
||||
|
||||
**audit-only mode (P4/P4a)**:
|
||||
- 제목에 `[INTEGRATION-AUDIT-*]`/`[AUDIT-ONLY]` 또는 `--audit-only` CLI flag
|
||||
- Stage 3 에서 `src/`, `templates/`, `tests/` 변경 자동 reject (deterministic git diff guard)
|
||||
- Stage 5 commit 범위 = `docs/architecture/INTEGRATION-AUDIT-*.md` + `BACKLOG.md` 만 허용
|
||||
- audit 이슈는 fix 안 함 → follow-up 이슈로 분리
|
||||
|
||||
---
|
||||
|
||||
## 6. Audit cycle (meta-governance)
|
||||
|
||||
이슈 진행으로 인한 누적 drift / 충돌 / 하드코딩 / 매핑 누락을 주기적으로 검증.
|
||||
|
||||
**audit 자체는 코드 안 만짐**. 발견 사항은 별도 이슈로 분리해서 일반 workflow 로 처리.
|
||||
|
||||
**현재까지**:
|
||||
- #50 INTEGRATION-AUDIT-01 (closed 2026-05-19)
|
||||
- 산출: [`INTEGRATION-AUDIT-01-REPORT.md`](INTEGRATION-AUDIT-01-REPORT.md) + [`INTEGRATION-AUDIT-01-MATRIX.md`](INTEGRATION-AUDIT-01-MATRIX.md)
|
||||
- 발견 F-1~F-5 → #51~#54 로 분리 (모두 closed)
|
||||
|
||||
**다음 audit 시점 trigger**:
|
||||
- 닫힌 IMP 이슈가 일정 수 누적될 때 (5+ 연속)
|
||||
- debug.json schema / layout / frame contract / router / visual_check_passed 의미가 바뀔 때
|
||||
- 새 parent axis 진입 직전 (예: #19 → #20 → ...)
|
||||
- 큰 feature 축 (#42 catalog 확장 / #38~#41 frontend bridge) 완료 후
|
||||
|
||||
---
|
||||
|
||||
## 7. 도착점 도달 기준
|
||||
|
||||
다음이 모두 작동해야 destination 도달:
|
||||
|
||||
- [ ] 22-step pipeline end-to-end (Step 0~22 모두 contract 준수, 회귀 0)
|
||||
- [ ] AI 가 frame 을 zone fit 기준으로 평가 → 안 맞으면 reject
|
||||
- [ ] reject 후 AI 가 zone 에 맞춰 frame 생성
|
||||
- [ ] 하드코딩 0 (sample-specific 코드 없음 — anti-hardcoding mechanical check 통과)
|
||||
- [ ] 모든 IMP 이슈 backlog 의 closed / documented (deferred) / pending 분류가 [`PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md`](PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md) 와 code reality 일치
|
||||
|
||||
---
|
||||
|
||||
## 8. 자주 헷갈리는 것들 (anti-patterns — 하지 말 것)
|
||||
|
||||
| 잘못된 framing | 옳은 framing |
|
||||
|---|---|
|
||||
| "Phase Q~Y heritage 를 보존한다" | Q~Y 는 부품 창고. 갭에 필요한 것만 선택적 참조 |
|
||||
| "MDX 03 잘 만들면 끝" | 재사용 가능한 pipeline contract 가 목표. 특정 샘플 최적화 X |
|
||||
| "audit 가 발견하면 그 자리에서 고친다" | follow-up 이슈로 분리. audit 자체는 코드 안 만짐 |
|
||||
| "Claude 가 좋다고 하면 OK" | Claude + Codex 합의 + evidence 필수 |
|
||||
| "이슈 본문은 참고일뿐" | 본문의 (관련 step, source, scope, guardrails) 가 binding anchor |
|
||||
| "Phase R / R' / Q 의 path 로 돌아가도 됨" | 회귀 금지선 4 항목 (INSIGHT-MAP §0) 절대 위반 X |
|
||||
| "destination 외 추가 기능도 욕심내자" | 22-step + AI frame generation 까지가 목표. 그 이상은 별도 결정 |
|
||||
| "문서에 박힌 dormant 항목은 자동 실행 안 됨" | L3 registry [`DORMANT-TRIGGERS.yaml`](DORMANT-TRIGGERS.yaml) + `scripts/check_dormant_triggers.py` 가 orchestrator Stage 4→5 transition 에서 informational alert 로 발화 (closed 이슈 #16/#17/#18/#19/#20 의 trigger-on-X contract) |
|
||||
|
||||
---
|
||||
|
||||
## 9. 핵심 참조 문서 한 곳에
|
||||
|
||||
| 문서 | 역할 |
|
||||
|---|---|
|
||||
| [`PROJECT-INTENT-AND-GOVERNANCE.md`](PROJECT-INTENT-AND-GOVERNANCE.md) | **이 문서** — 왜/무엇을 |
|
||||
| [`PHASE-Q-INSIGHT-TO-22STEP-MAP.md`](PHASE-Q-INSIGHT-TO-22STEP-MAP.md) | INSIGHT-MAP — Q~Y → Z 매핑 catalog |
|
||||
| [`PHASE-Z-PIPELINE-OVERVIEW.md`](PHASE-Z-PIPELINE-OVERVIEW.md) | 22-step pipeline 정의 |
|
||||
| [`PHASE-Z-PIPELINE-STATUS-BOARD.md`](PHASE-Z-PIPELINE-STATUS-BOARD.md) | 22-step 현재 status |
|
||||
| [`PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md`](PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md) | IMP 이슈 backlog (closed/documented/pending) |
|
||||
| [`PHASE-Z-ROADMAP.md`](PHASE-Z-ROADMAP.md) | 진행 로드맵 |
|
||||
| [`INTEGRATION-AUDIT-01-REPORT.md`](INTEGRATION-AUDIT-01-REPORT.md) | 첫 audit 사이클 결과 |
|
||||
| [`../../orchestrator.py`](../../orchestrator.py) | disciplined executor (Claude + Codex 합의 workflow) |
|
||||
| [`../../CLAUDE.md`](../../CLAUDE.md) | AI 가 코드 작업할 때 따를 규칙 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 한 줄 요약
|
||||
|
||||
> **Phase Z 가 "22-step pipeline + AI zone-fit frame generation" 까지 작동하는 것이 destination. Z 구현의 갭은 Phase Q~Y 를 부품 창고로 보고 선택적으로 참조해서 메움. INSIGHT-MAP 이 그 catalog, IMP 이슈가 execution unit. orchestrator 가 Claude + Codex 합의 + evidence 로 disciplined 하게 처리. INTEGRATION-AUDIT 가 주기적으로 누적 정합성 검증, 발견은 follow-up 이슈로 분리. 도착점은 22-step + AI frame generation 까지이고 그 이상은 별도 결정.**
|
||||
1956
orchestrator.py
Normal file
1956
orchestrator.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ dependencies = [
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.24",
|
||||
"pytest-json-report>=1.5",
|
||||
"ruff>=0.8",
|
||||
]
|
||||
|
||||
@@ -33,4 +34,5 @@ target-version = "py310"
|
||||
asyncio_mode = "auto"
|
||||
markers = [
|
||||
"integration: end-to-end pipeline integration tests (heavy; invoke Selenium)",
|
||||
"sweep: opt-in heavyweight sweep tests (IMP-43 u7b: 3 layouts × 3 mdx × frame-pin coverage). Invoke explicitly via `pytest -m sweep`; default CI must use `-m 'not sweep'`.",
|
||||
]
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
title: DX 지연 요인
|
||||
sidebar:
|
||||
order: 03
|
||||
slide_overrides:
|
||||
css: |
|
||||
.slide-body {
|
||||
grid-template-rows: 0.38fr 0.60fr !important;
|
||||
gap: 1.5% !important;
|
||||
}
|
||||
.f29b__cell .text-line + .text-line { margin-top: 1px !important; }
|
||||
.f29b__cell:nth-child(n+3) {
|
||||
padding-top: 3px !important;
|
||||
margin-top: 2px !important;
|
||||
}
|
||||
---
|
||||
|
||||
## 1. DX에 대한 인식
|
||||
|
||||
299
scripts/audit_frame_invariants.py
Normal file
299
scripts/audit_frame_invariants.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""Catalog ↔ partial ↔ builder invariant audit CLI (IMP-#85 u3a / u3b).
|
||||
|
||||
Offline audit of `templates/phase_z2/catalog/frame_contracts.yaml` against
|
||||
the on-disk frame partials and the runtime `PAYLOAD_BUILDERS` registry.
|
||||
|
||||
Reports diff surface so first-fix iteration sees the entire catalog drift,
|
||||
not just the first failure (matches the boot-time invariant's aggregation
|
||||
behavior in `_check_catalog_builder_invariant`).
|
||||
|
||||
Invariants (scope-locked per Stage 2):
|
||||
I1 partial existence — `templates/phase_z2/families/{template_id}.html`
|
||||
must exist for live (non-VP) contracts.
|
||||
I2 builder declared — live contracts must declare a non-empty
|
||||
`payload.builder`.
|
||||
I3 builder registered — declared builders must be members of
|
||||
`src.phase_z2_mapper.PAYLOAD_BUILDERS`.
|
||||
I4 slot_payload refs — every key generated by the contract's builder
|
||||
must appear as a `slot_payload.<key>` reference in
|
||||
the partial. Direction A only (dead generated key).
|
||||
Skipped when the partial uses dynamic bracket
|
||||
access (`slot_payload[...]`) — those refs cannot be
|
||||
resolved statically; the relevant generated keys
|
||||
are presumed reachable via the dynamic form.
|
||||
|
||||
`visual_pending: true` contracts are skipped for I1–I4 (data-driven from
|
||||
catalog, no hard-coded frame allow-list; matches u2 invariant scope).
|
||||
|
||||
Exit codes:
|
||||
0 — all invariants pass on live (non-VP) contracts.
|
||||
1 — one or more violations reported.
|
||||
|
||||
Usage::
|
||||
|
||||
python scripts/audit_frame_invariants.py
|
||||
python scripts/audit_frame_invariants.py --catalog <path> --partials-dir <path>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import yaml
|
||||
|
||||
DEFAULT_CATALOG_PATH = (
|
||||
REPO_ROOT / "templates" / "phase_z2" / "catalog" / "frame_contracts.yaml"
|
||||
)
|
||||
DEFAULT_PARTIALS_DIR = REPO_ROOT / "templates" / "phase_z2" / "families"
|
||||
|
||||
|
||||
def _format_path(path: Path) -> str:
|
||||
try:
|
||||
return str(path.relative_to(REPO_ROOT))
|
||||
except ValueError:
|
||||
return str(path)
|
||||
|
||||
|
||||
def _is_visual_pending(contract: dict) -> bool:
|
||||
return contract.get("visual_pending") is True
|
||||
|
||||
|
||||
def _iter_live_contracts(catalog: dict) -> Iterable[tuple[str, dict]]:
|
||||
for template_id, contract in catalog.items():
|
||||
if not isinstance(contract, dict):
|
||||
continue
|
||||
if _is_visual_pending(contract):
|
||||
continue
|
||||
yield template_id, contract
|
||||
|
||||
|
||||
def check_i1_partial_existence(
|
||||
catalog: dict, partials_dir: Path
|
||||
) -> list[str]:
|
||||
"""I1 — Live contracts must have `families/{template_id}.html` on disk."""
|
||||
violations: list[str] = []
|
||||
for template_id, _contract in _iter_live_contracts(catalog):
|
||||
partial_path = partials_dir / f"{template_id}.html"
|
||||
if not partial_path.is_file():
|
||||
violations.append(
|
||||
f"I1 partial-missing: contract '{template_id}' has no "
|
||||
f"partial file at {_format_path(partial_path)}."
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
def check_i2_builder_declared(catalog: dict) -> list[str]:
|
||||
"""I2 — Live contracts must declare a non-empty `payload.builder`."""
|
||||
violations: list[str] = []
|
||||
for template_id, contract in _iter_live_contracts(catalog):
|
||||
payload = contract.get("payload") or {}
|
||||
if not isinstance(payload, dict):
|
||||
violations.append(
|
||||
f"I2 builder-undeclared: contract '{template_id}' has "
|
||||
f"non-dict payload (type={type(payload).__name__})."
|
||||
)
|
||||
continue
|
||||
builder_name = payload.get("builder")
|
||||
if not builder_name:
|
||||
violations.append(
|
||||
f"I2 builder-undeclared: contract '{template_id}' is "
|
||||
f"missing payload.builder."
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
def check_i3_builder_registered(
|
||||
catalog: dict, registered_builders: set[str]
|
||||
) -> list[str]:
|
||||
"""I3 — Declared builders must be members of PAYLOAD_BUILDERS registry."""
|
||||
violations: list[str] = []
|
||||
for template_id, contract in _iter_live_contracts(catalog):
|
||||
payload = contract.get("payload") or {}
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
builder_name = payload.get("builder")
|
||||
if not builder_name:
|
||||
continue
|
||||
if builder_name not in registered_builders:
|
||||
violations.append(
|
||||
f"I3 builder-unregistered: contract '{template_id}' "
|
||||
f"references payload.builder='{builder_name}' not in "
|
||||
f"PAYLOAD_BUILDERS."
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
_SLOT_PAYLOAD_DOT_RE = re.compile(r"slot_payload\.([A-Za-z_][A-Za-z0-9_]*)")
|
||||
_SLOT_PAYLOAD_BRACKET_RE = re.compile(r"slot_payload\s*\[")
|
||||
|
||||
|
||||
def extract_static_slot_refs(partial_text: str) -> set[str]:
|
||||
"""Return the set of `slot_payload.<key>` dot-access references."""
|
||||
return set(_SLOT_PAYLOAD_DOT_RE.findall(partial_text))
|
||||
|
||||
|
||||
def partial_uses_dynamic_slot_access(partial_text: str) -> bool:
|
||||
"""True if the partial dereferences `slot_payload[...]` (dynamic key)."""
|
||||
return bool(_SLOT_PAYLOAD_BRACKET_RE.search(partial_text))
|
||||
|
||||
|
||||
def expected_payload_keys(contract: dict) -> set[str]:
|
||||
"""Statically compute the set of payload keys the contract's builder produces.
|
||||
|
||||
Mirrors `src.phase_z2_mapper`'s registered builders (IMP-#85 u3b). Returns
|
||||
an empty set when the builder is unknown — I3 already flags that drift.
|
||||
"""
|
||||
payload = contract.get("payload") or {}
|
||||
if not isinstance(payload, dict):
|
||||
return set()
|
||||
keys: set[str] = set()
|
||||
title_spec = payload.get("title")
|
||||
if isinstance(title_spec, dict) and title_spec.get("source"):
|
||||
keys.add("title")
|
||||
|
||||
builder = payload.get("builder")
|
||||
options = payload.get("builder_options") or {}
|
||||
if not isinstance(options, dict):
|
||||
options = {}
|
||||
|
||||
if builder == "items_with_role":
|
||||
array_root = options.get("array_root")
|
||||
if array_root:
|
||||
keys.add(array_root)
|
||||
elif builder == "process_product_pair":
|
||||
for col in options.get("columns") or []:
|
||||
if not isinstance(col, dict):
|
||||
continue
|
||||
if col.get("title_to"):
|
||||
keys.add(col["title_to"])
|
||||
if col.get("body_to"):
|
||||
keys.add(col["body_to"])
|
||||
elif builder == "quadrant_flat_slots":
|
||||
pad_to = int(options.get("pad_to", 4))
|
||||
label_key = options.get("label_key_pattern", "quadrant_{n}_label")
|
||||
body_key = options.get("body_key_pattern", "quadrant_{n}_body")
|
||||
for n in range(1, pad_to + 1):
|
||||
keys.add(label_key.format(n=n))
|
||||
keys.add(body_key.format(n=n))
|
||||
elif builder == "cycle_intersect_3":
|
||||
pad_to = int(options.get("pad_to", 3))
|
||||
label_key = options.get("label_key_pattern", "circle_{n}_label")
|
||||
for n in range(1, pad_to + 1):
|
||||
keys.add(label_key.format(n=n))
|
||||
keys.add("intersection")
|
||||
elif builder == "compare_table_2col":
|
||||
keys.update({"col_a_label", "col_b_label", "rows"})
|
||||
elif builder == "paired_rows_4x2_slots":
|
||||
label_key = options.get("label_key_pattern", "row_{r}_{side}_label")
|
||||
body_key = options.get("body_key_pattern", "row_{r}_{side}_body")
|
||||
rows = int(options.get("rows", 4))
|
||||
sides = options.get("sides", ["left", "right"]) or []
|
||||
for r in range(1, rows + 1):
|
||||
for side in sides:
|
||||
keys.add(label_key.format(r=r, side=side))
|
||||
keys.add(body_key.format(r=r, side=side))
|
||||
return keys
|
||||
|
||||
|
||||
def check_i4_slot_payload_refs(
|
||||
catalog: dict,
|
||||
partials_dir: Path,
|
||||
registered_builders: set[str],
|
||||
) -> list[str]:
|
||||
"""I4 — every generated payload key must be referenced by the partial.
|
||||
|
||||
Direction A only (dead key). Skipped when the partial uses dynamic
|
||||
bracket access (`slot_payload[...]`) — generated keys are presumed
|
||||
reached via the dynamic form and cannot be resolved statically.
|
||||
|
||||
Contracts already failing I1 (missing partial) or I3 (unregistered
|
||||
builder) are skipped so the same drift is not double-reported.
|
||||
"""
|
||||
violations: list[str] = []
|
||||
for template_id, contract in _iter_live_contracts(catalog):
|
||||
payload = contract.get("payload") or {}
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
builder_name = payload.get("builder")
|
||||
if not builder_name or builder_name not in registered_builders:
|
||||
continue
|
||||
partial_path = partials_dir / f"{template_id}.html"
|
||||
if not partial_path.is_file():
|
||||
continue
|
||||
partial_text = partial_path.read_text(encoding="utf-8")
|
||||
if partial_uses_dynamic_slot_access(partial_text):
|
||||
continue
|
||||
static_refs = extract_static_slot_refs(partial_text)
|
||||
expected = expected_payload_keys(contract)
|
||||
orphans = sorted(expected - static_refs)
|
||||
for key in orphans:
|
||||
violations.append(
|
||||
f"I4 generated-key-orphan: contract '{template_id}' builder "
|
||||
f"'{builder_name}' produces payload key '{key}' but partial "
|
||||
f"never references slot_payload.{key}."
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
def run_audit(
|
||||
catalog_path: Path = DEFAULT_CATALOG_PATH,
|
||||
partials_dir: Path = DEFAULT_PARTIALS_DIR,
|
||||
) -> list[str]:
|
||||
"""Load catalog + registry and aggregate I1-I4 violations.
|
||||
|
||||
Registry is imported here (not at module import) so the script can be
|
||||
inspected without triggering the boot-time catalog invariant.
|
||||
"""
|
||||
from src.phase_z2_mapper import PAYLOAD_BUILDERS
|
||||
|
||||
catalog = yaml.safe_load(catalog_path.read_text(encoding="utf-8")) or {}
|
||||
registered = set(PAYLOAD_BUILDERS.keys())
|
||||
|
||||
violations: list[str] = []
|
||||
violations.extend(check_i1_partial_existence(catalog, partials_dir))
|
||||
violations.extend(check_i2_builder_declared(catalog))
|
||||
violations.extend(check_i3_builder_registered(catalog, registered))
|
||||
violations.extend(check_i4_slot_payload_refs(catalog, partials_dir, registered))
|
||||
return violations
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Audit Phase Z-2 catalog ↔ partials ↔ builder registry."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--catalog",
|
||||
type=Path,
|
||||
default=DEFAULT_CATALOG_PATH,
|
||||
help="Path to frame_contracts.yaml",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--partials-dir",
|
||||
type=Path,
|
||||
default=DEFAULT_PARTIALS_DIR,
|
||||
help="Directory containing families/{template_id}.html partials",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
violations = run_audit(args.catalog, args.partials_dir)
|
||||
if not violations:
|
||||
print("audit_frame_invariants: PASS (I1-I4 clean on live contracts).")
|
||||
return 0
|
||||
|
||||
print(
|
||||
f"audit_frame_invariants: FAIL ({len(violations)} violation(s)):"
|
||||
)
|
||||
for v in violations:
|
||||
print(f" - {v}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
191
scripts/check_dormant_triggers.py
Normal file
191
scripts/check_dormant_triggers.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""Dormant trigger guard — L3 machine-readable check (issue #58, P5-2).
|
||||
|
||||
Reads docs/architecture/DORMANT-TRIGGERS.yaml, scans the changed-file surface
|
||||
(working tree via `git status --porcelain` + recent commit via
|
||||
`git diff HEAD~1..HEAD --name-only`), and writes any matching activation
|
||||
candidates to .orchestrator/dormant_alerts.json.
|
||||
|
||||
Guardrails (per Stage 1 scope-lock) :
|
||||
- Informational only. Exit code is ALWAYS 0 — orchestrator never blocks on alerts.
|
||||
- manual_evidence_required entries are skipped (require human gate).
|
||||
- followup_issue entries are skipped (already tracked by the open follow-up).
|
||||
- No LLM call. Deterministic file-pattern + content-pattern matching only.
|
||||
- No hardcoding : the registry yaml is the single source of truth.
|
||||
|
||||
Run :
|
||||
python scripts/check_dormant_triggers.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
REGISTRY_PATH = REPO_ROOT / "docs" / "architecture" / "DORMANT-TRIGGERS.yaml"
|
||||
ALERT_OUT_PATH = REPO_ROOT / ".orchestrator" / "dormant_alerts.json"
|
||||
|
||||
|
||||
def load_registry(path: Path = REGISTRY_PATH) -> list[dict]:
|
||||
if not path.exists():
|
||||
return []
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or []
|
||||
if not isinstance(data, list):
|
||||
raise ValueError(f"{path} must be a YAML list of entries.")
|
||||
return data
|
||||
|
||||
|
||||
def _git_lines(args: list[str]) -> list[str]:
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["git"] + args,
|
||||
cwd=str(REPO_ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return []
|
||||
if out.returncode != 0:
|
||||
return []
|
||||
return [ln for ln in out.stdout.splitlines() if ln.strip()]
|
||||
|
||||
|
||||
def collect_changed_files() -> list[str]:
|
||||
files: set[str] = set()
|
||||
for ln in _git_lines(["status", "--porcelain"]):
|
||||
path = ln[3:].strip() if len(ln) >= 4 else ln.strip()
|
||||
if "->" in path:
|
||||
path = path.split("->", 1)[1].strip()
|
||||
path = path.strip('"')
|
||||
if path:
|
||||
files.add(path.replace("\\", "/"))
|
||||
for ln in _git_lines(["diff", "HEAD~1..HEAD", "--name-only"]):
|
||||
if ln.strip():
|
||||
files.add(ln.strip().replace("\\", "/"))
|
||||
return sorted(files)
|
||||
|
||||
|
||||
def _glob_to_regex(pat: str) -> str:
|
||||
"""Translate a posix-style glob with ``**`` to an anchored regex.
|
||||
|
||||
``**/`` matches zero or more directory levels (so ``src/**/*.py`` matches
|
||||
both ``src/adapter.py`` and ``src/foo/adapter.py``). ``*`` and ``?`` do
|
||||
NOT cross directory separators. Mirrors common ``.gitignore``-style
|
||||
semantics; ``fnmatch.fnmatch`` alone cannot express this.
|
||||
"""
|
||||
out: list[str] = []
|
||||
i = 0
|
||||
n = len(pat)
|
||||
while i < n:
|
||||
if pat[i : i + 3] == "**/":
|
||||
out.append("(?:.*/)?")
|
||||
i += 3
|
||||
elif pat[i : i + 2] == "**":
|
||||
out.append(".*")
|
||||
i += 2
|
||||
elif pat[i] == "*":
|
||||
out.append("[^/]*")
|
||||
i += 1
|
||||
elif pat[i] == "?":
|
||||
out.append("[^/]")
|
||||
i += 1
|
||||
else:
|
||||
out.append(re.escape(pat[i]))
|
||||
i += 1
|
||||
return "^" + "".join(out) + "$"
|
||||
|
||||
|
||||
def _glob_match(path: str, patterns: list[str]) -> bool:
|
||||
for pat in patterns:
|
||||
if re.match(_glob_to_regex(pat), path):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _content_match(file_path: Path, patterns: list[str]) -> list[str]:
|
||||
if not patterns or not file_path.exists() or not file_path.is_file():
|
||||
return []
|
||||
try:
|
||||
text = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return []
|
||||
hits = []
|
||||
for pat in patterns:
|
||||
try:
|
||||
if re.search(pat, text):
|
||||
hits.append(pat)
|
||||
except re.error:
|
||||
if pat in text:
|
||||
hits.append(pat)
|
||||
return hits
|
||||
|
||||
|
||||
def check_entry(entry: dict, changed: list[str]) -> dict | None:
|
||||
trig = entry.get("trigger") or {}
|
||||
if trig.get("manual_evidence_required"):
|
||||
return None
|
||||
if entry.get("followup_issue"):
|
||||
return None
|
||||
file_patterns = trig.get("file_patterns") or []
|
||||
content_patterns = trig.get("content_patterns") or []
|
||||
if not file_patterns:
|
||||
return None
|
||||
matched_files = [p for p in changed if _glob_match(p, file_patterns)]
|
||||
if not matched_files:
|
||||
return None
|
||||
if content_patterns:
|
||||
hits: list[dict] = []
|
||||
for mf in matched_files:
|
||||
hit_patterns = _content_match(REPO_ROOT / mf, content_patterns)
|
||||
if hit_patterns:
|
||||
hits.append({"file": mf, "patterns": hit_patterns})
|
||||
if not hits:
|
||||
return None
|
||||
match_info = {"files": [h["file"] for h in hits], "content_hits": hits}
|
||||
else:
|
||||
match_info = {"files": matched_files, "content_hits": []}
|
||||
return {
|
||||
"issue": entry.get("issue"),
|
||||
"title": entry.get("title"),
|
||||
"doc": entry.get("doc"),
|
||||
"status": entry.get("status"),
|
||||
"on_trigger": entry.get("on_trigger"),
|
||||
"match": match_info,
|
||||
}
|
||||
|
||||
|
||||
def write_alerts(alerts: list[dict], path: Path = ALERT_OUT_PATH) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"registry": str(REGISTRY_PATH.relative_to(REPO_ROOT)).replace("\\", "/"),
|
||||
"alerts": alerts,
|
||||
}
|
||||
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
entries = load_registry()
|
||||
changed = collect_changed_files()
|
||||
alerts = [a for a in (check_entry(e, changed) for e in entries) if a]
|
||||
write_alerts(alerts)
|
||||
if alerts:
|
||||
print(f"[dormant-trigger-guard] {len(alerts)} alert(s) written -> "
|
||||
f"{ALERT_OUT_PATH.relative_to(REPO_ROOT)}")
|
||||
for a in alerts:
|
||||
print(f" - #{a['issue']} {a['title']} (files: {len(a['match']['files'])})")
|
||||
else:
|
||||
print("[dormant-trigger-guard] no dormant trigger alerts on current change surface.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
189
scripts/generate_frame_previews.py
Normal file
189
scripts/generate_frame_previews.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""IMP-13 build-time preview.png renderer for figma_to_html_agent/blocks/<frame_id> (u1-u6)."""
|
||||
from __future__ import annotations
|
||||
import argparse, hashlib, json, sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
DEFAULT_BLOCKS_DIR = REPO_ROOT / "figma_to_html_agent" / "blocks"
|
||||
DEFAULT_MANIFEST = DEFAULT_BLOCKS_DIR / "_preview_manifest.json"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FrameRow:
|
||||
frame_id: str
|
||||
block_dir: Path
|
||||
index_html_path: Path
|
||||
preview_png_path: Path
|
||||
has_index: bool
|
||||
has_preview: bool
|
||||
|
||||
|
||||
def discover(blocks_dir: Path) -> List[FrameRow]:
|
||||
if not blocks_dir.is_dir():
|
||||
return []
|
||||
rows: List[FrameRow] = []
|
||||
for entry in sorted(blocks_dir.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
idx, png = entry / "index.html", entry / "preview.png"
|
||||
rows.append(FrameRow(entry.name, entry, idx, png, idx.is_file(), png.is_file()))
|
||||
return rows
|
||||
|
||||
|
||||
def _build_driver() -> Any:
|
||||
"""Headless Chrome driver. Mirrors the run_overflow_check chromedriver-candidate + headless options pattern.
|
||||
Inline per Stage 2 (no shared module). Per-frame window-size is set by the caller (u3), not here."""
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
options = Options()
|
||||
options.add_argument("--headless=new")
|
||||
options.add_argument("--no-sandbox")
|
||||
options.add_argument("--disable-dev-shm-usage")
|
||||
candidates = [REPO_ROOT / "chromedriver", REPO_ROOT / "chromedriver.exe"]
|
||||
last_err: Exception | None = None
|
||||
for path in candidates:
|
||||
if path.is_file():
|
||||
try:
|
||||
return webdriver.Chrome(service=Service(str(path)), options=options)
|
||||
except Exception as exc: # noqa: BLE001 — propagate via aggregated error
|
||||
last_err = exc
|
||||
try:
|
||||
return webdriver.Chrome(options=options)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise RuntimeError(f"selenium init failed: {last_err or exc}") from exc
|
||||
|
||||
|
||||
def render_one(driver: Any, row: FrameRow) -> tuple[int, int, Path]:
|
||||
"""Render row.index_html_path -> row.preview_png_path via WebElement screenshot. Returns (w, h, path) or raises.
|
||||
Driver is injected (caller owns lifecycle). .slide bbox drives window-size; no hardcoded slide dimensions."""
|
||||
if not row.has_index:
|
||||
raise FileNotFoundError(f"missing index.html: {row.index_html_path}")
|
||||
from selenium.webdriver.common.by import By
|
||||
driver.get(row.index_html_path.resolve().as_uri())
|
||||
driver.set_script_timeout(15)
|
||||
driver.execute_async_script(
|
||||
"const cb=arguments[arguments.length-1];"
|
||||
"(document.fonts&&document.fonts.ready?document.fonts.ready:Promise.resolve()).then(()=>cb(true));"
|
||||
)
|
||||
rect = driver.execute_script(
|
||||
"const el=document.querySelector('.slide');"
|
||||
"if(!el)return null;"
|
||||
"const r=el.getBoundingClientRect();"
|
||||
"return [Math.round(r.width), Math.round(r.height)];"
|
||||
)
|
||||
if not rect:
|
||||
raise RuntimeError(f".slide not found in {row.index_html_path}")
|
||||
w, h = int(rect[0]), int(rect[1])
|
||||
driver.set_window_size(w, h)
|
||||
el = driver.find_element(By.CSS_SELECTOR, ".slide")
|
||||
row.preview_png_path.write_bytes(el.screenshot_as_png)
|
||||
return w, h, row.preview_png_path
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(65536), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def is_unchanged(row: FrameRow, last_entry: Optional[Dict[str, Any]]) -> bool:
|
||||
"""Stale-detect short-circuit: True iff preview.png mtime >= index.html mtime AND sha256 matches last_entry.
|
||||
Returns False when prior entry is absent, preview.png is missing, preview is older than index, or hash differs."""
|
||||
if last_entry is None or not row.has_index or not row.has_preview:
|
||||
return False
|
||||
try:
|
||||
idx_mtime = row.index_html_path.stat().st_mtime
|
||||
png_mtime = row.preview_png_path.stat().st_mtime
|
||||
except OSError:
|
||||
return False
|
||||
if png_mtime < idx_mtime:
|
||||
return False
|
||||
recorded = last_entry.get("index_sha256")
|
||||
if not recorded:
|
||||
return False
|
||||
return _sha256_file(row.index_html_path) == recorded
|
||||
|
||||
|
||||
def categorize(rows: List[FrameRow]) -> Dict[str, List[FrameRow]]:
|
||||
"""Bucket discover() rows so nothing is silently skipped (Stage 2 guardrail).
|
||||
renderable = has_index (eligible for render or skipped_unchanged decision in u6).
|
||||
missing_index_html = no index.html (catalog gap; IMP-04 follow-up).
|
||||
orphan = preview.png exists without index.html (subset of missing_index_html; stale artifact to flag).
|
||||
Buckets are intentionally non-disjoint: orphan is a subset of missing_index_html,
|
||||
matching the Stage 2 evidence counts (renderable=20, missing_index_html=13, orphan=1)."""
|
||||
renderable = [r for r in rows if r.has_index]
|
||||
missing = [r for r in rows if not r.has_index]
|
||||
orphan = [r for r in missing if r.has_preview]
|
||||
return {"renderable": renderable, "missing_index_html": missing, "orphan": orphan}
|
||||
|
||||
|
||||
def _load_manifest(path: Path) -> Dict[str, Any]:
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _render_entry(row: FrameRow, w: int, h: int) -> Dict[str, Any]:
|
||||
return {"status": "rendered", "index_sha256": _sha256_file(row.index_html_path),
|
||||
"index_mtime": row.index_html_path.stat().st_mtime,
|
||||
"preview_mtime": row.preview_png_path.stat().st_mtime,
|
||||
"viewport": {"w": w, "h": h}}
|
||||
|
||||
|
||||
def main(argv: Iterable[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(prog="generate_frame_previews", description="IMP-13 build-time preview.png renderer.")
|
||||
p.add_argument("--blocks-dir", type=Path, default=DEFAULT_BLOCKS_DIR)
|
||||
p.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
|
||||
p.add_argument("--dry-run", action="store_true")
|
||||
args = p.parse_args(list(argv) if argv is not None else None)
|
||||
rows = discover(args.blocks_dir)
|
||||
if args.dry_run:
|
||||
wi = sum(1 for r in rows if r.has_index)
|
||||
wp = sum(1 for r in rows if r.has_preview)
|
||||
print(f"discovered: total={len(rows)} with_index_html={wi} with_preview_png={wp}")
|
||||
return 0
|
||||
prev_frames = _load_manifest(args.manifest).get("frames") or {}
|
||||
buckets = categorize(rows)
|
||||
frames: Dict[str, Dict[str, Any]] = {}
|
||||
counts = {"rendered": 0, "skipped_unchanged": 0, "error": 0}
|
||||
driver = None
|
||||
try:
|
||||
for r in buckets["renderable"]:
|
||||
last = prev_frames.get(r.frame_id) if isinstance(prev_frames, dict) else None
|
||||
if is_unchanged(r, last):
|
||||
frames[r.frame_id] = {**last, "status": "skipped_unchanged"}
|
||||
counts["skipped_unchanged"] += 1
|
||||
continue
|
||||
if driver is None:
|
||||
driver = _build_driver()
|
||||
try:
|
||||
w, h, _ = render_one(driver, r)
|
||||
frames[r.frame_id] = _render_entry(r, w, h)
|
||||
counts["rendered"] += 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
frames[r.frame_id] = {"status": "error", "error": str(exc)}
|
||||
counts["error"] += 1
|
||||
finally:
|
||||
if driver is not None:
|
||||
try: driver.quit()
|
||||
except Exception: pass
|
||||
orphan_ids = {r.frame_id for r in buckets["orphan"]}
|
||||
for r in buckets["missing_index_html"]:
|
||||
frames[r.frame_id] = {"status": "orphan" if r.frame_id in orphan_ids else "missing_index_html", "has_preview": r.has_preview}
|
||||
summary = {"total": len(rows), "renderable": len(buckets["renderable"]), "missing_index_html": len(buckets["missing_index_html"]), "orphan": len(buckets["orphan"]), **counts}
|
||||
payload = {"schema": 1, "generated_at": datetime.now(timezone.utc).isoformat(), "blocks_dir": str(args.blocks_dir), "summary": summary, "frames": frames}
|
||||
args.manifest.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
||||
print(f"coverage: total={summary['total']} renderable={summary['renderable']} rendered={counts['rendered']} skipped_unchanged={counts['skipped_unchanged']} missing_index_html={summary['missing_index_html']} orphan={summary['orphan']} error={counts['error']}")
|
||||
return 1 if counts["error"] else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
178
scripts/measure_reuse_savings.py
Normal file
178
scripts/measure_reuse_savings.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""IMP-43 (#72) u8 — measure ``--reuse-from`` wall-clock savings.
|
||||
|
||||
Argv-driven measurement helper for the Stage 2 §u8 binding contract:
|
||||
re-derive a realistic savings target instead of mirroring the
|
||||
unverified issue-body 50–70% / 10–20s → 3–8s claim.
|
||||
|
||||
Per-iteration measurement protocol (mirrors the u7a equivalence
|
||||
harness, ``tests/test_phase_z2_reuse_from_equivalence_unit.py``):
|
||||
|
||||
(A) baseline full run, no overrides — reuse seed
|
||||
(B) full rerun full run + one --override-frame pin — control path
|
||||
(C) reuse --reuse-from <seed> + same pin — reuse path
|
||||
|
||||
Wall-clock = ``time.perf_counter()`` around the subprocess.run call.
|
||||
The (A) seed run time is reported separately and NOT included in the
|
||||
B-vs-C comparison (the reuse path's whole point is that the seed
|
||||
already exists from a prior interactive run).
|
||||
|
||||
For each iteration the frame pin is self-discovered from the seed
|
||||
run's ``step06_composition_plan.json``: the first unit's
|
||||
``frame_template_id`` is re-pinned to itself, exercising the
|
||||
``--override-frame`` CLI surface end-to-end without changing the
|
||||
semantic frame assignment (same approach the u7a/u7b equivalence
|
||||
tests already lock).
|
||||
|
||||
Output: a JSON document to stdout with per-iteration timings,
|
||||
B/C p50 + p95, and the ratio C/B. Stderr carries the subprocess
|
||||
stdout/stderr tails on non-zero exits.
|
||||
|
||||
Guardrails (Stage 2):
|
||||
* argv-driven, no hardcoded mdx — caller picks the sample
|
||||
* no hardcoded savings target — TBD until measured
|
||||
* value + path + upstream provenance lives in the printed JSON
|
||||
* does NOT mutate prev_run_dir; new runs land under fresh run_ids
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
RUNS_DIR = REPO_ROOT / "data" / "runs"
|
||||
|
||||
|
||||
def _unique_run_id(prefix: str) -> str:
|
||||
return f"{prefix}_imp43_u8_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _spawn(extra_args: list[str], timeout: int) -> tuple[subprocess.CompletedProcess, float]:
|
||||
start = time.perf_counter()
|
||||
cp = subprocess.run(
|
||||
[sys.executable, "-m", "src.phase_z2_pipeline", *extra_args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
cwd=str(REPO_ROOT),
|
||||
)
|
||||
return cp, time.perf_counter() - start
|
||||
|
||||
|
||||
def _assert_ok(label: str, cp: subprocess.CompletedProcess) -> None:
|
||||
if cp.returncode != 0:
|
||||
sys.stderr.write(
|
||||
f"[measure_reuse_savings] {label} failed rc={cp.returncode}\n"
|
||||
f"--- stderr tail ---\n{cp.stderr[-2000:]}\n"
|
||||
f"--- stdout tail ---\n{cp.stdout[-2000:]}\n"
|
||||
)
|
||||
raise SystemExit(2)
|
||||
|
||||
|
||||
def _discover_first_frame_pin(seed_run_id: str) -> tuple[str, str]:
|
||||
p = RUNS_DIR / seed_run_id / "phase_z2" / "steps" / "step06_composition_plan.json"
|
||||
payload = json.loads(p.read_text(encoding="utf-8"))
|
||||
for u in payload.get("data", {}).get("selected_units") or []:
|
||||
sids = u.get("source_section_ids") or []
|
||||
tpl = u.get("frame_template_id")
|
||||
if isinstance(sids, list) and sids and isinstance(tpl, str) and tpl:
|
||||
return ("+".join(str(s) for s in sids), tpl)
|
||||
raise SystemExit(
|
||||
f"[measure_reuse_savings] seed {seed_run_id} step06 has no pinnable "
|
||||
f"(unit_id, frame_template_id); path={p}"
|
||||
)
|
||||
|
||||
|
||||
def _percentile(values: list[float], pct: float) -> float:
|
||||
if not values:
|
||||
return float("nan")
|
||||
if len(values) == 1:
|
||||
return values[0]
|
||||
s = sorted(values)
|
||||
k = (len(s) - 1) * pct
|
||||
lo = int(k)
|
||||
hi = min(lo + 1, len(s) - 1)
|
||||
return s[lo] + (s[hi] - s[lo]) * (k - lo)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
prog="python -m scripts.measure_reuse_savings",
|
||||
description="Measure IMP-43 --reuse-from wall-clock savings.",
|
||||
)
|
||||
ap.add_argument("mdx_path", type=Path, help="MDX sample to measure against")
|
||||
ap.add_argument("--iterations", type=int, default=3, help="trials (default 3)")
|
||||
ap.add_argument("--timeout", type=int, default=900, help="per-run timeout seconds")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not args.mdx_path.is_file():
|
||||
sys.stderr.write(f"[measure_reuse_savings] mdx not found: {args.mdx_path}\n")
|
||||
return 2
|
||||
|
||||
iterations: list[dict] = []
|
||||
for i in range(args.iterations):
|
||||
seed_id = _unique_run_id(f"seed{i}")
|
||||
cp_a, t_a = _spawn([str(args.mdx_path), seed_id], args.timeout)
|
||||
_assert_ok(f"(A) seed iter={i}", cp_a)
|
||||
|
||||
unit_id, tpl_id = _discover_first_frame_pin(seed_id)
|
||||
override = ["--override-frame", f"{unit_id}={tpl_id}"]
|
||||
|
||||
full_id = _unique_run_id(f"full{i}")
|
||||
cp_b, t_b = _spawn([str(args.mdx_path), full_id, *override], args.timeout)
|
||||
_assert_ok(f"(B) full rerun iter={i}", cp_b)
|
||||
|
||||
reuse_id = _unique_run_id(f"reuse{i}")
|
||||
cp_c, t_c = _spawn(
|
||||
[str(args.mdx_path), reuse_id, "--reuse-from", seed_id, *override],
|
||||
args.timeout,
|
||||
)
|
||||
_assert_ok(f"(C) reuse iter={i}", cp_c)
|
||||
|
||||
iterations.append({
|
||||
"iter": i,
|
||||
"seed_run_id": seed_id,
|
||||
"full_run_id": full_id,
|
||||
"reuse_run_id": reuse_id,
|
||||
"override_frame": f"{unit_id}={tpl_id}",
|
||||
"seed_seconds": t_a,
|
||||
"full_rerun_seconds": t_b,
|
||||
"reuse_seconds": t_c,
|
||||
})
|
||||
|
||||
full_times = [it["full_rerun_seconds"] for it in iterations]
|
||||
reuse_times = [it["reuse_seconds"] for it in iterations]
|
||||
|
||||
summary = {
|
||||
"mdx_path": str(args.mdx_path),
|
||||
"iterations_count": len(iterations),
|
||||
"full_rerun_seconds_p50": _percentile(full_times, 0.50),
|
||||
"full_rerun_seconds_p95": _percentile(full_times, 0.95),
|
||||
"reuse_seconds_p50": _percentile(reuse_times, 0.50),
|
||||
"reuse_seconds_p95": _percentile(reuse_times, 0.95),
|
||||
"reuse_over_full_ratio_p50": (
|
||||
_percentile(reuse_times, 0.50) / _percentile(full_times, 0.50)
|
||||
if full_times and statistics.median(full_times) > 0
|
||||
else float("nan")
|
||||
),
|
||||
"iterations": iterations,
|
||||
"note": (
|
||||
"IMP-43 (#72) u8 measurement. Issue-body 50–70% / 10–20s → 3–8s "
|
||||
"claim is NOT honored here — actual numbers depend on host, "
|
||||
"Selenium cold-start, and AI cache state. Update "
|
||||
"docs/architecture/PHASE-Z-PIPELINE-STATUS-BOARD.md §8 with the "
|
||||
"p50/p95 reported here when run on the project's reference host."
|
||||
),
|
||||
}
|
||||
sys.stdout.write(json.dumps(summary, ensure_ascii=False, indent=2))
|
||||
sys.stdout.write("\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
75
scripts/update_status_board.py
Normal file
75
scripts/update_status_board.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""IMP-#91 u14 — idempotent status-board marker updater.
|
||||
|
||||
Reads a pytest-json-report artifact emitted by the IMP-91 CI workflow and
|
||||
rewrites paired ``<!-- IMP-91:<axis>:<mdx> -->...<!-- /IMP-91 -->`` markers
|
||||
inside the Phase Z status board with a single-character outcome symbol.
|
||||
|
||||
Pure functions (``parse_outcomes`` / ``update_board_text``) are exposed so
|
||||
``tests/scripts/test_update_status_board.py`` can exercise the contract
|
||||
without invoking pytest. The CLI just wires file IO around them so the
|
||||
GitHub Actions step in u15 can call it deterministically. The updater is
|
||||
additive: untouched markers stay; missing outcomes render ``?`` so a
|
||||
collection failure is loud, not silent. [[feedback_auto_pipeline_first]]
|
||||
[[feedback_artifact_status_naming]]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, Mapping, Tuple
|
||||
|
||||
AXIS_FROM_TEST = {
|
||||
"test_normalize_snapshot_matches": "F0",
|
||||
"test_v4_ranking_snapshot_matches": "F1",
|
||||
"test_slot_payload_snapshot_matches": "F2",
|
||||
"test_ai_classifier_snapshot_matches": "F3",
|
||||
"test_layout_snapshot_matches": "F4",
|
||||
"test_final_html_snapshot_matches": "F5",
|
||||
}
|
||||
SYMBOL = {"passed": "PASS", "failed": "FAIL", "error": "ERR", "skipped": "SKIP"}
|
||||
NODEID_RE = re.compile(r"::(test_[a-z0-9_]+)\[(\d{2})\]$")
|
||||
MARKER_RE = re.compile(
|
||||
r"(<!-- IMP-91:(F[0-5]):(\d{2}) -->)(.*?)(<!-- /IMP-91 -->)", re.DOTALL
|
||||
)
|
||||
|
||||
|
||||
def parse_outcomes(report: Mapping[str, object]) -> Dict[Tuple[str, str], str]:
|
||||
out: Dict[Tuple[str, str], str] = {}
|
||||
for test in report.get("tests", []) or []:
|
||||
m = NODEID_RE.search(str(test.get("nodeid", "")))
|
||||
if not m:
|
||||
continue
|
||||
axis = AXIS_FROM_TEST.get(m.group(1))
|
||||
if not axis:
|
||||
continue
|
||||
out[(axis, m.group(2))] = SYMBOL.get(str(test.get("outcome")), "?")
|
||||
return out
|
||||
|
||||
|
||||
def update_board_text(board: str, outcomes: Mapping[Tuple[str, str], str]) -> str:
|
||||
def repl(match: "re.Match[str]") -> str:
|
||||
key = (match.group(2), match.group(3))
|
||||
symbol = outcomes.get(key, "?")
|
||||
return f"{match.group(1)}{symbol}{match.group(5)}"
|
||||
|
||||
return MARKER_RE.sub(repl, board)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="IMP-91 status-board updater")
|
||||
parser.add_argument("--report", required=True, type=Path)
|
||||
parser.add_argument("--board", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
report = json.loads(args.report.read_text(encoding="utf-8"))
|
||||
outcomes = parse_outcomes(report)
|
||||
args.board.write_text(
|
||||
update_board_text(args.board.read_text(encoding="utf-8"), outcomes),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -8,6 +8,7 @@
|
||||
- 블록 CSS의 글씨 크기를 font_hierarchy에 맞게 조정 (프로세스 내 조정)
|
||||
- 콘텐츠는 PipelineContext에서 가져옴 (하드코딩 아님)
|
||||
- 블록은 콘텐츠에 맞게 재구성 (items 수 동적)
|
||||
[legacy Phase R'/Q example — INTEGRATION-AUDIT-01 §10.4]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -107,6 +107,7 @@ class TfidfBlockMatcher:
|
||||
text = text.replace("S/W", "SW 소프트웨어")
|
||||
text = text.replace("H/W", "HW 하드웨어")
|
||||
text = re.sub(r'\bDX\b', 'DX 디지털전환', text)
|
||||
# [legacy Phase R'/Q example — INTEGRATION-AUDIT-01 §10.4]
|
||||
text = re.sub(r'\bBIM\b', 'BIM 건설정보모델링', text)
|
||||
text = text.replace("(", " ").replace(")", " ")
|
||||
text = text.replace("[", " ").replace("]", " ")
|
||||
|
||||
@@ -20,9 +20,10 @@ import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from src import catalog as _catalog_mod
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 템플릿 디렉토리
|
||||
@@ -101,32 +102,18 @@ RELATION_CATEGORY_MAP: dict[str, list[str]] = {
|
||||
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# 카탈로그 로딩 (mtime 캐싱)
|
||||
# 카탈로그 로딩 (IMP-27: src.catalog 공유 로더 위임)
|
||||
# ══════════════════════════════════════
|
||||
|
||||
_catalog_cache: dict[str, Any] = {"data": None, "mtime": 0}
|
||||
|
||||
|
||||
def _load_catalog() -> list[dict]:
|
||||
"""catalog.yaml 로드 (mtime 캐싱)."""
|
||||
path = TEMPLATES_DIR / "catalog.yaml"
|
||||
mtime = path.stat().st_mtime
|
||||
if _catalog_cache["data"] is not None and _catalog_cache["mtime"] == mtime:
|
||||
return _catalog_cache["data"]
|
||||
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
blocks = data.get("blocks", [])
|
||||
_catalog_cache["data"] = blocks
|
||||
_catalog_cache["mtime"] = mtime
|
||||
return blocks
|
||||
"""catalog.yaml blocks list (IMP-27: shared loader delegation)."""
|
||||
return _catalog_mod.load_blocks()
|
||||
|
||||
|
||||
def _get_block_by_id(block_id: str) -> dict | None:
|
||||
"""블록 ID로 카탈로그 엔트리 조회."""
|
||||
for b in _load_catalog():
|
||||
if b["id"] == block_id:
|
||||
return b
|
||||
return None
|
||||
"""블록 ID로 카탈로그 엔트리 조회 (IMP-27: shared loader delegation)."""
|
||||
return _catalog_mod.get_block_by_id(block_id)
|
||||
|
||||
|
||||
# ══════════════════════════════════════
|
||||
@@ -399,6 +386,7 @@ _SAMPLE_DATA: dict[str, dict[str, Any]] = {
|
||||
"center_label": "DX",
|
||||
"center_sub": "디지털 전환",
|
||||
"items": [
|
||||
# [legacy Phase R'/Q example — INTEGRATION-AUDIT-01 §10.4]
|
||||
{"label": "BIM", "color": "#ff6b35"},
|
||||
{"label": "GIS", "color": "#00d4aa"},
|
||||
{"label": "DT", "color": "#ffd700"},
|
||||
@@ -406,6 +394,7 @@ _SAMPLE_DATA: dict[str, dict[str, Any]] = {
|
||||
},
|
||||
"keyword-circle-row": {
|
||||
"keywords": [
|
||||
# [legacy Phase R'/Q example — INTEGRATION-AUDIT-01 §10.4]
|
||||
{"letter": "B", "label": "BIM", "description": "건물정보모델링"},
|
||||
{"letter": "G", "label": "GIS", "description": "지리정보시스템"},
|
||||
{"letter": "D", "label": "DX", "description": "디지털 전환"},
|
||||
@@ -432,6 +421,7 @@ _SAMPLE_DATA: dict[str, dict[str, Any]] = {
|
||||
"right_title": "개선",
|
||||
"rows": [
|
||||
{"left": "수작업", "center": "프로세스", "right": "자동화"},
|
||||
# [legacy Phase R'/Q example — INTEGRATION-AUDIT-01 §10.4]
|
||||
{"left": "2D 도면", "center": "설계 도구", "right": "3D BIM"},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -5,24 +5,18 @@ AI에게 불가능한 선택지를 주지 않는다 (Beautiful.ai 원칙).
|
||||
|
||||
주요 함수:
|
||||
- select_block_candidates(): topic + 컨테이너 → 물리적으로 가능한 후보 2-4개
|
||||
- load_catalog(): catalog.yaml 로딩 + 캐싱
|
||||
- load_catalog(): catalog.yaml 로딩 + 캐싱 (IMP-27: src.catalog 공유 로더 위임)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from src import catalog as _catalog_mod
|
||||
from src.space_allocator import ContainerSpec, HEIGHT_COST_ORDER
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CATALOG_PATH = Path("templates/catalog.yaml")
|
||||
_catalog_cache: dict | None = None
|
||||
_catalog_mtime: float = 0.0
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# relation_type → 블록 카테고리 매핑 (Napkin.ai 방식)
|
||||
@@ -52,35 +46,16 @@ BLOCKS_FORCING_FORMAT_CHANGE = {
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# catalog.yaml 로딩 (mtime 캐시)
|
||||
# catalog.yaml 로딩 (IMP-27: src.catalog 공유 로더 위임)
|
||||
# ──────────────────────────────────────
|
||||
def load_catalog() -> dict:
|
||||
"""catalog.yaml을 로딩한다. mtime 기반 캐싱."""
|
||||
global _catalog_cache, _catalog_mtime
|
||||
|
||||
if not CATALOG_PATH.exists():
|
||||
logger.error(f"catalog.yaml 미발견: {CATALOG_PATH}")
|
||||
return {"blocks": []}
|
||||
|
||||
current_mtime = CATALOG_PATH.stat().st_mtime
|
||||
if _catalog_cache is not None and current_mtime == _catalog_mtime:
|
||||
return _catalog_cache
|
||||
|
||||
with open(CATALOG_PATH, encoding="utf-8") as f:
|
||||
_catalog_cache = yaml.safe_load(f)
|
||||
_catalog_mtime = current_mtime
|
||||
|
||||
block_count = len(_catalog_cache.get("blocks", []))
|
||||
logger.info(f"[Q-2] catalog.yaml 로딩: {block_count}개 블록")
|
||||
return _catalog_cache
|
||||
"""catalog.yaml root dict (IMP-27: shared loader delegation)."""
|
||||
return _catalog_mod.load_root_catalog()
|
||||
|
||||
|
||||
def _get_block_by_id(block_id: str, catalog: dict) -> dict | None:
|
||||
"""catalog에서 블록 ID로 검색."""
|
||||
for block in catalog.get("blocks", []):
|
||||
if block.get("id") == block_id:
|
||||
return block
|
||||
return None
|
||||
"""catalog-injected 블록 ID 조회 (IMP-27: shared loader delegation)."""
|
||||
return _catalog_mod.get_block_by_id(block_id, catalog)
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
|
||||
76
src/catalog.py
Normal file
76
src/catalog.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""IMP-27: Shared catalog.yaml loader (single file-read + mtime cache).
|
||||
|
||||
Phase Q evolution 중 block_reference, block_selector, renderer 가 각각 templates/
|
||||
catalog.yaml 을 읽고 mtime 캐시하던 중복을 한 곳으로 통합한다. call-site
|
||||
signature 는 그대로 유지되며, 각 wrapper 는 본 모듈의 결과를 자신이 약속하는
|
||||
형태(list[dict] / root dict / id→path projection)로 변환만 수행한다.
|
||||
|
||||
Functions:
|
||||
load_root_catalog() -> dict : raw catalog dict (matches block_selector contract)
|
||||
load_blocks() -> list[dict] : root_catalog.get("blocks", []) projection
|
||||
get_block_by_id(block_id, catalog=None) -> dict | None
|
||||
get_catalog_mtime() -> float : current cached mtime (renderer projection key)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CATALOG_PATH = Path(__file__).parent.parent / "templates" / "catalog.yaml"
|
||||
|
||||
_catalog_cache: dict | None = None
|
||||
_catalog_mtime: float = 0.0
|
||||
|
||||
|
||||
def load_root_catalog() -> dict:
|
||||
"""Load templates/catalog.yaml as root dict, with mtime caching.
|
||||
|
||||
Missing file → logs warning and returns ``{"blocks": []}`` (matches the
|
||||
pre-IMP-27 behavior of block_selector.load_catalog and renderer._load_catalog_map).
|
||||
"""
|
||||
global _catalog_cache, _catalog_mtime
|
||||
|
||||
if not CATALOG_PATH.exists():
|
||||
logger.warning(f"catalog.yaml 미발견: {CATALOG_PATH}")
|
||||
return {"blocks": []}
|
||||
|
||||
current_mtime = CATALOG_PATH.stat().st_mtime
|
||||
if _catalog_cache is not None and current_mtime == _catalog_mtime:
|
||||
return _catalog_cache
|
||||
|
||||
with open(CATALOG_PATH, encoding="utf-8") as f:
|
||||
_catalog_cache = yaml.safe_load(f)
|
||||
_catalog_mtime = current_mtime
|
||||
|
||||
block_count = len((_catalog_cache or {}).get("blocks", []))
|
||||
logger.info(f"[catalog] load: {block_count} blocks")
|
||||
return _catalog_cache
|
||||
|
||||
|
||||
def load_blocks() -> list[dict]:
|
||||
"""Return blocks list (= root_catalog.get('blocks', []))."""
|
||||
return load_root_catalog().get("blocks", [])
|
||||
|
||||
|
||||
def get_block_by_id(block_id: str, catalog: dict | None = None) -> dict | None:
|
||||
"""Locate a block entry by id.
|
||||
|
||||
``catalog=None`` → uses shared loader. caller-supplied catalog dict is
|
||||
accepted as-is so the existing block_selector contract (catalog-injected)
|
||||
keeps working unchanged.
|
||||
"""
|
||||
if catalog is None:
|
||||
catalog = load_root_catalog()
|
||||
for block in catalog.get("blocks", []):
|
||||
if block.get("id") == block_id:
|
||||
return block
|
||||
return None
|
||||
|
||||
|
||||
def get_catalog_mtime() -> float:
|
||||
"""Current cached mtime (renderer projection caches key off this)."""
|
||||
return _catalog_mtime
|
||||
@@ -14,6 +14,26 @@ class Settings(BaseSettings):
|
||||
slide_width: int = 1280
|
||||
slide_height: int = 720
|
||||
|
||||
# IMP-33 u1 — AI fallback policy. Fallback-path only; normal path AI=0.
|
||||
# Defaults locked by Stage 2 plan; do NOT inline literals downstream.
|
||||
ai_fallback_enabled: bool = False
|
||||
ai_fallback_model: str = "claude-opus-4-7"
|
||||
ai_fallback_timeout_s: float = 60.0
|
||||
ai_fallback_max_retries: int = 3
|
||||
ai_fallback_backoff_base_s: float = 1.0
|
||||
ai_fallback_backoff_cap_s: float = 8.0
|
||||
ai_fallback_backoff_jitter: float = 0.3
|
||||
ai_fallback_budget_per_run: int = 10
|
||||
ai_fallback_circuit_breaker_threshold: int = 5
|
||||
|
||||
# IMP-46 u5 — auto-cache flag. When True, `save_proposal` bypasses the
|
||||
# `user_approved` gate only (`visual_check_passed` is never bypassed).
|
||||
# Default OFF preserves the dual-gate contract; the CLI flag
|
||||
# `--auto-cache` in `src/phase_z2_pipeline.py` mutates this setting at
|
||||
# parse time. Downstream callers MUST source the flag from Settings,
|
||||
# never inline literals.
|
||||
ai_fallback_auto_cache: bool = False
|
||||
|
||||
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
|
||||
|
||||
|
||||
|
||||
@@ -8,9 +8,7 @@ Kei API 필수. fallback 없음. 성공할 때까지 무한 재시도.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import anthropic
|
||||
@@ -18,10 +16,14 @@ import httpx
|
||||
|
||||
from src.config import settings
|
||||
from src.design_director import BLOCK_SLOTS
|
||||
from src.json_utils import parse_json as _parse_json
|
||||
from src.sse_utils import stream_sse_tokens
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# [legacy Phase R'/Q examples — INTEGRATION-AUDIT-01 §10.4]
|
||||
# (sample-text literals at L43-L44 / L67 inside the EDITOR_PROMPT string below
|
||||
# — "건설산업 디지털화", "BIM 전면 도입", "DX와 BIM 개념" preserved verbatim)
|
||||
EDITOR_PROMPT = """당신은 도메인 전문가이자 콘텐츠 편집자이다.
|
||||
원본 콘텐츠의 핵심 내용을 유지하면서 각 블록의 슬롯에 맞게 텍스트를 정리한다.
|
||||
|
||||
@@ -438,38 +440,3 @@ async def fill_candidates(
|
||||
logger.warning(f"[Phase P] 꼭지 {tid}: 텍스트 편집 파싱 실패")
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
def _parse_json(text: str) -> dict[str, Any] | None:
|
||||
"""텍스트에서 JSON을 추출한다.
|
||||
|
||||
Kei API가 마크다운 리스트 접두사(- )를 붙여 응답하는 경우에도 처리.
|
||||
"""
|
||||
# 전처리: 각 줄 앞의 마크다운 리스트 접두사(- ) 제거
|
||||
lines = text.split("\n")
|
||||
cleaned_lines = []
|
||||
for line in lines:
|
||||
stripped = line.lstrip()
|
||||
if stripped.startswith("- "):
|
||||
cleaned_lines.append(stripped[2:])
|
||||
elif stripped.startswith("* "):
|
||||
cleaned_lines.append(stripped[2:])
|
||||
else:
|
||||
cleaned_lines.append(stripped)
|
||||
cleaned = "\n".join(cleaned_lines)
|
||||
|
||||
# 원본 먼저 시도 → 클린 버전 시도
|
||||
for target in [text, cleaned]:
|
||||
patterns = [
|
||||
r"```json\s*(.*?)```",
|
||||
r"```\s*(.*?)```",
|
||||
r"(\{.*\})",
|
||||
]
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, target, re.DOTALL)
|
||||
if match:
|
||||
try:
|
||||
return json.loads(match.group(1).strip())
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return None
|
||||
|
||||
@@ -5,9 +5,7 @@ Step B: 프리셋 안에서 블록 매핑 + 글자 수 가이드 (Sonnet)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -15,6 +13,7 @@ import httpx
|
||||
import yaml
|
||||
|
||||
from src.config import settings
|
||||
from src.json_utils import parse_json as _parse_json
|
||||
from src.sse_utils import stream_sse_tokens
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -29,6 +28,7 @@ BLOCK_SLOTS = {
|
||||
"slot_desc": {
|
||||
"title_ko": "한글 메인 타이틀",
|
||||
"title_en": "영문 서브 타이틀 (없으면 생략)",
|
||||
# [legacy Phase R'/Q example — INTEGRATION-AUDIT-01 §10.4]
|
||||
"breadcrumb": "상위 카테고리 경로 (예: 디지털전환 > BIM)",
|
||||
"bg_image": "배경 이미지 경로",
|
||||
},
|
||||
@@ -965,6 +965,7 @@ def _validate_height_budget(blocks: list[dict], preset: dict) -> list[dict]:
|
||||
for block in blocks_to_remove:
|
||||
blocks.remove(block)
|
||||
|
||||
# [legacy Phase R'/Q example — INTEGRATION-AUDIT-01 §10.4]
|
||||
# 삭제 후 zone_blocks 재구성 (후속 pill-pair/높이 체크에 반영)
|
||||
zone_blocks.clear()
|
||||
for block in blocks:
|
||||
@@ -1064,38 +1065,3 @@ def _validate_height_budget(blocks: list[dict], preset: dict) -> list[dict]:
|
||||
})
|
||||
|
||||
return overflows
|
||||
|
||||
|
||||
def _parse_json(text: str) -> dict[str, Any] | None:
|
||||
"""텍스트에서 JSON을 추출한다.
|
||||
|
||||
Kei API가 마크다운 리스트 접두사(- )를 붙여 응답하는 경우에도 처리.
|
||||
"""
|
||||
# 전처리: 각 줄 앞의 마크다운 리스트 접두사(- ) 제거
|
||||
lines = text.split("\n")
|
||||
cleaned_lines = []
|
||||
for line in lines:
|
||||
stripped = line.lstrip()
|
||||
if stripped.startswith("- "):
|
||||
cleaned_lines.append(stripped[2:])
|
||||
elif stripped.startswith("* "):
|
||||
cleaned_lines.append(stripped[2:])
|
||||
else:
|
||||
cleaned_lines.append(stripped)
|
||||
cleaned = "\n".join(cleaned_lines)
|
||||
|
||||
# 원본 먼저 시도 → 클린 버전 시도
|
||||
for target in [text, cleaned]:
|
||||
patterns = [
|
||||
r"```json\s*(.*?)```",
|
||||
r"```\s*(.*?)```",
|
||||
r"(\{.*\})",
|
||||
]
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, target, re.DOTALL)
|
||||
if match:
|
||||
try:
|
||||
return json.loads(match.group(1).strip())
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return None
|
||||
|
||||
@@ -84,6 +84,9 @@ border-radius: 8px, padding: 14px 30px, text-align: center
|
||||
|
||||
def get_layout_rules() -> str:
|
||||
"""Phase S 검증 결과 기반 레이아웃 규칙."""
|
||||
# [legacy Phase R'/Q example — INTEGRATION-AUDIT-01 §10.4]
|
||||
# (sample-text literal "DX와 BIM의 상세 비교" at ~L109 inside the return
|
||||
# string below is preserved verbatim as a documented intentional example)
|
||||
return """
|
||||
## 레이아웃 규칙 (검증 결과 기반 — 반드시 따를 것)
|
||||
|
||||
|
||||
@@ -609,6 +609,7 @@ class SupplementBlock:
|
||||
role: str
|
||||
block_id: str
|
||||
variant: str
|
||||
# [legacy Phase R'/Q example — INTEGRATION-AUDIT-01 §10.4]
|
||||
content_source: str # "popup:DX와 BIM의 구분" 등
|
||||
estimated_height_px: float
|
||||
available_px: float
|
||||
|
||||
@@ -164,6 +164,7 @@ def _preprocess_text(text: str) -> str:
|
||||
text = text.replace("S/W", "SW 소프트웨어")
|
||||
text = text.replace("H/W", "HW 하드웨어")
|
||||
text = re.sub(r'\bDX\b', 'DX 디지털전환', text)
|
||||
# [legacy Phase R'/Q example — INTEGRATION-AUDIT-01 §10.4]
|
||||
text = re.sub(r'\bBIM\b', 'BIM 건설정보모델링', text)
|
||||
|
||||
# 괄호 내용 유지하되 괄호 제거
|
||||
|
||||
264
src/image_id_stamper.py
Normal file
264
src/image_id_stamper.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""IMP-51 (#79) u4 — user-content image stamper for Phase Z final.html.
|
||||
|
||||
Annotates user-content ``<img>`` elements with a stable id + role
|
||||
attribute so the frontend SlideCanvas (u8~u11) can attach drag/resize
|
||||
handles and the backend CSS injector (u7) can re-apply persisted geometry
|
||||
on the next render.
|
||||
|
||||
DOM selector contract (single point of truth shared across the axis) :
|
||||
|
||||
.slide img[data-image-role="user-content"]
|
||||
|
||||
This selector is mirrored verbatim in :
|
||||
|
||||
- ``Front/client/src/components/SlideCanvas.tsx`` (u8 handle attach target)
|
||||
- ``Front/client/src/services/userOverridesApi.ts`` (u3 doc reference)
|
||||
- ``src/phase_z2_pipeline.py`` u7 hook (CSS injector — pending unit)
|
||||
|
||||
Decorative imgs (frame backgrounds, figma assets, dx-figures, decorative
|
||||
icons) are NOT stamped, so they are NOT matched by the selector and remain
|
||||
unaffected. The allowlist that decides "what counts as user-content" is
|
||||
passed in by the caller (typically ``stage0_normalized_assets["images"]``);
|
||||
this module does not encode the source-of-truth itself.
|
||||
|
||||
Stable id contract :
|
||||
|
||||
image_id = "img-" + sha1(src)[:10]
|
||||
|
||||
Deterministic across renders so persisted ``image_overrides`` entries
|
||||
(keyed on ``image_id`` per ``src/user_overrides_io.py`` u1) re-apply
|
||||
automatically. Duplicate srcs in the same slide get an ordinal suffix
|
||||
("-1", "-2", ...) appended in DOM order; the first occurrence has no
|
||||
suffix.
|
||||
|
||||
Forward-compat : current Phase Z final.html emits zero user-content
|
||||
``<img>`` elements (``stage0_normalized_assets["images"]`` is empty across
|
||||
all recent verify runs). ``stamp_user_content_images(html, sources=())``
|
||||
is a pure no-op in that case — returns ``(html, [])`` without scanning.
|
||||
|
||||
Guardrails :
|
||||
|
||||
- No-hardcoding : the allowlist is caller-supplied, never inferred from
|
||||
sample filenames or path heuristics.
|
||||
- Idempotent : stamping a previously-stamped tag is a no-op (the
|
||||
``data-image-role`` probe short-circuits before re-injecting).
|
||||
- AI-isolation : this module is pure deterministic Python; no LLM calls.
|
||||
- Carve-out (IMP-46 #62) : brand-new module, does not touch the
|
||||
#76 commit ``1186ad8`` cache region.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from typing import Iterable
|
||||
|
||||
USER_CONTENT_IMAGE_SELECTOR: str = '.slide img[data-image-role="user-content"]'
|
||||
|
||||
IMAGE_ROLE_ATTR: str = "data-image-role"
|
||||
IMAGE_ROLE_VALUE: str = "user-content"
|
||||
IMAGE_ID_ATTR: str = "data-image-id"
|
||||
|
||||
# Matches a single ``<img ...>`` tag. Permissive on attribute order and
|
||||
# whitespace; captures the inner attribute string + an optional XHTML
|
||||
# self-close slash. Phase Z renders well-formed Jinja2 output (no inline
|
||||
# ``<`` in attribute values), so a regex is safe here without pulling in
|
||||
# an HTML parser.
|
||||
_IMG_TAG_RE = re.compile(
|
||||
r"<img\b([^>]*?)(/?)>",
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
# Matches the ``src="..."`` or ``src='...'`` attribute. Group 1 = double,
|
||||
# group 2 = single. Quote style is preserved by callers that re-emit the
|
||||
# tag verbatim.
|
||||
_SRC_ATTR_RE = re.compile(
|
||||
r"""\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)')""",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
# Probe for an existing ``data-image-role`` attribute (any value, any
|
||||
# quote) so re-stamping is idempotent.
|
||||
_ROLE_ATTR_RE = re.compile(r"""\bdata-image-role\s*=""", flags=re.IGNORECASE)
|
||||
|
||||
|
||||
def stable_image_id(src: str, ordinal: int = 0) -> str:
|
||||
"""Return the deterministic ``image_id`` for ``src``.
|
||||
|
||||
``ordinal`` disambiguates repeated occurrences of the same ``src`` in
|
||||
the same slide (0 = first occurrence, no suffix; 1 → ``-1``; ...).
|
||||
"""
|
||||
if not isinstance(src, str):
|
||||
raise TypeError(f"src must be a string, got {type(src).__name__}: {src!r}")
|
||||
if ordinal < 0:
|
||||
raise ValueError(f"ordinal must be >= 0, got {ordinal}")
|
||||
digest = hashlib.sha1(src.encode("utf-8")).hexdigest()[:10]
|
||||
base = f"img-{digest}"
|
||||
return base if ordinal == 0 else f"{base}-{ordinal}"
|
||||
|
||||
|
||||
def stamp_user_content_images(
|
||||
html: str,
|
||||
sources: Iterable[str] = (),
|
||||
) -> tuple[str, list[str]]:
|
||||
"""Stamp user-content ``<img>`` tags in ``html`` with role + stable id.
|
||||
|
||||
``sources`` is the allowlist of ``src`` attribute values that count as
|
||||
user-content (typically ``stage0_normalized_assets["images"]``). Any
|
||||
``<img>`` whose ``src`` value is in ``sources`` is rewritten to include
|
||||
``data-image-role="user-content"`` and ``data-image-id="<stable_id>"``.
|
||||
Other ``<img>`` tags (decorative, figma, frame-internal) are left
|
||||
unchanged byte-for-byte.
|
||||
|
||||
Returns ``(modified_html, stamped_image_ids)`` where the id list is
|
||||
in DOM (left-to-right) order. The list may contain duplicates only
|
||||
via the ordinal-suffix path (``img-<hash>``, ``img-<hash>-1``, ...);
|
||||
ordering is what the caller persists as the canonical key sequence.
|
||||
|
||||
Forward-compat : empty / all-non-string ``sources`` → pure no-op
|
||||
(``html`` returned unchanged, empty list). This is the current Phase
|
||||
Z state since ``stage0_normalized_assets["images"]`` is empty.
|
||||
"""
|
||||
allow = {s for s in sources if isinstance(s, str) and s}
|
||||
if not allow:
|
||||
return html, []
|
||||
|
||||
stamped: list[str] = []
|
||||
seen_ordinal: dict[str, int] = {}
|
||||
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
attrs = match.group(1) or ""
|
||||
self_close = match.group(2) or ""
|
||||
src_match = _SRC_ATTR_RE.search(attrs)
|
||||
if src_match is None:
|
||||
return match.group(0)
|
||||
src = src_match.group(1) if src_match.group(1) is not None else src_match.group(2)
|
||||
if src not in allow:
|
||||
return match.group(0)
|
||||
if _ROLE_ATTR_RE.search(attrs):
|
||||
return match.group(0)
|
||||
ordinal = seen_ordinal.get(src, 0)
|
||||
seen_ordinal[src] = ordinal + 1
|
||||
image_id = stable_image_id(src, ordinal=ordinal)
|
||||
stamped.append(image_id)
|
||||
injected = (
|
||||
f' {IMAGE_ROLE_ATTR}="{IMAGE_ROLE_VALUE}"'
|
||||
f' {IMAGE_ID_ATTR}="{image_id}"'
|
||||
)
|
||||
return f"<img{injected}{attrs}{self_close}>"
|
||||
|
||||
new_html = _IMG_TAG_RE.sub(_replace, html)
|
||||
return new_html, stamped
|
||||
|
||||
|
||||
# ─── IMP-51 (#79) u7 — render-time CSS injection ──────────────────────────
|
||||
|
||||
# Marker comments wrap the injected ``<style>`` block so re-injection on a
|
||||
# previously-injected document is idempotent (the wrapper is found by a
|
||||
# simple substring probe and the inner CSS is replaced in place).
|
||||
_IMP51_STYLE_MARKER_OPEN: str = "<!-- IMP-51 image_overrides start -->"
|
||||
_IMP51_STYLE_MARKER_CLOSE: str = "<!-- IMP-51 image_overrides end -->"
|
||||
|
||||
_IMP51_STYLE_BLOCK_RE = re.compile(
|
||||
re.escape(_IMP51_STYLE_MARKER_OPEN) + r".*?" + re.escape(_IMP51_STYLE_MARKER_CLOSE),
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
_HEAD_CLOSE_RE = re.compile(r"</head\s*>", flags=re.IGNORECASE)
|
||||
_BODY_OPEN_RE = re.compile(r"<body\b[^>]*>", flags=re.IGNORECASE)
|
||||
|
||||
|
||||
def build_image_overrides_style(
|
||||
image_overrides: dict,
|
||||
stamped_ids: Iterable[str],
|
||||
) -> str:
|
||||
"""Build CSS rule text for persisted ``image_overrides``.
|
||||
|
||||
For every ``image_id`` that appears in BOTH ``stamped_ids`` (the DOM
|
||||
order of stamps returned by :func:`stamp_user_content_images`) AND
|
||||
``image_overrides`` (the persisted geometry mapping from ``u1``
|
||||
``user_overrides_io``), emit one absolute-position rule of the form ::
|
||||
|
||||
.slide img[data-image-role="user-content"][data-image-id="<id>"] {
|
||||
position: absolute;
|
||||
left: <x>%; top: <y>%;
|
||||
width: <w>%; height: <h>%;
|
||||
}
|
||||
|
||||
Coordinates are ``%`` of the slide bounding box (slide-absolute, per
|
||||
Stage 2 scope-lock). ``.slide`` already declares ``position: relative``
|
||||
in ``templates/phase_z2/slide_base.html`` so the absolute coordinates
|
||||
resolve against the slide frame.
|
||||
|
||||
Rules are emitted in ``stamped_ids`` order so the output is
|
||||
byte-deterministic across renders (critical for diff-based verifiers).
|
||||
Override entries for ids NOT in ``stamped_ids`` are silently dropped —
|
||||
those keys cannot be produced via the SlideCanvas pathway (the
|
||||
frontend only knows the ids actually present in the DOM). Per-entry
|
||||
malformed geometries (non-dict / missing axis / non-coercible value)
|
||||
are dropped silently; the whole batch is never rejected.
|
||||
|
||||
Returns ``""`` when no rules are emitted so the caller can skip
|
||||
``<style>`` injection entirely (forward-compat no-op when Phase Z
|
||||
final.html still emits zero user-content imgs).
|
||||
"""
|
||||
if not image_overrides:
|
||||
return ""
|
||||
rules: list[str] = []
|
||||
for iid in stamped_ids:
|
||||
geom = image_overrides.get(iid)
|
||||
if not isinstance(geom, dict):
|
||||
continue
|
||||
try:
|
||||
x = float(geom["x"])
|
||||
y = float(geom["y"])
|
||||
w = float(geom["w"])
|
||||
h = float(geom["h"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
rules.append(
|
||||
f'.slide img[{IMAGE_ROLE_ATTR}="{IMAGE_ROLE_VALUE}"]'
|
||||
f'[{IMAGE_ID_ATTR}="{iid}"] {{ '
|
||||
f"position: absolute; "
|
||||
f"left: {x}%; top: {y}%; "
|
||||
f"width: {w}%; height: {h}%; "
|
||||
f"}}"
|
||||
)
|
||||
return "\n".join(rules)
|
||||
|
||||
|
||||
def inject_image_overrides_style(html: str, css: str) -> str:
|
||||
"""Inject a marker-wrapped ``<style>`` block carrying ``css`` into ``html``.
|
||||
|
||||
Empty ``css`` → ``html`` returned unchanged (no DOM mutation). This
|
||||
preserves the byte-for-byte identity of forward-compat renders where
|
||||
no overrides apply.
|
||||
|
||||
When a previously-injected marker block is present, its inner CSS is
|
||||
replaced in place (idempotent re-injection — second call with the
|
||||
same overrides produces an identical document).
|
||||
|
||||
Injection precedence when no existing marker is found :
|
||||
|
||||
1. Before the first ``</head>`` (case-insensitive)
|
||||
2. Immediately after the first ``<body ...>`` open tag
|
||||
3. At the start of the document
|
||||
|
||||
Phase Z ``slide_base.html`` always emits ``</head>`` so path 1 wins
|
||||
for production renders; paths 2/3 are defensive fallbacks for
|
||||
unusual fragment inputs (tests, partials).
|
||||
"""
|
||||
if not css:
|
||||
return html
|
||||
block = (
|
||||
f"{_IMP51_STYLE_MARKER_OPEN}\n"
|
||||
f"<style>\n{css}\n</style>\n"
|
||||
f"{_IMP51_STYLE_MARKER_CLOSE}"
|
||||
)
|
||||
if _IMP51_STYLE_MARKER_OPEN in html:
|
||||
return _IMP51_STYLE_BLOCK_RE.sub(lambda _m: block, html, count=1)
|
||||
head_close = _HEAD_CLOSE_RE.search(html)
|
||||
if head_close is not None:
|
||||
idx = head_close.start()
|
||||
return html[:idx] + block + "\n" + html[idx:]
|
||||
body_open = _BODY_OPEN_RE.search(html)
|
||||
if body_open is not None:
|
||||
idx = body_open.end()
|
||||
return html[:idx] + "\n" + block + html[idx:]
|
||||
return block + "\n" + html
|
||||
46
src/json_utils.py
Normal file
46
src/json_utils.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""JSON 추출 공용 유틸리티.
|
||||
|
||||
Kei / Claude API 응답 텍스트에서 JSON 객체를 추출한다.
|
||||
content_editor, design_director, kei_client, pipeline 공통 헬퍼.
|
||||
|
||||
응답이 마크다운 리스트 접두사("- " / "* ")로 감싸진 경우에도 처리.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_JSON_PATTERNS: tuple[str, ...] = (
|
||||
r"```json\s*(.*?)```",
|
||||
r"```\s*(.*?)```",
|
||||
r"(\{.*\})",
|
||||
)
|
||||
|
||||
|
||||
def parse_json(text: str) -> dict[str, Any] | None:
|
||||
"""텍스트에서 JSON을 추출한다.
|
||||
|
||||
Kei API가 마크다운 리스트 접두사(- )를 붙여 응답하는 경우에도 처리.
|
||||
원본 → 리스트 접두사 제거 버전 순서로 fenced JSON / plain fenced / 베어 brace 패턴을
|
||||
차례로 시도한다. 모두 실패하면 None.
|
||||
"""
|
||||
lines = text.split("\n")
|
||||
cleaned_lines: list[str] = []
|
||||
for line in lines:
|
||||
stripped = line.lstrip()
|
||||
if stripped.startswith("- ") or stripped.startswith("* "):
|
||||
cleaned_lines.append(stripped[2:])
|
||||
else:
|
||||
cleaned_lines.append(stripped)
|
||||
cleaned = "\n".join(cleaned_lines)
|
||||
|
||||
for target in (text, cleaned):
|
||||
for pattern in _JSON_PATTERNS:
|
||||
match = re.search(pattern, target, re.DOTALL)
|
||||
if match:
|
||||
try:
|
||||
return json.loads(match.group(1).strip())
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return None
|
||||
@@ -13,6 +13,7 @@ from typing import Any
|
||||
import httpx
|
||||
|
||||
from src.config import settings
|
||||
from src.json_utils import parse_json as _parse_json
|
||||
from src.sse_utils import stream_sse_tokens
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -53,6 +54,7 @@ KEI_PROMPT = (
|
||||
" 문장을 재작성하지 마라. 원본 문장을 그대로 가져와라.\n"
|
||||
"- **결론 텍스트도 원본 그대로.** 임의로 만들지 마라.\n"
|
||||
"- 원본에 있는 내용을 임의로 제거하거나 다른 의미로 바꾸지 마라.\n"
|
||||
# [legacy Phase R'/Q example — INTEGRATION-AUDIT-01 §10.4]
|
||||
"- 텍스트 재구성이 허용되는 경우는 **빈 공간에 채울 요약(표, 팝업 요약)만**.\n"
|
||||
"- 각 꼭지의 source_hint에 원본의 어떤 부분이 가는지 명시.\n\n"
|
||||
"## 배치 규칙\n"
|
||||
@@ -162,6 +164,7 @@ KEI_PROMPT_B = (
|
||||
" - 원본에 이미지가 참조되면 반드시 [이미지: 제목] 마커를 포함하라.\n"
|
||||
" - 출처가 있으면 포함하라.\n"
|
||||
" - '활용 필요', '구체화 필요' 같은 지시사항을 쓰지 마라. 실제 콘텐츠 항목만 쓰라.\n"
|
||||
# [legacy Phase R'/Q examples — INTEGRATION-AUDIT-01 §10.4]
|
||||
" - 예시: '건설산업(종합산업, 기술 통합 융합), BIM(정보관리 도구, 출처: 국토교통부 2020)'\n"
|
||||
" - 예시: '[이미지: DX와 핵심기술간 상호관계] 다이어그램, GIS 역할(공간 분석). [팝업: DX와 BIM의 구분] 비교표'\n\n"
|
||||
"## 출력 형식 (JSON만)\n"
|
||||
@@ -789,6 +792,10 @@ async def call_kei_final_review(
|
||||
# I-9: Kei 넘침 판단 호출
|
||||
# ──────────────────────────────────────
|
||||
|
||||
# [legacy Phase R'/Q example — INTEGRATION-AUDIT-01 §10.4]
|
||||
# (sample-text literal "Option 2 (핵심 재구성 + 팝업 분리)" inside the
|
||||
# KEI_OVERFLOW_PROMPT triple-quoted string below is preserved verbatim
|
||||
# as a documented intentional example of overflow-judgment output)
|
||||
KEI_OVERFLOW_PROMPT = """당신은 슬라이드 콘텐츠 전문가이다.
|
||||
디자인 팀장이 배치한 블록들이 컨테이너(zone)의 높이 예산을 초과했다.
|
||||
콘텐츠의 중요도와 전달 메시지를 기준으로 어떻게 처리할지 판단하라.
|
||||
@@ -883,42 +890,6 @@ async def call_kei_overflow_judgment(
|
||||
return None
|
||||
|
||||
|
||||
def _parse_json(text: str) -> dict[str, Any] | None:
|
||||
"""텍스트에서 JSON을 추출한다.
|
||||
|
||||
Kei API가 마크다운 리스트 접두사(- )를 붙여 응답하는 경우에도 처리.
|
||||
"""
|
||||
# 전처리: 각 줄 앞의 마크다운 리스트 접두사(- ) 제거
|
||||
# Kei API가 JSON을 마크다운 리스트로 감싸서 응답하는 경우 대응
|
||||
lines = text.split("\n")
|
||||
cleaned_lines = []
|
||||
for line in lines:
|
||||
stripped = line.lstrip()
|
||||
if stripped.startswith("- "):
|
||||
cleaned_lines.append(stripped[2:])
|
||||
elif stripped.startswith("* "):
|
||||
cleaned_lines.append(stripped[2:])
|
||||
else:
|
||||
cleaned_lines.append(stripped)
|
||||
cleaned = "\n".join(cleaned_lines)
|
||||
|
||||
# 원본 + 클린 버전 둘 다 시도
|
||||
for target in [text, cleaned]:
|
||||
patterns = [
|
||||
r"```json\s*(.*?)```",
|
||||
r"```\s*(.*?)```",
|
||||
r"(\{.*\})",
|
||||
]
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, target, re.DOTALL)
|
||||
if match:
|
||||
try:
|
||||
return json.loads(match.group(1).strip())
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
async def select_best_candidate(
|
||||
topic_results: list[dict[str, Any]],
|
||||
analysis: dict[str, Any],
|
||||
|
||||
@@ -392,6 +392,32 @@ def _clean_text(text: str) -> str:
|
||||
# 메인 함수
|
||||
# ══════════════════════════════════════
|
||||
|
||||
def _extract_slide_overrides(metadata: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Surface the nested ``slide_overrides`` mapping from frontmatter.
|
||||
|
||||
IMP-45 (#74) u2 — slide-level CSS override axis intake. Returns a
|
||||
plain ``dict`` so callers (Step 13 injector) can read
|
||||
``slide_overrides.get("css")`` without re-parsing frontmatter.
|
||||
|
||||
Rules:
|
||||
- Absent or non-mapping → ``{}``.
|
||||
- Inside the mapping, ``css`` is kept only when it is a ``str``
|
||||
(non-string values dropped to fail-closed against typo'd YAML
|
||||
shapes such as ``css: [".x{}"]``).
|
||||
- Unknown sibling keys (e.g., future ``slide_overrides.js``) are
|
||||
preserved verbatim — generalization deferred per Stage 2 scope.
|
||||
"""
|
||||
raw = metadata.get("slide_overrides")
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
out: dict[str, Any] = {}
|
||||
for k, v in raw.items():
|
||||
if k == "css" and not isinstance(v, str):
|
||||
continue
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def normalize_mdx_content(raw_mdx: str) -> dict[str, Any]:
|
||||
"""MDX 원본을 4-Layer 파서로 정규화.
|
||||
|
||||
@@ -405,11 +431,13 @@ def normalize_mdx_content(raw_mdx: str) -> dict[str, Any]:
|
||||
"popups": [{"title": str, "content": str}],
|
||||
"tables": [{"headers": list, "rows": list}],
|
||||
"sections": [{"level": int, "title": str, "content": str}],
|
||||
"slide_overrides": {"css": str, ...} | {},
|
||||
}
|
||||
"""
|
||||
# ── Layer 1: frontmatter 분리 ──
|
||||
metadata, body = frontmatter.parse(raw_mdx)
|
||||
title = metadata.get("title", "")
|
||||
slide_overrides = _extract_slide_overrides(metadata)
|
||||
logger.info(f"[Layer 1] title='{title}', metadata keys={list(metadata.keys())}")
|
||||
|
||||
# ── Layer 2: 코드블록 보호 → MDX 패턴 처리 ──
|
||||
@@ -437,6 +465,7 @@ def normalize_mdx_content(raw_mdx: str) -> dict[str, Any]:
|
||||
"popups": popups,
|
||||
"tables": tables,
|
||||
"sections": sections,
|
||||
"slide_overrides": slide_overrides,
|
||||
}
|
||||
|
||||
|
||||
|
||||
15
src/phase_z2_ai_fallback/__init__.py
Normal file
15
src/phase_z2_ai_fallback/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""IMP-33 AI fallback package (fallback path only).
|
||||
|
||||
Module path locked by IMP-31-GATE-AUDIT.md (Stage 1 binding).
|
||||
Normal path AI call count MUST remain 0; this package only executes under
|
||||
classified fallback routes (reject / restructure / overflow). See
|
||||
`feedback_ai_isolation_contract`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_ai_fallback.schema import (
|
||||
AiFallbackProposal,
|
||||
ProposalKind,
|
||||
)
|
||||
|
||||
__all__ = ["AiFallbackProposal", "ProposalKind"]
|
||||
243
src/phase_z2_ai_fallback/cache.py
Normal file
243
src/phase_z2_ai_fallback/cache.py
Normal file
@@ -0,0 +1,243 @@
|
||||
"""IMP-46 u2 + u3 + u5 — Persistent JSON cache backend for AI fallback proposals.
|
||||
|
||||
Replaces the IMP-33 u6 ``NotImplementedError`` stub with a content-addressed
|
||||
store at ``data/frame_cache/{frame_id}/{signature_hash}.json``.
|
||||
|
||||
Key format:
|
||||
|
||||
* ``read_proposal(key)`` / ``save_proposal(key, ...)`` accept a string ``key``
|
||||
of the form ``"{frame_id}::{signature_hash}"``. The two components are
|
||||
parsed inside this module so that upstream callers (router, step 12)
|
||||
remain unaware of the on-disk layout.
|
||||
* ``read_proposal`` on a malformed (legacy) key silently returns ``None``
|
||||
— the IMP-33 u7 router currently passes a legacy ``cache_key`` string,
|
||||
and u4 will switch to the structural form. Until then, all such reads
|
||||
must miss safely (no exception, no false hit).
|
||||
* ``save_proposal`` on a malformed key raises ``ValueError`` (loud, never
|
||||
silent) — writes are gated and must use the structural form.
|
||||
|
||||
Stored payload (one JSON file per (frame_id, signature_hash) pair):
|
||||
|
||||
{
|
||||
"schema_version": 1,
|
||||
"proposal": <AiFallbackProposal.model_dump(mode="json")>,
|
||||
"slide_css": <str | null>,
|
||||
"fingerprints": {"contract_sha": ..., "partial_sha": ..., "catalog_sha": ...}
|
||||
}
|
||||
|
||||
u3 invalidation contract (this module is a *comparator*, not a *computer*):
|
||||
|
||||
* ``save_proposal`` persists the ``fingerprints`` dict supplied by the
|
||||
caller verbatim. Cache.py never computes any fingerprint — the three
|
||||
declared shas (``contract_sha`` / ``partial_sha`` / ``catalog_sha``) are
|
||||
computed by callers from the live contract YAML / partial templates /
|
||||
catalog payloads and handed in. Keeping the computation out of cache.py
|
||||
preserves AI isolation (no Phase Z runtime knowledge in the cache
|
||||
module) and keeps the cache schema-agnostic — additional fingerprint
|
||||
axes can be added without editing cache.py.
|
||||
* ``read_proposal`` accepts an optional ``fingerprints`` kwarg. When
|
||||
supplied, the stored ``fingerprints`` dict must equal the caller's dict
|
||||
exactly (strict equality, NOT subset). Any mismatch — including a key
|
||||
the caller demands but the stored entry lacks, OR a key the stored
|
||||
entry has but the caller does not pass — returns ``None``. Default
|
||||
``fingerprints=None`` performs no comparison (back-compat for legacy
|
||||
callers that have not yet adopted fingerprint-aware lookup).
|
||||
|
||||
Guardrails (locked by Stage 2 plan):
|
||||
|
||||
* Both write gates preserved — ``visual_check_passed=False`` always
|
||||
raises ``AiFallbackCacheGateError`` BEFORE any filesystem touch.
|
||||
``user_approved=False`` also raises by default; the IMP-46 u5
|
||||
``auto_cache=True`` override bypasses ONLY the ``user_approved`` gate
|
||||
(``visual_check_passed`` is never bypassed). Gate violation never
|
||||
silently no-ops.
|
||||
* Missing or corrupt files cause ``read_proposal`` to return ``None`` —
|
||||
the cache is a hint, never a hard dependency. Errors are not propagated
|
||||
to callers because the AI fallback path can always recompute.
|
||||
* ``mkdir(parents=True, exist_ok=True)`` is performed lazily on save.
|
||||
* No Anthropic / MDX / Phase Z runtime imports (AI isolation contract).
|
||||
* Cache root is held as a module-level :data:`CACHE_ROOT` so tests can
|
||||
redirect writes via ``monkeypatch.setattr`` without subclassing.
|
||||
|
||||
u5 auto-cache contract (CLI ``--auto-cache`` + ``settings.ai_fallback_auto_cache``):
|
||||
|
||||
* ``save_proposal(..., auto_cache=True)`` only bypasses the
|
||||
``user_approved`` gate; ``visual_check_passed`` remains mandatory.
|
||||
* ``auto_cache`` is keyword-only and defaults to ``False`` — existing
|
||||
callers (and the test suite) see the original dual-gate behaviour
|
||||
unless they opt in explicitly.
|
||||
* The truth table over ``(visual_check_passed, user_approved, auto_cache)``
|
||||
has eight cells; exactly three succeed:
|
||||
``(True, True, False)``, ``(True, True, True)``, and
|
||||
``(True, False, True)``. Every other cell raises
|
||||
``AiFallbackCacheGateError``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
from src.phase_z2_ai_fallback.schema import AiFallbackProposal
|
||||
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
KEY_DELIMITER = "::"
|
||||
CACHE_ROOT: pathlib.Path = pathlib.Path("data/frame_cache")
|
||||
|
||||
|
||||
class AiFallbackCacheGateError(RuntimeError):
|
||||
"""Raised when ``save_proposal`` is called without both IMP-46 gates True."""
|
||||
|
||||
|
||||
def _parse_key(key: str) -> tuple[str, str] | None:
|
||||
"""Parse a ``frame_id::signature_hash`` key. Returns ``None`` if malformed."""
|
||||
if KEY_DELIMITER not in key:
|
||||
return None
|
||||
frame_id, _, signature_hash = key.partition(KEY_DELIMITER)
|
||||
if not frame_id or not signature_hash:
|
||||
return None
|
||||
if KEY_DELIMITER in signature_hash:
|
||||
return None
|
||||
return frame_id, signature_hash
|
||||
|
||||
|
||||
def _cache_path(frame_id: str, signature_hash: str) -> pathlib.Path:
|
||||
return CACHE_ROOT / frame_id / f"{signature_hash}.json"
|
||||
|
||||
|
||||
def read_proposal(
|
||||
key: str,
|
||||
*,
|
||||
fingerprints: dict | None = None,
|
||||
) -> AiFallbackProposal | None:
|
||||
"""Look up a previously cached proposal by ``key``.
|
||||
|
||||
Returns ``None`` for:
|
||||
|
||||
* empty / non-string key → ``ValueError`` (loud);
|
||||
* non-dict ``fingerprints`` (when supplied) → ``TypeError`` (loud,
|
||||
symmetric with :func:`save_proposal`);
|
||||
* legacy key format (no ``::`` delimiter) → silent ``None`` (router
|
||||
back-compat until u4 switches to the structural form);
|
||||
* missing file under ``data/frame_cache/{frame_id}/{signature_hash}.json``;
|
||||
* corrupt JSON / payload schema mismatch — read errors never propagate;
|
||||
* ``fingerprints`` supplied AND stored ``fingerprints`` field is not a
|
||||
dict OR does not equal the supplied dict (strict equality,
|
||||
u3 invalidation).
|
||||
"""
|
||||
if not isinstance(key, str) or not key:
|
||||
raise ValueError("cache key must be a non-empty string")
|
||||
if fingerprints is not None and not isinstance(fingerprints, dict):
|
||||
raise TypeError("fingerprints must be a dict or None")
|
||||
parsed = _parse_key(key)
|
||||
if parsed is None:
|
||||
return None
|
||||
frame_id, signature_hash = parsed
|
||||
path = _cache_path(frame_id, signature_hash)
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
if fingerprints is not None:
|
||||
stored = data.get("fingerprints")
|
||||
if not isinstance(stored, dict) or stored != fingerprints:
|
||||
return None
|
||||
proposal_dict = data.get("proposal")
|
||||
if not isinstance(proposal_dict, dict):
|
||||
return None
|
||||
try:
|
||||
return AiFallbackProposal.model_validate(proposal_dict)
|
||||
except Exception: # noqa: BLE001 — corrupt payload must miss, not raise
|
||||
return None
|
||||
|
||||
|
||||
def save_proposal(
|
||||
key: str,
|
||||
proposal: AiFallbackProposal,
|
||||
*,
|
||||
visual_check_passed: bool,
|
||||
user_approved: bool,
|
||||
slide_css: str | None = None,
|
||||
fingerprints: dict | None = None,
|
||||
auto_cache: bool = False,
|
||||
) -> pathlib.Path:
|
||||
"""Persist ``proposal`` under ``key`` once the IMP-46 gates clear.
|
||||
|
||||
Gate contract (IMP-46 u5 truth table):
|
||||
|
||||
* ``visual_check_passed=False`` -> :class:`AiFallbackCacheGateError`
|
||||
always (never bypassable; ``auto_cache`` cannot override).
|
||||
* ``user_approved=False`` AND ``auto_cache=False`` ->
|
||||
:class:`AiFallbackCacheGateError`.
|
||||
* ``user_approved=False`` AND ``auto_cache=True`` -> bypass the
|
||||
user-approval gate (IMP-46 u5 CLI / settings opt-in).
|
||||
* Otherwise (``visual_check_passed=True`` AND either
|
||||
``user_approved=True`` OR ``auto_cache=True``) -> persist payload.
|
||||
|
||||
Gate violations are raised BEFORE any filesystem touch — no parent
|
||||
directory is created, no file is written. When the gates clear the
|
||||
JSON payload (schema_version + proposal + slide_css + fingerprints)
|
||||
is written to ``data/frame_cache/{frame_id}/{signature_hash}.json``
|
||||
and the resolved :class:`pathlib.Path` is returned.
|
||||
|
||||
``slide_css`` may be ``None`` (no slide-level CSS captured) or a
|
||||
string. ``fingerprints`` may be ``None`` (treated as empty dict) or a
|
||||
dict mapping fingerprint name to SHA hex digest.
|
||||
|
||||
``auto_cache`` is keyword-only and defaults to ``False``. It is wired
|
||||
from :data:`src.config.settings.ai_fallback_auto_cache`, which the
|
||||
``--auto-cache`` CLI flag in ``src/phase_z2_pipeline.py`` toggles at
|
||||
parse time. The cache module never reads the setting itself — the
|
||||
caller passes the resolved boolean — so AI-isolation contracts
|
||||
(no Phase Z runtime / no Anthropic import) remain intact.
|
||||
"""
|
||||
if not isinstance(key, str) or not key:
|
||||
raise ValueError("cache key must be a non-empty string")
|
||||
if not isinstance(proposal, AiFallbackProposal):
|
||||
raise TypeError(
|
||||
"proposal must be an AiFallbackProposal instance "
|
||||
f"(got {type(proposal).__name__})"
|
||||
)
|
||||
if not isinstance(auto_cache, bool):
|
||||
raise TypeError("auto_cache must be a bool")
|
||||
if not visual_check_passed:
|
||||
raise AiFallbackCacheGateError(
|
||||
"IMP-46 gate: visual_check_passed=False; refusing to cache an "
|
||||
"unverified proposal. (auto_cache cannot bypass this gate.)"
|
||||
)
|
||||
if not user_approved and not auto_cache:
|
||||
raise AiFallbackCacheGateError(
|
||||
"IMP-46 gate: user_approved=False and auto_cache=False; "
|
||||
"refusing to cache without explicit user approval. Pass "
|
||||
"auto_cache=True (or --auto-cache on the CLI) to bypass."
|
||||
)
|
||||
if slide_css is not None and not isinstance(slide_css, str):
|
||||
raise TypeError("slide_css must be a string or None")
|
||||
if fingerprints is None:
|
||||
fingerprints = {}
|
||||
elif not isinstance(fingerprints, dict):
|
||||
raise TypeError("fingerprints must be a dict or None")
|
||||
parsed = _parse_key(key)
|
||||
if parsed is None:
|
||||
raise ValueError(
|
||||
"cache key must be in "
|
||||
f"'frame_id{KEY_DELIMITER}signature_hash' format; got {key!r}"
|
||||
)
|
||||
frame_id, signature_hash = parsed
|
||||
path = _cache_path(frame_id, signature_hash)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"proposal": proposal.model_dump(mode="json"),
|
||||
"slide_css": slide_css,
|
||||
"fingerprints": dict(fingerprints),
|
||||
}
|
||||
path.write_text(
|
||||
json.dumps(payload, sort_keys=True, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return path
|
||||
141
src/phase_z2_ai_fallback/client.py
Normal file
141
src/phase_z2_ai_fallback/client.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""IMP-33 u4 — AI fallback Anthropic client (fallback path only).
|
||||
|
||||
Wraps ``anthropic.Anthropic.messages.create`` with the timeout / retry /
|
||||
backoff / budget / circuit-breaker policy locked in u1 ``Settings``. NO
|
||||
inline policy literals: every knob is sourced from ``src.config.settings``.
|
||||
Transient errors (timeout / connection / 429 / 5xx) are retried with
|
||||
capped exponential backoff + jitter; all other errors propagate without
|
||||
retry. PZ-1 invariant: this module is fallback-path only and MUST NOT be
|
||||
imported on the normal pipeline path.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import anthropic
|
||||
|
||||
from src.config import settings
|
||||
from src.phase_z2_ai_fallback.schema import AiFallbackProposal
|
||||
|
||||
_TRANSIENT_ERRORS: tuple[type[BaseException], ...] = (
|
||||
anthropic.APITimeoutError,
|
||||
anthropic.APIConnectionError,
|
||||
anthropic.RateLimitError,
|
||||
anthropic.InternalServerError,
|
||||
)
|
||||
|
||||
# Output cap is an Anthropic API requirement, not a policy knob (u1).
|
||||
_MAX_OUTPUT_TOKENS = 4096
|
||||
|
||||
# IMP-92 u2 — Anthropic SDK exception → operational error kind classifier.
|
||||
# Stamped onto Step 12 AI repair records (api_error_kind) so the frontend
|
||||
# operational alert formatter can surface quota / billing / auth to users
|
||||
# while keeping non-operational ("other") failures silent. The classifier
|
||||
# is type-based (not string parsing) and the four kinds are the only
|
||||
# values frontend operational formatter is allowed to render.
|
||||
_OPERATIONAL_ERROR_KIND_QUOTA = "quota"
|
||||
_OPERATIONAL_ERROR_KIND_BILLING = "billing"
|
||||
_OPERATIONAL_ERROR_KIND_AUTH = "auth"
|
||||
_OPERATIONAL_ERROR_KIND_OTHER = "other"
|
||||
|
||||
|
||||
def classify_operational_error(exc: BaseException) -> str:
|
||||
"""Return the operational error kind for an Anthropic SDK exception.
|
||||
|
||||
Dispatch combines SDK exception type with the HTTP status code so the
|
||||
issue body's explicit operational contract (429 quota / 402 billing /
|
||||
401 auth) is honoured even when the SDK surfaces a 402 as the generic
|
||||
``anthropic.APIStatusError`` rather than a typed subclass:
|
||||
|
||||
* ``anthropic.RateLimitError`` OR HTTP 429 → ``"quota"``
|
||||
* ``anthropic.PermissionDeniedError`` OR HTTP 402 → ``"billing"``
|
||||
(Anthropic Payment Required surfaces as 402; PermissionDenied/403
|
||||
is the SDK-typed billing/permission surface)
|
||||
* ``anthropic.AuthenticationError`` OR HTTP 401 → ``"auth"``
|
||||
* everything else → ``"other"`` (silent on UI)
|
||||
|
||||
The frontend formatter renders quota / billing / auth and returns
|
||||
``None`` for ``"other"`` so non-operational AI failures stay silent
|
||||
per the #84 replacement-plan contract.
|
||||
"""
|
||||
if isinstance(exc, anthropic.RateLimitError):
|
||||
return _OPERATIONAL_ERROR_KIND_QUOTA
|
||||
if isinstance(exc, anthropic.PermissionDeniedError):
|
||||
return _OPERATIONAL_ERROR_KIND_BILLING
|
||||
if isinstance(exc, anthropic.AuthenticationError):
|
||||
return _OPERATIONAL_ERROR_KIND_AUTH
|
||||
if isinstance(exc, anthropic.APIStatusError):
|
||||
status_code = getattr(exc, "status_code", None)
|
||||
if status_code is None:
|
||||
status_code = getattr(getattr(exc, "response", None), "status_code", None)
|
||||
if status_code == 429:
|
||||
return _OPERATIONAL_ERROR_KIND_QUOTA
|
||||
if status_code == 402:
|
||||
return _OPERATIONAL_ERROR_KIND_BILLING
|
||||
if status_code == 401:
|
||||
return _OPERATIONAL_ERROR_KIND_AUTH
|
||||
return _OPERATIONAL_ERROR_KIND_OTHER
|
||||
|
||||
|
||||
class AiFallbackBudgetExceeded(RuntimeError):
|
||||
"""Per-run AI call budget (u1 ai_fallback_budget_per_run) exhausted."""
|
||||
|
||||
|
||||
class AiFallbackCircuitOpen(RuntimeError):
|
||||
"""Circuit breaker tripped (u1 ai_fallback_circuit_breaker_threshold)."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AiFallbackClient:
|
||||
"""Stateful per-run fallback client (budget + circuit accounting)."""
|
||||
|
||||
client: Any = None
|
||||
_calls: int = 0
|
||||
_consecutive_failures: int = 0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.client is None:
|
||||
self.client = anthropic.Anthropic(
|
||||
api_key=settings.anthropic_api_key,
|
||||
timeout=settings.ai_fallback_timeout_s,
|
||||
)
|
||||
|
||||
def request_proposal(self, prompt: dict[str, str]) -> AiFallbackProposal:
|
||||
if self._calls >= settings.ai_fallback_budget_per_run:
|
||||
raise AiFallbackBudgetExceeded(
|
||||
f"per-run budget {settings.ai_fallback_budget_per_run} exhausted"
|
||||
)
|
||||
if self._consecutive_failures >= settings.ai_fallback_circuit_breaker_threshold:
|
||||
raise AiFallbackCircuitOpen(
|
||||
f"circuit open after {self._consecutive_failures} consecutive failures"
|
||||
)
|
||||
self._calls += 1
|
||||
last_error: BaseException | None = None
|
||||
for attempt in range(settings.ai_fallback_max_retries + 1):
|
||||
try:
|
||||
response = self.client.messages.create(
|
||||
model=settings.ai_fallback_model,
|
||||
max_tokens=_MAX_OUTPUT_TOKENS,
|
||||
system=prompt["system"],
|
||||
messages=[{"role": "user", "content": prompt["user"]}],
|
||||
)
|
||||
text = "".join(
|
||||
block.text for block in response.content if hasattr(block, "text")
|
||||
)
|
||||
self._consecutive_failures = 0
|
||||
return AiFallbackProposal.model_validate(json.loads(text))
|
||||
except _TRANSIENT_ERRORS as err:
|
||||
last_error = err
|
||||
if attempt >= settings.ai_fallback_max_retries:
|
||||
break
|
||||
base = settings.ai_fallback_backoff_base_s * (2 ** attempt)
|
||||
delay = min(settings.ai_fallback_backoff_cap_s, base)
|
||||
delay += random.uniform(0, delay * settings.ai_fallback_backoff_jitter)
|
||||
time.sleep(delay)
|
||||
self._consecutive_failures += 1
|
||||
assert last_error is not None
|
||||
raise last_error
|
||||
80
src/phase_z2_ai_fallback/prompts.py
Normal file
80
src/phase_z2_ai_fallback/prompts.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""IMP-33 u3 — AI fallback prompt builder (fallback path only).
|
||||
|
||||
System+user prompt for the Anthropic client (u4). MDX is READ-ONLY
|
||||
(`feedback_ai_isolation_contract`); output is constrained to the u2
|
||||
schema; frame_id swap is forbidden (V4 rank-1 protected,
|
||||
`feedback_phase_z_spacing_direction`). Inputs per Stage 2 plan: V4
|
||||
result (route=ai_adaptation_required, cardinality), frame_contract,
|
||||
frame_visual HTML, figma_to_html_agent partial JSON, Internal Region,
|
||||
MDX text.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from src.phase_z2_ai_fallback.schema import FORBIDDEN_KINDS, ProposalKind
|
||||
|
||||
V4_ROUTE_AI_ADAPTATION = "ai_adaptation_required"
|
||||
|
||||
_ALLOWED_KINDS = ", ".join(sorted(k.value for k in ProposalKind))
|
||||
_FORBIDDEN_KINDS = ", ".join(sorted(FORBIDDEN_KINDS))
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are an IMP-33 AI fallback adapter for Phase Z slide composition.\n"
|
||||
"STRICT RULES:\n"
|
||||
" 1. MDX text in the user payload is READ-ONLY. Do NOT rewrite, "
|
||||
"compress, or paraphrase MDX.\n"
|
||||
" 2. Output MUST be a single JSON object conforming to AiFallbackProposal.\n"
|
||||
f" 3. proposal_kind MUST be one of: {_ALLOWED_KINDS}.\n"
|
||||
f" 4. Do NOT propose any of: {_FORBIDDEN_KINDS}.\n"
|
||||
" 5. Do NOT change frame_id — V4 rank-1 frame is locked.\n"
|
||||
" 6. Keep declared frame slots (text/table/image/details) populated.\n"
|
||||
" 7. Respect Internal Region containment; place content units within "
|
||||
"the declared region only."
|
||||
)
|
||||
|
||||
|
||||
def build_ai_fallback_prompt(
|
||||
*,
|
||||
v4_result: dict[str, Any],
|
||||
frame_contract: dict[str, Any],
|
||||
frame_visual_html: str,
|
||||
figma_partial_json: dict[str, Any],
|
||||
internal_region: dict[str, Any],
|
||||
mdx_text: str,
|
||||
) -> dict[str, str]:
|
||||
"""Build system+user prompt strings for the fallback AI adapter.
|
||||
|
||||
Raises:
|
||||
ValueError: when ``v4_result.route`` is not
|
||||
``ai_adaptation_required`` — the fallback prompt MUST NOT be
|
||||
built outside this route (normal-path AI call count must
|
||||
remain 0; PZ-1).
|
||||
"""
|
||||
route = v4_result.get("route") or v4_result.get("imp05_route_hint")
|
||||
if route != V4_ROUTE_AI_ADAPTATION:
|
||||
raise ValueError(
|
||||
f"build_ai_fallback_prompt: v4_result.route={route!r} is not "
|
||||
f"{V4_ROUTE_AI_ADAPTATION!r}; fallback prompt MUST NOT be built "
|
||||
"outside the AI adaptation route."
|
||||
)
|
||||
user_payload = {
|
||||
"v4": {
|
||||
"route": route,
|
||||
"cardinality": v4_result.get("cardinality")
|
||||
or v4_result.get("cardinality_signature"),
|
||||
"label": v4_result.get("label"),
|
||||
"frame_id": v4_result.get("frame_id"),
|
||||
"rank": v4_result.get("rank"),
|
||||
},
|
||||
"frame_contract": frame_contract,
|
||||
"frame_visual_html": frame_visual_html,
|
||||
"figma_partial_json": figma_partial_json,
|
||||
"internal_region": internal_region,
|
||||
"mdx_text_READ_ONLY": mdx_text,
|
||||
}
|
||||
return {
|
||||
"system": SYSTEM_PROMPT,
|
||||
"user": json.dumps(user_payload, ensure_ascii=False),
|
||||
}
|
||||
95
src/phase_z2_ai_fallback/router.py
Normal file
95
src/phase_z2_ai_fallback/router.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""IMP-33 u7 — AI fallback router (fallback path only).
|
||||
|
||||
Composes the IMP-33 fallback flow:
|
||||
|
||||
1. flag gate (``settings.ai_fallback_enabled`` default OFF)
|
||||
2. V4 route gate (route must equal ``ai_adaptation_required``)
|
||||
3. cache read (u6 stub returns ``None`` until IMP-46 lands)
|
||||
4. build prompt (u3)
|
||||
5. call client (u4 ``request_proposal``)
|
||||
6. validate (u5 ``validate_proposal``)
|
||||
|
||||
Returns the validated ``AiFallbackProposal``. Save to cache is NOT
|
||||
performed here — it is caller-driven AFTER ``visual_check_passed=True``
|
||||
AND ``user_approved=True``, per the u6 IMP-46 gate. The router does not
|
||||
import ``save_proposal``; this is the structural guarantee that the
|
||||
router cannot persist a proposal before the caller's visual + user
|
||||
checks (`feedback_artifact_status_naming`).
|
||||
|
||||
Guardrails:
|
||||
|
||||
* PZ-1 — normal-path AI call count stays 0: flag-off OR route-mismatch
|
||||
short-circuits BEFORE the prompt builder or client are touched.
|
||||
* ``feedback_ai_isolation_contract`` — MDX READ-ONLY (u3 enforces in
|
||||
prompt; this module never reads or writes MDX).
|
||||
* ``feedback_phase_z_spacing_direction`` — V4 rank-1 protected (u5
|
||||
enforces; router only forwards the contract).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from src.config import settings
|
||||
from src.phase_z2_ai_fallback.cache import read_proposal
|
||||
from src.phase_z2_ai_fallback.client import AiFallbackClient
|
||||
from src.phase_z2_ai_fallback.prompts import (
|
||||
V4_ROUTE_AI_ADAPTATION,
|
||||
build_ai_fallback_prompt,
|
||||
)
|
||||
from src.phase_z2_ai_fallback.schema import AiFallbackProposal
|
||||
from src.phase_z2_ai_fallback.validate import validate_proposal
|
||||
|
||||
|
||||
def route_ai_fallback(
|
||||
*,
|
||||
cache_key: str,
|
||||
v4_result: dict[str, Any],
|
||||
frame_contract: dict[str, Any],
|
||||
frame_visual_html: str,
|
||||
figma_partial_json: dict[str, Any],
|
||||
internal_region: dict[str, Any],
|
||||
mdx_text: str,
|
||||
client: AiFallbackClient | None = None,
|
||||
fingerprints: dict | None = None,
|
||||
) -> AiFallbackProposal | None:
|
||||
"""Route a fallback request through cache → prompt → client → validate.
|
||||
|
||||
Returns ``None`` when the master flag is OFF or when the V4 route is
|
||||
not ``ai_adaptation_required`` — both gates short-circuit BEFORE any
|
||||
prompt/client work, so the normal-path AI call count stays at 0
|
||||
(PZ-1).
|
||||
|
||||
``fingerprints`` is forwarded into ``read_proposal`` so that
|
||||
contract / partial / catalog SHA mismatches invalidate stale cache
|
||||
entries (IMP-46 #62 Axis R). When ``None`` the cache layer skips
|
||||
fingerprint comparison (legacy behaviour).
|
||||
"""
|
||||
if not settings.ai_fallback_enabled:
|
||||
return None
|
||||
route = v4_result.get("route") or v4_result.get("imp05_route_hint")
|
||||
if route != V4_ROUTE_AI_ADAPTATION:
|
||||
return None
|
||||
cached = read_proposal(cache_key, fingerprints=fingerprints)
|
||||
if cached is not None:
|
||||
validate_proposal(
|
||||
cached,
|
||||
frame_contract=frame_contract,
|
||||
internal_region=internal_region,
|
||||
)
|
||||
return cached
|
||||
prompt = build_ai_fallback_prompt(
|
||||
v4_result=v4_result,
|
||||
frame_contract=frame_contract,
|
||||
frame_visual_html=frame_visual_html,
|
||||
figma_partial_json=figma_partial_json,
|
||||
internal_region=internal_region,
|
||||
mdx_text=mdx_text,
|
||||
)
|
||||
active_client = client if client is not None else AiFallbackClient()
|
||||
proposal = active_client.request_proposal(prompt)
|
||||
validate_proposal(
|
||||
proposal,
|
||||
frame_contract=frame_contract,
|
||||
internal_region=internal_region,
|
||||
)
|
||||
return proposal
|
||||
50
src/phase_z2_ai_fallback/schema.py
Normal file
50
src/phase_z2_ai_fallback/schema.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""IMP-33 u2 — AI fallback proposal schema.
|
||||
|
||||
Whitelisted proposal kinds (Stage 2 plan):
|
||||
- builder_options_patch : zone/frame builder option overrides
|
||||
- partial_overrides : Internal Region / Frame Slot content overrides
|
||||
- slot_mapping_proposal : restructuring proposal (content unit mapping)
|
||||
|
||||
Forbidden output forms (rejected by validator):
|
||||
- mdx_text (MDX read-only — `feedback_ai_isolation_contract`)
|
||||
- frame_id_change (V4 rank-1 protected — `feedback_phase_z_spacing_direction`)
|
||||
- raw_html (HTML structure is code-decided, not AI-generated)
|
||||
- raw_css (same)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class ProposalKind(str, Enum):
|
||||
BUILDER_OPTIONS_PATCH = "builder_options_patch"
|
||||
PARTIAL_OVERRIDES = "partial_overrides"
|
||||
SLOT_MAPPING_PROPOSAL = "slot_mapping_proposal"
|
||||
|
||||
|
||||
FORBIDDEN_KINDS: frozenset[str] = frozenset(
|
||||
{"mdx_text", "frame_id_change", "raw_html", "raw_css"}
|
||||
)
|
||||
|
||||
|
||||
class AiFallbackProposal(BaseModel):
|
||||
"""Single AI fallback proposal (output contract for u4 client)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
proposal_kind: ProposalKind
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
rationale: str = ""
|
||||
|
||||
@field_validator("proposal_kind", mode="before")
|
||||
@classmethod
|
||||
def _reject_forbidden_kind(cls, value: Any) -> Any:
|
||||
if isinstance(value, str) and value in FORBIDDEN_KINDS:
|
||||
raise ValueError(
|
||||
f"proposal_kind={value!r} is forbidden (MDX/frame/raw HTML/CSS "
|
||||
"mutations are not permitted under IMP-33)."
|
||||
)
|
||||
return value
|
||||
91
src/phase_z2_ai_fallback/signature.py
Normal file
91
src/phase_z2_ai_fallback/signature.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""IMP-46 u1 — Frame transformation cache signature builder.
|
||||
|
||||
Deterministic SHA256 over the 8 declared structural axes:
|
||||
frame_id, v4_label, cardinality, source_shape,
|
||||
h3_count, char_count_bucket, layout_preset, zone_position
|
||||
|
||||
Guardrails:
|
||||
* No sample/section identifiers in the signature surface (no-hardcoding lock).
|
||||
* source_shape constrained to the bullet/paragraph/table/mixed enum.
|
||||
* char_count_bucket is the *bucket label*; numeric counts must be projected
|
||||
via :func:`bucket_char_count` before being fed to :func:`build_signature`.
|
||||
* Schema version is embedded in the hashed payload so a future axis change
|
||||
breaks the digest by design (cache invalidation on schema bump).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from enum import Enum
|
||||
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
class SourceShape(str, Enum):
|
||||
BULLET = "bullet"
|
||||
PARAGRAPH = "paragraph"
|
||||
TABLE = "table"
|
||||
MIXED = "mixed"
|
||||
|
||||
|
||||
_CHAR_COUNT_BUCKETS: tuple[tuple[int, str], ...] = (
|
||||
(50, "0-50"),
|
||||
(150, "51-150"),
|
||||
(400, "151-400"),
|
||||
(1000, "401-1000"),
|
||||
)
|
||||
_CHAR_COUNT_BUCKET_OVERFLOW = "1001+"
|
||||
CHAR_COUNT_BUCKET_LABELS: tuple[str, ...] = tuple(
|
||||
label for _, label in _CHAR_COUNT_BUCKETS
|
||||
) + (_CHAR_COUNT_BUCKET_OVERFLOW,)
|
||||
|
||||
|
||||
def bucket_char_count(char_count: int) -> str:
|
||||
"""Project a non-negative character count to its fixed bucket label."""
|
||||
if isinstance(char_count, bool) or not isinstance(char_count, int):
|
||||
raise TypeError("char_count must be a non-negative int")
|
||||
if char_count < 0:
|
||||
raise ValueError("char_count must be non-negative")
|
||||
for upper, label in _CHAR_COUNT_BUCKETS:
|
||||
if char_count <= upper:
|
||||
return label
|
||||
return _CHAR_COUNT_BUCKET_OVERFLOW
|
||||
|
||||
|
||||
def build_signature(
|
||||
*,
|
||||
frame_id: str,
|
||||
v4_label: str,
|
||||
cardinality: int | None,
|
||||
source_shape: SourceShape | str,
|
||||
h3_count: int,
|
||||
char_count_bucket: str,
|
||||
layout_preset: str,
|
||||
zone_position: str,
|
||||
) -> str:
|
||||
"""Return a deterministic SHA256 hex digest over the 8 declared axes."""
|
||||
if isinstance(source_shape, SourceShape):
|
||||
source_shape_value = source_shape.value
|
||||
elif isinstance(source_shape, str):
|
||||
source_shape_value = SourceShape(source_shape).value
|
||||
else:
|
||||
raise TypeError("source_shape must be SourceShape or str")
|
||||
if char_count_bucket not in CHAR_COUNT_BUCKET_LABELS:
|
||||
raise ValueError(
|
||||
f"char_count_bucket={char_count_bucket!r} is not a known bucket "
|
||||
f"label (expected one of {CHAR_COUNT_BUCKET_LABELS})"
|
||||
)
|
||||
payload = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"frame_id": frame_id,
|
||||
"v4_label": v4_label,
|
||||
"cardinality": cardinality,
|
||||
"source_shape": source_shape_value,
|
||||
"h3_count": h3_count,
|
||||
"char_count_bucket": char_count_bucket,
|
||||
"layout_preset": layout_preset,
|
||||
"zone_position": zone_position,
|
||||
}
|
||||
encoded = json.dumps(payload, sort_keys=True, ensure_ascii=False).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
221
src/phase_z2_ai_fallback/step12.py
Normal file
221
src/phase_z2_ai_fallback/step12.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""IMP-33 u8 + IMP-46 u4 — Step 12 AI repair wiring with structural cache key.
|
||||
|
||||
Phase Z Step 12 = slot_payload (the runtime "light_edit / restructure" surface
|
||||
where AI-assisted frame-aware adaptation is allowed per IMP-17 carve-out).
|
||||
This module is the only call site that pipes Phase Z composition units into
|
||||
``src.phase_z2_ai_fallback.router.route_ai_fallback``. One structural gate
|
||||
preserves the AI isolation contract:
|
||||
|
||||
* IMP-30 provisional gate — units with ``provisional=False`` are skipped
|
||||
before any route classification. AI repair is reserved for first-render
|
||||
invariant survivors (no rank-1 V4 evidence, recovered as provisional).
|
||||
|
||||
Per IMP-47B u1+u2, the ``reject`` V4 label routes to
|
||||
``ai_adaptation_required`` (no longer ``design_reference_only``) and is
|
||||
admitted to the AI repair path; the legacy "reject gate" short-circuit is
|
||||
removed. Any unit whose ``route_hint`` is not ``ai_adaptation_required``
|
||||
still falls through to the catch-all ``route_not_ai_adaptation:<hint>``
|
||||
skip — that single gate continues to enforce the AI=0 normal path.
|
||||
|
||||
Combined with the u7 router's flag-off + route-gate short-circuits, the
|
||||
default Phase Z run path performs zero AI calls (PZ-1). Save to cache is
|
||||
NOT performed here — that is the caller's responsibility AFTER
|
||||
``visual_check_passed=True`` AND ``user_approved=True`` (u6 IMP-46 gate).
|
||||
|
||||
IMP-46 u4 — structural cache key + fingerprints
|
||||
------------------------------------------------
|
||||
|
||||
The legacy ``cache_key`` was ``"{template_id}::{sorted(source_section_ids)}"``
|
||||
which leaked sample / section identity into the cache surface
|
||||
(no-hardcoding lock violation: structurally identical content with
|
||||
different MDX section ids would miss). u4 replaces it with
|
||||
``"{frame_id}::{signature_hash}"`` where ``signature_hash`` is the
|
||||
deterministic SHA256 over the 8 declared structural axes (see
|
||||
``src.phase_z2_ai_fallback.signature``). Per-unit signature inputs are
|
||||
read from unit attributes:
|
||||
|
||||
* ``cardinality`` (int | None) — also forwarded to ``v4_result``
|
||||
* ``layout_preset`` (str)
|
||||
* ``zone_position`` (str)
|
||||
* ``source_shape`` (str) — bullet / paragraph / table / mixed
|
||||
* ``h3_count`` (int)
|
||||
* ``char_count`` (int) — bucketed via ``bucket_char_count``
|
||||
|
||||
In parallel the three invalidation fingerprints
|
||||
(``contract_sha`` / ``partial_sha`` / ``catalog_sha``) are computed and
|
||||
attached to the record. The cache.py module remains a *comparator* — all
|
||||
fingerprint *computation* happens here (or via injected loaders) so the
|
||||
cache schema-agnostic contract is preserved. The router's existing
|
||||
``read_proposal(cache_key)`` continues to perform exact-match lookup only
|
||||
(fuzzy is deferred per Stage 2 plan); read-side fingerprint validation
|
||||
through the router is a follow-up axis.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any, Callable, Iterable
|
||||
|
||||
from src.phase_z2_ai_fallback.client import classify_operational_error
|
||||
from src.phase_z2_ai_fallback.router import route_ai_fallback
|
||||
from src.phase_z2_ai_fallback.signature import bucket_char_count, build_signature
|
||||
|
||||
|
||||
_AI_ADAPTATION_ROUTE = "ai_adaptation_required"
|
||||
|
||||
|
||||
def _sha256_of(payload: Any) -> str:
|
||||
"""Deterministic SHA256 hex digest over a JSON-serialisable payload."""
|
||||
encoded = json.dumps(payload, sort_keys=True, ensure_ascii=False).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def gather_step12_ai_repair_proposals(
|
||||
units: Iterable[Any],
|
||||
*,
|
||||
route_for_label: Callable[[str | None], str | None],
|
||||
get_contract_fn: Callable[[str], dict | None],
|
||||
frame_visual_loader: Callable[[str], str],
|
||||
figma_partial_loader: Callable[[str], dict] | None = None,
|
||||
internal_region_lookup: Callable[[Any], dict] | None = None,
|
||||
mdx_text_loader: Callable[[Any], str] | None = None,
|
||||
catalog_sha_loader: Callable[[], str] | None = None,
|
||||
) -> list[dict]:
|
||||
"""Return one record per unit describing the Step 12 AI repair decision.
|
||||
|
||||
The record schema is stable across all gate decisions so the Step 12
|
||||
artifact consumer can rely on a single shape:
|
||||
|
||||
{
|
||||
"unit_index": int,
|
||||
"source_section_ids": list[str],
|
||||
"frame_template_id": str,
|
||||
"label": str | None,
|
||||
"route_hint": str | None,
|
||||
"provisional": bool,
|
||||
"ai_called": bool,
|
||||
"skip_reason": str | None,
|
||||
"proposal": dict | None,
|
||||
"error": str | None,
|
||||
"api_error_kind": str | None, # IMP-92 u2 (quota|billing|auth|other)
|
||||
"cache_key": str | None, # IMP-46 u4
|
||||
"fingerprints": dict | None, # IMP-46 u4
|
||||
}
|
||||
|
||||
``cache_key`` and ``fingerprints`` are populated only when the unit
|
||||
reaches the AI-eligible code path (provisional + ai_adaptation route).
|
||||
Skipped units retain ``None`` for both — the structural axes
|
||||
(layout_preset / zone_position / source_shape / h3_count / char_count)
|
||||
are not guaranteed to be set for non-AI paths.
|
||||
|
||||
``ai_called`` is True only when ``route_ai_fallback`` was invoked AND
|
||||
returned a proposal OR raised. Flag-off / route-mismatch returns
|
||||
``None`` from the router and is surfaced as ``ai_called=False`` with
|
||||
``skip_reason="router_short_circuit"`` so the caller can distinguish
|
||||
"router decided not to run" from "router ran and returned a proposal".
|
||||
"""
|
||||
records: list[dict] = []
|
||||
catalog_sha = (
|
||||
catalog_sha_loader() if catalog_sha_loader is not None else ""
|
||||
)
|
||||
for index, unit in enumerate(units):
|
||||
label = getattr(unit, "label", None)
|
||||
route_hint = route_for_label(label)
|
||||
record: dict = {
|
||||
"unit_index": index,
|
||||
"source_section_ids": list(getattr(unit, "source_section_ids", []) or []),
|
||||
"frame_template_id": getattr(unit, "frame_template_id", None),
|
||||
"label": label,
|
||||
"route_hint": route_hint,
|
||||
"provisional": bool(getattr(unit, "provisional", False)),
|
||||
"ai_called": False,
|
||||
"skip_reason": None,
|
||||
"proposal": None,
|
||||
"error": None,
|
||||
"api_error_kind": None,
|
||||
"cache_key": None,
|
||||
"fingerprints": None,
|
||||
}
|
||||
if not record["provisional"]:
|
||||
record["skip_reason"] = "not_provisional"
|
||||
records.append(record)
|
||||
continue
|
||||
if route_hint != _AI_ADAPTATION_ROUTE:
|
||||
record["skip_reason"] = f"route_not_ai_adaptation:{route_hint}"
|
||||
records.append(record)
|
||||
continue
|
||||
|
||||
template_id = record["frame_template_id"] or ""
|
||||
frame_contract = get_contract_fn(template_id) or {}
|
||||
frame_visual_html = frame_visual_loader(template_id)
|
||||
figma_partial_json = (
|
||||
figma_partial_loader(template_id) if figma_partial_loader is not None else {}
|
||||
)
|
||||
internal_region = (
|
||||
internal_region_lookup(unit) if internal_region_lookup is not None else {}
|
||||
)
|
||||
mdx_text = (
|
||||
mdx_text_loader(unit)
|
||||
if mdx_text_loader is not None
|
||||
else (getattr(unit, "raw_content", "") or "")
|
||||
)
|
||||
|
||||
frame_id_value = getattr(unit, "frame_id", "") or ""
|
||||
cardinality = getattr(unit, "cardinality", None)
|
||||
layout_preset = getattr(unit, "layout_preset", "") or ""
|
||||
zone_position = getattr(unit, "zone_position", "") or ""
|
||||
source_shape = getattr(unit, "source_shape", "paragraph") or "paragraph"
|
||||
h3_count = int(getattr(unit, "h3_count", 0) or 0)
|
||||
char_count = int(getattr(unit, "char_count", 0) or 0)
|
||||
char_count_bucket = bucket_char_count(char_count)
|
||||
signature_hash = build_signature(
|
||||
frame_id=frame_id_value,
|
||||
v4_label=label or "",
|
||||
cardinality=cardinality,
|
||||
source_shape=source_shape,
|
||||
h3_count=h3_count,
|
||||
char_count_bucket=char_count_bucket,
|
||||
layout_preset=layout_preset,
|
||||
zone_position=zone_position,
|
||||
)
|
||||
cache_key = f"{frame_id_value}::{signature_hash}"
|
||||
fingerprints = {
|
||||
"contract_sha": _sha256_of(frame_contract),
|
||||
"partial_sha": _sha256_of(figma_partial_json),
|
||||
"catalog_sha": catalog_sha,
|
||||
}
|
||||
record["cache_key"] = cache_key
|
||||
record["fingerprints"] = fingerprints
|
||||
|
||||
v4_result = {
|
||||
"route": route_hint,
|
||||
"label": label,
|
||||
"frame_id": getattr(unit, "frame_id", None),
|
||||
"rank": getattr(unit, "v4_rank", None),
|
||||
"cardinality": cardinality,
|
||||
}
|
||||
try:
|
||||
proposal = route_ai_fallback(
|
||||
cache_key=cache_key,
|
||||
v4_result=v4_result,
|
||||
frame_contract=frame_contract,
|
||||
frame_visual_html=frame_visual_html,
|
||||
figma_partial_json=figma_partial_json,
|
||||
internal_region=internal_region,
|
||||
mdx_text=mdx_text,
|
||||
fingerprints=fingerprints,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — record + continue, no AI re-raise
|
||||
record["ai_called"] = True
|
||||
record["error"] = f"{type(exc).__name__}: {exc}"
|
||||
record["api_error_kind"] = classify_operational_error(exc)
|
||||
records.append(record)
|
||||
continue
|
||||
if proposal is None:
|
||||
record["skip_reason"] = "router_short_circuit"
|
||||
records.append(record)
|
||||
continue
|
||||
record["ai_called"] = True
|
||||
record["proposal"] = proposal.model_dump()
|
||||
records.append(record)
|
||||
return records
|
||||
352
src/phase_z2_ai_fallback/step17.py
Normal file
352
src/phase_z2_ai_fallback/step17.py
Normal file
@@ -0,0 +1,352 @@
|
||||
"""IMP-33 u9 — Step 17 AI repair wiring (BLOCKED until IMP-34 + IMP-35 land).
|
||||
|
||||
Phase Z Step 17 = retry / salvage cascade (see ``src.phase_z2_pipeline``
|
||||
section 11.7 ``_attempt_salvage_chain`` and the existing IMP-12 u8/u9
|
||||
deterministic chain at ``src/phase_z2_pipeline.py:1994`` and
|
||||
``src/phase_z2_pipeline.py:4948``).
|
||||
|
||||
Per IMP-17 carve-out (``docs/architecture/IMP-17-CARVE-OUT.md`` lines 16,
|
||||
40-44), AI repair at Step 17 is permitted ONLY after the full deterministic
|
||||
chain is exhausted AND popup escalation is exhausted AND a user-approved
|
||||
fallback budget remains. IMP-34 (zone resize + compact retry) and IMP-35
|
||||
(``details_popup_escalation``) are explicit prerequisites under the IMP-33
|
||||
out-of-scope contract — neither has landed yet. Therefore Step 17 AI repair
|
||||
is STRUCTURALLY BLOCKED at u9.
|
||||
|
||||
This module:
|
||||
|
||||
1. **SPECIFIES** the canonical overflow cascade order via
|
||||
:data:`OVERFLOW_CASCADE_ORDER` — ``deterministic`` → ``popup`` →
|
||||
``ai_repair`` → ``user_override``. Downstream Step 17 consumers can rely
|
||||
on this single source of truth.
|
||||
2. **KEEPS** Step 17 AI repair structurally blocked. The entry point
|
||||
:func:`gather_step17_ai_repair_proposals` does NOT import
|
||||
``route_ai_fallback`` (u7), does NOT instantiate ``AiFallbackClient`` (u4),
|
||||
and does NOT call any Anthropic API. Every unit is recorded with
|
||||
``skip_reason="step17_ai_blocked_imp_34_35_prerequisites_missing"`` so
|
||||
the caller can distinguish "blocked by carve-out gate" from any other
|
||||
skip path (e.g., u8 ``not_provisional`` / ``design_reference_only_no_ai``).
|
||||
|
||||
Once IMP-34 + IMP-35 land AND a user-approved fallback budget is granted,
|
||||
this module will gain the actual ``route_ai_fallback`` wiring guarded by
|
||||
the cascade-stage conjunction. Today the gate is closed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any, Callable, Iterable
|
||||
|
||||
|
||||
class OverflowCascadeStage(str, Enum):
|
||||
"""Step 17 overflow cascade stages — canonical order (u9 single source of truth).
|
||||
|
||||
Members are ordered to match the AI isolation contract:
|
||||
|
||||
* ``DETERMINISTIC`` — IMP-12 u4/u5/u6 (``cross_zone_redistribute`` /
|
||||
``glue_compression`` / ``font_step_compression``) + IMP-12 terminal
|
||||
actions (``layout_adjust`` / ``frame_reselect``) + IMP-34
|
||||
(``zone resize + compact retry``, pending). No AI in any sub-stage.
|
||||
* ``POPUP`` — IMP-35 (``details_popup_escalation``, pending). Content
|
||||
popup escalation as the final deterministic resort before any AI.
|
||||
* ``AI_REPAIR`` — IMP-33 (this carve-out) + IMP-46 cache. Only reachable
|
||||
after DETERMINISTIC and POPUP are both exhausted AND user-approved
|
||||
fallback budget remains.
|
||||
* ``USER_OVERRIDE`` — explicit user override after all auto stages.
|
||||
"""
|
||||
|
||||
DETERMINISTIC = "deterministic"
|
||||
POPUP = "popup"
|
||||
AI_REPAIR = "ai_repair"
|
||||
USER_OVERRIDE = "user_override"
|
||||
|
||||
|
||||
OVERFLOW_CASCADE_ORDER: tuple[OverflowCascadeStage, ...] = (
|
||||
OverflowCascadeStage.DETERMINISTIC,
|
||||
OverflowCascadeStage.POPUP,
|
||||
OverflowCascadeStage.AI_REPAIR,
|
||||
OverflowCascadeStage.USER_OVERRIDE,
|
||||
)
|
||||
|
||||
|
||||
STEP17_AI_REPAIR_BLOCKED_REASON = (
|
||||
"step17_ai_blocked_imp_34_35_prerequisites_missing"
|
||||
)
|
||||
|
||||
|
||||
# IMP-35 (#64) u4 — POPUP cascade AI split-decision contract (API gated).
|
||||
#
|
||||
# Step 17 POPUP escalation needs an AI hook to decide *what content* stays in
|
||||
# the body (summary/subset) vs. moves into the <details> popup (full MDX).
|
||||
# That hook is the AI split-decision contract. u4 ships the contract surface
|
||||
# (function signature + record schema + cascade_stage + route_for_label +
|
||||
# skip_reason) WITHOUT enabling the Anthropic API. The deterministic POPUP
|
||||
# gate executor (u5) runs ahead of this contract and stamps
|
||||
# popup_escalation_plan + has_popup; u4's hook is a forward-compatible
|
||||
# placeholder so downstream wiring (u5 executor / future IMP activating the
|
||||
# API) can rely on a stable schema. ``api_gated=True`` on every record makes
|
||||
# the gate state machine-readable; ``ai_called`` stays False everywhere.
|
||||
#
|
||||
# Per feedback_ai_isolation_contract: AI = fallback path only. The contract
|
||||
# function MUST NOT import route_ai_fallback, the u4 client (despite name
|
||||
# collision — u4 here is the IMP-35 unit, not the Step 12 client module),
|
||||
# or any anthropic SDK symbol. Structural import guards in the test surface
|
||||
# already enforce this and continue to hold after this change.
|
||||
STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON = (
|
||||
"step17_popup_split_decision_api_gated"
|
||||
)
|
||||
|
||||
|
||||
# IMP-35 (#64) u5 — deterministic POPUP gate executor (cascade-terminal).
|
||||
#
|
||||
# Runs AFTER the DETERMINISTIC stage exhausts and BEFORE the AI_REPAIR
|
||||
# cascade stage (canonical OVERFLOW_CASCADE_ORDER). Per unit:
|
||||
#
|
||||
# 1. Idempotency (q2): if a unit carries ``has_popup=True`` already,
|
||||
# ``run_step17_popup_gate`` short-circuits with
|
||||
# ``gate_status="idempotent_short_circuit"``. No duplicate plan,
|
||||
# no re-routing. Re-running Step 17 on already-escalated units is
|
||||
# safe — the gate emits a deterministic record per unit but does
|
||||
# NOT re-stamp the plan or flip the marker. The persistence of
|
||||
# ``has_popup`` and ``popup_escalation_plan`` on the unit itself
|
||||
# (see step 4 below) is what makes the second call observe the
|
||||
# stamp from the first call and short-circuit correctly.
|
||||
# 2. Classification: ``classification_for_unit(unit)`` returns the
|
||||
# fit_classifier row associated with this unit (or ``None`` if the
|
||||
# unit has no overflow on this run).
|
||||
# 3. Plan: ``plan_for_classification(cls)`` is the router u3 stub
|
||||
# (``src.phase_z2_router.plan_details_popup_escalation``). Only
|
||||
# the categories in ``POPUP_ESCALATION_CATEGORIES`` of the router
|
||||
# surface (currently ``structural_major_overflow`` and
|
||||
# ``tabular_overflow``) emit a feasible plan; anything else falls
|
||||
# through to ``gate_status="infeasible_category"`` so the gate
|
||||
# never silently escalates the wrong overflow shape.
|
||||
# 4. Feasible plan → record stamps ``popup_escalation_plan`` and
|
||||
# flips ``has_popup=True`` in the returned record AND persists
|
||||
# the same two fields on the unit via ``setattr`` (``unit.has_popup``
|
||||
# and ``unit.popup_escalation_plan``). The unit-side persistence
|
||||
# is the q2 idempotency contract: a second call to
|
||||
# ``run_step17_popup_gate`` over the same unit reads
|
||||
# ``unit.has_popup=True`` at step 1 and short-circuits before
|
||||
# classification / plan callable invocation. The marker is also
|
||||
# what u6 composition binding and u7 render wiring read from the
|
||||
# unit downstream.
|
||||
#
|
||||
# AI isolation contract: NO Anthropic call inside this gate. The
|
||||
# deterministic split between popup body (full MDX) and preview
|
||||
# (summary/subset) is composed downstream from container px budgets
|
||||
# (q3 — preview_chars derives from container px telemetry already on
|
||||
# the retry_trace). The u4 AI hook (``gather_step17_popup_split_decisions``)
|
||||
# sits at the same cascade stage but is API-gated (``api_gated=True``)
|
||||
# and never invoked from this deterministic path. ``ai_called=False`` on
|
||||
# every record this gate emits.
|
||||
#
|
||||
# cascade_stage="popup" on every record so Step 17 retry-trace consumers
|
||||
# can multiplex DETERMINISTIC / POPUP / AI_REPAIR records without
|
||||
# ambiguity. The schema mirrors :func:`gather_step17_popup_split_decisions`
|
||||
# (unit_index / source_section_ids / frame_template_id / label /
|
||||
# route_hint / provisional) PLUS u5-specific fields:
|
||||
# ``gate_status`` / ``popup_escalation_plan`` / ``has_popup`` /
|
||||
# ``skip_reason`` (only set for non-escalated gate_status values).
|
||||
STEP17_POPUP_GATE_ESCALATED_REASON = "step17_popup_gate_escalated"
|
||||
STEP17_POPUP_GATE_IDEMPOTENT_SHORT_CIRCUIT_REASON = (
|
||||
"step17_popup_gate_idempotent_short_circuit"
|
||||
)
|
||||
STEP17_POPUP_GATE_INFEASIBLE_CATEGORY_REASON = (
|
||||
"step17_popup_gate_infeasible_category"
|
||||
)
|
||||
STEP17_POPUP_GATE_NO_CLASSIFICATION_REASON = (
|
||||
"step17_popup_gate_no_classification_for_unit"
|
||||
)
|
||||
|
||||
|
||||
def run_step17_popup_gate(
|
||||
units: Iterable[Any],
|
||||
*,
|
||||
classification_for_unit: Callable[[Any], dict | None],
|
||||
route_for_label: Callable[[str | None], str | None],
|
||||
plan_for_classification: Callable[[dict], dict],
|
||||
) -> list[dict]:
|
||||
"""Deterministic POPUP gate executor for Step 17 cascade (IMP-35 u5).
|
||||
|
||||
See module-level block comment (immediately above) for the full
|
||||
contract — idempotency (q2), classification source, router u3 stub
|
||||
coupling, AI isolation, and cascade_stage multiplexing.
|
||||
|
||||
Args:
|
||||
units: provisional / non-provisional Step 17 units. The gate is
|
||||
agnostic to provisional state; the marker ``has_popup`` flows
|
||||
from this function regardless.
|
||||
classification_for_unit: maps a unit to its fit_classifier
|
||||
classification row (or ``None`` if the unit has no overflow).
|
||||
Tests inject a fake dict / lookup; the pipeline composes
|
||||
this from ``fit_classification.classifications`` matched by
|
||||
``zone_position``.
|
||||
route_for_label: same callable shape as
|
||||
:func:`gather_step17_ai_repair_proposals` /
|
||||
:func:`gather_step17_popup_split_decisions`. The route hint
|
||||
is stamped on every record for downstream consumers.
|
||||
plan_for_classification: the router u3 stub
|
||||
(``src.phase_z2_router.plan_details_popup_escalation``).
|
||||
Injected as a callable so this module stays decoupled from
|
||||
the router surface and tests can stub the plan output.
|
||||
|
||||
Returns:
|
||||
list[dict] — one record per unit. Records carry
|
||||
``cascade_stage="popup"`` and ``ai_called=False`` everywhere.
|
||||
Feasible-escalation records also carry
|
||||
``popup_escalation_plan`` (the router u3 plan dict) and
|
||||
``has_popup=True``. Non-escalation records carry a
|
||||
``skip_reason`` enum.
|
||||
"""
|
||||
records: list[dict] = []
|
||||
for index, unit in enumerate(units):
|
||||
label = getattr(unit, "label", None)
|
||||
already_escalated = bool(getattr(unit, "has_popup", False))
|
||||
record: dict = {
|
||||
"unit_index": index,
|
||||
"source_section_ids": list(
|
||||
getattr(unit, "source_section_ids", []) or []
|
||||
),
|
||||
"frame_template_id": getattr(unit, "frame_template_id", None),
|
||||
"label": label,
|
||||
"route_hint": route_for_label(label),
|
||||
"provisional": bool(getattr(unit, "provisional", False)),
|
||||
"cascade_stage": OverflowCascadeStage.POPUP.value,
|
||||
"ai_called": False,
|
||||
"has_popup": already_escalated,
|
||||
"popup_escalation_plan": None,
|
||||
"gate_status": None,
|
||||
"skip_reason": None,
|
||||
}
|
||||
if already_escalated:
|
||||
# q2 idempotency — short-circuit. The previously stamped
|
||||
# popup_escalation_plan stays on the unit (carried by u6/u7
|
||||
# composition); this gate does NOT re-emit it.
|
||||
record["gate_status"] = "idempotent_short_circuit"
|
||||
record["skip_reason"] = (
|
||||
STEP17_POPUP_GATE_IDEMPOTENT_SHORT_CIRCUIT_REASON
|
||||
)
|
||||
records.append(record)
|
||||
continue
|
||||
classification = classification_for_unit(unit)
|
||||
if not classification:
|
||||
record["gate_status"] = "no_classification"
|
||||
record["skip_reason"] = STEP17_POPUP_GATE_NO_CLASSIFICATION_REASON
|
||||
records.append(record)
|
||||
continue
|
||||
plan = plan_for_classification(classification)
|
||||
record["popup_escalation_plan"] = plan
|
||||
if plan and plan.get("feasible"):
|
||||
record["gate_status"] = "escalated"
|
||||
record["has_popup"] = True
|
||||
record["skip_reason"] = None
|
||||
# q2 idempotency persistence — stamp the marker AND the plan
|
||||
# on the unit itself so a second run of the gate over the
|
||||
# same unit observes ``unit.has_popup=True`` at the top of
|
||||
# the loop and short-circuits before re-invoking the
|
||||
# classification / plan callables. The unit-side persistence
|
||||
# is also what u6 composition binding and u7 render wiring
|
||||
# read downstream.
|
||||
setattr(unit, "has_popup", True)
|
||||
setattr(unit, "popup_escalation_plan", plan)
|
||||
else:
|
||||
# Plan rejected by router (wrong category). Defensive guard —
|
||||
# the gate must not silently escalate the wrong overflow
|
||||
# shape (see router u3 plan_details_popup_escalation defensive
|
||||
# guard).
|
||||
record["gate_status"] = "infeasible_category"
|
||||
record["skip_reason"] = (
|
||||
STEP17_POPUP_GATE_INFEASIBLE_CATEGORY_REASON
|
||||
)
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
def gather_step17_popup_split_decisions(
|
||||
units: Iterable[Any],
|
||||
*,
|
||||
route_for_label: Callable[[str | None], str | None],
|
||||
) -> list[dict]:
|
||||
"""Return one API-gated split-decision record per unit (POPUP cascade).
|
||||
|
||||
Schema mirrors :func:`gather_step17_ai_repair_proposals` so a Step 17
|
||||
artifact consumer can multiplex DETERMINISTIC / POPUP / AI_REPAIR records
|
||||
onto the same retry trace. POPUP-specific fields:
|
||||
|
||||
* ``cascade_stage`` — always ``"popup"``.
|
||||
* ``api_gated`` — always ``True`` at u4. Future IMP activating the
|
||||
Anthropic API for popup splitting will flip this to ``False`` for
|
||||
units that traversed the deterministic POPUP gate (u5) without
|
||||
resolving via summary-only.
|
||||
* ``ai_called`` — always ``False`` at u4 (contract surface only).
|
||||
* ``skip_reason`` — always
|
||||
:data:`STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON`.
|
||||
* ``split_decision`` — always ``None`` at u4. Once activated, this will
|
||||
carry the AI-proposed ``{"body_preview": ..., "popup_full": ...}``
|
||||
pair; u5 deterministic gate fills the same field deterministically
|
||||
from container px budgets (preview_chars) and never invokes AI.
|
||||
|
||||
Per IMP-35 u4 binding contract: the API stays gated. No Anthropic call,
|
||||
no route_ai_fallback import, no client instantiation. Structural import
|
||||
tests in :mod:`tests.phase_z2_ai_fallback.test_step17` continue to lock
|
||||
these guarantees.
|
||||
"""
|
||||
records: list[dict] = []
|
||||
for index, unit in enumerate(units):
|
||||
label = getattr(unit, "label", None)
|
||||
record: dict = {
|
||||
"unit_index": index,
|
||||
"source_section_ids": list(
|
||||
getattr(unit, "source_section_ids", []) or []
|
||||
),
|
||||
"frame_template_id": getattr(unit, "frame_template_id", None),
|
||||
"label": label,
|
||||
"route_hint": route_for_label(label),
|
||||
"provisional": bool(getattr(unit, "provisional", False)),
|
||||
"cascade_stage": OverflowCascadeStage.POPUP.value,
|
||||
"ai_called": False,
|
||||
"api_gated": True,
|
||||
"skip_reason": STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON,
|
||||
"split_decision": None,
|
||||
"error": None,
|
||||
}
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
def gather_step17_ai_repair_proposals(
|
||||
units: Iterable[Any],
|
||||
*,
|
||||
route_for_label: Callable[[str | None], str | None],
|
||||
) -> list[dict]:
|
||||
"""Return one BLOCKED record per unit. No AI call is performed at u9.
|
||||
|
||||
The record schema mirrors :func:`src.phase_z2_ai_fallback.step12
|
||||
.gather_step12_ai_repair_proposals` so the Step 17 artifact consumer can
|
||||
reuse the same shape, with one addition: ``cascade_stage`` pins the
|
||||
stage this record belongs to (always ``ai_repair`` here).
|
||||
|
||||
Per Stage 2 contract (IMP-33 u9): Step 17 AI repair is blocked behind
|
||||
IMP-34 + IMP-35. Every unit returns with
|
||||
``skip_reason=STEP17_AI_REPAIR_BLOCKED_REASON`` and ``ai_called=False``.
|
||||
"""
|
||||
records: list[dict] = []
|
||||
for index, unit in enumerate(units):
|
||||
label = getattr(unit, "label", None)
|
||||
record: dict = {
|
||||
"unit_index": index,
|
||||
"source_section_ids": list(
|
||||
getattr(unit, "source_section_ids", []) or []
|
||||
),
|
||||
"frame_template_id": getattr(unit, "frame_template_id", None),
|
||||
"label": label,
|
||||
"route_hint": route_for_label(label),
|
||||
"provisional": bool(getattr(unit, "provisional", False)),
|
||||
"cascade_stage": OverflowCascadeStage.AI_REPAIR.value,
|
||||
"ai_called": False,
|
||||
"skip_reason": STEP17_AI_REPAIR_BLOCKED_REASON,
|
||||
"proposal": None,
|
||||
"error": None,
|
||||
}
|
||||
records.append(record)
|
||||
return records
|
||||
83
src/phase_z2_ai_fallback/validate.py
Normal file
83
src/phase_z2_ai_fallback/validate.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""IMP-33 u5 — AI fallback proposal validator (fallback path only).
|
||||
|
||||
Defence-in-depth layer between the u4 client output (already u2-schema-valid)
|
||||
and the caller. Adds the four Stage 2 guards that u2 cannot express purely at
|
||||
the schema level:
|
||||
|
||||
1. builder-options whitelist (BUILDER_OPTIONS_PATCH may only touch keys
|
||||
already declared in ``frame_contract.payload.builder_options``).
|
||||
2. dropped-slot guard (PARTIAL_OVERRIDES / SLOT_MAPPING_PROPOSAL must keep
|
||||
every declared ``sub_zones[*].id`` populated — text/table/image/details
|
||||
slots cannot disappear; `feedback_ai_isolation_contract`).
|
||||
3. frame-swap guard (no ``frame_id`` mutation inside payload — V4 rank-1
|
||||
protected; `feedback_phase_z_spacing_direction`).
|
||||
4. Internal Region containment (``payload.region_id`` must match the
|
||||
declared Internal Region id when present).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from src.phase_z2_ai_fallback.schema import AiFallbackProposal, ProposalKind
|
||||
|
||||
|
||||
class AiFallbackValidationError(ValueError):
|
||||
"""Raised when a proposal violates an IMP-33 u5 guard."""
|
||||
|
||||
|
||||
_SLOT_KINDS = (ProposalKind.PARTIAL_OVERRIDES, ProposalKind.SLOT_MAPPING_PROPOSAL)
|
||||
|
||||
|
||||
def validate_proposal(
|
||||
proposal: AiFallbackProposal,
|
||||
*,
|
||||
frame_contract: dict[str, Any],
|
||||
internal_region: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Validate an AI fallback proposal against the active frame contract.
|
||||
|
||||
Raises ``AiFallbackValidationError`` on any guard violation. Returns
|
||||
``None`` on success — caller is responsible for downstream application.
|
||||
"""
|
||||
AiFallbackProposal.model_validate(proposal.model_dump())
|
||||
|
||||
payload = proposal.payload
|
||||
frame_id = frame_contract.get("frame_id")
|
||||
if "frame_id" in payload and payload["frame_id"] != frame_id:
|
||||
raise AiFallbackValidationError(
|
||||
f"frame-swap guard: payload.frame_id={payload['frame_id']!r} "
|
||||
f"differs from contract frame_id={frame_id!r}; V4 rank-1 is locked."
|
||||
)
|
||||
|
||||
if proposal.proposal_kind is ProposalKind.BUILDER_OPTIONS_PATCH:
|
||||
declared = (frame_contract.get("payload") or {}).get("builder_options") or {}
|
||||
unknown = set(payload.keys()) - set(declared.keys())
|
||||
if unknown:
|
||||
raise AiFallbackValidationError(
|
||||
f"builder whitelist: keys {sorted(unknown)} not in "
|
||||
f"frame_contract.payload.builder_options {sorted(declared)}."
|
||||
)
|
||||
|
||||
if proposal.proposal_kind in _SLOT_KINDS:
|
||||
declared_slot_ids = [z.get("id") for z in (frame_contract.get("sub_zones") or [])]
|
||||
slots = payload.get("slots")
|
||||
if not isinstance(slots, dict):
|
||||
raise AiFallbackValidationError(
|
||||
"dropped-slot guard: PARTIAL_OVERRIDES / SLOT_MAPPING_PROPOSAL "
|
||||
"payload MUST include a 'slots' mapping."
|
||||
)
|
||||
missing = [sid for sid in declared_slot_ids if sid not in slots]
|
||||
if missing:
|
||||
raise AiFallbackValidationError(
|
||||
f"dropped-slot guard: declared slots {missing} are absent "
|
||||
"from payload.slots (text/table/image/details must remain populated)."
|
||||
)
|
||||
|
||||
region_id = payload.get("region_id")
|
||||
if region_id is not None and internal_region is not None:
|
||||
declared_region_id = internal_region.get("id")
|
||||
if region_id != declared_region_id:
|
||||
raise AiFallbackValidationError(
|
||||
f"Internal Region containment: payload.region_id={region_id!r} "
|
||||
f"differs from internal_region.id={declared_region_id!r}."
|
||||
)
|
||||
@@ -344,7 +344,7 @@ def classify_visual_runtime_check(overflow: dict, debug_zones: list[dict]) -> di
|
||||
|
||||
Returns:
|
||||
dict :
|
||||
visual_check_passed : Selenium 통과 여부
|
||||
visual_check_passed : Selenium 통과 여부 (overflow.passed AND no classifications)
|
||||
classifications : 각 overflow event 의 분류 결과 list
|
||||
summary : 텍스트 요약 (n events, categories seen)
|
||||
categories_seen : 등장한 카테고리 unique list
|
||||
@@ -353,6 +353,12 @@ def classify_visual_runtime_check(overflow: dict, debug_zones: list[dict]) -> di
|
||||
divergence + region / slot_assignment / rejection
|
||||
count) — passed 여부 무관 항상 surface
|
||||
"""
|
||||
# Deferred import — phase_z2_pipeline imports this module at module top, so
|
||||
# a top-level `from phase_z2_pipeline import ...` would be circular. Pulled
|
||||
# in at call time so both modules are fully loaded. Tolerances are owned by
|
||||
# phase_z2_pipeline (single source of truth — see IMP-15 실행-1/2).
|
||||
from phase_z2_pipeline import IMAGE_ASPECT_DELTA_TOL, TABLE_SCROLL_TOL_PX
|
||||
|
||||
# placement_diagnostics — debug_zones[i].placement_trace 를 per-zone diagnostic 으로 surface.
|
||||
# passed 여부 무관 항상 빌드 (B4 vs mapper divergence 가 passed 에서도 진단 가치).
|
||||
placement_diagnostics = [
|
||||
@@ -364,15 +370,9 @@ def classify_visual_runtime_check(overflow: dict, debug_zones: list[dict]) -> di
|
||||
for dz in (debug_zones or [])
|
||||
]
|
||||
|
||||
if overflow.get("passed", False):
|
||||
return {
|
||||
"visual_check_passed": True,
|
||||
"classifications": [],
|
||||
"summary": "visual check passed — no overflow to classify",
|
||||
"categories_seen": [],
|
||||
"unclassified_signals": [],
|
||||
"placement_diagnostics": placement_diagnostics,
|
||||
}
|
||||
# IMP-15 실행-3 (issue #47): no early-return on overflow.passed=True.
|
||||
# image_events / table_events scans below run unconditionally; the final
|
||||
# visual_check_passed is widened to: overflow.passed AND no classifications.
|
||||
|
||||
# zone position → debug_zones 매핑 (capacity_fit_status 추출용)
|
||||
capacity_status_by_position: dict[str, Optional[str]] = {}
|
||||
@@ -423,6 +423,53 @@ def classify_visual_runtime_check(overflow: dict, debug_zones: list[dict]) -> di
|
||||
cls["scroll_height"] = c.get("scrollHeight")
|
||||
classifications.append(cls)
|
||||
|
||||
# IMP-15 실행-3 (issue #47): image_events scan — image_aspect_mismatch emitter.
|
||||
# delta is None ⇒ skip (image not loaded; no false positive).
|
||||
# |delta| > IMAGE_ASPECT_DELTA_TOL ⇒ emit classification.
|
||||
for ev in (overflow.get("image_events") or []):
|
||||
delta = ev.get("delta")
|
||||
if delta is None:
|
||||
continue
|
||||
if abs(delta) > IMAGE_ASPECT_DELTA_TOL:
|
||||
classifications.append({
|
||||
"category": "image_aspect_mismatch",
|
||||
"source": "image_event",
|
||||
"zone_position": ev.get("zone_position"),
|
||||
"zone_template_id": ev.get("zone_template_id"),
|
||||
"src": ev.get("src"),
|
||||
"natural_ratio": ev.get("natural_ratio"),
|
||||
"rendered_ratio": ev.get("rendered_ratio"),
|
||||
"delta": delta,
|
||||
"rule_applied": (
|
||||
f"|delta|={abs(delta):.4f} > IMAGE_ASPECT_DELTA_TOL="
|
||||
f"{IMAGE_ASPECT_DELTA_TOL} (IMP-15 실행-3)"
|
||||
),
|
||||
})
|
||||
|
||||
# IMP-15 실행-3 (issue #47): table_events scan — tabular_overflow emitter.
|
||||
# wrapper_clipped_index is not None ⇒ skip (clipped_inner already covers this
|
||||
# case via zone cascade; honor dedup contract from pipeline producer).
|
||||
# excess_x or excess_y > TABLE_SCROLL_TOL_PX ⇒ emit tabular_overflow.
|
||||
for ev in (overflow.get("table_events") or []):
|
||||
if ev.get("wrapper_clipped_index") is not None:
|
||||
continue
|
||||
excess_x = ev.get("excess_x") or 0
|
||||
excess_y = ev.get("excess_y") or 0
|
||||
if excess_x > TABLE_SCROLL_TOL_PX or excess_y > TABLE_SCROLL_TOL_PX:
|
||||
classifications.append({
|
||||
"category": "tabular_overflow",
|
||||
"source": "table_event",
|
||||
"zone_position": ev.get("zone_position"),
|
||||
"zone_template_id": ev.get("zone_template_id"),
|
||||
"excess_x": excess_x,
|
||||
"excess_y": excess_y,
|
||||
"rule_applied": (
|
||||
f"table self-overflow — excess_x={excess_x} or excess_y="
|
||||
f"{excess_y} > TABLE_SCROLL_TOL_PX={TABLE_SCROLL_TOL_PX} "
|
||||
f"(wrapper not clipped; IMP-15 실행-3)"
|
||||
),
|
||||
})
|
||||
|
||||
# slide-level / slide-body overflow (zones 외부) 도 분류 시도 (보통 zone-level 에서 잡히지만 보조)
|
||||
unclassified: list[dict] = []
|
||||
slide_m = overflow.get("slide") or {}
|
||||
@@ -443,8 +490,11 @@ def classify_visual_runtime_check(overflow: dict, debug_zones: list[dict]) -> di
|
||||
})
|
||||
|
||||
categories = sorted({c["category"] for c in classifications})
|
||||
# IMP-15 실행-3 (issue #47): widened semantic — overflow.passed alone is not
|
||||
# enough; any image/table classification also flips visual_check_passed.
|
||||
visual_check_passed = bool(overflow.get("passed", False)) and not classifications
|
||||
return {
|
||||
"visual_check_passed": False,
|
||||
"visual_check_passed": visual_check_passed,
|
||||
"classifications": classifications,
|
||||
"summary": (
|
||||
f"{len(classifications)} overflow event(s) classified, "
|
||||
|
||||
@@ -21,6 +21,7 @@ Pipeline 의 빠진 layer = MDX 덩어리들을 *최종 zone unit* 으로 묶는
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
@@ -314,6 +315,321 @@ def select_display_strategy_candidates(
|
||||
return [s for s in order if s in eligible]
|
||||
|
||||
|
||||
# ─── IMP-35 (#64) u6 — Composition popup binding (yaml strategy -> zone payload) ─
|
||||
#
|
||||
# Stage 2 binding contract (unit u6):
|
||||
# Step 17 POPUP gate (u5 in src/phase_z2_ai_fallback/step17.py) stamps
|
||||
# ``unit.has_popup=True`` AND ``unit.popup_escalation_plan=<plan>`` on
|
||||
# composition units whose overflow category routes to
|
||||
# ``details_popup_escalation``. u6 is the composition-side binding that
|
||||
# translates the unit-side marker into a deterministic zone payload
|
||||
# structure that u7 (pipeline composer -> render_slide wiring) reads to
|
||||
# emit the ``<details>/<summary>`` markup u8 will add to slide_base.html.
|
||||
#
|
||||
# Inputs (unit-side, all duck-typed via getattr):
|
||||
# has_popup — bool (False default; u5 sets True on
|
||||
# feasible escalation only)
|
||||
# popup_escalation_plan — dict | None (u3 router plan from
|
||||
# plan_details_popup_escalation; carries
|
||||
# feasible / category / rationale /
|
||||
# needs_split_decision)
|
||||
# raw_content — str (the source MDX content; popup body
|
||||
# source per CLAUDE.md 자세히보기 원칙)
|
||||
#
|
||||
# Outputs (zone payload binding dict):
|
||||
# display_strategy — catalog strategy id read from
|
||||
# display_strategies.yaml (NOT hardcoded).
|
||||
# ``inline_full`` when has_popup=False.
|
||||
# ``inline_preview_with_details`` when
|
||||
# has_popup=True (preview = excerpt from
|
||||
# container px budget downstream; popup body
|
||||
# preserves the FULL original).
|
||||
# popup_body_source — str | None — the FULL raw_content. u7 passes
|
||||
# this verbatim to the renderer; the popup
|
||||
# body is the MDX 원문 (자세히보기 원칙),
|
||||
# never summarized in the body branch.
|
||||
# None when has_popup=False.
|
||||
# detail_trigger — dict | None — placement + label read from
|
||||
# the catalog strategy entry's
|
||||
# ``detail_trigger``. None when has_popup=False.
|
||||
# preserves_original — bool — echoed from the catalog entry.
|
||||
# MUST be True for popup-binding strategies
|
||||
# (absolute user lock — 오답노트 #5 /
|
||||
# IMPROVEMENT-REDESIGN.md §3.6 line 110).
|
||||
# has_popup — bool — echoed for downstream multiplex.
|
||||
# popup_escalation_plan — dict | None — echoed verbatim (u5 plan).
|
||||
# Provides traceability into the router
|
||||
# category + rationale for downstream debug.
|
||||
# strategy_meta — dict — full catalog entry (description /
|
||||
# applies_to / forbidden_for / detail_trigger)
|
||||
# so downstream traces can self-explain without
|
||||
# re-reading the yaml.
|
||||
#
|
||||
# Guardrails honored:
|
||||
# - feedback_ai_isolation_contract — NO AI call. Reads catalog + unit
|
||||
# state only. The deterministic POPUP gate (u5) already established
|
||||
# the marker; this function is pure composition-side binding.
|
||||
# - feedback_no_hardcoding — strategy id is the ONLY name reference, and
|
||||
# it is the catalog key (yaml is source of truth). detail_trigger
|
||||
# placement / label come from the catalog entry, not literals.
|
||||
# - MDX 원문 무손실 보존 — popup_body_source = full raw_content.
|
||||
# u6 NEVER trims or summarizes; the body preview (excerpt from
|
||||
# container px budget) is composed by u7 downstream.
|
||||
# - Phase Z spacing 방향 — u6 binds a strategy that EXPANDS capacity
|
||||
# (popup escalation) instead of shrinking common margins.
|
||||
|
||||
# Strategy id used when the unit carries no popup escalation marker.
|
||||
# Catalog read — yaml is source of truth.
|
||||
POPUP_BINDING_NO_POPUP_STRATEGY_ID = "inline_full"
|
||||
|
||||
# Strategy id used when the unit carries has_popup=True (deterministic
|
||||
# choice — the preview body is a px-budget excerpt of the original, the
|
||||
# popup body holds the FULL original per CLAUDE.md 자세히보기 원칙).
|
||||
# u5 q3 — preview_chars deterministic from container px telemetry; that
|
||||
# is an excerpt-from-original pattern, which matches
|
||||
# ``inline_preview_with_details``. ``details_only`` (summary-only body)
|
||||
# is the alternative future axis when an AI/summarizer is available.
|
||||
POPUP_BINDING_ESCALATED_STRATEGY_ID = "inline_preview_with_details"
|
||||
|
||||
|
||||
def bind_popup_display_strategy(unit) -> dict:
|
||||
"""Bind catalog popup display strategy to a zone payload (IMP-35 u6).
|
||||
|
||||
Reads the unit-side ``has_popup`` + ``popup_escalation_plan`` markers
|
||||
stamped by Step 17 POPUP gate (u5) and produces a zone payload dict
|
||||
that u7 wires into the renderer. The catalog
|
||||
(``display_strategies.yaml``) is the source of truth for both the
|
||||
strategy id and the detail_trigger placement / label — no hardcoded
|
||||
string literals.
|
||||
|
||||
Args:
|
||||
unit: a CompositionUnit (or any duck-typed object exposing
|
||||
``has_popup`` / ``popup_escalation_plan`` / ``raw_content``).
|
||||
``has_popup`` defaults to False when the attribute is absent
|
||||
(units that never went through the Step 17 POPUP gate).
|
||||
|
||||
Returns:
|
||||
zone payload binding dict (see module-level u6 contract block
|
||||
immediately above for the full schema).
|
||||
|
||||
Raises:
|
||||
RuntimeError: if the chosen catalog strategy id is missing from
|
||||
the loaded ``DISPLAY_STRATEGIES`` mapping. Defensive guard —
|
||||
yaml drift would otherwise cause downstream KeyError on a
|
||||
stale string literal. The constants
|
||||
``POPUP_BINDING_NO_POPUP_STRATEGY_ID`` /
|
||||
``POPUP_BINDING_ESCALATED_STRATEGY_ID`` must always resolve
|
||||
against the catalog at import time.
|
||||
"""
|
||||
has_popup = bool(getattr(unit, "has_popup", False))
|
||||
plan = getattr(unit, "popup_escalation_plan", None)
|
||||
raw_content = getattr(unit, "raw_content", "") or ""
|
||||
|
||||
strategy_id = (
|
||||
POPUP_BINDING_ESCALATED_STRATEGY_ID
|
||||
if has_popup
|
||||
else POPUP_BINDING_NO_POPUP_STRATEGY_ID
|
||||
)
|
||||
meta = DISPLAY_STRATEGIES.get(strategy_id)
|
||||
if meta is None:
|
||||
raise RuntimeError(
|
||||
f"bind_popup_display_strategy: catalog drift — strategy id "
|
||||
f"{strategy_id!r} is missing from display_strategies.yaml. "
|
||||
f"Loaded keys: {sorted(DISPLAY_STRATEGIES)}."
|
||||
)
|
||||
|
||||
if not has_popup:
|
||||
return {
|
||||
"display_strategy": strategy_id,
|
||||
"popup_body_source": None,
|
||||
"detail_trigger": None,
|
||||
"preserves_original": bool(meta.get("preserves_original")),
|
||||
"has_popup": False,
|
||||
"popup_escalation_plan": None,
|
||||
"strategy_meta": meta,
|
||||
}
|
||||
|
||||
# has_popup=True path. preserves_original MUST be True per the catalog
|
||||
# absolute user lock — defensive guard against yaml drift.
|
||||
if not meta.get("preserves_original"):
|
||||
raise RuntimeError(
|
||||
f"bind_popup_display_strategy: catalog invariant violated — "
|
||||
f"popup-binding strategy {strategy_id!r} has preserves_original="
|
||||
f"{meta.get('preserves_original')!r}; MDX 원문 무손실 보존 "
|
||||
f"requires preserves_original=True (오답노트 #5 / "
|
||||
f"IMPROVEMENT-REDESIGN.md §3.6 line 110)."
|
||||
)
|
||||
trigger_meta = meta.get("detail_trigger") or {}
|
||||
return {
|
||||
"display_strategy": strategy_id,
|
||||
# MDX 원문 무손실 보존 — popup body = full raw_content (verbatim).
|
||||
"popup_body_source": raw_content,
|
||||
"detail_trigger": {
|
||||
"placement": trigger_meta.get("placement"),
|
||||
"label": trigger_meta.get("label"),
|
||||
},
|
||||
"preserves_original": True,
|
||||
"has_popup": True,
|
||||
"popup_escalation_plan": plan,
|
||||
"strategy_meta": meta,
|
||||
}
|
||||
|
||||
|
||||
# ─── IMP-35 (#64) u7 — Pipeline composer -> render_slide wiring ──
|
||||
#
|
||||
# Stage 2 wiring contract (unit u7):
|
||||
# u6 (``bind_popup_display_strategy``) produced the deterministic zone
|
||||
# binding from the unit-side marker stamped by Step 17 POPUP gate (u5).
|
||||
# u7 wires that binding into the pipeline composer's zones_data so the
|
||||
# render_slide call site (and downstream slide_base.html consumer u8)
|
||||
# sees three uniform render-context field names per zone:
|
||||
#
|
||||
# has_popup : bool — escalation marker echo
|
||||
# popup_html : str — popup body source (full ``raw_content`` per u6;
|
||||
# u8 wraps it in ``<details>/<summary>``).
|
||||
# ``None`` when has_popup=False.
|
||||
# preview_text : str — px-budgeted excerpt of ``raw_content`` shown in
|
||||
# the body / inline_preview slot. NEVER trims
|
||||
# inside a line — line-boundary cut only — and
|
||||
# the popup body retains the FULL original
|
||||
# (MDX 원문 무손실 보존). ``None`` when
|
||||
# has_popup=False.
|
||||
#
|
||||
# The full u6 binding is also echoed on the zone dict under
|
||||
# ``popup_binding`` so downstream debug / catalog-aware consumers can
|
||||
# self-explain without re-reading the yaml.
|
||||
#
|
||||
# Why the preview is a deterministic line-budget cut (u5 q3 resolution):
|
||||
# The popup body holds the FULL original verbatim, so the preview loses
|
||||
# no information — it just truncates at a deterministic boundary that
|
||||
# fits the container height telemetry. Container telemetry source is the
|
||||
# per-unit ``min_height_px`` (frame visual_hints), which is what the
|
||||
# pipeline composer already knows at the zones_data append site.
|
||||
#
|
||||
# We never re-summarize, never AI-call, never reorder. Char-budget cut
|
||||
# would risk splitting CJK words mid-character — line-boundary cut is
|
||||
# the closest deterministic surface to ``raw_content`` semantics
|
||||
# (MDX paragraph / bullet boundaries).
|
||||
#
|
||||
# Guardrails honored:
|
||||
# - feedback_ai_isolation_contract — pure deterministic helper. No
|
||||
# anthropic import, no AI fallback router path.
|
||||
# - MDX 원문 무손실 보존 — preview is a CUT, never a rewrite; popup body
|
||||
# stays equal to ``raw_content``.
|
||||
# - feedback_no_hardcoding — line metric is parametric (line_height_px
|
||||
# defaults to slide_base.html body line metric ~18 px = 11 px font *
|
||||
# 1.6 line-height + ~0.4 px ascent guard). u9 will surface the literal
|
||||
# value source.
|
||||
|
||||
# Line height in px used to convert a container-height budget into a
|
||||
# line-count budget. Matches slide_base.html ``--font-body`` (11 px) at
|
||||
# the ``.text-line`` line-height (1.6). Default — NOT a hardcoded magic
|
||||
# constant: ``compute_popup_preview_text`` accepts an override so the
|
||||
# downstream renderer (u8) or per-frame contracts can pass a tighter
|
||||
# value if a frame uses a smaller body font.
|
||||
POPUP_PREVIEW_DEFAULT_LINE_HEIGHT_PX = 18.0
|
||||
|
||||
|
||||
def compute_popup_preview_text(
|
||||
raw_content: str,
|
||||
container_height_px: float,
|
||||
*,
|
||||
line_height_px: float = POPUP_PREVIEW_DEFAULT_LINE_HEIGHT_PX,
|
||||
) -> str:
|
||||
"""Px-budgeted preview excerpt of ``raw_content`` (IMP-35 u7).
|
||||
|
||||
Deterministic line-boundary cut — returns the leading lines of
|
||||
``raw_content`` that fit within ``container_height_px`` at the slide
|
||||
body line metric. Never trims inside a line (no mid-CJK-word cut);
|
||||
the popup body (u6 ``popup_body_source``) retains the FULL original
|
||||
verbatim so this excerpt loses no information.
|
||||
|
||||
Args:
|
||||
raw_content: the unit's source MDX content; the popup body
|
||||
source per CLAUDE.md 자세히보기 원칙.
|
||||
container_height_px: container height telemetry. The pipeline
|
||||
composer passes ``min_height_px`` (frame visual_hints) at
|
||||
the zones_data append site. Non-positive values fall back
|
||||
to returning the full content unchanged (popup gate would
|
||||
not have fired without a real container budget anyway).
|
||||
line_height_px: px per body line. Default matches slide_base.html
|
||||
``.text-line`` (11 px font * 1.6 line-height + guard).
|
||||
Overridable for tighter-font frames.
|
||||
|
||||
Returns:
|
||||
The leading lines that fit the budget, joined verbatim. If the
|
||||
content already fits, returns ``raw_content`` unchanged.
|
||||
"""
|
||||
if not raw_content:
|
||||
return ""
|
||||
if container_height_px <= 0 or line_height_px <= 0:
|
||||
# No budget signal — return the full content unchanged. u5 POPUP
|
||||
# gate would not have fired without a real container budget, so
|
||||
# this branch is only reachable for non-popup units (where the
|
||||
# preview is anyway unused — see compose_zone_popup_payload).
|
||||
return raw_content
|
||||
max_lines = int(container_height_px // line_height_px)
|
||||
if max_lines < 1:
|
||||
max_lines = 1
|
||||
lines = raw_content.splitlines(keepends=False)
|
||||
if len(lines) <= max_lines:
|
||||
return raw_content
|
||||
# Re-join with "\n" — splitlines drops the terminator so a verbatim
|
||||
# round-trip of the leading lines is "\n".join(...). Preserves the
|
||||
# exact head of raw_content up to the chosen line boundary.
|
||||
return "\n".join(lines[:max_lines])
|
||||
|
||||
|
||||
def compose_zone_popup_payload(unit, container_height_px: float) -> dict:
|
||||
"""Compose the per-zone popup render-context payload (IMP-35 u7).
|
||||
|
||||
Reads u6 ``bind_popup_display_strategy(unit)`` and surfaces the three
|
||||
uniform render-context field names the pipeline composer attaches to
|
||||
each zone in ``zones_data``. The full u6 binding is also echoed
|
||||
under ``popup_binding`` so downstream debug / u8 / u9 consumers can
|
||||
self-explain without re-reading the yaml.
|
||||
|
||||
Args:
|
||||
unit: a CompositionUnit (or any duck-typed object exposing
|
||||
``has_popup`` / ``popup_escalation_plan`` / ``raw_content``).
|
||||
container_height_px: container height telemetry. The pipeline
|
||||
composer passes ``min_height_px`` at the zones_data append
|
||||
site. The non-popup branch ignores the value (preview_text
|
||||
is always None when has_popup=False).
|
||||
|
||||
Returns:
|
||||
Dict with the four wiring keys (``has_popup``, ``popup_html``,
|
||||
``preview_text``, ``popup_binding``). Spreadable into a zone
|
||||
dict via ``zones_data.append({..., **payload})``.
|
||||
"""
|
||||
binding = bind_popup_display_strategy(unit)
|
||||
has_popup = bool(binding.get("has_popup"))
|
||||
if not has_popup:
|
||||
return {
|
||||
"has_popup": False,
|
||||
"popup_html": None,
|
||||
"preview_text": None,
|
||||
"popup_binding": binding,
|
||||
}
|
||||
raw_content = getattr(unit, "raw_content", "") or ""
|
||||
popup_html = binding.get("popup_body_source")
|
||||
preview_text = compute_popup_preview_text(raw_content, container_height_px)
|
||||
return {
|
||||
"has_popup": True,
|
||||
# popup body = FULL raw_content (u6 popup_body_source). u8 wraps
|
||||
# this in <details>/<summary> markup on slide_base.html.
|
||||
"popup_html": popup_html,
|
||||
# body preview = px-budgeted line-boundary cut of raw_content.
|
||||
# NEVER trims inside a line; popup body holds the FULL original
|
||||
# so this excerpt loses no information.
|
||||
"preview_text": preview_text,
|
||||
# Full u6 binding echo — downstream debug surfaces (catalog
|
||||
# detail_trigger placement, popup_escalation_plan category /
|
||||
# rationale) without re-reading yaml.
|
||||
"popup_binding": binding,
|
||||
}
|
||||
|
||||
|
||||
# ─── CompositionUnit ────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
@@ -367,17 +683,33 @@ class CompositionUnit:
|
||||
# 0 길이 = "no_non_reject_v4_candidate" 신호 (Step 9 application_plan input).
|
||||
v4_candidates: list = field(default_factory=list)
|
||||
|
||||
# IMP-30 u2 — provisional first-render flag. True when the V4Match
|
||||
# backing this unit was synthesized via lookup_v4_match_with_fallback
|
||||
# (allow_provisional=True) after chain_exhausted, or when u3 inserts
|
||||
# a last-resort provisional fill for an uncovered section. Carried as
|
||||
# data (not re-derived from label/selection_path downstream) so the
|
||||
# render path / status / zone template can surface "needs adaptation"
|
||||
# uniformly. Default False keeps non-provisional units byte-identical.
|
||||
provisional: bool = False
|
||||
|
||||
|
||||
# ─── Heading Tree ──────────────────────────────────────────────
|
||||
|
||||
def derive_parent_id(section_id: str) -> Optional[str]:
|
||||
"""section_id 에서 parent 도출 — V4 키 컨벤션 기반.
|
||||
"""Section id -> parent id derivation by V4 key convention.
|
||||
|
||||
예시 (코멘트, 룰 X) :
|
||||
- "04-2.1" → "04-2" (decimal suffix → strip)
|
||||
- "04-1" → None (top-level, no parent)
|
||||
- "04" → None
|
||||
IMP-08 B-3 : canonical ordinal `${parent}-sub-${n}` recognised first;
|
||||
legacy decimal `04-2.1` kept as fallback alias path.
|
||||
|
||||
Examples (illustrative, not rules) :
|
||||
- "03-1-sub-2" -> "03-1" (canonical ordinal, IMP-08)
|
||||
- "04-2.1" -> "04-2" (decimal suffix, legacy V4 key style)
|
||||
- "04-1" -> None (top-level, no parent)
|
||||
- "04" -> None
|
||||
"""
|
||||
m = re.fullmatch(r"(.+?)-sub-(\d+)", section_id)
|
||||
if m:
|
||||
return m.group(1)
|
||||
parts = section_id.split("-", 1)
|
||||
if len(parts) != 2:
|
||||
return None
|
||||
@@ -482,6 +814,7 @@ def collect_candidates(sections, v4_lookup_fn, v4_label_to_status: dict,
|
||||
raw_content=s.raw_content,
|
||||
title=s.title,
|
||||
v4_candidates=_v4_cands(s.section_id),
|
||||
provisional=getattr(match, "provisional", False),
|
||||
)
|
||||
_apply_capacity_fit(c, capacity_fit_fn)
|
||||
candidates.append(c)
|
||||
@@ -516,6 +849,7 @@ def collect_candidates(sections, v4_lookup_fn, v4_label_to_status: dict,
|
||||
raw_content=merged_raw,
|
||||
title=pid,
|
||||
v4_candidates=_v4_cands(pid),
|
||||
provisional=getattr(parent_match, "provisional", False),
|
||||
)
|
||||
_apply_capacity_fit(c_pm, capacity_fit_fn)
|
||||
candidates.append(c_pm)
|
||||
@@ -616,6 +950,10 @@ def collect_candidates(sections, v4_lookup_fn, v4_label_to_status: dict,
|
||||
notes=notes,
|
||||
# rep_child 의 V4 후보 list (rep_match 와 같은 출처, frame_* 와 일관).
|
||||
v4_candidates=_v4_cands(rep_child.section_id),
|
||||
# IMP-30 u2 — rep_match drives frame selection so its provisional
|
||||
# flag flows here. If a non-rep child match is provisional but the
|
||||
# rep is not, this unit is not provisional (the rep frame is real).
|
||||
provisional=getattr(rep_match, "provisional", False),
|
||||
)
|
||||
_apply_capacity_fit(c_inf, capacity_fit_fn)
|
||||
candidates.append(c_inf)
|
||||
@@ -662,7 +1000,13 @@ def score_candidate(c: CompositionUnit) -> CompositionUnit:
|
||||
|
||||
# ─── Selection ─────────────────────────────────────────────────
|
||||
|
||||
def select_composition_units(candidates, allowed_statuses: set[str]) -> list[CompositionUnit]:
|
||||
def select_composition_units(
|
||||
candidates,
|
||||
allowed_statuses: set[str],
|
||||
*,
|
||||
all_section_ids: Optional[list[str]] = None,
|
||||
allow_provisional_fill: bool = False,
|
||||
) -> list[CompositionUnit]:
|
||||
"""Greedy non-overlapping selection by score, with coverage tiebreak.
|
||||
|
||||
1. 모든 candidate 점수 매김
|
||||
@@ -677,6 +1021,27 @@ def select_composition_units(candidates, allowed_statuses: set[str]) -> list[Com
|
||||
|
||||
auto_selectable=False candidate 는 자동 선택 X. debug 의 candidates_summary 에는 남음.
|
||||
UI/editor layer 에서 사용자가 별도 처리 가능 (현 v0 범위 X).
|
||||
|
||||
IMP-30 u3 — last-resort provisional fill (opt-in via allow_provisional_fill):
|
||||
After the normal greedy pass, sections in ``all_section_ids`` that are
|
||||
still uncovered are filled with the highest-score *provisional*
|
||||
candidate (``c.provisional == True``) that includes at least one
|
||||
uncovered section and does not collide with already-covered ones. A
|
||||
provisional candidate's backing V4Match was synthesized via
|
||||
``lookup_v4_match_with_fallback(allow_provisional=True)`` (IMP-30 u1)
|
||||
after chain_exhausted; its ``phase_z_status`` is therefore typically
|
||||
*outside* ``allowed_statuses`` (extract_matched_zone / fallback_candidate),
|
||||
which is why it gets filtered out of the normal greedy pass. The fill
|
||||
preserves first-render invariant for sections whose rank-1~3 are all
|
||||
restructure/reject. Default ``allow_provisional_fill=False`` keeps
|
||||
pre-u3 behavior byte-identical (IMP-05 regression guard).
|
||||
|
||||
Args:
|
||||
candidates: full candidate pool from collect_candidates().
|
||||
allowed_statuses: phase_z_status set considered auto-renderable.
|
||||
all_section_ids: ordered section id list (only consulted when
|
||||
allow_provisional_fill=True; required for coverage check).
|
||||
allow_provisional_fill: opt-in for last-resort provisional fill.
|
||||
"""
|
||||
scored = [score_candidate(c) for c in candidates]
|
||||
viable = [
|
||||
@@ -693,6 +1058,28 @@ def select_composition_units(candidates, allowed_statuses: set[str]) -> list[Com
|
||||
selected.append(c)
|
||||
covered.update(c.source_section_ids)
|
||||
|
||||
# IMP-30 u3 — last-resort provisional fill (opt-in, default off).
|
||||
# Honors first-render invariant by surfacing chain_exhausted sections as
|
||||
# provisional zones instead of dropping them. Skip reasons on
|
||||
# non-provisional filtered candidates are preserved (not mutated here).
|
||||
if allow_provisional_fill and all_section_ids:
|
||||
uncovered = {sid for sid in all_section_ids if sid not in covered}
|
||||
if uncovered:
|
||||
provisional_pool = [
|
||||
c for c in scored
|
||||
if c.provisional
|
||||
and any(sid in uncovered for sid in c.source_section_ids)
|
||||
]
|
||||
provisional_pool.sort(
|
||||
key=lambda c: (c.score, len(c.source_section_ids)),
|
||||
reverse=True,
|
||||
)
|
||||
for c in provisional_pool:
|
||||
if any(sid in covered for sid in c.source_section_ids):
|
||||
continue
|
||||
selected.append(c)
|
||||
covered.update(c.source_section_ids)
|
||||
|
||||
return selected
|
||||
|
||||
|
||||
@@ -732,7 +1119,9 @@ def select_layout_preset(units: list[CompositionUnit]) -> Optional[str]:
|
||||
def plan_composition(sections, v4_lookup_fn, v4_label_to_status: dict,
|
||||
allowed_statuses: set[str],
|
||||
capacity_fit_fn=None,
|
||||
v4_candidates_lookup_fn=None) -> tuple[list[CompositionUnit], Optional[str], dict]:
|
||||
v4_candidates_lookup_fn=None,
|
||||
*,
|
||||
allow_provisional_fill: bool = False) -> tuple[list[CompositionUnit], Optional[str], dict]:
|
||||
"""Composition planner v0.2 entry.
|
||||
|
||||
v0.2 변경 :
|
||||
@@ -745,6 +1134,14 @@ def plan_composition(sections, v4_lookup_fn, v4_label_to_status: dict,
|
||||
logic 변화 X — 단일 frame_template_id / frame_id / label / confidence 는 그대로.
|
||||
runtime 결과 무변. Step 9 application_plan input 위한 schema 확장.
|
||||
|
||||
IMP-30 u3 — last-resort provisional fill (opt-in, default off):
|
||||
``allow_provisional_fill`` is plumbed to select_composition_units().
|
||||
When True, uncovered sections receive a provisional fill from candidates
|
||||
whose backing V4Match was synthesized via ``allow_provisional=True``
|
||||
(IMP-30 u1). ``_candidate_state`` returns ``selected_provisional`` for
|
||||
those filled units so the debug summary distinguishes greedy selections
|
||||
from provisional fills. Default False keeps IMP-05 behavior identical.
|
||||
|
||||
v0.1 / v0.1.1 동작 (유지) :
|
||||
- parent_merged_inferred candidate 생성 (parent V4 없어도)
|
||||
- review 개념 X. auto_selectable + filter_reasons 만으로 자동 결정
|
||||
@@ -763,11 +1160,22 @@ def plan_composition(sections, v4_lookup_fn, v4_label_to_status: dict,
|
||||
)
|
||||
scored_all = [score_candidate(c) for c in candidates]
|
||||
|
||||
units = select_composition_units(candidates, allowed_statuses)
|
||||
units = select_composition_units(
|
||||
candidates,
|
||||
allowed_statuses,
|
||||
all_section_ids=[s.section_id for s in sections] if allow_provisional_fill else None,
|
||||
allow_provisional_fill=allow_provisional_fill,
|
||||
)
|
||||
preset = select_layout_preset(units)
|
||||
|
||||
def _candidate_state(c: CompositionUnit) -> str:
|
||||
if c in units:
|
||||
# IMP-30 u3 — provisional-fill units surface as a distinct state so
|
||||
# downstream debug consumers can tell greedy selection apart from
|
||||
# last-resort fill. unit.provisional flows from u1 (V4Match
|
||||
# synthesis) → u2 (CompositionUnit propagation).
|
||||
if c.provisional:
|
||||
return "selected_provisional"
|
||||
return "selected"
|
||||
if c.phase_z_status not in allowed_statuses:
|
||||
return "filtered_status" # V4 label → status not auto-renderable
|
||||
@@ -832,3 +1240,341 @@ def plan_composition(sections, v4_lookup_fn, v4_label_to_status: dict,
|
||||
}
|
||||
|
||||
return units, preset, debug
|
||||
|
||||
|
||||
# ─── IMP-48 — Re-split All-Reject Merges (#77, Stage 2 / u1~u3) ─────
|
||||
|
||||
def resplit_all_reject_merges(
|
||||
units: list[CompositionUnit],
|
||||
sections,
|
||||
v4_lookup_fn,
|
||||
v4_label_to_status: dict,
|
||||
allowed_statuses: set[str],
|
||||
*,
|
||||
capacity_fit_fn=None,
|
||||
v4_candidates_lookup_fn=None,
|
||||
section_assignment_override: bool = False,
|
||||
) -> tuple[list[CompositionUnit], dict]:
|
||||
"""Re-split merged composition units whose rank-1 V4 label is ``reject``.
|
||||
|
||||
IMP-48 (#77) — Step 6 post-pass that decomposes a merged unit
|
||||
(``parent_merged`` / ``parent_merged_inferred``) carrying ``label=reject``
|
||||
into per-section singles, so child sections with non-reject rank-1 V4
|
||||
evidence can flow through the normal use_as_is / light_edit / restructure
|
||||
paths instead of being handed to IMP-47B (#76) as a single blob.
|
||||
|
||||
Stage 2 / u3 slice (current revision) :
|
||||
u1 contract (detection scan + override skip + idempotent single-
|
||||
exclusion) + u2 per-section Branch-1 rebuild (each rebuilt single
|
||||
carries ``merge_type="single"`` + the section's OWN rank-1 V4
|
||||
evidence via ``v4_lookup_fn`` + the section's original
|
||||
``raw_content`` from ``sections``) are both preserved. u3 adds the
|
||||
gating + swap path :
|
||||
|
||||
1. **Coverage equality** — every child section in
|
||||
``source_section_ids`` MUST rebuild successfully. Any
|
||||
``section_not_found`` / ``no_v4_match`` rebuild result short-
|
||||
circuits that merged unit to ``reason="incomplete_rebuild"``.
|
||||
2. **Beneficial split** — at least one rebuilt single MUST have
|
||||
``label != "reject"`` (Stage 2 Q2 Codex YES — "≥1 section
|
||||
gains non-reject frame"). Otherwise that merged unit short-
|
||||
circuits to ``reason="no_beneficial_split"`` and IMP-47B (#76)
|
||||
handles the merge directly.
|
||||
3. **Layout cap (≤ 4 units)** — projected post-split unit count
|
||||
(across ALL detected merges that would split) MUST be ≤ 4.
|
||||
Otherwise EVERY would-be split is aborted with
|
||||
``reason="layout_cap_exceeded"`` (Stage 2 Q2 default — keep
|
||||
merged, no partial split; v0 ``select_layout_preset`` supports
|
||||
1~4 units max).
|
||||
4. **Telemetry** — every single produced by an APPLIED split has
|
||||
``selection_path="resplit_from_merge"`` (Stage 1 Q3 YES,
|
||||
additive field reuse — no schema add).
|
||||
5. **Audit payload** — ``audit["applied"]`` reflects whether ANY
|
||||
merge actually split. ``audit["split_units"]`` /
|
||||
``audit["skipped_units"]`` capture per-merge decisions.
|
||||
``audit["post_split_unit_count"]`` reflects the returned list
|
||||
length. ``audit["post_split_layout_preset"]`` is filled via
|
||||
``select_layout_preset(out_units)`` when ``applied=True``,
|
||||
None otherwise (u5 also re-derives in pipeline scope).
|
||||
|
||||
``out_units`` is the post-resplit unit list (merged removed +
|
||||
singles inserted, in original ordering). When no merge splits,
|
||||
``out_units`` is byte-identical to input ``units`` and
|
||||
``applied=False`` — the audit's ``skipped_reason`` becomes
|
||||
``"no_split_applied"``.
|
||||
|
||||
Detection signal (★ no-hardcoding, AI=0) :
|
||||
``merge_type ∈ {"parent_merged", "parent_merged_inferred"}``
|
||||
AND ``label == "reject"``
|
||||
AND ``len(source_section_ids) >= 2``
|
||||
|
||||
Signal uses only ``merge_type`` + ``label`` + section count — never
|
||||
section_id, template_id, MDX filename, or sample identifier.
|
||||
|
||||
Override skip (Stage 2 Q1 — kwarg per Codex YES) :
|
||||
``section_assignment_override=True`` makes the helper a no-op. User-
|
||||
driven ``zoneSections`` (#6 IMP-06) is the ground truth and must not
|
||||
be second-guessed by an automatic re-split.
|
||||
|
||||
Idempotency (max_retry=1, Stage 2 lock) :
|
||||
u2's rebuilt units carry ``merge_type="single"``, which is excluded
|
||||
from the detection filter by construction. A second pass through
|
||||
this helper finds nothing — no inner loop, no recursion.
|
||||
|
||||
Frame-swap guardrail (★ feedback_ai_isolation_contract) :
|
||||
u2 rebuilds each child section's single from its OWN rank-1 V4
|
||||
evidence via ``v4_lookup_fn``. The merged unit's parent /
|
||||
representative ``template_id`` is discarded along with the merge
|
||||
itself — no swap of one section's frame onto another section.
|
||||
|
||||
Args:
|
||||
units: composition units from ``plan_composition()``.
|
||||
sections: original section list (forwarded to u2 for per-section
|
||||
``raw_content`` lookup — merged units carry the joined string,
|
||||
not the individual child source).
|
||||
v4_lookup_fn: ``(section_id) -> V4Match | None`` (rank-1). Forwarded
|
||||
to u2 — identical evidence source as ``plan_composition``.
|
||||
v4_label_to_status: V4 label → Phase Z status mapping (forwarded).
|
||||
allowed_statuses: auto-renderable status set (forwarded).
|
||||
capacity_fit_fn: optional capacity fit injector (forwarded to u2).
|
||||
v4_candidates_lookup_fn: optional Step 6-A candidates fn (forwarded).
|
||||
section_assignment_override: True iff user supplied
|
||||
``zoneSections`` / ``section_assignment_plan`` (IMP-06 chain).
|
||||
|
||||
Returns:
|
||||
``(out_units, audit)`` :
|
||||
``out_units`` = post-resplit units (u1: identical to input).
|
||||
``audit`` = ``imp48_resplit`` payload following Stage 1 schema::
|
||||
|
||||
{
|
||||
"applied": bool, # u1: always False
|
||||
"split_units": [...], # u3 fills with per-section singles
|
||||
"skipped_units": [...], # u3 fills with kept-merged + reason
|
||||
"post_split_unit_count": int,
|
||||
"post_split_layout_preset": Optional[str],
|
||||
"skipped_reason": str, # u1: contract-stage reason
|
||||
"detected_units": [...], # u1: u2's rebuild targets
|
||||
}
|
||||
"""
|
||||
# ``allowed_statuses`` is forwarded for signature symmetry with
|
||||
# ``plan_composition`` but unused inside the helper — Stage 2 / Codex YES
|
||||
# fixed the beneficial-split threshold to ``single.label != "reject"``
|
||||
# (Stage 1 contract "non-reject rank-1"). Future axes may widen the
|
||||
# threshold using ``allowed_statuses``; until then the parameter is
|
||||
# explicitly deleted to silence lint without losing the public contract.
|
||||
del allowed_statuses
|
||||
|
||||
audit: dict = {
|
||||
"applied": False,
|
||||
"split_units": [],
|
||||
"skipped_units": [],
|
||||
"post_split_unit_count": len(units),
|
||||
"post_split_layout_preset": None,
|
||||
"detected_units": [],
|
||||
"rebuild_attempts": [],
|
||||
}
|
||||
|
||||
if section_assignment_override:
|
||||
audit["skipped_reason"] = "section_assignment_override"
|
||||
return units, audit
|
||||
|
||||
detected = [
|
||||
u for u in units
|
||||
if u.merge_type in {"parent_merged", "parent_merged_inferred"}
|
||||
and u.label == "reject"
|
||||
and len(u.source_section_ids) >= 2
|
||||
]
|
||||
audit["detected_units"] = [
|
||||
{
|
||||
"source_section_ids": list(u.source_section_ids),
|
||||
"merge_type": u.merge_type,
|
||||
"template_id": u.frame_template_id,
|
||||
"label": u.label,
|
||||
}
|
||||
for u in detected
|
||||
]
|
||||
if not detected:
|
||||
audit["skipped_reason"] = "no_detection"
|
||||
return units, audit
|
||||
|
||||
# u2 — per-section Branch-1 rebuild for each detected merged-reject unit.
|
||||
# Mirrors ``collect_candidates`` Branch 1 (single per section). Each rebuilt
|
||||
# single carries the section's OWN rank-1 V4 evidence — the merged unit's
|
||||
# parent/representative template_id is discarded along with the merge.
|
||||
# ★ feedback_ai_isolation_contract : no frame swap (each section's own V4).
|
||||
# ★ MDX_raw_content_invariant : raw_content taken from sections list.
|
||||
# ★ idempotency : merge_type="single" excludes singles
|
||||
# from re-detection on any later pass.
|
||||
section_by_id = {s.section_id: s for s in sections}
|
||||
|
||||
def _v4_cands(section_id: str) -> list:
|
||||
return v4_candidates_lookup_fn(section_id) if v4_candidates_lookup_fn else []
|
||||
|
||||
rebuild_attempts: list[dict] = []
|
||||
for merged_unit in detected:
|
||||
section_singles: list[dict] = []
|
||||
for sid in merged_unit.source_section_ids:
|
||||
section = section_by_id.get(sid)
|
||||
if section is None:
|
||||
section_singles.append({
|
||||
"section_id": sid,
|
||||
"build_result": "section_not_found",
|
||||
"unit": None,
|
||||
})
|
||||
continue
|
||||
match = v4_lookup_fn(sid)
|
||||
if match is None:
|
||||
section_singles.append({
|
||||
"section_id": sid,
|
||||
"build_result": "no_v4_match",
|
||||
"unit": None,
|
||||
})
|
||||
continue
|
||||
single = CompositionUnit(
|
||||
source_section_ids=[sid],
|
||||
merge_type="single",
|
||||
frame_template_id=match.template_id,
|
||||
frame_id=match.frame_id,
|
||||
frame_number=match.frame_number,
|
||||
confidence=match.confidence,
|
||||
label=match.label,
|
||||
phase_z_status=v4_label_to_status.get(match.label, "unknown"),
|
||||
v4_rank=getattr(match, "v4_rank", None),
|
||||
selection_path=getattr(match, "selection_path", "rank_1"),
|
||||
fallback_reason=getattr(match, "fallback_reason", None),
|
||||
raw_content=section.raw_content,
|
||||
title=section.title,
|
||||
v4_candidates=_v4_cands(sid),
|
||||
provisional=getattr(match, "provisional", False),
|
||||
)
|
||||
_apply_capacity_fit(single, capacity_fit_fn)
|
||||
score_candidate(single)
|
||||
section_singles.append({
|
||||
"section_id": sid,
|
||||
"build_result": "ok",
|
||||
"unit": single,
|
||||
})
|
||||
rebuild_attempts.append({
|
||||
"merged_source_section_ids": list(merged_unit.source_section_ids),
|
||||
"merged_merge_type": merged_unit.merge_type,
|
||||
"merged_template_id": merged_unit.frame_template_id,
|
||||
"section_singles": section_singles,
|
||||
})
|
||||
|
||||
audit["rebuild_attempts"] = rebuild_attempts
|
||||
|
||||
# u3 — gating + swap path.
|
||||
# Per-merge decision: split | skip(reason). Then a cumulative layout-cap
|
||||
# check aborts ALL would-be splits if projected post-split count > 4
|
||||
# (Stage 2 Q2 default — keep merged, no partial split; v0
|
||||
# ``select_layout_preset`` supports 1~4 units max).
|
||||
plans: list[dict] = []
|
||||
for merged_unit, attempt in zip(detected, rebuild_attempts):
|
||||
required_sids = set(merged_unit.source_section_ids)
|
||||
built_sids = {
|
||||
entry["section_id"]
|
||||
for entry in attempt["section_singles"]
|
||||
if entry["build_result"] == "ok"
|
||||
}
|
||||
if built_sids != required_sids:
|
||||
# Some sections failed to rebuild — coverage equality violated.
|
||||
# IMP-47B (#76) will handle the merged unit directly.
|
||||
plans.append({
|
||||
"merged": merged_unit,
|
||||
"decision": "skip",
|
||||
"reason": "incomplete_rebuild",
|
||||
"missing": sorted(required_sids - built_sids),
|
||||
})
|
||||
continue
|
||||
built_units = [
|
||||
entry["unit"]
|
||||
for entry in attempt["section_singles"]
|
||||
if entry["build_result"] == "ok"
|
||||
]
|
||||
non_reject_count = sum(1 for u in built_units if u.label != "reject")
|
||||
if non_reject_count == 0:
|
||||
# No child section gains a non-reject frame — split is not
|
||||
# beneficial. IMP-47B (#76) handles the merge directly.
|
||||
plans.append({
|
||||
"merged": merged_unit,
|
||||
"decision": "skip",
|
||||
"reason": "no_beneficial_split",
|
||||
})
|
||||
continue
|
||||
plans.append({
|
||||
"merged": merged_unit,
|
||||
"decision": "split",
|
||||
"singles": built_units,
|
||||
"non_reject_count": non_reject_count,
|
||||
})
|
||||
|
||||
# Cumulative layout-cap projection across all would-be splits.
|
||||
projected_count = len(units)
|
||||
for plan in plans:
|
||||
if plan["decision"] == "split":
|
||||
projected_count += len(plan["singles"]) - 1
|
||||
if projected_count > 4:
|
||||
for plan in plans:
|
||||
if plan["decision"] == "split":
|
||||
plan["decision"] = "skip"
|
||||
plan["reason"] = "layout_cap_exceeded"
|
||||
plan["projected_count"] = projected_count
|
||||
|
||||
# Build out_units by walking the input list once. Identity match by
|
||||
# ``id(unit)`` keeps the swap deterministic and preserves order.
|
||||
plan_by_unit_id = {id(plan["merged"]): plan for plan in plans}
|
||||
out_units: list[CompositionUnit] = []
|
||||
applied = False
|
||||
for unit in units:
|
||||
plan = plan_by_unit_id.get(id(unit))
|
||||
if plan is None:
|
||||
out_units.append(unit)
|
||||
continue
|
||||
if plan["decision"] == "split":
|
||||
applied = True
|
||||
for single in plan["singles"]:
|
||||
# ★ Stage 1 Q3 YES — additive telemetry tag, no schema add.
|
||||
# Overrides the v4 match's selection_path for split-produced
|
||||
# singles only; non-resplit code paths are unaffected.
|
||||
single.selection_path = "resplit_from_merge"
|
||||
out_units.extend(plan["singles"])
|
||||
audit["split_units"].append({
|
||||
"merged_source_section_ids": list(plan["merged"].source_section_ids),
|
||||
"merged_template_id": plan["merged"].frame_template_id,
|
||||
"non_reject_count": plan["non_reject_count"],
|
||||
"split_singles": [
|
||||
{
|
||||
"section_id": s.source_section_ids[0],
|
||||
"template_id": s.frame_template_id,
|
||||
"label": s.label,
|
||||
"phase_z_status": s.phase_z_status,
|
||||
}
|
||||
for s in plan["singles"]
|
||||
],
|
||||
})
|
||||
else: # skip
|
||||
out_units.append(unit)
|
||||
skip_entry: dict = {
|
||||
"merged_source_section_ids": list(plan["merged"].source_section_ids),
|
||||
"merged_template_id": plan["merged"].frame_template_id,
|
||||
"reason": plan["reason"],
|
||||
}
|
||||
if plan["reason"] == "incomplete_rebuild":
|
||||
skip_entry["missing_section_ids"] = list(plan["missing"])
|
||||
if plan["reason"] == "layout_cap_exceeded":
|
||||
skip_entry["projected_post_split_count"] = plan["projected_count"]
|
||||
audit["skipped_units"].append(skip_entry)
|
||||
|
||||
audit["applied"] = applied
|
||||
audit["post_split_unit_count"] = len(out_units)
|
||||
if applied:
|
||||
# ``select_layout_preset`` is deterministic on unit count (v0).
|
||||
# u5 (pipeline) re-derives layout preset over the same out_units list;
|
||||
# both values stay consistent by construction.
|
||||
audit["post_split_layout_preset"] = select_layout_preset(out_units)
|
||||
audit.pop("skipped_reason", None)
|
||||
else:
|
||||
audit["post_split_layout_preset"] = None
|
||||
audit["skipped_reason"] = "no_split_applied"
|
||||
|
||||
return out_units, audit
|
||||
|
||||
@@ -7,26 +7,68 @@ A3 (zone_ratio_retry) 의 결과 (retry_trace) 를 받아 :
|
||||
본 module 은 ***분류 + 매핑까지만***. layout_adjust / frame_reselect / details_popup
|
||||
실행 X. retry_trace 에 `failure_classification` + `next_action_proposal` 두 필드 추가.
|
||||
|
||||
**잠근 매핑** (사용자 잠금 — 2026-04-29) :
|
||||
**잠근 매핑** (사용자 잠금 — 2026-05-17, IMP-12 u3 cascade) :
|
||||
|
||||
| failure_type | next_proposed_action |
|
||||
| failure_type | next_proposed_action |
|
||||
|---|---|
|
||||
| donor_slack_insufficient | layout_adjust |
|
||||
| no_donor_candidates | layout_adjust |
|
||||
| rerender_still_fails | frame_reselect |
|
||||
| not_attempted | none |
|
||||
| donor_slack_insufficient | cross_zone_redistribute |
|
||||
| no_donor_candidates | cross_zone_redistribute |
|
||||
| cross_zone_redistribute_insufficient | glue_compression |
|
||||
| glue_absorption_insufficient | font_step_compression |
|
||||
| font_step_insufficient | layout_adjust |
|
||||
| rerender_still_fails | frame_reselect |
|
||||
| not_attempted | none |
|
||||
|
||||
**escalation 단계 hierarchy** (이번 기본 매핑이 따르는 원칙) :
|
||||
**escalation 단계 hierarchy** (Step 17 deterministic salvage cascade → layout/frame) :
|
||||
```
|
||||
layout_adjust (가장 가벼움 — zone 배치만 변경)
|
||||
cross_zone_redistribute (fit_verifier.redistribute — role-height adjustment)
|
||||
↓ 그래도 안 되면
|
||||
frame_reselect (중간 — frame 자체 변경)
|
||||
glue_compression (SPACING_GLUE envelope, frame-scoped)
|
||||
↓ 그래도 안 되면
|
||||
font_step_compression (FONT_SIZE_STEPS, zone-scoped)
|
||||
↓ 그래도 안 되면
|
||||
layout_adjust (zone topology 변경 — 8-preset switch)
|
||||
↓ 그래도 안 되면
|
||||
frame_internal_fit_candidate (frame contract envelope 안 internal fit 변형)
|
||||
↓ 그래도 안 되면
|
||||
frame_reselect (V4 top-k 의 다른 frame)
|
||||
↓ 그래도 안 되면
|
||||
details_popup_escalation (가장 invasive — content popup, 마지막 resort)
|
||||
```
|
||||
|
||||
`details_popup_escalation` 은 본 매핑에 *없음* — tabular_overflow / structural_major_overflow /
|
||||
frame_reselect 실패 이후 단계에서 다룸 (별 step).
|
||||
IMP-35 (#64) u2 — cascade terminal landed. `frame_reselect_insufficient`
|
||||
(post-frame remeasure failure, classifier path locked in u1) now routes onto
|
||||
`details_popup_escalation`. The status table records the popup action as
|
||||
MISSING here; the actual executor stub + MISSING→IMPLEMENTED flip lives in
|
||||
`src/phase_z2_router.py` (u3 surface), so this module advertises the cascade
|
||||
terminal without claiming an implementation it does not own.
|
||||
|
||||
IMP-88 (#88) u2 — Step 17 retry chain extension. Three new failure_type
|
||||
producers + cascade rows wire the three issue-body axes onto the deterministic
|
||||
chain WITHOUT activating any AI path or shared-margin shrink:
|
||||
|
||||
| failure_type | next_proposed_action |
|
||||
|---|---|
|
||||
| layout_adjust_insufficient | frame_internal_fit_candidate |
|
||||
| frame_internal_fit_candidate_insufficient | frame_reselect |
|
||||
| image_fit_insufficient | layout_adjust |
|
||||
|
||||
`layout_adjust_insufficient` is the cascade extension between
|
||||
`font_step_insufficient → layout_adjust` (existing) and the legacy
|
||||
`rerender_still_fails → frame_reselect` rejoin point — closing the open
|
||||
cascade tail that previously terminated salvage at `layout_adjust` with no
|
||||
next-step record. `frame_internal_fit_candidate_insufficient` rejoins the
|
||||
existing `frame_reselect` mid-cascade, so V4 top-k swap remains reachable
|
||||
after the in-envelope salvage exhausts. `image_fit_insufficient` (Step 17
|
||||
single-pass entry per u7) escalates onto the main cascade at `layout_adjust`
|
||||
so an image-driven overflow that cannot be fit inside the frame envelope
|
||||
benefits from layout topology change instead of any margin shrink
|
||||
(feedback_phase_z_spacing_direction guardrail).
|
||||
|
||||
The three new `next_action` destinations (`layout_adjust`,
|
||||
`frame_internal_fit_candidate`, `image_fit`) are advertised as MISSING here.
|
||||
The MISSING → IMPLEMENTED flip lives on the deterministic planner units
|
||||
(u3/u4/u5) in `src/phase_z2_retry.py`; this module owns mapping only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -53,42 +95,199 @@ FAILURE_TYPE_DESCRIPTIONS: dict[str, str] = {
|
||||
"redistribution 실행 + rerender 까지 했는데도 visual_check 실패. "
|
||||
"현재 frame/zone 조합이 content 와 맞지 않음"
|
||||
),
|
||||
"cross_zone_redistribute_insufficient": (
|
||||
"cross_zone_redistribute salvage step failed — fit_verifier.redistribute "
|
||||
"could not find a feasible role-height adjustment within the frame envelope"
|
||||
),
|
||||
"glue_absorption_insufficient": (
|
||||
"glue_compression salvage step failed — frame envelope cannot absorb "
|
||||
"remaining overflow via SPACING_GLUE overrides (no global spacing shrink)"
|
||||
),
|
||||
"font_step_insufficient": (
|
||||
"font_step_compression salvage step failed — FONT_SIZE_STEPS exhausted "
|
||||
"down to the floor without resolving overflow (or text_metrics missing)"
|
||||
),
|
||||
"frame_reselect_insufficient": (
|
||||
"frame_reselect salvage step failed — V4 top-k alternate frame swap "
|
||||
"re-rendered + post-frame remeasure (run_overflow_check) still fails. "
|
||||
"IMP-35 (#64) u1 contract: emitted from salvage_steps[-1].action == "
|
||||
"'frame_reselect' AND passed=False AND post_salvage_overflow present. "
|
||||
"Routes to details_popup_escalation in u2 (cascade terminal)."
|
||||
),
|
||||
# IMP-88 (#88) u2 — three new salvage failure producers wired onto the
|
||||
# deterministic cascade. Classifier reuses the salvage_steps[-1] path
|
||||
# introduced in IMP-12 u2 (SALVAGE_FAILURE_TYPE_BY_ACTION).
|
||||
"layout_adjust_insufficient": (
|
||||
"layout_adjust salvage step failed — 8-preset layout switch executed "
|
||||
"but overflow persists post-rerender. Cascade exits onto "
|
||||
"frame_internal_fit_candidate (frame envelope internal fit variant) "
|
||||
"before V4 top-k frame_reselect."
|
||||
),
|
||||
"frame_internal_fit_candidate_insufficient": (
|
||||
"frame_internal_fit_candidate salvage step failed — variant adjustments "
|
||||
"inside the declared frame contract envelope could not absorb the "
|
||||
"remaining overflow. Cascade exits onto frame_reselect (V4 top-k "
|
||||
"alternate frame swap)."
|
||||
),
|
||||
"image_fit_insufficient": (
|
||||
"image_fit salvage step failed — Step 17 single-pass image fit "
|
||||
"(object-fit + max-w/h scoped to the offending frame) did not resolve "
|
||||
"image_aspect_mismatch. Escalates onto the main cascade at "
|
||||
"layout_adjust so a different layout topology can host the image "
|
||||
"natural ratio (no shared margin shrink — Phase Z spacing direction)."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ─── §A4-1b salvage_steps[-1].action → failure_type table ──────────
|
||||
# u2 (IMP-12): _attempt_salvage_chain (u8) writes per-step records into
|
||||
# retry_trace["salvage_steps"] with {action, passed, failure_reason}. classifier
|
||||
# inspects salvage_steps[-1] so u3 can route 3 new types onto the cascade.
|
||||
|
||||
SALVAGE_FAILURE_TYPE_BY_ACTION: dict[str, str] = {
|
||||
"cross_zone_redistribute": "cross_zone_redistribute_insufficient",
|
||||
"glue_compression": "glue_absorption_insufficient",
|
||||
"font_step_compression": "font_step_insufficient",
|
||||
# IMP-35 (#64) u1: post-frame remeasure failure. frame_reselect salvage step
|
||||
# writes a salvage_steps entry with action='frame_reselect', passed=False,
|
||||
# and post_salvage_overflow populated by run_overflow_check on the swapped
|
||||
# frame's HTML. classifier reads that entry; u2 adds the NEXT_ACTION row
|
||||
# that routes this onto details_popup_escalation.
|
||||
"frame_reselect": "frame_reselect_insufficient",
|
||||
# IMP-88 (#88) u2: producers for the three Step 17 retry chain actions
|
||||
# (layout_adjust / image_fit / frame_internal_fit_candidate). The u6
|
||||
# dispatcher (src/phase_z2_pipeline.py) appends salvage_steps entries with
|
||||
# these action names when their planner-driven executor (u3/u4/u5) emits
|
||||
# passed=False. The classifier path below already inspects
|
||||
# salvage_steps[-1].action so no classifier change is required; u3 just
|
||||
# registers the producer rows so the cascade keeps flowing instead of
|
||||
# falling through to the defensive "not_attempted" fallback.
|
||||
"layout_adjust": "layout_adjust_insufficient",
|
||||
"image_fit": "image_fit_insufficient",
|
||||
"frame_internal_fit_candidate": "frame_internal_fit_candidate_insufficient",
|
||||
}
|
||||
|
||||
|
||||
# ─── §A4-2 next_action mapping (사용자 잠금) ──────────────────────
|
||||
|
||||
NEXT_ACTION_BY_FAILURE: dict[str, str] = {
|
||||
"donor_slack_insufficient": "layout_adjust",
|
||||
"no_donor_candidates": "layout_adjust",
|
||||
"rerender_still_fails": "frame_reselect",
|
||||
"not_attempted": "none",
|
||||
"donor_slack_insufficient": "cross_zone_redistribute",
|
||||
"no_donor_candidates": "cross_zone_redistribute",
|
||||
"cross_zone_redistribute_insufficient": "glue_compression",
|
||||
"glue_absorption_insufficient": "font_step_compression",
|
||||
"font_step_insufficient": "layout_adjust",
|
||||
"rerender_still_fails": "frame_reselect",
|
||||
# IMP-35 (#64) u2 — cascade terminal. frame_reselect salvage exhausted
|
||||
# (post-frame remeasure failed; classifier path gated on
|
||||
# post_salvage_overflow per u1/q4) escalates onto details_popup_escalation.
|
||||
# Popup body holds full MDX source; preview shows summary/subset
|
||||
# (CLAUDE.md 자세히보기 원칙). Executor + MISSING→IMPLEMENTED flip lands
|
||||
# in u3 (src/phase_z2_router.py); this module owns the cascade mapping
|
||||
# only.
|
||||
"frame_reselect_insufficient": "details_popup_escalation",
|
||||
"not_attempted": "none",
|
||||
# IMP-88 (#88) u2 — Step 17 retry chain cascade extension. Closes the
|
||||
# previously open tail at layout_adjust + adds the frame_internal_fit
|
||||
# mid-cascade rejoin onto frame_reselect. image_fit (single-pass entry,
|
||||
# u7) escalates onto layout_adjust when its single-pass transform cannot
|
||||
# resolve image_aspect_mismatch — Phase Z spacing direction guardrail
|
||||
# routes through layout/frame instead of shrinking shared margins.
|
||||
"layout_adjust_insufficient": "frame_internal_fit_candidate",
|
||||
"frame_internal_fit_candidate_insufficient": "frame_reselect",
|
||||
"image_fit_insufficient": "layout_adjust",
|
||||
}
|
||||
|
||||
NEXT_ACTION_RATIONALE: dict[str, str] = {
|
||||
"donor_slack_insufficient": (
|
||||
"현재 layout 안 redistribution 끝남 → 다른 layout topology 검토 "
|
||||
"(layout_adjust). frame 자체는 아직 의심 대상 X"
|
||||
"primary donor slack 한도 도달 → cross_zone_redistribute 로 sibling zone "
|
||||
"전체 role-height 재분배 (fit_verifier.redistribute). layout 변경은 cascade 끝"
|
||||
),
|
||||
"no_donor_candidates": (
|
||||
"donor 자체 없거나 모두 막힘 → layout topology 부터 재구성하여 "
|
||||
"sibling/space 다시 만들어 보는 게 우선 (layout_adjust). frame 변경은 그 다음"
|
||||
"단일 donor 후보 없음 → cross_zone_redistribute 로 role-height 전체 "
|
||||
"재할당 시도 (fit_verifier.redistribute). layout 변경은 cascade 끝"
|
||||
),
|
||||
"cross_zone_redistribute_insufficient": (
|
||||
"role-height 재분배도 frame envelope 못 맞춤 → glue_compression "
|
||||
"(SPACING_GLUE frame-scoped) 으로 frame 내부 여백 축소"
|
||||
),
|
||||
"glue_absorption_insufficient": (
|
||||
"frame 여백 envelope 도 부족 → font_step_compression "
|
||||
"(FONT_SIZE_STEPS zone-scoped) 으로 폰트 한 단계 축소"
|
||||
),
|
||||
"font_step_insufficient": (
|
||||
"deterministic salvage cascade 모두 소진 → layout_adjust 로 zone "
|
||||
"topology 부터 재구성. frame_reselect 는 그 다음 단계"
|
||||
),
|
||||
"rerender_still_fails": (
|
||||
"redistribution + rerender 까지 했는데도 visual fail → 현재 "
|
||||
"frame/zone 조합 자체 부적합, V4 top-k 의 다른 frame 평가 (frame_reselect). "
|
||||
"popup 직행은 아직 빠름 (tabular / structural_major 가 아닌 한)"
|
||||
),
|
||||
"frame_reselect_insufficient": (
|
||||
"V4 top-k frame swap + 명시적 post-frame remeasure 까지 했는데도 overflow "
|
||||
"잔존 → cascade terminal 인 details_popup_escalation 으로 escalate. "
|
||||
"본문 = summary/subset, popup = MDX 원문 (자세히보기 원칙). "
|
||||
"AI repair 진입 전 deterministic 마지막 단계."
|
||||
),
|
||||
"not_attempted": (
|
||||
"retry 시도 자체가 없었음 (visual ok 등) — escalation 불필요"
|
||||
),
|
||||
# IMP-88 (#88) u2 — Step 17 retry chain cascade rationale entries
|
||||
"layout_adjust_insufficient": (
|
||||
"layout_adjust salvage (8-preset switch) 후에도 overflow 잔존 → "
|
||||
"frame_internal_fit_candidate 로 frame contract envelope 안 internal "
|
||||
"fit 변형 시도. frame_reselect (V4 top-k 다른 frame) 는 cascade 다음 단계."
|
||||
),
|
||||
"frame_internal_fit_candidate_insufficient": (
|
||||
"frame contract envelope 안 internal fit 변형 (density / line rhythm) "
|
||||
"도 overflow 못 흡수 → frame_reselect (V4 top-k 다른 frame) 로 escalate. "
|
||||
"popup 직행은 frame_reselect 까지 소진 후 (cascade terminal)."
|
||||
),
|
||||
"image_fit_insufficient": (
|
||||
"image_fit Step 17 single-pass (object-fit / max-w/h frame-scoped) 가 "
|
||||
"image_aspect_mismatch 못 해결 → layout_adjust 로 main cascade 진입. "
|
||||
"공통 image CSS / 공통 spacing 축소 X (Phase Z spacing direction)."
|
||||
),
|
||||
}
|
||||
|
||||
# 본 매핑이 가리키는 next action 들의 *현재 코드* 구현 상태
|
||||
# IMP-12 u7 (2026-05-18): 3 cascade salvage actions registered as IMPLEMENTED.
|
||||
# plan/apply pairs live in phase_z2_retry (u4/u5/u6); pipeline orchestrator wiring
|
||||
# (_attempt_salvage_chain) lands in u8/u9. router-level mapping is decoupled from
|
||||
# orchestrator wiring on purpose so route_retry_failure → impl_status reflects
|
||||
# the deterministic surface availability, not whether a given pipeline run has
|
||||
# already invoked it.
|
||||
NEXT_ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
|
||||
"layout_adjust": "MISSING",
|
||||
"frame_reselect": "MISSING",
|
||||
"none": "n/a",
|
||||
"cross_zone_redistribute": "IMPLEMENTED", # u4 plan_cross_zone_redistribute + apply_cross_zone_redistribute_css
|
||||
"glue_compression": "IMPLEMENTED", # u5 plan_glue_compression + apply_glue_compression_css
|
||||
"font_step_compression": "IMPLEMENTED", # u6 plan_font_step_compression + apply_font_step_compression_css
|
||||
# IMP-88 (#88) u1→u7 (2026-05-24): layout_adjust flips here on the
|
||||
# failure-router surface alongside the primary router surface. The
|
||||
# cascade entry chains font_step_insufficient → layout_adjust and
|
||||
# image_fit_insufficient → layout_adjust both reach this destination,
|
||||
# which is now wired end-to-end via u3 (plan_layout_adjust) + u6
|
||||
# (salvage dispatcher branch) + u7 (cascade entry trigger).
|
||||
"layout_adjust": "IMPLEMENTED",
|
||||
"frame_reselect": "MISSING",
|
||||
# IMP-35 (#64) u2 — cascade terminal advertised as MISSING here. The
|
||||
# router executor stub + MISSING→IMPLEMENTED flip lives in
|
||||
# src/phase_z2_router.py (u3). Keeping this entry as MISSING until u3
|
||||
# lands prevents premature "popup ready" claims from the failure-router
|
||||
# surface.
|
||||
"details_popup_escalation": "MISSING",
|
||||
# IMP-88 (#88) u1→u7 (2026-05-24): Step 17 retry chain destinations
|
||||
# flipped to IMPLEMENTED. frame_internal_fit_candidate is a cascade
|
||||
# destination (layout_adjust_insufficient → frame_internal_fit_candidate)
|
||||
# wired via u5 planner + u6 dispatcher branch + u7 cascade entry.
|
||||
# image_fit is a Step 17 single-pass entry wired via u4 planner +
|
||||
# u7 _attempt_step17_image_fit_single_pass; it also surfaces here so
|
||||
# route_retry_failure never returns 'unknown' when image_fit_insufficient
|
||||
# cascades onto layout_adjust. (Same precedent as IMP-12 u7 cascade
|
||||
# actions above — planner-surface availability + orchestrator wiring
|
||||
# together constitute IMPLEMENTED on the deterministic surface.)
|
||||
"frame_internal_fit_candidate": "IMPLEMENTED",
|
||||
"image_fit": "IMPLEMENTED",
|
||||
"none": "n/a",
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +305,48 @@ def classify_retry_failure(retry_trace: dict) -> Optional[dict]:
|
||||
if retry_trace.get("retry_passed"):
|
||||
return None
|
||||
|
||||
# case 0.5 : salvage chain 자체 성공 — failure 없음 (u8/u9 wiring)
|
||||
if retry_trace.get("salvage_passed"):
|
||||
return None
|
||||
|
||||
# case 0.7 : salvage chain attempted and ended in a salvage-level failure.
|
||||
# zone_ratio_retry 가 먼저 실패한 뒤 _attempt_salvage_chain 이 가동된 path —
|
||||
# 마지막 salvage step 의 action 으로 failure_type 을 분류한다. u3 가 routing.
|
||||
#
|
||||
# IMP-35 (#64) u1 — q4 explicit remeasure contract: the frame_reselect
|
||||
# branch is gated on post_salvage_overflow being present on the salvage
|
||||
# step. A bare passed=False flag with no remeasure payload is *not*
|
||||
# sufficient to emit frame_reselect_insufficient (which routes to
|
||||
# details_popup_escalation in u2). When the gate fails, the classifier
|
||||
# falls through to lower-priority cases so the salvage trace surfaces as
|
||||
# an unmatched defensive fallback instead of a spurious popup escalation.
|
||||
salvage_steps = retry_trace.get("salvage_steps") or []
|
||||
if salvage_steps:
|
||||
last = salvage_steps[-1] or {}
|
||||
if not last.get("passed"):
|
||||
action = (last.get("action") or "").lower()
|
||||
frame_reselect_blocked = (
|
||||
action == "frame_reselect"
|
||||
and not last.get("post_salvage_overflow")
|
||||
)
|
||||
if not frame_reselect_blocked:
|
||||
ftype = SALVAGE_FAILURE_TYPE_BY_ACTION.get(action)
|
||||
if ftype is not None:
|
||||
reason = last.get("failure_reason") or ""
|
||||
rule_suffix = (
|
||||
" AND post_salvage_overflow present"
|
||||
if action == "frame_reselect"
|
||||
else ""
|
||||
)
|
||||
return {
|
||||
"failure_type": ftype,
|
||||
"classification_rule": (
|
||||
f"salvage_steps[-1].action == {action!r} "
|
||||
f"AND passed=False{rule_suffix}. "
|
||||
f"raw failure_reason: {reason!r}"
|
||||
),
|
||||
}
|
||||
|
||||
# case 1 : retry 시도 자체 안 됨 (router_active=False 또는 다른 action)
|
||||
if not retry_trace.get("retry_attempted"):
|
||||
return {
|
||||
@@ -204,7 +445,7 @@ def route_retry_failure(failure_type: str) -> dict:
|
||||
"next_action_implementation_status": NEXT_ACTION_IMPLEMENTATION_STATUS.get(
|
||||
next_action, "unknown"
|
||||
),
|
||||
"mapping_source": "A4 NEXT_ACTION_BY_FAILURE (사용자 잠금 2026-04-29)",
|
||||
"mapping_source": "A4 NEXT_ACTION_BY_FAILURE (사용자 잠금 2026-05-17, IMP-12 u3 cascade)",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import yaml
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
CATALOG_PATH = PROJECT_ROOT / "templates" / "phase_z2" / "catalog" / "frame_contracts.yaml"
|
||||
V4_FALLBACK_POLICY_PATH = PROJECT_ROOT / "templates" / "phase_z2" / "catalog" / "v4_fallback_policy.yaml"
|
||||
|
||||
|
||||
class FitError(Exception):
|
||||
@@ -41,6 +42,22 @@ class FitError(Exception):
|
||||
"""
|
||||
|
||||
|
||||
class BuilderMissingError(FitError):
|
||||
"""Contract.payload.builder ↔ PAYLOAD_BUILDERS registry mismatch.
|
||||
|
||||
FitError subclass — pipeline 의 기존 `except FitError` 경로가 그대로
|
||||
adapter_needed 로 라우팅 (mdx04 hard crash 차단, IMP-#85 u1).
|
||||
"""
|
||||
|
||||
|
||||
class CatalogInvariantError(Exception):
|
||||
"""Catalog ↔ runtime registry drift detected at load time.
|
||||
|
||||
Boot-time invariant violation (IMP-#85 u2). Distinct from FitError:
|
||||
runtime fallback 대상이 아니라 catalog wiring 결함 (fail-fast).
|
||||
"""
|
||||
|
||||
|
||||
# ─── Catalog loading ──────────────────────────────────────────────
|
||||
|
||||
_CATALOG_CACHE: dict | None = None
|
||||
@@ -49,7 +66,9 @@ _CATALOG_CACHE: dict | None = None
|
||||
def load_frame_contracts() -> dict:
|
||||
global _CATALOG_CACHE
|
||||
if _CATALOG_CACHE is None:
|
||||
_CATALOG_CACHE = yaml.safe_load(CATALOG_PATH.read_text(encoding="utf-8")) or {}
|
||||
catalog = yaml.safe_load(CATALOG_PATH.read_text(encoding="utf-8")) or {}
|
||||
_check_catalog_builder_invariant(catalog)
|
||||
_CATALOG_CACHE = catalog
|
||||
return _CATALOG_CACHE
|
||||
|
||||
|
||||
@@ -57,6 +76,44 @@ def get_contract(template_id: str) -> dict | None:
|
||||
return load_frame_contracts().get(template_id)
|
||||
|
||||
|
||||
# ─── V4 fallback policy loading (IMP-38) ──────────────────────────
|
||||
|
||||
_V4_FALLBACK_POLICY_CACHE: dict | None = None
|
||||
|
||||
_V4_FALLBACK_POLICY_DEFAULT: dict = {
|
||||
"policy_type": "static",
|
||||
"usable_threshold": 1,
|
||||
"default_max_rank": 3,
|
||||
"extended_max_rank": 3, # graceful: yaml 없을 시 확장 X (byte-identical to pre-IMP-38)
|
||||
}
|
||||
|
||||
|
||||
def load_v4_fallback_policy() -> dict:
|
||||
"""IMP-38 V4 fallback policy loader (separate yaml, catalog 오염 방지).
|
||||
|
||||
Returns dict with keys: policy_type, usable_threshold, default_max_rank, extended_max_rank.
|
||||
|
||||
Codex #1 권장: frame_contracts.yaml top-level 오염 회피 (별 yaml).
|
||||
Codex #3 LOCK: load_frame_contracts() shape 변경 X (이 함수는 별 cache).
|
||||
|
||||
Graceful fallback:
|
||||
yaml 파일 없을 시 → _V4_FALLBACK_POLICY_DEFAULT (default_max_rank=3, extended=3)
|
||||
→ backward compat byte-identical to pre-IMP-38 behavior.
|
||||
|
||||
Returns:
|
||||
dict — 정책 키 (정책 yaml 의 superset 가능, 알 수 없는 키는 무시 권장).
|
||||
"""
|
||||
global _V4_FALLBACK_POLICY_CACHE
|
||||
if _V4_FALLBACK_POLICY_CACHE is None:
|
||||
if V4_FALLBACK_POLICY_PATH.exists():
|
||||
loaded = yaml.safe_load(V4_FALLBACK_POLICY_PATH.read_text(encoding="utf-8")) or {}
|
||||
# merge with default (yaml 키 부분 누락 시 default 로 fall through)
|
||||
_V4_FALLBACK_POLICY_CACHE = {**_V4_FALLBACK_POLICY_DEFAULT, **loaded}
|
||||
else:
|
||||
_V4_FALLBACK_POLICY_CACHE = dict(_V4_FALLBACK_POLICY_DEFAULT)
|
||||
return _V4_FALLBACK_POLICY_CACHE
|
||||
|
||||
|
||||
# ─── Source-shape splitters ──────────────────────────────────────
|
||||
|
||||
def _split_top_bullets(content: str) -> list[tuple[str, list[str]]]:
|
||||
@@ -522,12 +579,23 @@ def _build_compare_table_2col(section, units, contract) -> dict:
|
||||
|
||||
builder_options :
|
||||
item_parser : ITEM_PARSERS key (예: `compare_row_2col_item`)
|
||||
col_a_label_default : col_a header (MDX 미명시 시 fallback. F1-a fix)
|
||||
col_b_label_default : col_b header (MDX 미명시 시 fallback)
|
||||
col_a_label_default : col_a header literal in catalog.
|
||||
Semantics depend on col_a_label_default_role.
|
||||
col_a_label_default_role : "placeholder" | "fallback" (IMP-40 #69).
|
||||
placeholder = Figma visual placeholder; suppressed
|
||||
at runtime → col_a_label emitted as "".
|
||||
fallback = MDX 미명시 시 catalog literal 사용.
|
||||
absent = legacy contracts default to fallback.
|
||||
col_b_label_default : col_b header literal (same policy as col_a).
|
||||
col_b_label_default_role : same role discriminator for col_b (IMP-40 #69).
|
||||
strip_col_prefix_aliases : list[str] — col_a/col_b 값의 prefix `<alias>:`
|
||||
를 strip (Codex round 43 §F1-b — narrow alias).
|
||||
예 : ["BIM", "DX"]. default [] (no stripping).
|
||||
max_rows : N (default 999 — practical 한계).
|
||||
|
||||
NOTE: MDX 측 col_a_label / col_b_label inflow 경로 없음
|
||||
(compare_row_2col_item parser → {label,col_a,col_b}, _resolve_title → title only).
|
||||
placeholder role 은 col_*_label 을 빈 문자열로 확정 — 정책 결정점은 catalog 한 곳뿐.
|
||||
"""
|
||||
options = contract["payload"]["builder_options"]
|
||||
parser_name = options["item_parser"]
|
||||
@@ -538,8 +606,21 @@ def _build_compare_table_2col(section, units, contract) -> dict:
|
||||
f"but ITEM_PARSERS has no such entry."
|
||||
)
|
||||
|
||||
col_a_label = options.get("col_a_label_default", "")
|
||||
col_b_label = options.get("col_b_label_default", "")
|
||||
def _resolve_label_default(col_key: str) -> str:
|
||||
default_key = f"{col_key}_label_default"
|
||||
role_key = f"{col_key}_label_default_role"
|
||||
role = options.get(role_key, "fallback")
|
||||
if role == "placeholder":
|
||||
return ""
|
||||
if role == "fallback":
|
||||
return options.get(default_key, "")
|
||||
raise ValueError(
|
||||
f"Contract '{contract['template_id']}' builder_options.{role_key}='{role}' "
|
||||
f"is invalid; expected 'placeholder' or 'fallback' (IMP-40 #69)."
|
||||
)
|
||||
|
||||
col_a_label = _resolve_label_default("col_a")
|
||||
col_b_label = _resolve_label_default("col_b")
|
||||
strip_aliases = options.get("strip_col_prefix_aliases", []) or []
|
||||
max_rows = options.get("max_rows", 999)
|
||||
|
||||
@@ -647,6 +728,50 @@ PAYLOAD_BUILDERS: dict[str, Callable] = {
|
||||
}
|
||||
|
||||
|
||||
# ─── Catalog builder invariant (IMP-#85 u2) ──────────────────────
|
||||
|
||||
def _check_catalog_builder_invariant(catalog: dict) -> None:
|
||||
"""Every non-`visual_pending` contract must declare a registered builder.
|
||||
|
||||
`visual_pending: true` contracts are scaffolding records whose builders
|
||||
are tracked as VP backlog (별 axis IMP-04b / #42) — skipped here so the
|
||||
catalog can keep declaring them without breaking boot.
|
||||
|
||||
Violations are aggregated and raised together so first-fix iteration sees
|
||||
the full drift surface, not just the first row.
|
||||
|
||||
Raises:
|
||||
CatalogInvariantError — when one or more live (non-VP) contracts
|
||||
either omit `payload.builder` or reference a name absent from
|
||||
`PAYLOAD_BUILDERS`.
|
||||
"""
|
||||
violations: list[str] = []
|
||||
for template_id, contract in catalog.items():
|
||||
if not isinstance(contract, dict):
|
||||
continue
|
||||
if contract.get("visual_pending") is True:
|
||||
continue
|
||||
payload = contract.get("payload") or {}
|
||||
builder_name = payload.get("builder") if isinstance(payload, dict) else None
|
||||
if not builder_name:
|
||||
violations.append(
|
||||
f"Contract '{template_id}' (non-VP) missing payload.builder."
|
||||
)
|
||||
continue
|
||||
if builder_name not in PAYLOAD_BUILDERS:
|
||||
violations.append(
|
||||
f"Contract '{template_id}' (non-VP) references payload.builder="
|
||||
f"'{builder_name}' not in PAYLOAD_BUILDERS registry."
|
||||
)
|
||||
if violations:
|
||||
raise CatalogInvariantError(
|
||||
f"Catalog builder invariant violated "
|
||||
f"({len(violations)} non-VP contract(s)):\n - "
|
||||
+ "\n - ".join(violations)
|
||||
+ f"\nRegistered builders: {sorted(PAYLOAD_BUILDERS.keys())}"
|
||||
)
|
||||
|
||||
|
||||
# ─── Generic mapper (single dispatch via builder) ────────────────
|
||||
|
||||
def _check_cardinality(contract: dict, units: list, section) -> None:
|
||||
@@ -804,13 +929,13 @@ def map_with_contract(section, contract: dict) -> dict:
|
||||
payload_spec = contract["payload"]
|
||||
builder_name = payload_spec.get("builder")
|
||||
if not builder_name:
|
||||
raise ValueError(
|
||||
raise BuilderMissingError(
|
||||
f"Contract '{contract['template_id']}' missing payload.builder. "
|
||||
f"available: {sorted(PAYLOAD_BUILDERS.keys())}"
|
||||
)
|
||||
builder = PAYLOAD_BUILDERS.get(builder_name)
|
||||
if builder is None:
|
||||
raise ValueError(
|
||||
raise BuilderMissingError(
|
||||
f"Contract '{contract['template_id']}' references payload.builder="
|
||||
f"'{builder_name}' but PAYLOAD_BUILDERS has no such entry. "
|
||||
f"available: {sorted(PAYLOAD_BUILDERS.keys())}"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -120,11 +120,30 @@ def plan_zone_ratio_retry(
|
||||
continue
|
||||
|
||||
# rule 4-(d) 현재 height > min_height
|
||||
# IMP-34 u1: donor capacity bounded by measured empty space
|
||||
# (clientHeight - scrollHeight from Step 14) when both fields are present,
|
||||
# falling back to static contract slack when absent. Prevents the donor
|
||||
# from being over-allocated when it is already full but not overflowing.
|
||||
height = zones_before.get(pos)
|
||||
min_h = zone_min_by_pos.get(pos)
|
||||
if height is None or min_h is None:
|
||||
continue
|
||||
slack = height - min_h
|
||||
static_slack = height - min_h
|
||||
client_h = zinfo.get("clientHeight")
|
||||
scroll_h = zinfo.get("scrollHeight")
|
||||
if (
|
||||
isinstance(client_h, (int, float))
|
||||
and isinstance(scroll_h, (int, float))
|
||||
and not isinstance(client_h, bool)
|
||||
and not isinstance(scroll_h, bool)
|
||||
):
|
||||
measured_empty_px = max(0, int(client_h) - int(scroll_h))
|
||||
slack = min(static_slack, measured_empty_px)
|
||||
slack_bound_source = "measured_bound"
|
||||
else:
|
||||
measured_empty_px = None
|
||||
slack = static_slack
|
||||
slack_bound_source = "static_fallback"
|
||||
if slack <= 0:
|
||||
continue
|
||||
|
||||
@@ -134,6 +153,8 @@ def plan_zone_ratio_retry(
|
||||
"min_height": min_h,
|
||||
"slack": slack,
|
||||
"capacity_fit_status": cap_status,
|
||||
"measured_empty_px": measured_empty_px,
|
||||
"slack_bound_source": slack_bound_source,
|
||||
})
|
||||
|
||||
# rule 4-(f) 여러 후보면 slack 가장 큰 것부터
|
||||
@@ -162,35 +183,55 @@ def plan_zone_ratio_retry(
|
||||
),
|
||||
}
|
||||
|
||||
# A3 minimal : single primary donor (multi-donor 는 future)
|
||||
primary_donor = donor_candidates[0]
|
||||
if primary_donor["slack"] < target_added_px:
|
||||
# IMP-12 u1 : multi-donor greedy aggregation (slack-desc 순서대로 합산)
|
||||
aggregate_slack_available = sum(d["slack"] for d in donor_candidates)
|
||||
if aggregate_slack_available < target_added_px:
|
||||
return {
|
||||
**base_plan,
|
||||
"feasible": False,
|
||||
"donor_zone_position": primary_donor["position"],
|
||||
"donor_max_slack": primary_donor["slack"],
|
||||
"donor_zone_position": donor_candidates[0]["position"],
|
||||
"donor_max_slack": donor_candidates[0]["slack"],
|
||||
"donor_reduced_px": 0,
|
||||
"donors_used": [],
|
||||
"aggregate_slack_used": 0,
|
||||
"aggregate_slack_available": aggregate_slack_available,
|
||||
"zones_after": dict(zones_before),
|
||||
"failure_reason": (
|
||||
f"primary donor '{primary_donor['position']}' slack {primary_donor['slack']}px "
|
||||
f"< target_added_px {target_added_px}px (excess_y {target_excess_y} + "
|
||||
f"safety_margin {safety_margin_px}). multi-donor aggregation is future axis."
|
||||
f"primary donor '{donor_candidates[0]['position']}' slack "
|
||||
f"{donor_candidates[0]['slack']}px (aggregate "
|
||||
f"{aggregate_slack_available}px across {len(donor_candidates)} "
|
||||
f"candidate(s)) < target_added_px {target_added_px}px "
|
||||
f"(excess_y {target_excess_y} + safety_margin {safety_margin_px})."
|
||||
),
|
||||
}
|
||||
|
||||
# feasible
|
||||
# feasible — greedy aggregation: 각 donor 에서 필요한 만큼만 차감
|
||||
zones_after = dict(zones_before)
|
||||
zones_after[target_zone_position] = zones_before[target_zone_position] + target_added_px
|
||||
zones_after[primary_donor["position"]] = (
|
||||
zones_before[primary_donor["position"]] - target_added_px
|
||||
)
|
||||
donors_used: list[dict] = []
|
||||
remaining = target_added_px
|
||||
for donor in donor_candidates:
|
||||
if remaining <= 0:
|
||||
break
|
||||
take = min(donor["slack"], remaining)
|
||||
zones_after[donor["position"]] = zones_before[donor["position"]] - take
|
||||
donors_used.append({
|
||||
"position": donor["position"],
|
||||
"reduced_px": take,
|
||||
"slack_before": donor["slack"],
|
||||
"slack_after": donor["slack"] - take,
|
||||
})
|
||||
remaining -= take
|
||||
|
||||
primary_donor = donors_used[0]
|
||||
return {
|
||||
**base_plan,
|
||||
"feasible": True,
|
||||
"donor_zone_position": primary_donor["position"],
|
||||
"donor_reduced_px": target_added_px,
|
||||
"donor_reduced_px": primary_donor["reduced_px"],
|
||||
"donors_used": donors_used,
|
||||
"aggregate_slack_used": target_added_px,
|
||||
"aggregate_slack_available": aggregate_slack_available,
|
||||
"zones_after": zones_after,
|
||||
}
|
||||
|
||||
@@ -213,3 +254,551 @@ def apply_retry_to_layout_css(layout_css: dict, plan: dict, zones_data: list[dic
|
||||
new_layout_css["raw_zone_layout"] = (layout_css.get("raw_zone_layout") or {}).copy()
|
||||
new_layout_css["raw_zone_layout"]["retry_applied"] = True
|
||||
return new_layout_css
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# IMP-12 u4 : cross_zone_redistribute (Step 17 salvage cascade — stage 1)
|
||||
# Wraps src.fit_verifier.redistribute in the Step-17 plan signature so the
|
||||
# failure-router cascade (donor_slack_insufficient → cross_zone_redistribute)
|
||||
# can drive it deterministically. Plan-only — no rerender / no final.html
|
||||
# mutation. Side-effect-free (operates on deepcopy of fit_analysis).
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
def plan_cross_zone_redistribute(
|
||||
*,
|
||||
fit_analysis,
|
||||
containers: dict,
|
||||
min_margin_px: float | None = None,
|
||||
) -> dict:
|
||||
"""Cross-zone (intra-zone role-to-role) redistribute plan.
|
||||
|
||||
Plan-only — no rerender / no final.html mutation. Side-effect-free
|
||||
(operates on deepcopy of fit_analysis).
|
||||
"""
|
||||
from copy import deepcopy
|
||||
from src.fit_verifier import redistribute as _fv_redistribute
|
||||
|
||||
role_heights_before = {
|
||||
role: float(rf.allocated_px) for role, rf in (fit_analysis.roles or {}).items()
|
||||
}
|
||||
base_plan = {
|
||||
"action": "cross_zone_redistribute",
|
||||
"role_heights_before": role_heights_before,
|
||||
}
|
||||
if not role_heights_before:
|
||||
return {**base_plan, "feasible": False, "role_heights_after": {},
|
||||
"can_redistribute": False,
|
||||
"failure_reason": "no roles in fit_analysis — cannot redistribute."}
|
||||
|
||||
result = _fv_redistribute(deepcopy(fit_analysis), containers, min_margin_px=min_margin_px)
|
||||
redistribution = dict(result.redistribution or {})
|
||||
can_redistribute = bool(result.can_redistribute)
|
||||
|
||||
if not can_redistribute or not redistribution:
|
||||
return {
|
||||
**base_plan,
|
||||
"feasible": False,
|
||||
"role_heights_after": redistribution or dict(role_heights_before),
|
||||
"can_redistribute": can_redistribute,
|
||||
"failure_reason": (
|
||||
"fit_verifier.redistribute can_redistribute=False — single-role zone(s) "
|
||||
"or surplus insufficient to cover deficit within envelope."
|
||||
),
|
||||
}
|
||||
return {**base_plan, "feasible": True, "role_heights_after": redistribution,
|
||||
"can_redistribute": True}
|
||||
|
||||
|
||||
def apply_cross_zone_redistribute_css(plan: dict) -> str:
|
||||
"""Emit scoped role-height CSS overrides — [data-role="<role>"] only.
|
||||
|
||||
Honors feedback_phase_z_spacing_direction: no :root / body / .slide / .zone selectors.
|
||||
"""
|
||||
if not plan.get("feasible"):
|
||||
return ""
|
||||
role_heights_after = plan.get("role_heights_after") or {}
|
||||
role_heights_before = plan.get("role_heights_before") or {}
|
||||
rules: list[str] = []
|
||||
for role, new_height in role_heights_after.items():
|
||||
before = role_heights_before.get(role)
|
||||
if before is None or abs(float(before) - float(new_height)) < 0.5:
|
||||
continue
|
||||
new_h_int = int(round(float(new_height)))
|
||||
rules.append(
|
||||
f'[data-role="{role}"] {{ height: {new_h_int}px; min-height: {new_h_int}px; }}'
|
||||
)
|
||||
return "\n".join(rules)
|
||||
|
||||
|
||||
# IMP-12 u5 : glue_compression — Step 17 salvage cascade (stage 2).
|
||||
# Wraps space_allocator.compute_glue_css_overrides in the Step-17 plan signature.
|
||||
# Frame-scoped: overrides emitted only under [data-zone-position="<pos>"]
|
||||
# (feedback_phase_z_spacing_direction — no :root/body/.slide/.zone mutation).
|
||||
|
||||
|
||||
def plan_glue_compression(
|
||||
*, excess_px: float, block_count: int, zone_position: str,
|
||||
) -> dict:
|
||||
"""Glue compression plan (frame-scoped). feasible only when envelope absorbs excess."""
|
||||
from src.space_allocator import (
|
||||
calculate_glue_absorption, compute_glue_css_overrides,
|
||||
)
|
||||
base = {"action": "glue_compression", "zone_position": zone_position,
|
||||
"excess_px": float(excess_px), "block_count": int(block_count)}
|
||||
if excess_px <= 0:
|
||||
return {**base, "feasible": False, "overrides": {}, "absorption_max_px": 0.0,
|
||||
"failure_reason": "excess_px <= 0 — no compression needed."}
|
||||
absorption_max = float(calculate_glue_absorption(block_count))
|
||||
overrides = compute_glue_css_overrides(excess_px, block_count) or {}
|
||||
if excess_px > absorption_max:
|
||||
return {**base, "feasible": False, "overrides": overrides,
|
||||
"absorption_max_px": absorption_max,
|
||||
"failure_reason": (
|
||||
f"glue envelope insufficient — excess_px {excess_px:.1f} > "
|
||||
f"max absorption {absorption_max:.1f}px "
|
||||
f"(block_count={block_count}, SPACING_GLUE shrink budget)."
|
||||
)}
|
||||
return {**base, "feasible": True, "overrides": overrides,
|
||||
"absorption_max_px": absorption_max}
|
||||
|
||||
|
||||
def apply_glue_compression_css(plan: dict) -> str:
|
||||
"""Emit zone-scoped glue CSS — wrapped in [data-zone-position="<pos>"] only."""
|
||||
if not plan.get("feasible"):
|
||||
return ""
|
||||
zone_position = plan.get("zone_position")
|
||||
overrides = plan.get("overrides") or {}
|
||||
if not zone_position or not overrides:
|
||||
return ""
|
||||
var_lines = "\n".join(f" {k}: {v};" for k, v in overrides.items())
|
||||
return f'[data-zone-position="{zone_position}"] {{\n{var_lines}\n}}'
|
||||
|
||||
|
||||
# IMP-12 u6 : font_step_compression — Step 17 salvage cascade (stage 3).
|
||||
# Wraps space_allocator.find_fitting_font_size in the Step-17 plan signature.
|
||||
# Zone-scoped: only [data-zone-position="<pos>"] (no :root/body/.slide/.zone).
|
||||
|
||||
|
||||
def plan_font_step_compression(
|
||||
*, current_font_px: float, excess_after_glue_px: float,
|
||||
available_lines: int, chars_per_line: int, zone_position: str,
|
||||
) -> dict:
|
||||
"""Font-step compression plan (zone-scoped). feasible only when FONT_SIZE_STEPS
|
||||
contains a size whose line-height savings cover excess_after_glue_px. Missing
|
||||
text_metrics yields feasible=False (cascade routes onward to layout_adjust)."""
|
||||
from src.space_allocator import FONT_SIZE_STEPS, find_fitting_font_size
|
||||
floor = float(FONT_SIZE_STEPS[-1])
|
||||
base = {"action": "font_step_compression", "zone_position": zone_position,
|
||||
"current_font_px": float(current_font_px),
|
||||
"excess_after_glue_px": float(excess_after_glue_px),
|
||||
"available_lines": int(available_lines or 0),
|
||||
"chars_per_line": int(chars_per_line or 0),
|
||||
"font_floor_px": floor}
|
||||
if excess_after_glue_px <= 0:
|
||||
return {**base, "feasible": False, "target_font_px": None,
|
||||
"failure_reason": "excess_after_glue_px <= 0 — no font compression needed."}
|
||||
if not available_lines or available_lines <= 0 or not chars_per_line or chars_per_line <= 0:
|
||||
return {**base, "feasible": False, "target_font_px": None,
|
||||
"failure_reason": "text_metrics missing — available_lines/chars_per_line required."}
|
||||
if current_font_px <= floor:
|
||||
return {**base, "feasible": False, "target_font_px": None,
|
||||
"failure_reason": (
|
||||
f"current_font_px {current_font_px:.1f} already at FONT_SIZE_STEPS floor {floor:.1f}px.")}
|
||||
target = find_fitting_font_size(
|
||||
current_font_px=float(current_font_px),
|
||||
excess_after_glue_px=float(excess_after_glue_px),
|
||||
available_lines=int(available_lines), chars_per_line=int(chars_per_line))
|
||||
if target is None:
|
||||
return {**base, "feasible": False, "target_font_px": None,
|
||||
"failure_reason": (
|
||||
f"font_step floor — {floor:.1f}px cannot absorb "
|
||||
f"excess_after_glue_px={excess_after_glue_px:.1f}px "
|
||||
f"(available_lines={available_lines}, FONT_SIZE_STEPS exhausted).")}
|
||||
return {**base, "feasible": True, "target_font_px": float(target)}
|
||||
|
||||
|
||||
def apply_font_step_compression_css(plan: dict) -> str:
|
||||
"""Emit zone-scoped font-size CSS — [data-zone-position="<pos>"] only."""
|
||||
if not plan.get("feasible"):
|
||||
return ""
|
||||
zone_position = plan.get("zone_position")
|
||||
target_font_px = plan.get("target_font_px")
|
||||
if not zone_position or target_font_px is None:
|
||||
return ""
|
||||
return (f'[data-zone-position="{zone_position}"] {{\n'
|
||||
f" font-size: {float(target_font_px):.1f}px;\n}}")
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# IMP-88 u3 : layout_adjust — Step 17 retry chain (8-preset topology swap).
|
||||
# Honors feedback_phase_z_spacing_direction: no shared margin / gap / slide-body
|
||||
# shrink. Cascade entry per u2: image_fit_insufficient → layout_adjust;
|
||||
# downstream per u2: layout_adjust_insufficient → frame_internal_fit_candidate.
|
||||
# Plan-only — dispatcher (u6) consumes new_layout_preset + new_zones_data and
|
||||
# rebuilds layout_css via apply_layout_adjust_layout_css(plan, gap_px).
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
def _layout_swap_priority(current_topology: str, candidate_topology: str) -> int:
|
||||
"""Lower = preferred swap target. Honors topology-axis mirroring first."""
|
||||
pair = frozenset({current_topology, candidate_topology})
|
||||
if pair == frozenset({"rows", "cols"}):
|
||||
return 0
|
||||
if pair == frozenset({"T", "inverted-T"}):
|
||||
return 1
|
||||
if pair == frozenset({"side-T-left", "side-T-right"}):
|
||||
return 2
|
||||
return 3
|
||||
|
||||
|
||||
def plan_layout_adjust(
|
||||
*, current_layout_preset: str, zones_data: list[dict],
|
||||
) -> dict:
|
||||
"""Layout-preset switch plan (Step 17 retry chain — IMP-88 u3).
|
||||
|
||||
Finds a render-ready sibling preset (same candidate_when.unit_count) and
|
||||
remaps zone positions in catalog order. No common spacing shrink —
|
||||
feedback_phase_z_spacing_direction lock: escalate via layout topology only.
|
||||
"""
|
||||
from src.phase_z2_composition import LAYOUT_PRESETS
|
||||
base = {
|
||||
"action": "layout_adjust",
|
||||
"current_layout_preset": current_layout_preset,
|
||||
}
|
||||
current_spec = LAYOUT_PRESETS.get(current_layout_preset)
|
||||
if current_spec is None:
|
||||
return {
|
||||
**base, "feasible": False, "new_layout_preset": None,
|
||||
"candidates_considered": [],
|
||||
"failure_reason": (
|
||||
f"current_layout_preset '{current_layout_preset}' not in "
|
||||
f"LAYOUT_PRESETS catalog — cannot enumerate same-unit-count siblings."
|
||||
),
|
||||
}
|
||||
current_positions = list(current_spec.get("positions") or [])
|
||||
if len(zones_data) != len(current_positions):
|
||||
return {
|
||||
**base, "feasible": False, "new_layout_preset": None,
|
||||
"candidates_considered": [],
|
||||
"failure_reason": (
|
||||
f"zones_data length {len(zones_data)} != current preset "
|
||||
f"'{current_layout_preset}' positions {current_positions} — "
|
||||
f"cannot remap to a sibling preset."
|
||||
),
|
||||
}
|
||||
unit_count = (current_spec.get("candidate_when") or {}).get("unit_count")
|
||||
candidates = [
|
||||
pid for pid, spec in LAYOUT_PRESETS.items()
|
||||
if pid != current_layout_preset
|
||||
and spec.get("render_ready", False)
|
||||
and ((spec.get("candidate_when") or {}).get("unit_count") == unit_count)
|
||||
]
|
||||
base = {**base, "unit_count": unit_count,
|
||||
"candidates_considered": list(candidates)}
|
||||
if not candidates:
|
||||
return {
|
||||
**base, "feasible": False, "new_layout_preset": None,
|
||||
"failure_reason": (
|
||||
f"no render-ready 8-preset sibling for unit_count {unit_count} "
|
||||
f"(current='{current_layout_preset}'). single (1) and grid-2x2 (4) "
|
||||
f"have no swap target by catalog design."
|
||||
),
|
||||
}
|
||||
catalog_order = list(LAYOUT_PRESETS.keys())
|
||||
current_topo = current_spec.get("topology")
|
||||
candidates.sort(key=lambda pid: (
|
||||
_layout_swap_priority(current_topo, LAYOUT_PRESETS[pid].get("topology")),
|
||||
catalog_order.index(pid),
|
||||
))
|
||||
new_preset = candidates[0]
|
||||
new_positions = list(LAYOUT_PRESETS[new_preset].get("positions") or [])
|
||||
new_zones_data = [
|
||||
{**zd, "position": new_positions[i]} for i, zd in enumerate(zones_data)
|
||||
]
|
||||
return {
|
||||
**base,
|
||||
"feasible": True,
|
||||
"new_layout_preset": new_preset,
|
||||
"swap_topology_from": current_topo,
|
||||
"swap_topology_to": LAYOUT_PRESETS[new_preset].get("topology"),
|
||||
"position_remap": dict(zip(current_positions, new_positions)),
|
||||
"new_zones_data": new_zones_data,
|
||||
}
|
||||
|
||||
|
||||
def apply_layout_adjust_layout_css(plan: dict, gap_px: int) -> Optional[dict]:
|
||||
"""Build a fresh layout_css dict for the swapped preset.
|
||||
|
||||
Returns None when plan is infeasible. Dispatcher (u6) re-renders with
|
||||
render_slide(zones_data=plan['new_zones_data'], layout_preset=plan
|
||||
['new_layout_preset'], layout_css=<this return>, gap_px=gap_px).
|
||||
"""
|
||||
if not plan.get("feasible"):
|
||||
return None
|
||||
new_preset = plan.get("new_layout_preset")
|
||||
new_zones_data = plan.get("new_zones_data") or []
|
||||
if not new_preset or not new_zones_data:
|
||||
return None
|
||||
from src.phase_z2_pipeline import build_layout_css
|
||||
new_layout_css = dict(build_layout_css(new_preset, new_zones_data, gap=gap_px))
|
||||
raw = dict(new_layout_css.get("raw_zone_layout") or {})
|
||||
raw["layout_adjust_applied"] = True
|
||||
raw["layout_adjust_from"] = plan.get("current_layout_preset")
|
||||
raw["layout_adjust_to"] = new_preset
|
||||
new_layout_css["raw_zone_layout"] = raw
|
||||
return new_layout_css
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# IMP-88 u4 : image_fit — Step 17 single-pass image-scoped CSS override.
|
||||
# Consumes overflow_metrics.image_events directly (natural_w/h, rendered_w/h,
|
||||
# natural_ratio, rendered_ratio, delta). Honors feedback_phase_z_spacing
|
||||
# _direction — image-scoped CSS only, no shared margin / frame envelope shrink.
|
||||
# Plan-only — Step 17 entry (u7) is the runtime caller.
|
||||
# Default delta_tol mirrors src.phase_z2_pipeline.IMAGE_ASPECT_DELTA_TOL = 0.05;
|
||||
# overridable arg keeps tests free of the pipeline import cycle.
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
def plan_image_fit(
|
||||
*, image_event: dict, delta_tol: float = 0.05,
|
||||
) -> dict:
|
||||
"""Image_fit planner (Step 17 retry chain — IMP-88 u4).
|
||||
|
||||
Emits frame-scoped object-fit + max-width/height from a single
|
||||
image_event (overflow_metrics.image_events). Image-scoped only.
|
||||
"""
|
||||
base = {
|
||||
"action": "image_fit",
|
||||
"src": image_event.get("src"),
|
||||
"zone_position": image_event.get("zone_position"),
|
||||
"zone_template_id": image_event.get("zone_template_id"),
|
||||
}
|
||||
delta = image_event.get("delta")
|
||||
if delta is None:
|
||||
return {
|
||||
**base, "feasible": False, "css_overrides": None,
|
||||
"failure_reason": (
|
||||
"image_event delta is None — image not loaded; no aspect "
|
||||
"mismatch can be measured."
|
||||
),
|
||||
}
|
||||
if abs(float(delta)) <= float(delta_tol):
|
||||
return {
|
||||
**base, "feasible": False, "css_overrides": None,
|
||||
"delta": float(delta),
|
||||
"failure_reason": (
|
||||
f"|delta|={abs(float(delta)):.4f} <= delta_tol={delta_tol} — "
|
||||
f"no image_aspect_mismatch to correct (planner no-op)."
|
||||
),
|
||||
}
|
||||
rendered_w = image_event.get("rendered_w")
|
||||
rendered_h = image_event.get("rendered_h")
|
||||
if not (isinstance(rendered_w, (int, float)) and rendered_w > 0
|
||||
and isinstance(rendered_h, (int, float)) and rendered_h > 0):
|
||||
return {
|
||||
**base, "feasible": False, "css_overrides": None,
|
||||
"delta": float(delta),
|
||||
"failure_reason": (
|
||||
"image_event missing positive rendered_w / rendered_h — "
|
||||
"cannot bound max-width / max-height for image-scoped CSS."
|
||||
),
|
||||
}
|
||||
return {
|
||||
**base,
|
||||
"feasible": True,
|
||||
"delta": float(delta),
|
||||
"natural_ratio": image_event.get("natural_ratio"),
|
||||
"rendered_ratio": image_event.get("rendered_ratio"),
|
||||
"natural_w": image_event.get("natural_w"),
|
||||
"natural_h": image_event.get("natural_h"),
|
||||
"rendered_w": int(rendered_w),
|
||||
"rendered_h": int(rendered_h),
|
||||
# delta > 0 ⇒ rendered_ratio > natural_ratio ⇒ rendered too wide ⇒
|
||||
# width axis correction; delta < 0 ⇒ height axis correction.
|
||||
"correction_axis": "width" if float(delta) > 0 else "height",
|
||||
"css_overrides": {
|
||||
"object_fit": "contain",
|
||||
"max_width_px": int(rendered_w),
|
||||
"max_height_px": int(rendered_h),
|
||||
"width": "auto",
|
||||
"height": "auto",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def apply_image_fit_css(plan: dict) -> Optional[str]:
|
||||
"""Build a frame-scoped CSS snippet from a feasible image_fit plan.
|
||||
|
||||
Returns None when plan is infeasible. u7 (Step 17 entry) injects the
|
||||
snippet into the per-slide style override and re-renders.
|
||||
"""
|
||||
if not plan.get("feasible"):
|
||||
return None
|
||||
overrides = plan.get("css_overrides") or {}
|
||||
if not overrides:
|
||||
return None
|
||||
src = plan.get("src") or ""
|
||||
zone_position = plan.get("zone_position") or ""
|
||||
if src:
|
||||
selector = (
|
||||
f".zone[data-zone-position=\"{zone_position}\"] "
|
||||
f"img[src=\"{src}\"]"
|
||||
)
|
||||
else:
|
||||
selector = f".zone[data-zone-position=\"{zone_position}\"] img"
|
||||
return (
|
||||
f"{selector} {{\n"
|
||||
f" object-fit: {overrides.get('object_fit', 'contain')};\n"
|
||||
f" max-width: {int(overrides.get('max_width_px') or 0)}px;\n"
|
||||
f" max-height: {int(overrides.get('max_height_px') or 0)}px;\n"
|
||||
f" width: {overrides.get('width', 'auto')};\n"
|
||||
f" height: {overrides.get('height', 'auto')};\n"
|
||||
f"}}"
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# IMP-88 u5 : frame_internal_fit_candidate — Step 17 retry chain.
|
||||
# Operates ONLY inside the frame contract's declared `internal_envelope`
|
||||
# (PHASE-Z-PIPELINE-OVERVIEW.md:333 lock). Sub-mechanism names allowed by
|
||||
# the OVERVIEW: density envelope / line rhythm / internal grid row / text
|
||||
# block allocation — all unified under the single action label
|
||||
# `frame_internal_fit_candidate` so common-CSS/padding shrink antipatterns
|
||||
# stay quarantined ([[feedback_phase_z_spacing_direction]]).
|
||||
#
|
||||
# Envelope shape (dormant — catalog adds when contracts declare it):
|
||||
# frame_contract["internal_envelope"] = {
|
||||
# "variants": [
|
||||
# {"name": "<sub_mechanism_name>",
|
||||
# "excess_budget_px": <int — px of vertical excess this variant can absorb>,
|
||||
# "css_overrides": {<css-property>: <value>, ...}},
|
||||
# ...
|
||||
# ]
|
||||
# }
|
||||
# Selection = walk variants in catalog order, pick first whose excess_budget_px
|
||||
# >= effective excess_y (greedy). Catalog order = catalog author's priority.
|
||||
#
|
||||
# No frame contract currently declares `internal_envelope`, so the planner
|
||||
# returns infeasible(envelope_present=False) for every live frame today.
|
||||
# Cascade hand-off: NEXT_ACTION_BY_FAILURE['frame_internal_fit_candidate_
|
||||
# insufficient'] = 'frame_reselect' (set in u2). Plan-only — u6 (salvage
|
||||
# dispatcher) and u7 (Step 17 entry) own the runtime call site.
|
||||
#
|
||||
# frame_contract is an overridable kwarg so tests don't pay the mapper
|
||||
# catalog cache cost / pipeline import cycle (mirrors u4's delta_tol).
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
def plan_frame_internal_fit_candidate(
|
||||
*, frame_template_id: str,
|
||||
frame_contract: Optional[dict] = None,
|
||||
overflow_zone: Optional[dict] = None,
|
||||
) -> dict:
|
||||
"""frame_internal_fit_candidate planner (Step 17 retry chain — IMP-88 u5).
|
||||
|
||||
Walks `frame_contract['internal_envelope']['variants']` in catalog order
|
||||
and picks the first variant whose excess_budget_px covers `overflow_zone
|
||||
['excess_y']`. Returns infeasible when no contract / no envelope / no
|
||||
variant fits. No common-margin shrink — sub-mechanism CSS is frame-scoped.
|
||||
"""
|
||||
base = {
|
||||
"action": "frame_internal_fit_candidate",
|
||||
"frame_template_id": frame_template_id,
|
||||
}
|
||||
if frame_contract is None:
|
||||
from src.phase_z2_mapper import get_contract
|
||||
frame_contract = get_contract(frame_template_id)
|
||||
if frame_contract is None:
|
||||
return {
|
||||
**base, "feasible": False, "envelope_present": False,
|
||||
"candidates_considered": [], "selected_variant": None,
|
||||
"css_overrides": None,
|
||||
"failure_reason": (
|
||||
f"no frame contract registered for template_id "
|
||||
f"'{frame_template_id}' — cannot enumerate internal_envelope."
|
||||
),
|
||||
}
|
||||
envelope = frame_contract.get("internal_envelope")
|
||||
if not isinstance(envelope, dict):
|
||||
return {
|
||||
**base, "feasible": False, "envelope_present": False,
|
||||
"candidates_considered": [], "selected_variant": None,
|
||||
"css_overrides": None,
|
||||
"failure_reason": (
|
||||
f"frame contract '{frame_template_id}' does not declare "
|
||||
f"internal_envelope — cascade should escalate to frame_reselect."
|
||||
),
|
||||
}
|
||||
variants = list(envelope.get("variants") or [])
|
||||
candidates_considered = [v.get("name") for v in variants if isinstance(v, dict)]
|
||||
if not variants:
|
||||
return {
|
||||
**base, "feasible": False, "envelope_present": True,
|
||||
"envelope_keys": sorted(envelope.keys()),
|
||||
"candidates_considered": candidates_considered,
|
||||
"selected_variant": None, "css_overrides": None,
|
||||
"failure_reason": (
|
||||
f"frame contract '{frame_template_id}' internal_envelope "
|
||||
f"declares no variants — no sub-mechanism available."
|
||||
),
|
||||
}
|
||||
excess_y = 0
|
||||
if isinstance(overflow_zone, dict):
|
||||
ey = overflow_zone.get("excess_y")
|
||||
if isinstance(ey, (int, float)) and ey > 0:
|
||||
excess_y = int(math.ceil(float(ey)))
|
||||
selected: Optional[dict] = None
|
||||
for variant in variants:
|
||||
if not isinstance(variant, dict):
|
||||
continue
|
||||
budget = variant.get("excess_budget_px")
|
||||
if not isinstance(budget, (int, float)):
|
||||
continue
|
||||
if int(budget) >= excess_y:
|
||||
selected = variant
|
||||
break
|
||||
if selected is None:
|
||||
return {
|
||||
**base, "feasible": False, "envelope_present": True,
|
||||
"envelope_keys": sorted(envelope.keys()),
|
||||
"candidates_considered": candidates_considered,
|
||||
"selected_variant": None, "css_overrides": None,
|
||||
"excess_y": excess_y,
|
||||
"failure_reason": (
|
||||
f"all {len(variants)} internal_envelope variant(s) for "
|
||||
f"'{frame_template_id}' have excess_budget_px below excess_y="
|
||||
f"{excess_y}px — internal fit cannot absorb overflow."
|
||||
),
|
||||
}
|
||||
overrides = selected.get("css_overrides") or {}
|
||||
return {
|
||||
**base, "feasible": True, "envelope_present": True,
|
||||
"envelope_keys": sorted(envelope.keys()),
|
||||
"candidates_considered": candidates_considered,
|
||||
"selected_variant": selected.get("name"),
|
||||
"selected_variant_budget_px": int(selected.get("excess_budget_px") or 0),
|
||||
"excess_y": excess_y,
|
||||
"css_overrides": dict(overrides),
|
||||
}
|
||||
|
||||
|
||||
def apply_frame_internal_fit_candidate_css(plan: dict) -> Optional[str]:
|
||||
"""Build a frame-scoped CSS snippet from a feasible frame_internal_fit
|
||||
plan. Returns None when plan is infeasible. u6 / u7 inject the snippet
|
||||
into the per-slide style override and re-render.
|
||||
"""
|
||||
if not plan.get("feasible"):
|
||||
return None
|
||||
overrides = plan.get("css_overrides") or {}
|
||||
if not overrides:
|
||||
return None
|
||||
template_id = plan.get("frame_template_id") or ""
|
||||
if not template_id:
|
||||
return None
|
||||
selector = f".zone[data-template-id=\"{template_id}\"]"
|
||||
body_lines = [
|
||||
f" {prop}: {value};" for prop, value in overrides.items()
|
||||
]
|
||||
return f"{selector} {{\n" + "\n".join(body_lines) + "\n}"
|
||||
|
||||
301
src/phase_z2_reuse_snapshot.py
Normal file
301
src/phase_z2_reuse_snapshot.py
Normal file
@@ -0,0 +1,301 @@
|
||||
"""IMP-43 (#72) u2 — Step 6 reuse snapshot schema (JSON-only).
|
||||
|
||||
Stage 2 plan (locked) — ``--reuse-from PREV_RUN_ID`` reuses the
|
||||
Step 0 / 1 / 2 / 5 / 6 deterministic artifact subset plus the
|
||||
in-memory state that downstream steps need but that the existing
|
||||
``step02_normalized.json`` / ``step05_v4_evidence.json`` /
|
||||
``step06_composition_plan.json`` artifacts do not capture in a
|
||||
deserialize-ready form (e.g. ``CompositionUnit`` instances,
|
||||
``comp_debug``, ``v4_fallback_traces`` raw map, pre-override
|
||||
``layout_preset``). This module owns the schema for the additional
|
||||
``_reuse_snapshot.json`` sidecar written next to ``step06_composition_plan.json``.
|
||||
|
||||
Scope (u2 only, Stage 2 unit split):
|
||||
* Pure schema + serializers + validator. No file I/O.
|
||||
* JSON-only — pickle is forbidden per Stage 2 guardrails.
|
||||
* Provenance per top-level field: ``{value, source_path, upstream_step}``.
|
||||
* ``mdx_sha256`` integrity key — ``--reuse-from`` must fail closed when
|
||||
the prev run's MDX bytes don't match the current MDX bytes.
|
||||
* ``schema_version`` — bumped on any non-additive shape change.
|
||||
|
||||
Out of scope (deferred to later units):
|
||||
* Writing the snapshot into the run_dir (u3).
|
||||
* Copy / restore on ``--reuse-from`` (u4).
|
||||
* Fail-closed snapshot/path errors at restore time (u4b).
|
||||
* Threading ``reuse_from`` through ``run_phase_z2_mvp1`` (u5).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
SNAPSHOT_VERSION = 1
|
||||
SNAPSHOT_FILENAME = "_reuse_snapshot.json"
|
||||
|
||||
|
||||
# Required top-level keys. Bare scalars (no provenance wrapper):
|
||||
# - schema_version (contract key)
|
||||
# - mdx_sha256 (integrity key)
|
||||
# All other keys are wrapped {value, source_path, upstream_step}.
|
||||
REQUIRED_TOP_LEVEL_KEYS: tuple[str, ...] = (
|
||||
"schema_version",
|
||||
"mdx_sha256",
|
||||
"slide_title",
|
||||
"slide_footer",
|
||||
"sections",
|
||||
"stage0_adapter_diagnostics",
|
||||
"stage0_normalized_assets",
|
||||
"v4_evidence",
|
||||
"layout_preset_pre_override",
|
||||
"units",
|
||||
"comp_debug",
|
||||
"v4_fallback_traces",
|
||||
"ai_preflight",
|
||||
)
|
||||
|
||||
_BARE_KEYS: frozenset[str] = frozenset({"schema_version", "mdx_sha256"})
|
||||
|
||||
|
||||
def _wrap(value: Any, *, source_path: str, upstream_step: str) -> dict[str, Any]:
|
||||
return {
|
||||
"value": value,
|
||||
"source_path": source_path,
|
||||
"upstream_step": upstream_step,
|
||||
}
|
||||
|
||||
|
||||
def serialize_section(section: Any) -> dict[str, Any]:
|
||||
"""Serialize an ``MdxSection``-shaped object into a JSON-safe dict.
|
||||
|
||||
Duck-typed: accepts the production ``MdxSection`` dataclass or any
|
||||
object exposing the same attribute names. Preserves the subset of
|
||||
fields needed to reconstruct downstream pipeline behavior on the
|
||||
reuse path.
|
||||
"""
|
||||
return {
|
||||
"section_id": section.section_id,
|
||||
"section_num": section.section_num,
|
||||
"title": section.title,
|
||||
"raw_content": section.raw_content,
|
||||
"heading_number": getattr(section, "heading_number", None),
|
||||
"v4_alias_keys": list(getattr(section, "v4_alias_keys", []) or []),
|
||||
"sub_sections": list(getattr(section, "sub_sections", []) or []),
|
||||
}
|
||||
|
||||
|
||||
def serialize_unit(unit: Any) -> dict[str, Any]:
|
||||
"""Serialize a ``CompositionUnit``-shaped object into a JSON-safe dict.
|
||||
|
||||
``v4_candidates`` entries are V4Match-duck-typed per the
|
||||
CompositionUnit docstring; each is unwrapped to its 6 named
|
||||
attributes so the snapshot file does not pin V4Match's dataclass
|
||||
layout. ``v4_rank`` is included so the reuse path's Step 9
|
||||
application-plan payload (``_build_application_plan_unit``)
|
||||
remains byte-equivalent to the full-rerun path — full rerun stamps
|
||||
each candidate's rank via ``_v4_match_from_judgment`` (e.g. 1, 2,
|
||||
3, …) and Step 9 surfaces it under ``v4_candidates[i].v4_rank``.
|
||||
Persisting it here lets the rehydrated ``_RehydratedV4Candidate``
|
||||
expose the same attribute end-to-end and avoids None drift in the
|
||||
Step 13 equivalence comparison (u7a).
|
||||
"""
|
||||
return {
|
||||
"source_section_ids": list(unit.source_section_ids),
|
||||
"merge_type": unit.merge_type,
|
||||
"frame_template_id": unit.frame_template_id,
|
||||
"frame_id": unit.frame_id,
|
||||
"frame_number": unit.frame_number,
|
||||
"confidence": float(unit.confidence),
|
||||
"label": unit.label,
|
||||
"phase_z_status": unit.phase_z_status,
|
||||
"raw_content": unit.raw_content,
|
||||
"title": unit.title,
|
||||
"v4_rank": unit.v4_rank,
|
||||
"selection_path": unit.selection_path,
|
||||
"fallback_reason": unit.fallback_reason,
|
||||
"score": float(unit.score),
|
||||
"rationale": dict(unit.rationale or {}),
|
||||
"auto_selectable": bool(unit.auto_selectable),
|
||||
"filter_reasons": list(unit.filter_reasons or []),
|
||||
"notes": list(unit.notes or []),
|
||||
"v4_candidates": [
|
||||
{
|
||||
"template_id": c.template_id,
|
||||
"frame_id": c.frame_id,
|
||||
"frame_number": c.frame_number,
|
||||
"confidence": float(c.confidence),
|
||||
"label": c.label,
|
||||
"v4_rank": getattr(c, "v4_rank", None),
|
||||
}
|
||||
for c in (unit.v4_candidates or [])
|
||||
],
|
||||
"provisional": bool(getattr(unit, "provisional", False)),
|
||||
}
|
||||
|
||||
|
||||
def build_snapshot(
|
||||
*,
|
||||
mdx_sha256: str,
|
||||
slide_title: Optional[str],
|
||||
slide_footer: Optional[str],
|
||||
sections: list,
|
||||
stage0_adapter_diagnostics: Optional[dict],
|
||||
stage0_normalized_assets: Optional[dict],
|
||||
v4_evidence: list,
|
||||
layout_preset_pre_override: Optional[str],
|
||||
units: list,
|
||||
comp_debug: Optional[dict],
|
||||
v4_fallback_traces: Optional[dict],
|
||||
ai_preflight: Optional[dict],
|
||||
) -> dict[str, Any]:
|
||||
"""Build a JSON-serializable Step 6 reuse snapshot with provenance.
|
||||
|
||||
Each top-level entry — except the two bare contract / integrity
|
||||
keys (``schema_version``, ``mdx_sha256``) — is wrapped with
|
||||
``{value, source_path, upstream_step}``.
|
||||
|
||||
The function calls ``json.dumps(snapshot)`` at the end to enforce
|
||||
JSON-safety at build time: any latent non-JSON value (set, Path,
|
||||
dataclass instance, etc.) raises ``TypeError`` at the call site,
|
||||
not later at restore.
|
||||
"""
|
||||
snapshot: dict[str, Any] = {
|
||||
"schema_version": SNAPSHOT_VERSION,
|
||||
"mdx_sha256": mdx_sha256,
|
||||
"slide_title": _wrap(
|
||||
slide_title,
|
||||
source_path="steps/step02_normalized.json#/slide_title",
|
||||
upstream_step="step02",
|
||||
),
|
||||
"slide_footer": _wrap(
|
||||
slide_footer,
|
||||
source_path="steps/step02_normalized.json#/slide_footer",
|
||||
upstream_step="step02",
|
||||
),
|
||||
"sections": _wrap(
|
||||
[serialize_section(s) for s in sections],
|
||||
source_path="steps/step02_normalized.json#/sections",
|
||||
upstream_step="step02",
|
||||
),
|
||||
"stage0_adapter_diagnostics": _wrap(
|
||||
dict(stage0_adapter_diagnostics or {}),
|
||||
source_path="steps/step02_normalized.json#/stage0_adapter_diagnostics",
|
||||
upstream_step="step02",
|
||||
),
|
||||
"stage0_normalized_assets": _wrap(
|
||||
dict(stage0_normalized_assets or {}),
|
||||
source_path="steps/step02_normalized.json#/stage0_normalized_assets",
|
||||
upstream_step="step02",
|
||||
),
|
||||
"v4_evidence": _wrap(
|
||||
list(v4_evidence or []),
|
||||
source_path="steps/step05_v4_evidence.json#/evidence_per_section",
|
||||
upstream_step="step05",
|
||||
),
|
||||
"layout_preset_pre_override": _wrap(
|
||||
layout_preset_pre_override,
|
||||
source_path="steps/step06_composition_plan.json#/layout_preset_decided",
|
||||
upstream_step="step06",
|
||||
),
|
||||
"units": _wrap(
|
||||
[serialize_unit(u) for u in units],
|
||||
source_path="steps/step06_composition_plan.json#/selected_units",
|
||||
upstream_step="step06",
|
||||
),
|
||||
"comp_debug": _wrap(
|
||||
dict(comp_debug or {}),
|
||||
source_path="steps/step06_composition_plan.json#/*",
|
||||
upstream_step="step06",
|
||||
),
|
||||
"v4_fallback_traces": _wrap(
|
||||
dict(v4_fallback_traces or {}),
|
||||
# v4_fallback_traces is assembled inside run_phase_z2_mvp1
|
||||
# (see phase_z2_pipeline.py around the Step 5/6 boundary) and
|
||||
# surfaces only partially into step06_composition_plan.json
|
||||
# via the v4_fallback_summary / imp48_resplit fields. The
|
||||
# canonical untruncated source is the in-memory dict at end
|
||||
# of Step 6 — that's what the reuse path needs.
|
||||
source_path="phase_z2_pipeline.run_phase_z2_mvp1::v4_fallback_traces",
|
||||
upstream_step="step06",
|
||||
),
|
||||
"ai_preflight": _wrap(
|
||||
dict(ai_preflight or {}),
|
||||
source_path="steps/step00_preconditions.json#/ai_preflight",
|
||||
upstream_step="step00",
|
||||
),
|
||||
}
|
||||
json.dumps(snapshot)
|
||||
return snapshot
|
||||
|
||||
|
||||
class SnapshotValidationError(ValueError):
|
||||
"""Raised by ``validate_snapshot`` when the snapshot is structurally
|
||||
unusable or fails the ``mdx_sha256`` integrity check.
|
||||
|
||||
Subclass of ``ValueError`` so existing ``except ValueError`` callers
|
||||
(u4b will add a tighter ``except SnapshotValidationError``) still
|
||||
catch it without escaping to the outer CLI.
|
||||
"""
|
||||
|
||||
|
||||
def validate_snapshot(
|
||||
snapshot: Any,
|
||||
*,
|
||||
expected_mdx_sha256: str,
|
||||
) -> None:
|
||||
"""Validate a loaded snapshot dict (fail-closed).
|
||||
|
||||
Raises ``SnapshotValidationError`` when:
|
||||
* ``snapshot`` is not a dict
|
||||
* ``schema_version`` is missing or != ``SNAPSHOT_VERSION``
|
||||
* ``mdx_sha256`` is missing, non-string, or doesn't match
|
||||
``expected_mdx_sha256``
|
||||
* any required top-level key is missing
|
||||
* a wrapped entry doesn't expose ``{value, source_path, upstream_step}``
|
||||
|
||||
Returns ``None`` on success.
|
||||
|
||||
Callers (u4b) translate the raised error into an exit-code-2 abort
|
||||
with the failing axis surfaced as `value + path + upstream`
|
||||
(factual-verification guardrail).
|
||||
"""
|
||||
if not isinstance(snapshot, dict):
|
||||
raise SnapshotValidationError(
|
||||
f"snapshot is not a dict (got {type(snapshot).__name__})"
|
||||
)
|
||||
|
||||
version = snapshot.get("schema_version")
|
||||
if version != SNAPSHOT_VERSION:
|
||||
raise SnapshotValidationError(
|
||||
f"schema_version mismatch: expected {SNAPSHOT_VERSION!r}, got {version!r}"
|
||||
)
|
||||
|
||||
actual_sha = snapshot.get("mdx_sha256")
|
||||
if not isinstance(actual_sha, str) or not actual_sha:
|
||||
raise SnapshotValidationError(
|
||||
f"mdx_sha256 missing or non-string: got {actual_sha!r}"
|
||||
)
|
||||
if actual_sha != expected_mdx_sha256:
|
||||
raise SnapshotValidationError(
|
||||
f"mdx_sha256 mismatch: snapshot={actual_sha!r} "
|
||||
f"expected={expected_mdx_sha256!r}"
|
||||
)
|
||||
|
||||
missing = [k for k in REQUIRED_TOP_LEVEL_KEYS if k not in snapshot]
|
||||
if missing:
|
||||
raise SnapshotValidationError(
|
||||
f"missing required keys: {missing!r}"
|
||||
)
|
||||
|
||||
for key, entry in snapshot.items():
|
||||
if key in _BARE_KEYS:
|
||||
continue
|
||||
if not isinstance(entry, dict):
|
||||
raise SnapshotValidationError(
|
||||
f"key {key!r}: expected wrapper dict, got {type(entry).__name__}"
|
||||
)
|
||||
for field_name in ("value", "source_path", "upstream_step"):
|
||||
if field_name not in entry:
|
||||
raise SnapshotValidationError(
|
||||
f"key {key!r}: wrapper missing {field_name!r}"
|
||||
)
|
||||
@@ -25,13 +25,27 @@ from typing import Optional
|
||||
# ─── §4 mapping table (spec PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC §4) ──
|
||||
|
||||
# category → proposed_action (primary)
|
||||
# IMP-88 (#88) u1 (2026-05-24): two ACTION_BY_CATEGORY edits to align the
|
||||
# primary router surface with PHASE-Z-PIPELINE-OVERVIEW.md Step 16 + Step 17
|
||||
# spec (anchor PHASE-Z-PIPELINE-OVERVIEW.md:321):
|
||||
# 1. NEW row `image_aspect_mismatch → image_fit` — closes the unmapped
|
||||
# classifier emission (phase_z2_classifier.py:434-447) that previously
|
||||
# returned proposed_action=None and stalled visual_check on overflow
|
||||
# runs carrying image_event payloads.
|
||||
# 2. REMAP `frame_capacity_mismatch → frame_internal_fit_candidate`
|
||||
# (previously frame_reselect) — OVERVIEW.md Step 17 locks
|
||||
# frame_internal_fit_candidate as the per-zone first-pass salvage
|
||||
# *inside* the declared frame envelope; frame_reselect (V4 top-k
|
||||
# alternate frame swap) stays available downstream via the
|
||||
# failure_router cascade (rerender_still_fails → frame_reselect).
|
||||
ACTION_BY_CATEGORY: dict[str, str] = {
|
||||
"minor_overflow": "zone_ratio_retry",
|
||||
"moderate_overflow": "layout_adjust",
|
||||
"structural_minor_overflow": "zone_ratio_retry",
|
||||
"structural_major_overflow": "details_popup_escalation",
|
||||
"tabular_overflow": "details_popup_escalation",
|
||||
"frame_capacity_mismatch": "frame_reselect",
|
||||
"image_aspect_mismatch": "image_fit",
|
||||
"frame_capacity_mismatch": "frame_internal_fit_candidate",
|
||||
"layout_zone_mismatch": "layout_adjust",
|
||||
"hard_visual_fail": "abort",
|
||||
}
|
||||
@@ -48,23 +62,60 @@ ACTION_RATIONALE: dict[str, str] = {
|
||||
"1+ structural unit 완전 잘림 → 의미 손실, popup 으로 escalate",
|
||||
"tabular_overflow":
|
||||
"표는 행 단위로 잘리면 의미 손실 → popup escalate (또는 table-friendly frame reselect)",
|
||||
"image_aspect_mismatch":
|
||||
"image 자연 비율과 렌더 비율 mismatch → frame 내부 image fit (object-fit / "
|
||||
"max-w/h) 로 envelope 안에서 비율 회복. 공통 image CSS 변경 X (frame-scoped).",
|
||||
"frame_capacity_mismatch":
|
||||
"composition capacity_fit 가 이미 mismatch 신호 → V4 top-k 의 다른 frame 평가",
|
||||
"composition capacity_fit 가 이미 mismatch 신호 → frame contract envelope "
|
||||
"안 internal fit 변형 (density / line rhythm / row 배치) 우선. "
|
||||
"frame swap 은 cascade 다음 단계 (rerender_still_fails → frame_reselect).",
|
||||
"layout_zone_mismatch":
|
||||
"frame root 자체 overflow → layout preset 변경 또는 zone 키움",
|
||||
"hard_visual_fail":
|
||||
"위 매핑 모두 미적용 — 마지막 fallback (현재 코드는 sys.exit 으로 abort)",
|
||||
}
|
||||
|
||||
# 각 action 의 *현재 코드* 구현 상태 (2026-04-29 기준)
|
||||
# 각 action 의 *현재 코드* 구현 상태 (2026-04-29 기준; IMP-12 u7 cascade 2026-05-18;
|
||||
# IMP-35 u3 popup-stub 2026-05-23)
|
||||
# A2 단계에서 이 매핑이 *어디까지 자동 처리되고 어디서 막히는지* trace 확보용
|
||||
ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
|
||||
"zone_ratio_retry": "IMPLEMENTED", # A3 (2026-04-29) phase_z2_retry.plan_zone_ratio_retry + pipeline orchestration
|
||||
"layout_adjust": "MISSING",
|
||||
"details_popup_escalation": "MISSING", # CLAUDE.md 의 <details> 원칙은 있음, runtime 미구현
|
||||
# IMP-88 (#88) u1→u7 (2026-05-24): three Step 17 retry actions registered
|
||||
# here. u1 added the data-surface rows (initial state MISSING). u3/u4/u5
|
||||
# landed the deterministic planners in src/phase_z2_retry.py. u6 wired the
|
||||
# salvage dispatcher (_attempt_salvage_chain), and u7 wired the Step 17
|
||||
# entry runtime (_attempt_step17_image_fit_single_pass + §11.7.1/§11.7.2).
|
||||
# Status flips MISSING→IMPLEMENTED land here on u7 completion — once the
|
||||
# end-to-end path (planner + apply + dispatcher + entry) is wired the
|
||||
# action is IMPLEMENTED on the deterministic surface. (Same convention as
|
||||
# IMP-12 u7 cascade rows below: planner-surface availability + orchestrator
|
||||
# wiring together constitute IMPLEMENTED; route_action's
|
||||
# implementation_status field reflects surface availability, not whether a
|
||||
# given pipeline run has invoked the action.)
|
||||
"layout_adjust": "IMPLEMENTED", # u3 plan_layout_adjust + u6 dispatcher branch + u7 cascade entry
|
||||
"image_fit": "IMPLEMENTED", # u4 plan_image_fit + u7 _attempt_step17_image_fit_single_pass entry
|
||||
"frame_internal_fit_candidate": "IMPLEMENTED", # u5 plan_frame_internal_fit_candidate + u6 dispatcher branch + u7 cascade entry
|
||||
# IMP-35 (#64) u3 — MISSING → IMPLEMENTED on the primary router surface.
|
||||
# `plan_details_popup_escalation` (below) provides the deterministic stub
|
||||
# that downstream units consume: u4 binds the AI split-decision contract
|
||||
# in `src/phase_z2_ai_fallback/step17.py`; u5 wires the Step 17 POPUP
|
||||
# gate executor in `src/phase_z2_pipeline.py`. Router-level mapping is
|
||||
# decoupled from orchestrator wiring (same precedent as the IMP-12 u7
|
||||
# cascade actions below): IMPLEMENTED here reflects deterministic
|
||||
# *surface availability* (importable stub), not whether a given pipeline
|
||||
# run has invoked it. The failure_router companion surface
|
||||
# (NEXT_ACTION_IMPLEMENTATION_STATUS in phase_z2_failure_router.py) keeps
|
||||
# `details_popup_escalation` as MISSING until u5 lands the pipeline gate.
|
||||
"details_popup_escalation": "IMPLEMENTED",
|
||||
"frame_reselect": "PARTIAL", # IMP-05 pre-render rank-2/3 fallback implemented; post-render rerender trace-only
|
||||
"adapter_needed": "PARTIAL", # composition v0.1.1 의 mapper FitError catch
|
||||
"abort": "IMPLEMENTED", # sys.exit(1) — pipeline 의 현재 default
|
||||
# IMP-12 u7 (2026-05-18): cascade-only salvage actions (no ACTION_BY_CATEGORY row;
|
||||
# surfaced via NEXT_ACTION_BY_FAILURE in phase_z2_failure_router). plan/apply pairs
|
||||
# implemented in phase_z2_retry; pipeline orchestrator wiring lands in u8/u9.
|
||||
"cross_zone_redistribute": "IMPLEMENTED", # u4 phase_z2_retry.plan_cross_zone_redistribute + apply_cross_zone_redistribute_css
|
||||
"glue_compression": "IMPLEMENTED", # u5 phase_z2_retry.plan_glue_compression + apply_glue_compression_css
|
||||
"font_step_compression": "IMPLEMENTED", # u6 phase_z2_retry.plan_font_step_compression + apply_font_step_compression_css
|
||||
}
|
||||
|
||||
|
||||
@@ -179,3 +230,112 @@ def route_fit_classification(fit_classification: dict) -> dict:
|
||||
"MISSING 이면 그 action 은 실행 X 이고 기존 abort/status 흐름 (sys.exit(1)) 으로 종료."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ─── IMP-35 (#64) u3 — details_popup_escalation deterministic stub ─
|
||||
# Surface contract for the cascade-terminal popup escalation. This stub
|
||||
# does NOT mutate HTML / CSS / MDX content; it emits the canonical plan
|
||||
# marker that the Step 17 POPUP gate (u5) and the AI split-decision hook
|
||||
# (u4) consume. Keeping the executor surface here (next to the primary
|
||||
# ACTION_BY_CATEGORY mapping) lets the router report IMPLEMENTED for
|
||||
# `details_popup_escalation` while u4/u5 are still landing.
|
||||
#
|
||||
# Contract (locked in Stage 2 IMPLEMENTATION_UNITS u3):
|
||||
# - Inputs: classification dict (a single fit_classifier output row).
|
||||
# The category MUST be one of the two ACTION_BY_CATEGORY
|
||||
# rows that map onto `details_popup_escalation` —
|
||||
# `structural_major_overflow` or `tabular_overflow`.
|
||||
# Other categories raise the stub's defensive guard (so
|
||||
# callers do not silently popup-escalate the wrong category).
|
||||
# - Output: popup_escalation_plan dict with `feasible=True`,
|
||||
# `stub=True`, the source category, the canonical
|
||||
# ACTION_RATIONALE entry, and `needs_split_decision=True`
|
||||
# to flag that u4 (AI hook) must run before u5 renders.
|
||||
# - No side effects (no AI call, no MDX read, no HTML mutation).
|
||||
#
|
||||
# Guardrails honored:
|
||||
# - feedback_ai_isolation_contract: stub is deterministic-with-data;
|
||||
# no AI call inside the router surface.
|
||||
# - Phase Z spacing 방향: stub does not shrink common margins; it
|
||||
# expands capacity by routing content to popup downstream.
|
||||
# - 자세히보기 원칙 (CLAUDE.md): plan carries the marker that u5 uses
|
||||
# to put MDX 원문 in popup body and a summary/subset in preview.
|
||||
# - 1 turn = 1 unit: this is router-surface only. u4/u5 own the
|
||||
# downstream wiring on their respective files.
|
||||
|
||||
|
||||
# Categories that legitimately escalate onto details_popup_escalation
|
||||
# per the ACTION_BY_CATEGORY mapping above. Kept as a derived constant
|
||||
# so the router cannot drift away from the single source of truth.
|
||||
POPUP_ESCALATION_CATEGORIES: frozenset[str] = frozenset(
|
||||
category
|
||||
for category, action in ACTION_BY_CATEGORY.items()
|
||||
if action == "details_popup_escalation"
|
||||
)
|
||||
|
||||
|
||||
def plan_details_popup_escalation(classification: dict) -> dict:
|
||||
"""Cascade-terminal popup escalation plan stub (IMP-35 u3).
|
||||
|
||||
Returns a deterministic popup_escalation_plan marker. The actual
|
||||
content split (popup_html / preview_text / has_popup payload) is
|
||||
composed downstream: u4 binds the AI split-decision contract on
|
||||
`src/phase_z2_ai_fallback/step17.py`; u5 wires the Step 17 POPUP
|
||||
gate executor on `src/phase_z2_pipeline.py`.
|
||||
|
||||
Args:
|
||||
classification: a single fit_classifier classification dict.
|
||||
Must contain a `category` key. Only the categories that
|
||||
map onto `details_popup_escalation` in ACTION_BY_CATEGORY
|
||||
(currently `structural_major_overflow` and `tabular_overflow`)
|
||||
are accepted; any other category produces an
|
||||
`feasible=False` plan with `failure_reason` so the caller
|
||||
never silently popup-escalates the wrong overflow shape.
|
||||
|
||||
Returns:
|
||||
popup_escalation_plan dict with at least:
|
||||
action : "details_popup_escalation"
|
||||
feasible : True/False (True for accepted categories)
|
||||
stub : True (marks u3 surface; u4/u5 fill in)
|
||||
category : echoed from input
|
||||
rationale : canonical ACTION_RATIONALE entry
|
||||
needs_split_decision : True (u4 AI hook must run before u5 renders)
|
||||
mapping_source : "IMP-35 u3 plan_details_popup_escalation stub"
|
||||
note : downstream-wiring pointer text
|
||||
"""
|
||||
category = (classification or {}).get("category")
|
||||
base = {
|
||||
"action": "details_popup_escalation",
|
||||
"stub": True,
|
||||
"category": category,
|
||||
"mapping_source": "IMP-35 u3 plan_details_popup_escalation stub",
|
||||
}
|
||||
if category not in POPUP_ESCALATION_CATEGORIES:
|
||||
return {
|
||||
**base,
|
||||
"feasible": False,
|
||||
"needs_split_decision": False,
|
||||
"rationale": "",
|
||||
"failure_reason": (
|
||||
f"category {category!r} does not map onto details_popup_escalation "
|
||||
f"in ACTION_BY_CATEGORY. Accepted categories: "
|
||||
f"{sorted(POPUP_ESCALATION_CATEGORIES)}. Defensive guard — "
|
||||
f"router must not silently popup-escalate the wrong overflow shape."
|
||||
),
|
||||
"note": (
|
||||
"u3 stub — caller passed a category that should not popup-escalate. "
|
||||
"Honour the ACTION_BY_CATEGORY mapping at the router entry point."
|
||||
),
|
||||
}
|
||||
return {
|
||||
**base,
|
||||
"feasible": True,
|
||||
"needs_split_decision": True,
|
||||
"rationale": ACTION_RATIONALE.get(category, ""),
|
||||
"note": (
|
||||
"u3 stub — actual content split planning lands in u4 "
|
||||
"(AI split-decision contract on src/phase_z2_ai_fallback/step17.py) "
|
||||
"and u5 (Step 17 POPUP gate executor on src/phase_z2_pipeline.py). "
|
||||
"popup body = MDX 원문, preview = summary/subset (자세히보기 원칙)."
|
||||
),
|
||||
}
|
||||
|
||||
335
src/phase_z2_verification_utils.py
Normal file
335
src/phase_z2_verification_utils.py
Normal file
@@ -0,0 +1,335 @@
|
||||
"""Phase Z2 deterministic verification utilities (IMP-16-U1 port).
|
||||
|
||||
Ports the H3 deterministic subset of src/content_verifier.py into a
|
||||
Phase Z-owned module so the Phase Z pipeline never imports the Phase Q
|
||||
reference-only module (which co-hosts H4/H5 Kei/AI assets).
|
||||
|
||||
Scope: deterministic, pure, no I/O, no LLM call, no httpx/SSE.
|
||||
Wiring into Step 1/2/14/21/22 is gated behind IMP-07 (see
|
||||
docs/architecture/IMP-16-U2-WIRING-DESIGN.md when u11 lands).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from difflib import SequenceMatcher
|
||||
from html.parser import HTMLParser
|
||||
|
||||
|
||||
@dataclass
|
||||
class VerificationResult:
|
||||
"""Single-axis deterministic verification outcome.
|
||||
|
||||
Mirrors the Phase Q VerificationResult shape so callers ported from
|
||||
that surface keep their field access; the value semantics are
|
||||
Phase Z-owned (no Phase Q area defaults baked in).
|
||||
"""
|
||||
|
||||
passed: bool
|
||||
area_name: str
|
||||
checks: dict[str, bool] = field(default_factory=dict)
|
||||
score: float = 0.0
|
||||
errors: list[str] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class _TextExtractor(HTMLParser):
|
||||
"""Extract visible text only. Skips <style> and <script> bodies.
|
||||
|
||||
Pure stdlib (html.parser). Whitespace-only data chunks are dropped;
|
||||
surviving chunks are stripped before appending to preserve token
|
||||
boundaries for downstream normalization / keyword logic.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.texts: list[str] = []
|
||||
self._skip = False
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag in ("style", "script"):
|
||||
self._skip = True
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag in ("style", "script"):
|
||||
self._skip = False
|
||||
|
||||
def handle_data(self, data):
|
||||
if not self._skip:
|
||||
stripped = data.strip()
|
||||
if stripped:
|
||||
self.texts.append(stripped)
|
||||
|
||||
|
||||
def extract_text_from_html(html: str) -> list[str]:
|
||||
"""Return ordered list of visible text fragments from an HTML string.
|
||||
|
||||
Deterministic, pure: no I/O, no LLM, no network. Used by Phase Z
|
||||
verification to compare reverse-path HTML against MDX text without
|
||||
importing the Phase Q reference-only module.
|
||||
"""
|
||||
parser = _TextExtractor()
|
||||
parser.feed(html)
|
||||
return parser.texts
|
||||
|
||||
|
||||
_PARTICLES: list[str] = sorted(
|
||||
["에서", "으로", "부터", "까지", "에게", "한테",
|
||||
"은", "는", "이", "가", "을", "를", "에", "의",
|
||||
"로", "와", "과", "도", "만", "께"],
|
||||
key=len, reverse=True,
|
||||
)
|
||||
|
||||
_ENDING_NORMALIZE: dict[str, str] = {
|
||||
"있음": "있다", "됨": "된다", "함": "한다", "임": "이다",
|
||||
"없음": "없다", "았음": "았다", "었음": "었다",
|
||||
}
|
||||
|
||||
|
||||
def normalize_for_comparison(text: str) -> str:
|
||||
"""Normalize text for deterministic comparison (Phase Z H3 port).
|
||||
|
||||
Steps (order matters): collapse whitespace, strip bullet markers,
|
||||
decode the small HTML-entity set used by the reverse path, then
|
||||
fold a single trailing 개조식 ending to its 서술형 form.
|
||||
"""
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
text = re.sub(r"[•◦·\-▪▸►]", "", text).strip()
|
||||
text = text.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
text = text.replace(" ", " ").replace("'", "'").replace(""", '"')
|
||||
for gaejo, seosul in _ENDING_NORMALIZE.items():
|
||||
if text.endswith(gaejo):
|
||||
text = text[: -len(gaejo)] + seosul
|
||||
break
|
||||
return text
|
||||
|
||||
|
||||
def extract_keywords(text: str) -> list[str]:
|
||||
"""Extract length>=3 tokens, then strip a trailing Korean particle.
|
||||
|
||||
Deterministic, pure: tokenises on the Phase Z H3 character class
|
||||
``[가-힣a-zA-Z0-9()]+``, drops tokens shorter than 3 characters,
|
||||
and folds a single longest-match trailing particle from
|
||||
``_PARTICLES`` when the remaining stem is still length >= 2.
|
||||
"""
|
||||
words = re.findall(r"[가-힣a-zA-Z0-9()]+", text)
|
||||
keywords: list[str] = []
|
||||
for w in words:
|
||||
if len(w) < 3:
|
||||
continue
|
||||
for p in _PARTICLES:
|
||||
if w.endswith(p) and len(w) - len(p) >= 2:
|
||||
w = w[: -len(p)]
|
||||
break
|
||||
if len(w) >= 2:
|
||||
keywords.append(w)
|
||||
return keywords
|
||||
|
||||
|
||||
_META_PREFIXES: list[str] = [
|
||||
"제목 라벨:",
|
||||
"표현 의도:",
|
||||
"슬라이드 주인공",
|
||||
"가장 큰 시각적 비중",
|
||||
"시각적으로",
|
||||
"간결하게 제기",
|
||||
"개별 증거로 제시",
|
||||
"계층적으로 시각화",
|
||||
]
|
||||
|
||||
_META_INLINE_FRAGMENTS: tuple[str, ...] = (
|
||||
"현상-문제 인과관계",
|
||||
"상위-하위 포함 관계",
|
||||
"독립적 나열",
|
||||
)
|
||||
|
||||
|
||||
def strip_meta_lines(text: str) -> str:
|
||||
"""Drop Kei prompt meta/instruction lines before verification.
|
||||
|
||||
A line is dropped if its stripped form starts with any prefix in
|
||||
``_META_PREFIXES`` (e.g. ``제목 라벨:``) or contains any inline
|
||||
expression-hint fragment in ``_META_INLINE_FRAGMENTS`` (e.g.
|
||||
``현상-문제 인과관계``). These are prompt directives, not slide
|
||||
content; they must not enter sentence/keyword extraction for the
|
||||
B-2 reverse path. Deterministic, pure: no I/O, no LLM, no regex
|
||||
against runtime data.
|
||||
"""
|
||||
filtered: list[str] = []
|
||||
for line in text.split("\n"):
|
||||
stripped = line.strip()
|
||||
if any(stripped.startswith(prefix) for prefix in _META_PREFIXES):
|
||||
continue
|
||||
if any(fragment in stripped for fragment in _META_INLINE_FRAGMENTS):
|
||||
continue
|
||||
filtered.append(line)
|
||||
return "\n".join(filtered)
|
||||
|
||||
|
||||
_BULLET_MARKER_PATTERN = re.compile(r"^[\-•◦·\d]+[.)]\s*")
|
||||
_SENTENCE_SPLIT_PATTERN = re.compile(r"(?<=\.)\s+")
|
||||
_MIN_SENTENCE_LEN = 5
|
||||
|
||||
|
||||
def split_into_sentences(text: str) -> list[str]:
|
||||
"""Split text into sentences for deterministic comparison.
|
||||
|
||||
Pipeline (order matters): drop Kei meta/instruction lines via
|
||||
``strip_meta_lines``, split on newline, skip empties and ``#``-led
|
||||
header lines, strip any leading bullet/numeric marker matching
|
||||
``_BULLET_MARKER_PATTERN``, then split on inter-sentence whitespace
|
||||
following a period. Parts shorter than ``_MIN_SENTENCE_LEN`` are
|
||||
dropped so single-token noise (e.g. residual punctuation) cannot
|
||||
enter the preservation/invented-text checks.
|
||||
"""
|
||||
text = strip_meta_lines(text)
|
||||
sentences: list[str] = []
|
||||
for line in text.split("\n"):
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
line = _BULLET_MARKER_PATTERN.sub("", line).strip()
|
||||
if not line:
|
||||
continue
|
||||
for part in _SENTENCE_SPLIT_PATTERN.split(line):
|
||||
part = part.strip()
|
||||
if len(part) >= _MIN_SENTENCE_LEN:
|
||||
sentences.append(part)
|
||||
return sentences
|
||||
|
||||
|
||||
_SENTENCE_KEYWORD_MATCH_THRESHOLD = 0.6
|
||||
_SENTENCE_SEQUENCE_MATCH_THRESHOLD = 0.65
|
||||
|
||||
|
||||
def _sentence_matches_html(
|
||||
sentence: str,
|
||||
html_combined: str,
|
||||
html_texts: list[str],
|
||||
) -> bool:
|
||||
"""Return True if ``sentence`` is preserved in the HTML side.
|
||||
|
||||
Two-axis match: a keyword-ratio gate against ``html_combined`` (the
|
||||
pre-normalized join of all visible HTML text fragments) and a
|
||||
SequenceMatcher fallback against each individual normalized HTML
|
||||
fragment. A sentence whose keyword set is empty after normalization
|
||||
is treated as preserved (no falsifiable signal). Pure helper used
|
||||
by ``verify_text_preservation`` (u8); thresholds are lifted to
|
||||
named module constants so the surface is auditable.
|
||||
"""
|
||||
norm_orig = normalize_for_comparison(sentence)
|
||||
keywords = extract_keywords(norm_orig)
|
||||
if not keywords:
|
||||
return True
|
||||
kw_found = sum(1 for kw in keywords if kw in html_combined)
|
||||
kw_ratio = kw_found / len(keywords)
|
||||
best_ratio = 0.0
|
||||
for html_text in html_texts:
|
||||
norm_html = normalize_for_comparison(html_text)
|
||||
ratio = SequenceMatcher(None, norm_orig, norm_html).ratio()
|
||||
if ratio > best_ratio:
|
||||
best_ratio = ratio
|
||||
return (
|
||||
kw_ratio >= _SENTENCE_KEYWORD_MATCH_THRESHOLD
|
||||
or best_ratio >= _SENTENCE_SEQUENCE_MATCH_THRESHOLD
|
||||
)
|
||||
|
||||
|
||||
_TEXT_PRESERVATION_DEFAULT_THRESHOLD = 0.70
|
||||
_MISSING_SENTENCE_REPORT_LIMIT = 5
|
||||
_MISSING_SENTENCE_TRUNCATE_LEN = 60
|
||||
|
||||
|
||||
def verify_text_preservation(
|
||||
original_mdx: str,
|
||||
generated_html: str,
|
||||
area_name: str,
|
||||
threshold: float = _TEXT_PRESERVATION_DEFAULT_THRESHOLD,
|
||||
) -> VerificationResult:
|
||||
"""Verify the original MDX text is preserved in the generated HTML.
|
||||
|
||||
Splits MDX via u6, pre-normalizes joined HTML via u2+u3, then per
|
||||
sentence delegates to u7. Empty sentence list -> passed True,
|
||||
score 1.0. Missing sentences are capped at the report limit and
|
||||
each truncated to the truncate length constant.
|
||||
"""
|
||||
original_sentences = split_into_sentences(original_mdx)
|
||||
if not original_sentences:
|
||||
return VerificationResult(passed=True, area_name=area_name,
|
||||
checks={"text_preservation": True}, score=1.0)
|
||||
html_texts = extract_text_from_html(generated_html)
|
||||
html_combined = normalize_for_comparison(" ".join(html_texts))
|
||||
matched = 0
|
||||
missing: list[str] = []
|
||||
for sentence in original_sentences:
|
||||
if _sentence_matches_html(sentence, html_combined, html_texts):
|
||||
matched += 1
|
||||
else:
|
||||
missing.append(sentence)
|
||||
score = matched / len(original_sentences)
|
||||
passed = score >= threshold
|
||||
errors: list[str] = []
|
||||
if not passed:
|
||||
errors = [f"누락 문장 ({len(missing)}/{len(original_sentences)}):"]
|
||||
for s in missing[:_MISSING_SENTENCE_REPORT_LIMIT]:
|
||||
errors.append(
|
||||
f" - \"{s[:_MISSING_SENTENCE_TRUNCATE_LEN]}...\""
|
||||
if len(s) > _MISSING_SENTENCE_TRUNCATE_LEN else f" - \"{s}\""
|
||||
)
|
||||
warnings = ([f"보존율: {score:.0%} ({matched}/{len(original_sentences)} 문장)"]
|
||||
if score < 1.0 else [])
|
||||
return VerificationResult(
|
||||
passed=passed, area_name=area_name,
|
||||
checks={"text_preservation": passed}, score=score,
|
||||
errors=errors, warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
_INVENTED_TEXT_MIN_LENGTH = 15
|
||||
_INVENTED_TEXT_ALLOWED_LABELS: frozenset[str] = frozenset({
|
||||
"용어 정의", "핵심 메시지", "상세 비교",
|
||||
})
|
||||
_INVENTED_TEXT_CSS_NUMBER_PATTERN = re.compile(r"^[\d\s.,%px#rgb()]+$")
|
||||
_INVENTED_TEXT_KEYWORD_THRESHOLD = 0.4
|
||||
_INVENTED_TEXT_TRUNCATE_LEN = 80
|
||||
|
||||
|
||||
def detect_invented_text(
|
||||
original_mdx: str,
|
||||
generated_html: str,
|
||||
min_length: int = _INVENTED_TEXT_MIN_LENGTH,
|
||||
) -> list[str]:
|
||||
"""Detect HTML text fragments that are not anchored in the source MDX.
|
||||
|
||||
Phase Z port of the H3 hallucination guard (Phase Q reference:
|
||||
``src/content_verifier.py:276-315``). Pipeline (order matters):
|
||||
drop short fragments (< ``min_length``), drop structural label
|
||||
exceptions in ``_INVENTED_TEXT_ALLOWED_LABELS``, drop CSS/numeric
|
||||
noise matching ``_INVENTED_TEXT_CSS_NUMBER_PATTERN``, then per
|
||||
surviving fragment compute keyword ratio (via u4 ``extract_keywords``
|
||||
on the normalized fragment, checked against the normalized MDX). A
|
||||
fragment is flagged when ``kw_ratio < _INVENTED_TEXT_KEYWORD_THRESHOLD``;
|
||||
flagged values are truncated to ``_INVENTED_TEXT_TRUNCATE_LEN`` chars
|
||||
before being returned. Empty keyword sets short-circuit as
|
||||
non-falsifiable (matches Phase Q parity). Deterministic, pure.
|
||||
"""
|
||||
html_texts = extract_text_from_html(generated_html)
|
||||
norm_mdx = normalize_for_comparison(original_mdx)
|
||||
invented: list[str] = []
|
||||
for text in html_texts:
|
||||
text = text.strip()
|
||||
if len(text) < min_length:
|
||||
continue
|
||||
if text in _INVENTED_TEXT_ALLOWED_LABELS:
|
||||
continue
|
||||
if _INVENTED_TEXT_CSS_NUMBER_PATTERN.match(text):
|
||||
continue
|
||||
norm_text = normalize_for_comparison(text)
|
||||
keywords = extract_keywords(norm_text)
|
||||
if not keywords:
|
||||
continue
|
||||
kw_found = sum(1 for kw in keywords if kw in norm_mdx)
|
||||
kw_ratio = kw_found / len(keywords)
|
||||
if kw_ratio < _INVENTED_TEXT_KEYWORD_THRESHOLD:
|
||||
invented.append(text[:_INVENTED_TEXT_TRUNCATE_LEN])
|
||||
return invented
|
||||
@@ -36,6 +36,7 @@ from src.image_utils import get_image_sizes, embed_images
|
||||
from src.space_allocator import calculate_container_specs
|
||||
from src.slide_measurer import measure_rendered_heights, capture_slide_screenshot
|
||||
from src.config import settings
|
||||
from src.json_utils import parse_json as _parse_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1182,6 +1183,7 @@ async def generate_slide(
|
||||
yield {"event": "progress", "data": "3/7 슬라이드 HTML 생성 중..."}
|
||||
|
||||
async def stage_2(context: PipelineContext) -> dict:
|
||||
# [legacy Phase R'/Q example — INTEGRATION-AUDIT-01 §10.4]
|
||||
# Phase X-BX': Type B는 code_assembled 직접 사용, Sonnet 재구성 스킵
|
||||
if context.analysis.layout_template in ("B", "B'", "B''"):
|
||||
from src.block_assembler import assemble_slide_html_final
|
||||
@@ -1190,6 +1192,7 @@ async def generate_slide(
|
||||
logger.info(f"[Stage 2] Type B: slide-base + 블록 (font_scale={fs:.1f})")
|
||||
return {"generated_html": generated}
|
||||
|
||||
# [legacy Phase R'/Q example — INTEGRATION-AUDIT-01 §10.4]
|
||||
# Type A: 기존 Sonnet 재구성 코드 그대로
|
||||
from src.content_verifier import generate_with_retry
|
||||
|
||||
@@ -1998,6 +2001,7 @@ async def _apply_adjustments(
|
||||
block["detail_target"] = True
|
||||
if "data" in block:
|
||||
del block["data"]
|
||||
# [legacy Phase R'/Q example — INTEGRATION-AUDIT-01 §10.4]
|
||||
block["reason"] = f"재구성: {detail}"
|
||||
logger.info(
|
||||
f"조정: {area} → kei_restructure (detail_target)"
|
||||
@@ -2077,20 +2081,3 @@ def _convert_kei_judgment(
|
||||
new_adjs.append(adj)
|
||||
|
||||
review_result["adjustments"] = new_adjs
|
||||
|
||||
|
||||
def _parse_json(text: str) -> dict[str, Any] | None:
|
||||
"""텍스트에서 JSON을 추출한다."""
|
||||
patterns = [
|
||||
r"```json\s*(.*?)```",
|
||||
r"```\s*(.*?)```",
|
||||
r"(\{.*\})",
|
||||
]
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, text, re.DOTALL)
|
||||
if match:
|
||||
try:
|
||||
return json.loads(match.group(1).strip())
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return None
|
||||
|
||||
137
src/region_marker_stamper.py
Normal file
137
src/region_marker_stamper.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""IMP-94 (#94) u1 — region/content marker stamper for Phase Z final.html.
|
||||
|
||||
Annotates each rendered family-partial root ``<div>`` with stable
|
||||
``data-region-id="..."`` and ``data-content-unit-id="..."`` attributes so
|
||||
downstream Layer A telemetry (placement_trace ↔ DOM parity, Step 21 self-
|
||||
report, fit_classifier read targets §6.4) can resolve a rendered zone
|
||||
back to its PlacementPlan ``slot_assignments[]`` entry.
|
||||
|
||||
DOM contract (single point of truth — mirrored verbatim across the axis) ::
|
||||
|
||||
<div class="..." data-region-id="{region_id}" data-content-unit-id="{cuid}" ...
|
||||
data-frame-id="..." data-template-id="...">
|
||||
|
||||
The anchor is the uniform root-div emitted by every Phase Z family
|
||||
partial under ``templates/phase_z2/families/`` (13 partials, evidence
|
||||
confirmed via ``grep -l data-template-id`` = 13/13). All 13 partials
|
||||
carry the pattern::
|
||||
|
||||
<div class="<fNb>" data-frame-id="..." data-template-id="<family>">
|
||||
|
||||
The stamper finds the FIRST such opening tag with a permissive regex
|
||||
and injects ``data-region-id`` + ``data-content-unit-id`` as new
|
||||
attributes. Existing attributes (class, data-frame-id, data-template-id,
|
||||
etc.) are preserved verbatim. The injection is idempotent — a zone that
|
||||
already carries ``data-region-id`` on its root div is left alone.
|
||||
|
||||
Source of marker values : ``PlacementPlan.slot_assignments[].region_id``
|
||||
and ``.content_unit_id`` (see ``src/phase_z2_placement_planner.py``
|
||||
L253-258). u3 wires the live B4 path; u4 ensures non-live append paths
|
||||
default to ``placement_markers=[]`` so this stamper safely no-ops.
|
||||
|
||||
Forward-compat / safety :
|
||||
- Empty / None ``markers`` → passthrough (returns ``zone_html`` unchanged).
|
||||
- Non-str / empty ``zone_html`` → passthrough.
|
||||
- Re-stamping (idempotent) preserves the first stamp.
|
||||
- Only the FIRST data-template-id root div is stamped (one per zone).
|
||||
- Markers with empty / missing ``region_id`` AND ``content_unit_id`` →
|
||||
passthrough (no attribute injection).
|
||||
|
||||
Guardrails (refs : Stage 1 binding contract, Stage 2 unit u1) :
|
||||
- AI-isolation : pure deterministic Python; no LLM calls.
|
||||
- Additive only : never edits / removes existing attributes.
|
||||
- Idempotent : ``data-region-id`` probe short-circuits before re-inject.
|
||||
- Disjoint from #96 (``data-frame-slot-id`` is a separate axis / attr).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
REGION_ID_ATTR: str = "data-region-id"
|
||||
CONTENT_UNIT_ID_ATTR: str = "data-content-unit-id"
|
||||
|
||||
# Matches the FIRST ``<div ... data-template-id="...">`` opening tag.
|
||||
# Group 1 captures the inner attribute string verbatim (incl. leading
|
||||
# whitespace) so the rewriter can re-emit it unchanged after injection.
|
||||
_ROOT_DIV_TAG_RE = re.compile(
|
||||
r'<div\b((?=[^>]*\bdata-template-id\s*=\s*"[^"]+")[^>]*?)>',
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
# Probe for an existing ``data-region-id`` attribute (any value, any
|
||||
# quote) so re-stamping is idempotent.
|
||||
_HAS_REGION_ID_RE = re.compile(r"""\bdata-region-id\s*=""", flags=re.IGNORECASE)
|
||||
|
||||
|
||||
def _coerce_marker_value(value: Any) -> str:
|
||||
"""Return a safe attribute-value string for ``value``.
|
||||
|
||||
Non-str / None → ''. Strings are returned verbatim (caller responsible
|
||||
for not embedding ``"`` since marker ids derive from
|
||||
PlacementPlan.slot_assignments which are deterministic identifiers).
|
||||
"""
|
||||
if value is None:
|
||||
return ""
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
return value
|
||||
|
||||
|
||||
def stamp_zone_html(
|
||||
zone_html: str,
|
||||
markers: Iterable[Mapping[str, Any]] | None,
|
||||
) -> str:
|
||||
"""Stamp the root family-partial ``<div>`` with region / content-unit ids.
|
||||
|
||||
``markers`` is an iterable of mapping objects shaped as ::
|
||||
|
||||
{
|
||||
"region_id": "<region_id>",
|
||||
"content_unit_id": "<content_unit_id>",
|
||||
# optional, ignored here — reserved for #96 (89-d):
|
||||
"frame_slot_id": "<frame_slot_id>",
|
||||
}
|
||||
|
||||
Only ``markers[0]`` is consumed (one root div per zone). Excess
|
||||
markers are reserved for a future per-slot stamper (#96) and are
|
||||
silently ignored by this module.
|
||||
|
||||
Returns ``zone_html`` unchanged when:
|
||||
- ``zone_html`` is not a non-empty string,
|
||||
- ``markers`` is None / empty,
|
||||
- no ``data-template-id`` root div is found,
|
||||
- the root div already carries ``data-region-id`` (idempotent),
|
||||
- the first marker carries neither ``region_id`` nor ``content_unit_id``.
|
||||
"""
|
||||
if not isinstance(zone_html, str) or not zone_html:
|
||||
return zone_html
|
||||
if markers is None:
|
||||
return zone_html
|
||||
marker_list = list(markers)
|
||||
if not marker_list:
|
||||
return zone_html
|
||||
first = marker_list[0]
|
||||
if not isinstance(first, Mapping):
|
||||
return zone_html
|
||||
region_id = _coerce_marker_value(first.get("region_id"))
|
||||
content_unit_id = _coerce_marker_value(first.get("content_unit_id"))
|
||||
if not region_id and not content_unit_id:
|
||||
return zone_html
|
||||
|
||||
stamped = {"done": False}
|
||||
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
if stamped["done"]:
|
||||
return match.group(0)
|
||||
attrs = match.group(1) or ""
|
||||
if _HAS_REGION_ID_RE.search(attrs):
|
||||
stamped["done"] = True
|
||||
return match.group(0)
|
||||
stamped["done"] = True
|
||||
injected = (
|
||||
f' {REGION_ID_ATTR}="{region_id}"'
|
||||
f' {CONTENT_UNIT_ID_ATTR}="{content_unit_id}"'
|
||||
)
|
||||
return f"<div{injected}{attrs}>"
|
||||
|
||||
return _ROOT_DIV_TAG_RE.sub(_replace, zone_html, count=1)
|
||||
@@ -13,86 +13,76 @@ from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from src import catalog as _catalog_mod
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
|
||||
STATIC_DIR = Path(__file__).parent.parent / "static"
|
||||
CATALOG_PATH = TEMPLATES_DIR / "catalog.yaml"
|
||||
|
||||
# 카테고리 검색 순서
|
||||
BLOCK_CATEGORIES = ["headers", "cards", "tables", "visuals", "emphasis", "media"]
|
||||
|
||||
# catalog.yaml에서 id → template 경로 매핑 로드 (BF-10: mtime 체크로 자동 갱신)
|
||||
# id → template 경로 매핑 (IMP-27: src.catalog 공유 로더 위임, renderer-local projection cache)
|
||||
_CATALOG_MAP: dict[str, str] | None = None
|
||||
_CATALOG_MTIME: float = 0.0
|
||||
_CATALOG_MAP_MTIME: float = 0.0
|
||||
|
||||
# Phase R: variant별 template 경로 캐시 (renderer-local projection)
|
||||
_CATALOG_VARIANT_MAP: dict[str, str] | None = None
|
||||
_CATALOG_VARIANT_MAP_MTIME: float = 0.0
|
||||
|
||||
|
||||
def _load_catalog_map() -> dict[str, str]:
|
||||
"""catalog.yaml에서 블록 id → template 경로 매핑을 로드한다.
|
||||
"""블록 id → template 경로 projection (IMP-27: src.catalog 공유 로더 위임).
|
||||
|
||||
파일 수정시간(mtime)을 확인하여, 변경 시에만 재로드한다.
|
||||
catalog 파일 읽기와 mtime 캐싱은 ``src.catalog`` 가 단독 소유. 본 함수는
|
||||
그 결과를 ``id → template`` 형태로 변환한 renderer-local projection 캐시만
|
||||
유지하며, projection 무효화는 ``src.catalog.get_catalog_mtime()`` 키잉.
|
||||
"""
|
||||
global _CATALOG_MAP, _CATALOG_MTIME
|
||||
global _CATALOG_MAP, _CATALOG_MAP_MTIME
|
||||
|
||||
current_mtime = CATALOG_PATH.stat().st_mtime if CATALOG_PATH.exists() else 0.0
|
||||
blocks = _catalog_mod.load_blocks()
|
||||
current_mtime = _catalog_mod.get_catalog_mtime()
|
||||
|
||||
if _CATALOG_MAP is not None and _CATALOG_MTIME == current_mtime:
|
||||
return _CATALOG_MAP # 파일 변경 없음 → 캐시 재사용
|
||||
if _CATALOG_MAP is not None and _CATALOG_MAP_MTIME == current_mtime:
|
||||
return _CATALOG_MAP
|
||||
|
||||
# 변경 감지 또는 첫 로드 → 새로 읽기
|
||||
_CATALOG_MTIME = current_mtime
|
||||
_CATALOG_MAP_MTIME = current_mtime
|
||||
_CATALOG_MAP = {}
|
||||
if CATALOG_PATH.exists():
|
||||
try:
|
||||
with open(CATALOG_PATH, encoding="utf-8") as f:
|
||||
catalog = yaml.safe_load(f)
|
||||
for block in catalog.get("blocks", []):
|
||||
block_id = block.get("id", "")
|
||||
template = block.get("template", "")
|
||||
if block_id and template:
|
||||
_CATALOG_MAP[block_id] = template
|
||||
logger.info(f"catalog.yaml 로드: {len(_CATALOG_MAP)}개 블록 매핑")
|
||||
except Exception as e:
|
||||
logger.warning(f"catalog.yaml 로드 실패: {e}")
|
||||
else:
|
||||
logger.warning(f"catalog.yaml 미발견: {CATALOG_PATH}")
|
||||
for block in blocks:
|
||||
block_id = block.get("id", "")
|
||||
template = block.get("template", "")
|
||||
if block_id and template:
|
||||
_CATALOG_MAP[block_id] = template
|
||||
logger.info(f"catalog.yaml 로드: {len(_CATALOG_MAP)}개 블록 매핑")
|
||||
|
||||
return _CATALOG_MAP
|
||||
|
||||
|
||||
# Phase R: variant별 template 경로 캐시
|
||||
_CATALOG_VARIANT_MAP: dict[str, str] | None = None
|
||||
|
||||
|
||||
def _load_catalog_map_with_variants() -> dict[str, str]:
|
||||
"""catalog.yaml에서 variant별 template 경로 매핑을 로드한다.
|
||||
"""variant별 template 경로 projection (IMP-27: src.catalog 공유 로더 위임).
|
||||
|
||||
키: "block_id--variant_id" → 값: template 경로
|
||||
키: "block_id--variant_id" → 값: template 경로.
|
||||
"""
|
||||
global _CATALOG_VARIANT_MAP
|
||||
global _CATALOG_VARIANT_MAP, _CATALOG_VARIANT_MAP_MTIME
|
||||
|
||||
# _load_catalog_map이 이미 캐시 관리하므로 같은 mtime 사용
|
||||
_load_catalog_map() # 캐시 갱신 보장
|
||||
blocks = _catalog_mod.load_blocks()
|
||||
current_mtime = _catalog_mod.get_catalog_mtime()
|
||||
|
||||
if _CATALOG_VARIANT_MAP is not None and _CATALOG_MTIME == (CATALOG_PATH.stat().st_mtime if CATALOG_PATH.exists() else 0.0):
|
||||
if _CATALOG_VARIANT_MAP is not None and _CATALOG_VARIANT_MAP_MTIME == current_mtime:
|
||||
return _CATALOG_VARIANT_MAP
|
||||
|
||||
_CATALOG_VARIANT_MAP_MTIME = current_mtime
|
||||
_CATALOG_VARIANT_MAP = {}
|
||||
if CATALOG_PATH.exists():
|
||||
try:
|
||||
with open(CATALOG_PATH, encoding="utf-8") as f:
|
||||
catalog = yaml.safe_load(f)
|
||||
for block in catalog.get("blocks", []):
|
||||
block_id = block.get("id", "")
|
||||
for variant in block.get("variants", []):
|
||||
vid = variant.get("id", "default")
|
||||
vtemplate = variant.get("template", "")
|
||||
if vid != "default" and vtemplate:
|
||||
_CATALOG_VARIANT_MAP[f"{block_id}--{vid}"] = vtemplate
|
||||
except Exception as e:
|
||||
logger.warning(f"catalog variant 로드 실패: {e}")
|
||||
for block in blocks:
|
||||
block_id = block.get("id", "")
|
||||
for variant in block.get("variants", []):
|
||||
vid = variant.get("id", "default")
|
||||
vtemplate = variant.get("template", "")
|
||||
if vid != "default" and vtemplate:
|
||||
_CATALOG_VARIANT_MAP[f"{block_id}--{vid}"] = vtemplate
|
||||
|
||||
return _CATALOG_VARIANT_MAP
|
||||
|
||||
|
||||
87
src/slide_css_injector.py
Normal file
87
src/slide_css_injector.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""IMP-45 (#74) u3 — slide-level CSS override injector for Phase Z final.html.
|
||||
|
||||
Mirror of :func:`src.image_id_stamper.inject_image_overrides_style` contract
|
||||
(image_id_stamper.py:226-264) for the new ``slide_css`` override axis
|
||||
registered by u1 in :data:`src.user_overrides_io.KNOWN_AXES` and surfaced
|
||||
by u2 in :func:`src.mdx_normalizer.normalize_mdx_content` under the
|
||||
``slide_overrides.css`` frontmatter key.
|
||||
|
||||
Single entry point :
|
||||
|
||||
:func:`inject_slide_css` (html, css) -> str
|
||||
|
||||
Semantics (identical contract to image_overrides injector) :
|
||||
|
||||
- Empty / falsy ``css`` -> ``html`` returned unchanged (no DOM mutation).
|
||||
- Marker-wrapped ``<style>`` block; re-injection replaces inner CSS in
|
||||
place (idempotent on identical input; latest-wins on different input).
|
||||
- Injection precedence : (1) before first ``</head>`` (case-insensitive),
|
||||
(2) immediately after the first ``<body ...>`` open tag, (3) at the
|
||||
start of the document. Phase Z ``slide_base.html`` always emits
|
||||
``</head>`` so path 1 wins for production renders; paths 2/3 are
|
||||
defensive fallbacks for fragment inputs.
|
||||
|
||||
Marker sentinels (distinct from image_overrides markers so the two
|
||||
injectors can co-exist on the same document without collision; the
|
||||
literal form is pinned by the Stage 2 binding contract for IMP-45 /
|
||||
issue #74) :
|
||||
|
||||
<!--IMP45-SLIDE-CSS:OPEN-->
|
||||
<!--IMP45-SLIDE-CSS:CLOSE-->
|
||||
|
||||
Both injectors target ``</head>`` first, so call order determines DOM
|
||||
order. u4 calls ``inject_image_overrides_style`` first (existing Step 13
|
||||
behavior) and then ``inject_slide_css``, putting slide-level overrides
|
||||
after image overrides in cascade order so the editor-authored slide CSS
|
||||
wins ties at the same specificity (intended by IMP-45 scope).
|
||||
|
||||
Guardrails :
|
||||
|
||||
- No-hardcoding : ``css`` is caller-supplied verbatim. No sample-id or
|
||||
frame-id branches.
|
||||
- AI-isolation : pure deterministic Python; no LLM calls.
|
||||
- Carve-out (IMP-46 #62) : brand-new module, does not touch the
|
||||
#76 commit ``1186ad8`` cache region.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
_IMP45_STYLE_MARKER_OPEN: str = "<!--IMP45-SLIDE-CSS:OPEN-->"
|
||||
_IMP45_STYLE_MARKER_CLOSE: str = "<!--IMP45-SLIDE-CSS:CLOSE-->"
|
||||
|
||||
_IMP45_STYLE_BLOCK_RE = re.compile(
|
||||
re.escape(_IMP45_STYLE_MARKER_OPEN) + r".*?" + re.escape(_IMP45_STYLE_MARKER_CLOSE),
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
_HEAD_CLOSE_RE = re.compile(r"</head\s*>", flags=re.IGNORECASE)
|
||||
_BODY_OPEN_RE = re.compile(r"<body\b[^>]*>", flags=re.IGNORECASE)
|
||||
|
||||
|
||||
def inject_slide_css(html: str, css: str | None) -> str:
|
||||
"""Inject a marker-wrapped ``<style>`` block carrying ``css`` into ``html``.
|
||||
|
||||
Empty or ``None`` ``css`` -> ``html`` returned unchanged. Re-injection
|
||||
is idempotent : when a previously-injected marker block is present,
|
||||
its inner CSS is replaced in place.
|
||||
|
||||
Injection precedence : ``</head>`` > ``<body ...>`` > document start.
|
||||
"""
|
||||
if not css:
|
||||
return html
|
||||
block = (
|
||||
f"{_IMP45_STYLE_MARKER_OPEN}\n"
|
||||
f"<style>\n{css}\n</style>\n"
|
||||
f"{_IMP45_STYLE_MARKER_CLOSE}"
|
||||
)
|
||||
if _IMP45_STYLE_MARKER_OPEN in html:
|
||||
return _IMP45_STYLE_BLOCK_RE.sub(lambda _m: block, html, count=1)
|
||||
head_close = _HEAD_CLOSE_RE.search(html)
|
||||
if head_close is not None:
|
||||
idx = head_close.start()
|
||||
return html[:idx] + block + "\n" + html[idx:]
|
||||
body_open = _BODY_OPEN_RE.search(html)
|
||||
if body_open is not None:
|
||||
idx = body_open.end()
|
||||
return html[:idx] + "\n" + block + html[idx:]
|
||||
return block + "\n" + html
|
||||
189
src/structure_override_resolver.py
Normal file
189
src/structure_override_resolver.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""IMP-56 (#90) u6 — structure_override resolver (validator + apply).
|
||||
|
||||
Step-22 user structure-edit persist axis. Consumed by Step 12 (u7 wiring)
|
||||
so a prior render's reorder / hide choices re-apply to the next render
|
||||
without re-clicking.
|
||||
|
||||
Schema (defined verbatim in ``src/user_overrides_io.py:30`` u2) ::
|
||||
|
||||
structure_overrides = {
|
||||
<zone_id>: {
|
||||
"slot_order": [<slot_key>, ...], # optional, partial reorder
|
||||
"hidden_slots": [<slot_key>, ...], # optional, hide these slot_keys
|
||||
},
|
||||
...
|
||||
}
|
||||
|
||||
SCOPE LOCK (Stage 2 u6 contract, IMP-56 #90 u2 docstring) :
|
||||
|
||||
The only allowed inner keys are ``slot_order`` and ``hidden_slots``.
|
||||
Any other key (e.g., ``frame_id``, ``template_id``, ``unit_id``,
|
||||
``slot_payload``) is treated as a frame-swap / DOM-rebuild attempt and
|
||||
is DROPPED at validate time. Frame swap stays on the existing
|
||||
``frames`` axis so the Phase Z no-AI-HTML-structure invariant remains
|
||||
intact. There is intentionally NO escape hatch through this axis.
|
||||
|
||||
API (deterministic, no AI) :
|
||||
|
||||
- ``validate_structure_overrides(overrides)`` → sanitized copy. Per-entry
|
||||
tolerant (drops malformed rows; never rejects the whole batch — mirrors
|
||||
``src.text_override_resolver.validate_text_overrides`` u4 contract).
|
||||
- ``apply_structure_override(zone, override)`` → ``True`` if the slot-payload
|
||||
mapping was mutated (any hide or any reorder), ``False`` otherwise. The
|
||||
``zone`` argument is the slot-payload mapping at Step 12 (a mutable
|
||||
mapping whose keys are slot_keys and whose values are typically
|
||||
``list[str]`` of lines). Identity-preserving: mutates in-place via
|
||||
``clear`` + ``update`` so caller references remain valid.
|
||||
|
||||
Guardrails (refs : Stage 1 binding contract, Stage 2 unit u6) :
|
||||
|
||||
- raw_content preservation is a wiring-layer (u7) responsibility — the
|
||||
resolver only ever reorders / removes top-level slot_payload entries.
|
||||
Per-slot ``list[str]`` line content is never inspected or mutated here.
|
||||
- AI-isolation : pure deterministic Python; no LLM calls.
|
||||
- Carve-out (IMP-46 #62) : brand-new module, does not touch the #76
|
||||
commit ``1186ad8`` cache region.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping, MutableMapping
|
||||
|
||||
|
||||
class InvalidStructureOverride(ValueError):
|
||||
"""Reserved for future strict-mode parse errors.
|
||||
|
||||
Currently unused — the resolver follows the u4 per-entry-tolerant
|
||||
contract and silently drops malformed rows at validate time rather
|
||||
than raising. Kept as a public surface so u7 wiring (and future
|
||||
strict-mode callers) can distinguish source-malformation from
|
||||
stale-DOM misses without an API rev.
|
||||
"""
|
||||
|
||||
|
||||
_ALLOWED_INNER_KEYS: frozenset[str] = frozenset({"slot_order", "hidden_slots"})
|
||||
|
||||
|
||||
def _sanitize_slot_list(raw: Any) -> list[str]:
|
||||
"""Return a fresh list of non-empty string slot_keys (drop the rest)."""
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for slot in raw:
|
||||
if not isinstance(slot, str) or not slot:
|
||||
continue
|
||||
if slot in seen:
|
||||
# De-dup defensively — a duplicate slot_key in slot_order would
|
||||
# be meaningless (dicts can hold each key once); duplicate in
|
||||
# hidden_slots is redundant. Drop subsequent occurrences.
|
||||
continue
|
||||
seen.add(slot)
|
||||
out.append(slot)
|
||||
return out
|
||||
|
||||
|
||||
def validate_structure_overrides(
|
||||
overrides: Any,
|
||||
) -> dict[str, dict[str, list[str]]]:
|
||||
"""Return a sanitized copy of ``overrides`` (per-entry tolerant).
|
||||
|
||||
Drops:
|
||||
- non-string or empty zone_ids,
|
||||
- non-mapping per-zone payloads,
|
||||
- per-zone inner keys other than ``slot_order`` / ``hidden_slots``
|
||||
(frame-swap attempts are dropped at this gate — see SCOPE LOCK),
|
||||
- non-list ``slot_order`` / ``hidden_slots`` values,
|
||||
- non-string or empty slot_key entries within those lists,
|
||||
- per-zone payloads that contain neither a non-empty ``slot_order``
|
||||
nor a non-empty ``hidden_slots`` after sanitization (empty intent
|
||||
carries no signal).
|
||||
|
||||
Returns a fresh ``dict`` AND fresh nested dicts / lists so callers can
|
||||
use the result as a working buffer without aliasing the persisted
|
||||
payload from ``user_overrides_io.load``.
|
||||
"""
|
||||
if not isinstance(overrides, Mapping):
|
||||
return {}
|
||||
out: dict[str, dict[str, list[str]]] = {}
|
||||
for zone_id, mapping in overrides.items():
|
||||
if not isinstance(zone_id, str) or not zone_id:
|
||||
continue
|
||||
if not isinstance(mapping, Mapping):
|
||||
continue
|
||||
zone_out: dict[str, list[str]] = {}
|
||||
for inner_key, inner_value in mapping.items():
|
||||
if inner_key not in _ALLOWED_INNER_KEYS:
|
||||
# Frame-swap attempt or unknown key — drop silently per
|
||||
# SCOPE LOCK. No mechanism through this axis.
|
||||
continue
|
||||
sanitized = _sanitize_slot_list(inner_value)
|
||||
if sanitized:
|
||||
zone_out[inner_key] = sanitized
|
||||
if zone_out:
|
||||
out[zone_id] = zone_out
|
||||
return out
|
||||
|
||||
|
||||
def apply_structure_override(
|
||||
zone: MutableMapping[str, Any],
|
||||
override: Mapping[str, Any],
|
||||
) -> bool:
|
||||
"""Apply ONE structure override to ``zone`` in-place.
|
||||
|
||||
``zone`` is the slot-payload mapping at Step 12 — i.e. a mutable
|
||||
mapping whose keys are slot_keys and whose values are the per-slot
|
||||
line lists (or other content payload). Mutation is restricted to
|
||||
top-level key membership + ordering; per-slot values are NEVER
|
||||
inspected or modified here.
|
||||
|
||||
``override`` is the per-zone payload after :func:`validate_structure_overrides`
|
||||
sanitization — i.e. a mapping with only ``slot_order`` and / or
|
||||
``hidden_slots`` keys, each holding a list of non-empty str slot_keys.
|
||||
This function is also defensive: if non-list values leak through, they
|
||||
are treated as empty (no raise).
|
||||
|
||||
Semantics :
|
||||
1. ``hidden_slots`` are popped first. Entries absent from ``zone``
|
||||
are silently skipped (stale slot_keys from a prior frame).
|
||||
2. ``slot_order`` partially reorders the surviving slot_keys:
|
||||
listed keys (that are present in ``zone``) move to the front in
|
||||
the given order; remaining keys keep their original relative
|
||||
order at the tail. Unknown slot_keys are silently skipped.
|
||||
|
||||
Returns ``True`` if the zone's slot-payload mapping was mutated (any
|
||||
hide that removed a key OR any reorder that changed key order),
|
||||
``False`` otherwise. Identity-preserving: rebuilds via
|
||||
``clear`` + ``update`` so the caller's reference to ``zone`` remains
|
||||
valid.
|
||||
"""
|
||||
mutated = False
|
||||
|
||||
raw_hidden = override.get("hidden_slots") if isinstance(override, Mapping) else None
|
||||
hidden = _sanitize_slot_list(raw_hidden)
|
||||
for slot in hidden:
|
||||
if slot in zone:
|
||||
del zone[slot]
|
||||
mutated = True
|
||||
|
||||
raw_order = override.get("slot_order") if isinstance(override, Mapping) else None
|
||||
desired_order_seed = _sanitize_slot_list(raw_order)
|
||||
|
||||
current_order = list(zone.keys())
|
||||
desired_order: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for slot in desired_order_seed:
|
||||
if slot in zone and slot not in seen:
|
||||
desired_order.append(slot)
|
||||
seen.add(slot)
|
||||
for slot in current_order:
|
||||
if slot not in seen:
|
||||
desired_order.append(slot)
|
||||
seen.add(slot)
|
||||
|
||||
if desired_order != current_order:
|
||||
snapshot = {k: zone[k] for k in desired_order}
|
||||
zone.clear()
|
||||
zone.update(snapshot)
|
||||
mutated = True
|
||||
|
||||
return mutated
|
||||
143
src/text_override_resolver.py
Normal file
143
src/text_override_resolver.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""IMP-56 (#90) u4 — text_override resolver (validator + apply).
|
||||
|
||||
Step-22 user text-edit persist axis. Consumed by Step 12 (u5 wiring) so a
|
||||
prior render's text edits re-apply to the next render without re-clicking.
|
||||
|
||||
Schema (defined verbatim in ``src/user_overrides_io.py:29`` u1) ::
|
||||
|
||||
text_overrides = {
|
||||
<zone_id>: {<text_path>: <value: str>},
|
||||
...
|
||||
}
|
||||
|
||||
``text_path`` is the ``{slot_key}.{line_index}`` stamp emitted at Step 13
|
||||
by the u8 ``text_path_stamper`` (pending unit) and surfaced to the frontend
|
||||
SlideCanvas (u12) as ``data-text-path`` attributes on editable text nodes.
|
||||
The ``{slot_key}`` is a frame contract slot identifier (e.g.,
|
||||
``slot_title``); the ``{line_index}`` is the 0-based ordinal of the line
|
||||
within that slot's rendered text (typically one bullet / one paragraph).
|
||||
|
||||
API (deterministic, no AI) :
|
||||
|
||||
- ``parse_text_path(text_path)`` → ``(slot_key, line_index)`` or raises.
|
||||
- ``validate_text_overrides(overrides)`` → sanitized copy (drops malformed
|
||||
per-entry; never rejects the whole batch — mirrors the per-entry
|
||||
tolerance contract of ``src.image_id_stamper.build_image_overrides_style``
|
||||
IMP-51 #79 u7).
|
||||
- ``apply_text_override(zone, text_path, value)`` → ``True`` on in-place
|
||||
mutation; ``False`` if the path is absent / out-of-range. The ``zone``
|
||||
argument is the slot-lines mapping at Step 12 — i.e. a mutable mapping
|
||||
where ``zone[slot_key]`` is a ``list[str]`` of line strings. Wiring at
|
||||
Step 12 (u5) is responsible for extracting that mapping from whatever
|
||||
composition object holds it; this resolver is decoupled from the wrapper
|
||||
shape so it can be re-targeted at Stage 5 (Step 12) layer-A or layer-B
|
||||
composition data without an API rev.
|
||||
|
||||
Guardrails (refs : Stage 1 binding contract, Stage 2 unit u4) :
|
||||
|
||||
- raw_content preservation is a wiring-layer (u5) responsibility — the
|
||||
resolver itself only ever mutates the lines mapping it was handed.
|
||||
- AI-isolation : pure deterministic Python; no LLM calls.
|
||||
- Carve-out (IMP-46 #62) : brand-new module, does not touch the #76
|
||||
commit ``1186ad8`` cache region.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping, MutableMapping
|
||||
|
||||
|
||||
class InvalidTextOverride(ValueError):
|
||||
"""Raised when a ``text_path`` is malformed (parse-time)."""
|
||||
|
||||
|
||||
def parse_text_path(text_path: str) -> tuple[str, int]:
|
||||
"""Parse ``{slot_key}.{line_index}`` into ``(slot_key, line_index)``.
|
||||
|
||||
``slot_key`` may itself contain ``.`` (e.g., compound keys), so the
|
||||
parse splits on the LAST ``.`` only — ``rpartition`` semantics.
|
||||
"""
|
||||
if not isinstance(text_path, str) or not text_path:
|
||||
raise InvalidTextOverride(
|
||||
f"text_path must be a non-empty string, got: {text_path!r}"
|
||||
)
|
||||
if "." not in text_path:
|
||||
raise InvalidTextOverride(
|
||||
f"text_path must contain '.' separator, got: {text_path!r}"
|
||||
)
|
||||
slot_key, _, idx_str = text_path.rpartition(".")
|
||||
if not slot_key or not idx_str:
|
||||
raise InvalidTextOverride(
|
||||
f"text_path slot_key and line_index must both be non-empty, "
|
||||
f"got: {text_path!r}"
|
||||
)
|
||||
try:
|
||||
idx = int(idx_str)
|
||||
except ValueError as exc:
|
||||
raise InvalidTextOverride(
|
||||
f"text_path line_index must be int, got: {text_path!r}"
|
||||
) from exc
|
||||
if idx < 0:
|
||||
raise InvalidTextOverride(
|
||||
f"text_path line_index must be >= 0, got: {idx} in {text_path!r}"
|
||||
)
|
||||
return slot_key, idx
|
||||
|
||||
|
||||
def validate_text_overrides(overrides: Any) -> dict[str, dict[str, str]]:
|
||||
"""Return a sanitized copy of ``overrides`` (per-entry tolerant).
|
||||
|
||||
Drops:
|
||||
- non-string or empty zone_ids,
|
||||
- non-mapping per-zone payloads,
|
||||
- non-string text_path keys, non-string values,
|
||||
- text_paths that fail :func:`parse_text_path`.
|
||||
|
||||
Returns a fresh ``dict`` so callers can mutate without aliasing the
|
||||
persisted payload from ``user_overrides_io.load``.
|
||||
"""
|
||||
if not isinstance(overrides, Mapping):
|
||||
return {}
|
||||
out: dict[str, dict[str, str]] = {}
|
||||
for zone_id, mapping in overrides.items():
|
||||
if not isinstance(zone_id, str) or not zone_id:
|
||||
continue
|
||||
if not isinstance(mapping, Mapping):
|
||||
continue
|
||||
zone_out: dict[str, str] = {}
|
||||
for text_path, value in mapping.items():
|
||||
if not isinstance(text_path, str) or not isinstance(value, str):
|
||||
continue
|
||||
try:
|
||||
parse_text_path(text_path)
|
||||
except InvalidTextOverride:
|
||||
continue
|
||||
zone_out[text_path] = value
|
||||
if zone_out:
|
||||
out[zone_id] = zone_out
|
||||
return out
|
||||
|
||||
|
||||
def apply_text_override(
|
||||
zone: MutableMapping[str, Any],
|
||||
text_path: str,
|
||||
value: str,
|
||||
) -> bool:
|
||||
"""Apply ONE text override to ``zone`` in-place.
|
||||
|
||||
``zone`` is the slot-lines mapping at Step 12 — i.e. a mutable mapping
|
||||
where ``zone[slot_key]`` is a ``list[str]`` of line strings.
|
||||
|
||||
Returns ``True`` when the value was replaced. Returns ``False`` (no
|
||||
mutation) when the ``slot_key`` is absent, the slot is not a list, or
|
||||
``line_index`` is out of range. Out-of-range / absent paths are NOT an
|
||||
error — they happen naturally when a prior render's overrides target a
|
||||
slot the new render no longer emits (frame swap, layout regression).
|
||||
"""
|
||||
slot_key, idx = parse_text_path(text_path)
|
||||
if slot_key not in zone:
|
||||
return False
|
||||
lines = zone[slot_key]
|
||||
if not isinstance(lines, list) or idx >= len(lines):
|
||||
return False
|
||||
lines[idx] = value
|
||||
return True
|
||||
155
src/text_path_stamper.py
Normal file
155
src/text_path_stamper.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""IMP-56 (#90) u8 — text_path stamper for Phase Z final.html.
|
||||
|
||||
Annotates rendered ``text-line`` DOM elements with a stable
|
||||
``data-text-path="{slot_key}.{line_index}"`` attribute so the frontend
|
||||
SlideCanvas (u10~u12) can attribute per-line edits back to the
|
||||
``text_overrides`` axis (u1 schema, u4 resolver, u5 Step-12 apply).
|
||||
|
||||
DOM contract (single point of truth — mirrored verbatim across the axis) ::
|
||||
|
||||
.text-line[data-text-path="{slot_key}.{line_index}"]
|
||||
|
||||
The ``{slot_key}.{line_index}`` grammar matches
|
||||
:func:`src.text_override_resolver.parse_text_path` verbatim (split on LAST
|
||||
``.`` — compound slot keys with embedded dots are supported).
|
||||
|
||||
The text-line element format is emitted by every Phase Z family / frame
|
||||
template (e.g. ``templates/phase_z2/families/bim_current_problems_paired.html``
|
||||
line 143)::
|
||||
|
||||
<div class="text-line[ ...modifier classes...]">{{ line.text | safe }}</div>
|
||||
|
||||
The stamper finds each ``text-line`` opening tag with a permissive regex
|
||||
and injects ``data-text-path="..."`` as the FIRST attribute. Existing
|
||||
attributes (class, etc.) are preserved verbatim. The injection is
|
||||
idempotent — a previously stamped element is left alone.
|
||||
|
||||
Stamping order : the stamper iterates ``slot_payload`` in dict-iteration
|
||||
order and yields one stamp per ``list`` entry. The DOM walk consumes
|
||||
stamps in left-to-right order; templates currently emit slot lines in
|
||||
the same order they appear in ``slot_payload`` so the alignment holds.
|
||||
If a future template diverges, u9 wiring can pre-build the desired
|
||||
``(slot_key, line_index)`` sequence and pass it explicitly through the
|
||||
``stamps`` arg of :func:`stamp_zone_html`.
|
||||
|
||||
Forward-compat / safety :
|
||||
- Scalar (non-list) slot values are silently skipped — they render
|
||||
outside ``text-line`` divs (frame title, pill labels, etc.) and are
|
||||
not addressable via the line-index grammar.
|
||||
- Excess ``text-line`` elements beyond ``sum(len(v) for v in
|
||||
slot_payload.values() if isinstance(v, list))`` are left unstamped.
|
||||
- Re-stamping (idempotent) preserves the first stamp.
|
||||
|
||||
Guardrails (refs : Stage 1 binding contract, Stage 2 unit u8) :
|
||||
- AI-isolation : pure deterministic Python; no LLM calls.
|
||||
- Carve-out (IMP-46 #62) : brand-new module; does not touch the #76
|
||||
commit ``1186ad8`` cache region.
|
||||
- Idempotent : ``data-text-path`` probe short-circuits before re-inject.
|
||||
- u9 wiring (separate unit) is the only consumer; this module emits no
|
||||
artifacts and reads no global state.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Iterable, Iterator, Mapping
|
||||
|
||||
TEXT_PATH_ATTR: str = "data-text-path"
|
||||
|
||||
# Matches a ``<div ... class="... text-line ..." ...>`` opening tag.
|
||||
# Group 1 captures the inner attribute string verbatim (incl. leading
|
||||
# whitespace) so the rewriter can re-emit it unchanged after injection.
|
||||
_TEXT_LINE_TAG_RE = re.compile(
|
||||
r'<div\b((?=[^>]*\bclass\s*=\s*"[^"]*\btext-line\b)[^>]*?)>',
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
# Probe for an existing ``data-text-path`` attribute (any value, any
|
||||
# quote) so re-stamping is idempotent.
|
||||
_HAS_TEXT_PATH_RE = re.compile(r"""\bdata-text-path\s*=""", flags=re.IGNORECASE)
|
||||
|
||||
|
||||
def build_text_path(slot_key: str, line_index: int) -> str:
|
||||
"""Return the canonical ``{slot_key}.{line_index}`` text_path string.
|
||||
|
||||
Mirrors the inverse of :func:`src.text_override_resolver.parse_text_path`
|
||||
(last-dot split). ``slot_key`` may itself contain ``.`` (compound keys).
|
||||
"""
|
||||
if not isinstance(slot_key, str) or not slot_key:
|
||||
raise ValueError(
|
||||
f"slot_key must be a non-empty string, got: {slot_key!r}"
|
||||
)
|
||||
if isinstance(line_index, bool) or not isinstance(line_index, int):
|
||||
raise ValueError(
|
||||
f"line_index must be a non-negative int, got: {line_index!r}"
|
||||
)
|
||||
if line_index < 0:
|
||||
raise ValueError(
|
||||
f"line_index must be a non-negative int, got: {line_index!r}"
|
||||
)
|
||||
return f"{slot_key}.{line_index}"
|
||||
|
||||
|
||||
def iter_zone_stamps(
|
||||
slot_payload: Mapping[str, Any],
|
||||
) -> Iterator[tuple[str, int]]:
|
||||
"""Yield ``(slot_key, line_index)`` for every list-valued slot line.
|
||||
|
||||
Iteration order matches ``slot_payload`` dict iteration order. Non-
|
||||
string / empty slot_keys are skipped. Non-list values are skipped
|
||||
(scalar slots render outside ``text-line`` divs).
|
||||
"""
|
||||
if not isinstance(slot_payload, Mapping):
|
||||
return
|
||||
for slot_key, value in slot_payload.items():
|
||||
if not isinstance(slot_key, str) or not slot_key:
|
||||
continue
|
||||
if not isinstance(value, list):
|
||||
continue
|
||||
for line_index in range(len(value)):
|
||||
yield slot_key, line_index
|
||||
|
||||
|
||||
def stamp_zone_html(
|
||||
zone_html: str,
|
||||
slot_payload_or_stamps: Mapping[str, Any] | Iterable[tuple[str, int]],
|
||||
) -> str:
|
||||
"""Stamp ``text-line`` opening tags in ``zone_html`` with ``data-text-path``.
|
||||
|
||||
The second arg accepts either:
|
||||
- a ``slot_payload`` ``Mapping`` (uses :func:`iter_zone_stamps` order), or
|
||||
- an iterable of pre-built ``(slot_key, line_index)`` tuples.
|
||||
|
||||
Stamps are consumed in left-to-right DOM order. A text-line already
|
||||
carrying ``data-text-path`` is left unchanged (idempotent). Excess
|
||||
text-line elements beyond the stamp sequence are also left unchanged.
|
||||
|
||||
Returns ``zone_html`` unchanged when there are no stamps to apply or
|
||||
the input is not a non-empty string.
|
||||
"""
|
||||
if not isinstance(zone_html, str) or not zone_html:
|
||||
return zone_html
|
||||
if isinstance(slot_payload_or_stamps, Mapping):
|
||||
stamps = list(iter_zone_stamps(slot_payload_or_stamps))
|
||||
else:
|
||||
stamps = [
|
||||
(sk, li)
|
||||
for (sk, li) in slot_payload_or_stamps
|
||||
if isinstance(sk, str) and sk and isinstance(li, int)
|
||||
and not isinstance(li, bool) and li >= 0
|
||||
]
|
||||
if not stamps:
|
||||
return zone_html
|
||||
counter = {"i": 0}
|
||||
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
attrs = match.group(1) or ""
|
||||
if _HAS_TEXT_PATH_RE.search(attrs):
|
||||
return match.group(0)
|
||||
i = counter["i"]
|
||||
if i >= len(stamps):
|
||||
return match.group(0)
|
||||
counter["i"] = i + 1
|
||||
slot_key, line_index = stamps[i]
|
||||
path = build_text_path(slot_key, line_index)
|
||||
return f'<div {TEXT_PATH_ATTR}="{path}"{attrs}>'
|
||||
|
||||
return _TEXT_LINE_TAG_RE.sub(_replace, zone_html)
|
||||
206
src/user_overrides_io.py
Normal file
206
src/user_overrides_io.py
Normal file
@@ -0,0 +1,206 @@
|
||||
"""IMP-52 (#80) u1 — user_overrides.json persistence layer (backend IO).
|
||||
|
||||
Persists the CLI-wired override axes per MDX so a subsequent render
|
||||
auto-restores user choices without re-clicking. Source of truth = MDX-keyed
|
||||
file (stem of the MDX path), NOT ``data/runs/<run_id>/`` which mints a fresh
|
||||
run_id per ``/api/run`` invocation.
|
||||
|
||||
Schema (9 axes; stable order; IMP-51 #79 u1 added ``image_overrides``;
|
||||
IMP-45 #74 u1 added ``slide_css``; IMP-55 #93 u1 added
|
||||
``manual_section_assignment`` as a bool intent marker so the backend can
|
||||
distinguish a user drag-drop from frontend auto-carry zone_sections;
|
||||
IMP-56 #90 u1 added ``text_overrides`` as a Step-22 text-edit persist axis
|
||||
keyed by ``{zone_id: {text_path: value}}`` where ``text_path`` is the
|
||||
``{slot_key}.{line_index}`` stamp emitted by u8; IMP-56 #90 u2 added
|
||||
``structure_overrides`` as a Step-22 structure-edit persist axis keyed by
|
||||
``{zone_id: {"slot_order": [<slot_key>, ...], "hidden_slots": [<slot_key>, ...]}}``
|
||||
— scope is intentionally LOCKED to slot reorder + hide; frame swap stays
|
||||
on the existing ``frames`` axis to prevent the Phase Z regression of
|
||||
AI-driven HTML structure mutation):
|
||||
|
||||
{
|
||||
"layout": <string|null>,
|
||||
"zone_geometries": {<zone_id>: {"x": float, "y": float, "w": float, "h": float}},
|
||||
"zone_sections": {<zone_id>: [<section_id>, ...]},
|
||||
"frames": {<unit_id>: <template_id>},
|
||||
"image_overrides": {<image_id>: {"x": float, "y": float, "w": float, "h": float}},
|
||||
"slide_css": <string|null>,
|
||||
"manual_section_assignment": <bool>,
|
||||
"text_overrides": {<zone_id>: {<text_path>: <string>}},
|
||||
"structure_overrides": {<zone_id>: {"slot_order": [<slot_key>, ...], "hidden_slots": [<slot_key>, ...]}}
|
||||
}
|
||||
|
||||
``image_id`` is the stable identifier emitted by the user-content image
|
||||
stamper (IMP-51 u4) and matched via the selector
|
||||
``.slide img[data-image-role="user-content"]``. Coordinates are
|
||||
percent-of-slide (zone-agnostic, slide-absolute) to match the SlideCanvas
|
||||
edit-mode handle conventions in IMP-51 u8~u11.
|
||||
|
||||
``unit_id`` is the convention already used by ``--override-frame`` :
|
||||
``"+".join(source_section_ids)`` (e.g., ``"03-1"`` or ``"03-1+03-2"``).
|
||||
|
||||
Behavior :
|
||||
- ``load(key)`` — file missing or corrupt → ``{}`` (warning to stderr on corrupt).
|
||||
- ``save(key, partial)`` — merges only the supplied axes onto the existing
|
||||
file, preserving (a) unknown top-level keys (foreign-key preserve) and
|
||||
(b) axes not present in the partial payload. Atomic write via tmp+rename.
|
||||
- ``override_path(key, root=None)`` — resolves the persistence path under
|
||||
``data/user_overrides/<key>.json``.
|
||||
|
||||
Guardrails (refs : ``user_overrides_io`` Stage 2 lock) :
|
||||
- Deterministic code, no AI fallback.
|
||||
- ``key`` validation rejects path traversal / separators / dot-prefix.
|
||||
- ``save`` is a deep-shallow merge — per-axis dict mutation does not delete
|
||||
prior keys unless caller passes ``None`` for that axis (explicit clear).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
# Persistence root — MDX-keyed, decoupled from data/runs/<run_id>/.
|
||||
# Resolved at call time so tests can monkeypatch via ``root=`` parameter.
|
||||
_PKG_ROOT = Path(__file__).resolve().parent.parent
|
||||
DEFAULT_OVERRIDES_ROOT = _PKG_ROOT / "data" / "user_overrides"
|
||||
|
||||
# The nine in-scope axes (IMP-51 #79 u1 added ``image_overrides``; IMP-45
|
||||
# #74 u1 added ``slide_css``; IMP-55 #93 u1 added
|
||||
# ``manual_section_assignment`` — bool intent marker that gates whether
|
||||
# persisted ``zone_sections`` are consumed by the backend pipeline; IMP-56
|
||||
# #90 u1 added ``text_overrides`` — Step-22 text-edit persist axis keyed by
|
||||
# ``{zone_id: {text_path: value}}`` where ``text_path`` is the
|
||||
# ``{slot_key}.{line_index}`` stamp emitted by u8 / consumed by u4+u5;
|
||||
# IMP-56 #90 u2 added ``structure_overrides`` — Step-22 structure-edit
|
||||
# persist axis keyed by ``{zone_id: {"slot_order": [...], "hidden_slots":
|
||||
# [...]}}``, scope LOCKED to slot reorder + hide so the resolver (u6) /
|
||||
# Step-12 apply (u7) cannot mutate frame identity — frame swap stays on
|
||||
# the existing ``frames`` axis to keep Phase Z's no-AI-HTML-structure
|
||||
# invariant intact). Any other top-level key in the file is preserved but
|
||||
# ignored by callers — keeps the file forward-compatible with future axes
|
||||
# (e.g., zone_sizes) without a schema bump here.
|
||||
KNOWN_AXES: tuple[str, ...] = (
|
||||
"layout",
|
||||
"zone_geometries",
|
||||
"zone_sections",
|
||||
"frames",
|
||||
"image_overrides",
|
||||
"slide_css",
|
||||
"manual_section_assignment",
|
||||
"text_overrides",
|
||||
"structure_overrides",
|
||||
)
|
||||
|
||||
# Key validation — MDX stem must be safe for filesystem use. Allow
|
||||
# alphanumerics, underscore, hyphen, and dot in the middle (sample stems
|
||||
# are e.g. ``01``, ``03``, ``03__DX...``). Reject leading dot, path
|
||||
# separators, and traversal.
|
||||
_KEY_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*$")
|
||||
|
||||
|
||||
class InvalidOverrideKey(ValueError):
|
||||
"""Raised when ``key`` is not a safe MDX stem."""
|
||||
|
||||
|
||||
def validate_key(key: str) -> str:
|
||||
"""Validate that ``key`` is a safe MDX stem; return it unchanged.
|
||||
|
||||
Rejects empty strings, path separators (``/`` ``\\``), traversal
|
||||
(``..``), and leading dot. Callers should pass ``Path(mdx_path).stem``.
|
||||
"""
|
||||
if not isinstance(key, str) or not key:
|
||||
raise InvalidOverrideKey(f"key must be a non-empty string, got: {key!r}")
|
||||
if not _KEY_RE.match(key):
|
||||
raise InvalidOverrideKey(
|
||||
f"key must match {_KEY_RE.pattern!r} (alphanumerics, '_', '-', '.'; "
|
||||
f"no leading dot, no separators); got: {key!r}"
|
||||
)
|
||||
if ".." in key:
|
||||
raise InvalidOverrideKey(f"key must not contain '..'; got: {key!r}")
|
||||
return key
|
||||
|
||||
|
||||
def override_path(key: str, root: Optional[Path] = None) -> Path:
|
||||
"""Resolve the on-disk path for ``key``'s override file."""
|
||||
validate_key(key)
|
||||
base = Path(root) if root is not None else DEFAULT_OVERRIDES_ROOT
|
||||
return base / f"{key}.json"
|
||||
|
||||
|
||||
def load(key: str, root: Optional[Path] = None) -> dict[str, Any]:
|
||||
"""Load persisted overrides for ``key``.
|
||||
|
||||
Missing file → ``{}``. Corrupt JSON → warning to stderr + ``{}``.
|
||||
Returns the raw mapping (including any foreign keys); callers should
|
||||
pick the KNOWN_AXES they care about.
|
||||
"""
|
||||
path = override_path(key, root=root)
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
print(
|
||||
f"[user_overrides_io] warning: failed to read {path} ({exc}); "
|
||||
f"treating as empty.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
print(
|
||||
f"[user_overrides_io] warning: {path} is not a JSON object "
|
||||
f"(got {type(data).__name__}); treating as empty.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return {}
|
||||
return data
|
||||
|
||||
|
||||
def save(key: str, partial: dict[str, Any], root: Optional[Path] = None) -> Path:
|
||||
"""Merge ``partial`` onto the persisted overrides for ``key`` and write atomically.
|
||||
|
||||
Merge semantics :
|
||||
- Only keys present in ``partial`` are mutated. Other axes (including
|
||||
foreign keys outside KNOWN_AXES) are preserved verbatim.
|
||||
- For each axis present in ``partial``, the new value REPLACES the prior
|
||||
value (no per-zone deep-merge). Callers that want to add a single
|
||||
zone must read → mutate → save with the full updated axis dict.
|
||||
- Pass ``None`` for an axis to clear it (remove the key from the file).
|
||||
"""
|
||||
if not isinstance(partial, dict):
|
||||
raise TypeError(
|
||||
f"partial must be a dict, got {type(partial).__name__}: {partial!r}"
|
||||
)
|
||||
path = override_path(key, root=root)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
current = load(key, root=root)
|
||||
for axis_key, axis_value in partial.items():
|
||||
if axis_value is None:
|
||||
current.pop(axis_key, None)
|
||||
else:
|
||||
current[axis_key] = axis_value
|
||||
_atomic_write_json(path, current)
|
||||
return path
|
||||
|
||||
|
||||
def _atomic_write_json(path: Path, data: dict[str, Any]) -> None:
|
||||
"""Write ``data`` to ``path`` atomically via tmp file + os.replace."""
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
f.write("\n")
|
||||
os.replace(tmp_name, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_name)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
File diff suppressed because it is too large
Load Diff
50
templates/phase_z2/catalog/ranking_sort_policy.yaml
Normal file
50
templates/phase_z2/catalog/ranking_sort_policy.yaml
Normal file
@@ -0,0 +1,50 @@
|
||||
# IMP-39 single-source ranking sort policy — backend ↔ frontend mirror.
|
||||
#
|
||||
# 도입 배경 (issue #68):
|
||||
# Backend `lookup_v4_match_with_fallback` 는 V4 raw confidence-desc 순서로
|
||||
# first-eligible 선택 (label_priority 무시). Frontend `designAgentApi.ts` 는
|
||||
# 동일 source 를 (label_priority asc, confidence desc) 로 재정렬 후 slice.
|
||||
# 결과: 낮은-confidence 높은-priority label 이 raw 상 뒤에 있을 때
|
||||
# backend "rank 1 selected" ≠ frontend `frame_candidates[0]` divergence.
|
||||
#
|
||||
# 정책 결정 (Stage 1~2 LOCK, 4 round 합의):
|
||||
# - 단일 source 위치 = 본 yaml (catalog hot-reload + frontend mirror 가능)
|
||||
# - frame_contracts.yaml / v4_fallback_policy.yaml 오염 회피 (분리 파일)
|
||||
# - 정렬 axes = (label_priority asc, confidence desc, v4_rank asc)
|
||||
# - tie-break = 원본 v4_rank 보존 (frontend LABEL_PRIORITY 와 1:1)
|
||||
#
|
||||
# 적용 path:
|
||||
# - backend: src/phase_z2_pipeline.py `apply_ranking_sort` (helper, u1)
|
||||
# + `lookup_v4_match_with_fallback` selector loop (u2)
|
||||
# + `_build_application_plan_unit` Step 9 payload (u3)
|
||||
# - frontend: Front/client/src/services/designAgentApi.ts (u4)
|
||||
# → unit.ranking_sort_policy + unit.sorted_candidate_evidence 우선 read
|
||||
# → local LABEL_PRIORITY 는 warn-fallback only
|
||||
|
||||
policy_type: deterministic_label_priority_then_confidence
|
||||
|
||||
# label_priority:
|
||||
# lower value = higher priority (use_as_is 가 첫 후보)
|
||||
# sort key = (label_priority asc, confidence desc, v4_rank asc)
|
||||
label_priority:
|
||||
use_as_is: 0
|
||||
light_edit: 1
|
||||
restructure: 2
|
||||
reject: 3
|
||||
|
||||
# unknown_label_priority:
|
||||
# label 이 위 매트릭스에 없을 시 부여되는 우선순위 (최하위 push).
|
||||
# frontend `LABEL_PRIORITY[label] ?? 99` 와 1:1.
|
||||
unknown_label_priority: 99
|
||||
|
||||
# tie_break_axes:
|
||||
# 동일 label_priority 시 적용 순서 — frontend mirror 와 1:1.
|
||||
# confidence_desc: 큰 confidence 가 앞
|
||||
# v4_rank_asc: 동일 confidence 시 raw v4 rank (1, 2, 3 ...) 작은 게 앞
|
||||
tie_break_axes:
|
||||
- confidence_desc
|
||||
- v4_rank_asc
|
||||
|
||||
# graceful fallback (yaml 없을 시):
|
||||
# loader 가 default policy_type=deterministic_label_priority_then_confidence
|
||||
# + 위 label_priority 매트릭스 로 fall through (backward compat / boot-safe).
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user