Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36685a3f16 | ||
|
|
c43f330e2e | ||
|
|
fedb49d322 | ||
|
|
e46b4c5892 | ||
|
|
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 |
+5
-1
@@ -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/
|
||||
|
||||
@@ -19,6 +19,20 @@ interface FramePanelProps {
|
||||
onNoDesignToggle: () => void;
|
||||
}
|
||||
|
||||
// ─── IMP-41 u3 — application_mode consequence tooltip map (issue #70) ────────
|
||||
// Keyed by application_mode VALUE (backend authoritative), NOT V4 label.
|
||||
// Source = src/phase_z2_pipeline.py APPLICATION_MODE_BY_V4_LABEL (:107-112)
|
||||
// emitted via Step 9 unit.application_candidates[] and forwarded by
|
||||
// designAgentApi.ts (IMP-41 u2). When applicationMode is absent (legacy
|
||||
// fixtures pre-IMP-32, or candidate filtered out at Step 9) the tooltip
|
||||
// falls back to the raw V4 label string per Stage 2 contract.
|
||||
const APPLICATION_MODE_TOOLTIP_KR: Record<string, string> = {
|
||||
direct_insert: "코드 직접 적용",
|
||||
same_frame_with_adjustment: "AI 보강 필요",
|
||||
layout_or_region_change: "AI restructure 필요",
|
||||
exclude: "render path 제외",
|
||||
};
|
||||
|
||||
export default function FramePanel({
|
||||
slidePlan,
|
||||
selectedZone,
|
||||
@@ -46,6 +60,26 @@ export default function FramePanel({
|
||||
return userSelection.overrides.zone_frames[targetRegion.id] || targetRegion.frame_match_strategy.frame_id;
|
||||
}, [selectedZone, selectedRegion, userSelection.overrides.zone_frames]);
|
||||
|
||||
// IMP-47B u11 — reject-click confirm guard. Per #76 policy: 사용자가 reject
|
||||
// 카드 명시 클릭 → backend `--override-frame` 전달 + reject frame 유지 + AI 재구성.
|
||||
// The window.confirm makes the AI-rebuild intent explicit (deselecting an
|
||||
// already-applied reject frame does not prompt). Pure UX gate — no state
|
||||
// mutation here; the parent `onFrameSelect` still owns the override apply.
|
||||
const handleFrameSelect = React.useCallback(
|
||||
(candidate: FrameCandidate) => {
|
||||
const isReject = candidate.label === "reject";
|
||||
const alreadyApplied = currentFrameId === candidate.id;
|
||||
if (isReject && !alreadyApplied) {
|
||||
const ok = window.confirm(
|
||||
`"${candidate.name}" 은 V4 reject 라벨입니다.\n선택 시 frame 은 유지되고 AI 가 콘텐츠를 frame 구조에 맞게 재구성합니다.\n계속하시겠습니까?`,
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
onFrameSelect(candidate.id);
|
||||
},
|
||||
[currentFrameId, onFrameSelect],
|
||||
);
|
||||
|
||||
if (!selectedZone) {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center bg-slate-50 p-8 text-center text-slate-400">
|
||||
@@ -82,11 +116,67 @@ export default function FramePanel({
|
||||
) : (
|
||||
candidates.map((candidate, index) => {
|
||||
const isSelected = currentFrameId === candidate.id;
|
||||
|
||||
|
||||
const isReject = candidate.label === "reject";
|
||||
// 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 +185,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 +195,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,20 +269,91 @@ export default function FramePanel({
|
||||
</span>
|
||||
)}
|
||||
{/* V4 label badge */}
|
||||
{candidate.label && (
|
||||
{candidate.label && (() => {
|
||||
// IMP-41 u3 — applicationMode-keyed Korean consequence
|
||||
// tooltip with legacy fallback. applicationMode is
|
||||
// forwarded by designAgentApi.ts (u2) from Step 9
|
||||
// unit.application_candidates[]; undefined when the
|
||||
// backend did not emit a mapping for this candidate.
|
||||
const consequence = candidate.applicationMode
|
||||
? APPLICATION_MODE_TOOLTIP_KR[candidate.applicationMode]
|
||||
: undefined;
|
||||
const badgeTitle = consequence
|
||||
? `${consequence} (${candidate.applicationMode})`
|
||||
: `V4 label: ${candidate.label}`;
|
||||
return (
|
||||
<span
|
||||
className={`text-[8px] font-black uppercase tracking-tight px-1.5 py-0.5 rounded ${
|
||||
candidate.label === "use_as_is"
|
||||
? "bg-emerald-100 text-emerald-700"
|
||||
: candidate.label === "light_edit"
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: candidate.label === "restructure"
|
||||
? "bg-amber-100 text-amber-700"
|
||||
: "bg-red-100 text-red-700"
|
||||
}`}
|
||||
title={badgeTitle}
|
||||
>
|
||||
{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 ${
|
||||
candidate.label === "use_as_is"
|
||||
? "bg-emerald-100 text-emerald-700"
|
||||
: candidate.label === "light_edit"
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: candidate.label === "restructure"
|
||||
capacityMismatch
|
||||
? "bg-amber-100 text-amber-700"
|
||||
: "bg-red-100 text-red-700"
|
||||
: "bg-slate-100 text-slate-500"
|
||||
}`}
|
||||
title={`capacity_fit: ${candidate.capacityFit!.fit_status}${
|
||||
candidate.capacityFit!.mismatch_reason
|
||||
? ` — ${candidate.capacityFit!.mismatch_reason}`
|
||||
: ""
|
||||
}`}
|
||||
title={`V4 label: ${candidate.label}`}
|
||||
>
|
||||
{candidate.label}
|
||||
{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 && (
|
||||
|
||||
@@ -186,6 +186,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;
|
||||
@@ -283,37 +289,21 @@ 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"
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
style={{ pointerEvents: isEditMode ? "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 비율 등) 지정.
|
||||
@@ -612,6 +602,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}
|
||||
@@ -695,6 +707,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">
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
runPipeline,
|
||||
loadRun,
|
||||
computeZonePositions,
|
||||
formatAiRepairHumanReviewMessage,
|
||||
type RunMeta,
|
||||
type PipelineOverrides,
|
||||
} from "../services/designAgentApi";
|
||||
@@ -370,6 +371,13 @@ export default function Home() {
|
||||
}));
|
||||
setRunMeta(runMeta);
|
||||
toast.success(`run "${result.run_id}" 완료 — ${runMeta.status}`);
|
||||
// IMP-47B u11 — surface Step 12 AI repair failure axes (error /
|
||||
// coverage_violated / unsupported_kind) as a human_review notification.
|
||||
// Auto-pipeline first ([[feedback_auto_pipeline_first]]): no review_queue
|
||||
// insertion — just an explicit error toast directing the user to pick
|
||||
// another frame or edit manually. Helper returns null on success path.
|
||||
const aiReviewMsg = formatAiRepairHumanReviewMessage(runMeta.ai_repair_status);
|
||||
if (aiReviewMsg) toast.error(aiReviewMsg);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error(
|
||||
@@ -377,7 +385,7 @@ 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) => {
|
||||
@@ -527,6 +535,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>
|
||||
|
||||
@@ -207,6 +207,51 @@ 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;
|
||||
}
|
||||
|
||||
// IMP-47B u11 — verbatim mirror of step20_slide_status.ai_repair_status (u8 schema).
|
||||
// Surfaces Step 12 AI repair outcomes so the frontend can render a
|
||||
// human_review notification when AI proposal validation, coverage, or call
|
||||
// itself failed. Enum / field names kept verbatim — no frontend redefinition.
|
||||
export interface AiRepairStatus {
|
||||
status: "ok" | "applied" | "unsupported_kind" | "coverage_violated" | "error" | string;
|
||||
counts: {
|
||||
total: number;
|
||||
applied: number;
|
||||
no_proposal: number;
|
||||
no_zone_match: number;
|
||||
unsupported_kind: number;
|
||||
error: number;
|
||||
};
|
||||
unsupported_kind_records: Array<{
|
||||
unit_index?: number | null;
|
||||
source_section_ids: string[];
|
||||
apply_status: string;
|
||||
}>;
|
||||
error_records: Array<{
|
||||
unit_index?: number | null;
|
||||
source_section_ids: string[];
|
||||
error: string;
|
||||
}>;
|
||||
coverage_status: string;
|
||||
dropped_section_ids: string[];
|
||||
human_review_required: boolean;
|
||||
}
|
||||
|
||||
export interface RunMeta {
|
||||
run_id: string;
|
||||
mdx_path: string;
|
||||
@@ -214,11 +259,45 @@ 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
|
||||
/** IMP-47B u11 — Step 12 AI repair outcome (u8 surfacing). null when
|
||||
* step20 omits the field (legacy runs / pipeline aborted before Step 12). */
|
||||
ai_repair_status: AiRepairStatus | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* IMP-47B u11 — Build the human_review notification text when Step 12 AI repair
|
||||
* reports a failure axis. Returns null when no notification is needed (success,
|
||||
* no AI invocation, or human_review_required=false). Pure function — no DOM, no
|
||||
* toast side-effect — so it can be unit-tested without React Testing Library.
|
||||
*
|
||||
* Failure axes mapped to user-facing text (verbatim policy from
|
||||
* IMP-47B #76 guardrail: "AI 호출 실패 / proposal validation 실패 / coverage 미달
|
||||
* → frontend 에 명확한 notification").
|
||||
*/
|
||||
export function formatAiRepairHumanReviewMessage(
|
||||
ai: AiRepairStatus | null | undefined,
|
||||
): string | null {
|
||||
if (!ai || !ai.human_review_required) return null;
|
||||
if (ai.status === "error") {
|
||||
const n = ai.counts?.error ?? ai.error_records?.length ?? 0;
|
||||
return `AI 재구성 호출 실패 (${n}건) — 다른 frame 선택 또는 수동 편집 필요`;
|
||||
}
|
||||
if (ai.status === "coverage_violated") {
|
||||
const dropped = (ai.dropped_section_ids || []).join(", ");
|
||||
return `AI 재구성 후 콘텐츠 누락 (dropped: ${dropped || "?"}) — 다른 frame 선택 또는 수동 편집 필요`;
|
||||
}
|
||||
if (ai.status === "unsupported_kind") {
|
||||
const n = ai.counts?.unsupported_kind ?? ai.unsupported_kind_records?.length ?? 0;
|
||||
return `AI 제안 형식 미지원 (${n}건) — 다른 frame 선택 또는 수동 편집 필요`;
|
||||
}
|
||||
return `AI 재구성 human_review 필요 (status: ${ai.status})`;
|
||||
}
|
||||
|
||||
export interface LoadRunResult {
|
||||
@@ -393,6 +472,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 ?? [],
|
||||
@@ -408,6 +489,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 ──
|
||||
@@ -483,17 +565,51 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
|
||||
restructure: 2,
|
||||
reject: 3,
|
||||
};
|
||||
const rawSource = (unit.v4_all_judgments?.length > 0)
|
||||
? unit.v4_all_judgments
|
||||
: (unit.v4_candidates ?? []);
|
||||
// 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 candidateEvidence = Array.isArray(unit.candidate_evidence)
|
||||
? unit.candidate_evidence
|
||||
: [];
|
||||
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);
|
||||
};
|
||||
candidateEvidence.forEach(pushCandidate);
|
||||
(unit.v4_all_judgments ?? []).forEach(pushCandidate);
|
||||
(unit.v4_candidates ?? []).forEach(pushCandidate);
|
||||
const rawSource = Array.from(candidateMap.values());
|
||||
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-41 u2 — 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). Enrichment ONLY — does NOT alter
|
||||
// candidate source priority, sorting, or TOP_N_FRAMES slicing.
|
||||
const applicationCandidates: any[] = Array.isArray(unit.application_candidates)
|
||||
? unit.application_candidates
|
||||
: [];
|
||||
const applicationModeMap = new Map<string, any>();
|
||||
applicationCandidates.forEach((ac: any) => {
|
||||
const key = ac?.template_id;
|
||||
if (typeof key === "string" && key.length > 0) {
|
||||
applicationModeMap.set(key, ac);
|
||||
}
|
||||
});
|
||||
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,
|
||||
@@ -505,9 +621,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] ??
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -346,8 +346,10 @@ function vitePluginPhaseZApi(): Plugin {
|
||||
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,
|
||||
@@ -355,7 +357,6 @@ function vitePluginPhaseZApi(): Plugin {
|
||||
...process.env,
|
||||
PHASE_Z_ALLOW_RESTRUCTURE: "1",
|
||||
PHASE_Z_ALLOW_REJECT: "1",
|
||||
PHASE_Z_MAX_RANK: "32",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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."
|
||||
@@ -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.
|
||||
@@ -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`. |
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.*
|
||||
@@ -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).
|
||||
@@ -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. 현재 병목 (한 줄)
|
||||
|
||||
@@ -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 까지이고 그 이상은 별도 결정.**
|
||||
+1828
File diff suppressed because it is too large
Load Diff
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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("]", " ")
|
||||
|
||||
+10
-20
@@ -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"},
|
||||
],
|
||||
},
|
||||
|
||||
+7
-32
@@ -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)
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
|
||||
@@ -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-6-20250415"
|
||||
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"}
|
||||
|
||||
|
||||
|
||||
+4
-37
@@ -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
|
||||
|
||||
+3
-37
@@ -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)
|
||||
|
||||
# 괄호 내용 유지하되 괄호 제거
|
||||
|
||||
@@ -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
|
||||
+7
-36
@@ -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],
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -0,0 +1,92 @@
|
||||
"""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
|
||||
|
||||
|
||||
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
|
||||
@@ -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),
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"""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,
|
||||
) -> 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).
|
||||
"""
|
||||
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)
|
||||
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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1,216 @@
|
||||
"""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.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,
|
||||
"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,
|
||||
"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,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — record + continue, no AI re-raise
|
||||
record["ai_called"] = True
|
||||
record["error"] = f"{type(exc).__name__}: {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
|
||||
@@ -0,0 +1,111 @@
|
||||
"""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"
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
@@ -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}."
|
||||
)
|
||||
+61
-11
@@ -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, "
|
||||
|
||||
@@ -368,6 +368,15 @@ 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 ──────────────────────────────────────────────
|
||||
|
||||
@@ -490,6 +499,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)
|
||||
@@ -524,6 +534,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)
|
||||
@@ -624,6 +635,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)
|
||||
@@ -670,7 +685,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 점수 매김
|
||||
@@ -685,6 +706,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 = [
|
||||
@@ -701,6 +743,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
|
||||
|
||||
|
||||
@@ -740,7 +804,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 변경 :
|
||||
@@ -753,6 +819,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 만으로 자동 결정
|
||||
@@ -771,11 +845,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
|
||||
|
||||
+101
-21
@@ -7,20 +7,29 @@ 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 변경)
|
||||
↓ 그래도 안 되면
|
||||
frame_reselect (V4 top-k 의 다른 frame)
|
||||
↓ 그래도 안 되면
|
||||
details_popup_escalation (가장 invasive — content popup, 마지막 resort)
|
||||
```
|
||||
@@ -53,26 +62,65 @@ 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)"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ─── §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",
|
||||
}
|
||||
|
||||
|
||||
# ─── §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",
|
||||
"not_attempted": "none",
|
||||
}
|
||||
|
||||
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 → 현재 "
|
||||
@@ -85,10 +133,19 @@ NEXT_ACTION_RATIONALE: dict[str, str] = {
|
||||
}
|
||||
|
||||
# 본 매핑이 가리키는 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
|
||||
"layout_adjust": "MISSING",
|
||||
"frame_reselect": "MISSING",
|
||||
"none": "n/a",
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +163,29 @@ 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.
|
||||
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()
|
||||
ftype = SALVAGE_FAILURE_TYPE_BY_ACTION.get(action)
|
||||
if ftype is not None:
|
||||
reason = last.get("failure_reason") or ""
|
||||
return {
|
||||
"failure_type": ftype,
|
||||
"classification_rule": (
|
||||
f"salvage_steps[-1].action == {action!r} "
|
||||
f"AND passed=False. raw failure_reason: {reason!r}"
|
||||
),
|
||||
}
|
||||
|
||||
# case 1 : retry 시도 자체 안 됨 (router_active=False 또는 다른 action)
|
||||
if not retry_trace.get("retry_attempted"):
|
||||
return {
|
||||
@@ -204,7 +284,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):
|
||||
@@ -57,6 +58,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]]]:
|
||||
|
||||
+2003
-204
File diff suppressed because it is too large
Load Diff
+229
-14
@@ -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,177 @@ 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}}")
|
||||
|
||||
@@ -56,7 +56,7 @@ ACTION_RATIONALE: dict[str, str] = {
|
||||
"위 매핑 모두 미적용 — 마지막 fallback (현재 코드는 sys.exit 으로 abort)",
|
||||
}
|
||||
|
||||
# 각 action 의 *현재 코드* 구현 상태 (2026-04-29 기준)
|
||||
# 각 action 의 *현재 코드* 구현 상태 (2026-04-29 기준; IMP-12 u7 cascade 2026-05-18)
|
||||
# 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
|
||||
@@ -65,6 +65,12 @@ ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
|
||||
"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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
+4
-17
@@ -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
|
||||
|
||||
+39
-49
@@ -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
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
# IMP-38 V4 max_rank 정책 — separate yaml (catalog 오염 방지)
|
||||
#
|
||||
# 도입 배경:
|
||||
# 기존 `lookup_v4_match_with_fallback(max_rank=3)` hardcoded → rank 4~32 의 등록 frame 도달 못함
|
||||
# mdx05-2 같이 V4 rank 1~9 가 catalog 미등록 + rank 10~ 등록 case → chain_exhausted → unit 생성 X
|
||||
#
|
||||
# 4 round 합의 (IMP-38 #67):
|
||||
# - Codex #1: frame_contracts.yaml 오염 회피 → 별 yaml 파일 (이 파일)
|
||||
# - Codex #2: 3 변수 분리 (configured / judgments / catalog count)
|
||||
# - Codex #3: effective_extended_ceiling = min(configured, len(judgments_full32))
|
||||
#
|
||||
# 적용 path: src/phase_z2_mapper.py 의 load_v4_fallback_policy() loader
|
||||
# + src/phase_z2_pipeline.py 의 lookup_v4_match_with_fallback() 동적 max_rank logic
|
||||
|
||||
policy_type: dynamic_usable_count_based
|
||||
|
||||
# usable_threshold N:
|
||||
# rank 1~default_max_rank 중 "usable" predicate 충족 frame 수 >= N → default_max_rank 유지
|
||||
# < N → extended_max_rank 로 확장
|
||||
usable_threshold: 1
|
||||
|
||||
# default_max_rank:
|
||||
# normal case (usable_count >= threshold) 의 fallback chain 길이
|
||||
# mdx03 같이 rank 1 use_as_is 매칭 잘 되는 case 보호
|
||||
default_max_rank: 3
|
||||
|
||||
# extended_max_rank:
|
||||
# usable_count < threshold case 의 확장 ceiling
|
||||
# mdx05-2 같이 rank 1~9 미등록 case 처리
|
||||
# ★ 실제 effective_extended_ceiling = min(extended_max_rank, len(judgments_full32))
|
||||
# (Codex #2 정정: yaml ceiling 무력화 방지 + V4 schema 범위 초과 방지)
|
||||
extended_max_rank: 32
|
||||
|
||||
# usable predicate (3-tier):
|
||||
# (a) phase_z_status in MVP1_ALLOWED_STATUSES (matched_zone / adapt_matched_zone)
|
||||
# (b) get_contract(template_id) is not None (catalog 등록)
|
||||
# (c) capacity_fit ok (raw_content 제공 시만 — optional)
|
||||
|
||||
# 의미 신뢰 vs catalog presence trade-off:
|
||||
# N=1 = 가장 보수 (rank 1 usable 시 확장 X — mdx03 정상 case 보호)
|
||||
# default_max_rank=3 = 의미 신뢰 범위 (V4 rank 1~3)
|
||||
# extended_max_rank=32 = catalog presence fallback (rank 4~32)
|
||||
|
||||
# graceful fallback (yaml 없을 시):
|
||||
# loader 가 default {default_max_rank: 3, extended_max_rank: 3} 로 fall through (backward compat)
|
||||
@@ -0,0 +1,38 @@
|
||||
# Phase Z Families — WIP Marker
|
||||
|
||||
**Status:** intentionally untracked, uncontracted, out-of-scope for runtime matcher.
|
||||
**Closes audit follow-up:** INTEGRATION-AUDIT-01-REPORT.md §10.2 F-2 (option c).
|
||||
**Gate for promote/remove:** Gitea issue #42 (`IMP-04b Catalog extension to 32 frames`).
|
||||
|
||||
## Baseline lock (2026-05-19)
|
||||
|
||||
| Surface | Count | Notes |
|
||||
|---|---|---|
|
||||
| `git ls-files templates/phase_z2/families/*.html` | 11 | tracked family templates |
|
||||
| `templates/phase_z2/catalog/frame_contracts.yaml` top-level keys | 11 | 1:1 with tracked basenames |
|
||||
| `Get-ChildItem templates/phase_z2/families/*.html` | 13 | tracked 11 + WIP 2 (below) |
|
||||
|
||||
Active contracted family count = **11**. Tracked basenames ↔ `frame_contracts.yaml` top-level keys are set-equal. Drift between disk (13) and contracted (11) is fully explained by the 2 WIP files below.
|
||||
|
||||
## WIP family templates (uncontracted)
|
||||
|
||||
| File | Figma frame | Status |
|
||||
|---|---|---|
|
||||
|
||||
> **#42 IMP-04b u3 (2026-05-21)** — frame 23 (`1171281203`) partial absorbed → `frame_contracts.yaml::app_sw_package_vs_solution`. Counts in `## Baseline lock (2026-05-19)` are historical (post-u3: 11 tracked + 1 WIP / 12 contract).
|
||||
> **#42 IMP-04b u4 (2026-05-21)** — frame 9 (`1171281180`) partial absorbed → `frame_contracts.yaml::pre_construction_model_info_stacked`. WIP family table now empty (post-u4: 13 tracked / 13 contract).
|
||||
|
||||
These files are partials authored during Phase Z-2 MVP-1.5b exploration. They are **not** part of the contracted Phase Z runtime catalog and must not be enumerated by frame selection, matcher, or any Stage 3 pipeline surface.
|
||||
|
||||
## Rules
|
||||
|
||||
- Adding a file to `templates/phase_z2/families/*.html` without a matching `frame_contracts.yaml` entry is **only** permitted if it is named here as WIP.
|
||||
- `tests/test_family_contract_baseline.py` enforces this invariant: tracked families ↔ `frame_contracts.yaml` keys must be set-equal, modulo the WIP allowlist parsed from this file.
|
||||
- Promoting a WIP file (add `frame_contracts.yaml` entry + register with matcher) or removing it must happen under issue #42 or a follow-up issue, not silently.
|
||||
|
||||
## References
|
||||
|
||||
- `docs/architecture/INTEGRATION-AUDIT-01-REPORT.md` §10.2 F-2 (audit finding closed by issue #52, option c)
|
||||
- `docs/architecture/IMP-18-SVG-GAP-REPORT.md` L28, L51 (count basis corrected to `11 contracted + 2 WIP`)
|
||||
- Gitea issue #52 (this reconciliation)
|
||||
- Gitea issue #42 (pre-flight gate for promote/remove)
|
||||
@@ -1,11 +1,23 @@
|
||||
<!-- Phase Z-2 MVP-1.5b — single slide + Type B layout (top/bottom zones).
|
||||
원래 Phase Z 설계 복귀: MDX 1 = slide 1, layout preset = zone 분할, frame-derived block ⊂ zone. -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<html lang="ko"{% if embedded_mode == "embedded" %} class="embedded"{% endif %}>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=1280">
|
||||
<title>{{ slide_title }}</title>
|
||||
{% if embedded_mode == "auto" %}
|
||||
<script>
|
||||
(function(){
|
||||
try {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
if (params.get('embedded') === '1' || window.self !== window.top) {
|
||||
document.documentElement.classList.add('embedded');
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
<style>
|
||||
/* ── existing tokens (inlined) ── */
|
||||
{{ token_css | safe }}
|
||||
@@ -20,6 +32,19 @@
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
/* ── IMP-14 A-4: embedded mode reset (iframe consumer) ──
|
||||
standalone-only body centering/min-height/padding undone so the .slide
|
||||
(1280×720) sits at origin without vertical shift or clipping. */
|
||||
html.embedded body {
|
||||
background: transparent;
|
||||
display: block;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
}
|
||||
html.embedded .slide {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ── 16:9 슬라이드 (single, 1280×720) ── */
|
||||
.slide {
|
||||
width: 1280px; height: 720px;
|
||||
@@ -89,6 +114,43 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ── IMP-30 u5 : provisional zone marker (first-render invariant) ──
|
||||
When V4 rank-1 candidate falls outside MVP1_ALLOWED_STATUSES (chain_exhausted)
|
||||
the pipeline still renders the rank-1 frame so the first-render invariant
|
||||
holds, but the zone is tagged `provisional` so the user/AI can adapt later
|
||||
(IMP-31). Visual contract:
|
||||
- dashed amber border + striped wash → "needs adaptation" at a glance
|
||||
- inline badge top-right → text label for non-color-perceiving readers
|
||||
MDX content is preserved as-is; no shrink, no rewrite. */
|
||||
.zone--provisional {
|
||||
outline: 2px dashed #b8860b;
|
||||
outline-offset: -2px;
|
||||
background-image: repeating-linear-gradient(
|
||||
45deg,
|
||||
rgba(184, 134, 11, 0.04) 0,
|
||||
rgba(184, 134, 11, 0.04) 8px,
|
||||
transparent 8px,
|
||||
transparent 16px
|
||||
);
|
||||
}
|
||||
.zone--provisional .zone__needs-adaptation-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
z-index: 10;
|
||||
padding: 2px 6px;
|
||||
background: #b8860b;
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0.04em;
|
||||
border-radius: 2px;
|
||||
text-transform: uppercase;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* ── Frame-family text layout contract (shared, reusable) ──
|
||||
feedback-1 (mvp1.5b_test7): visible improvement 강화.
|
||||
Stronger hanging indent + breathing line spacing + visible hierarchy. */
|
||||
@@ -239,7 +301,8 @@
|
||||
<div class="slide-body">
|
||||
<div class="layout-{{ layout_preset }}">
|
||||
{% for zone in zones %}
|
||||
<div class="zone" data-zone-position="{{ zone.position }}" data-template-id="{{ zone.template_id }}" style="grid-area: {{ zone.position }};">
|
||||
<div class="zone{% if zone.provisional %} zone--provisional{% endif %}" data-zone-position="{{ zone.position }}" data-template-id="{{ zone.template_id }}"{% if zone.provisional %} data-provisional="1"{% endif %} style="grid-area: {{ zone.position }};">
|
||||
{% if zone.provisional %}<span class="zone__needs-adaptation-badge" aria-label="needs user or AI adaptation">needs adaptation</span>{% endif %}
|
||||
{{ zone.partial_html | safe }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
# CLAUDE.md — 매칭 시스템 작업 컨텍스트
|
||||
|
||||
이 파일은 Claude (AI) 가 `tests/` 디렉토리에서 작업할 때 참고하는 컨텍스트입니다.
|
||||
프로젝트 루트의 [../CLAUDE.md](../CLAUDE.md) 와 함께 사용.
|
||||
|
||||
## 작업 디렉토리
|
||||
|
||||
- 메인 작업 디렉토리: `tests/matching/`
|
||||
- 데이터 / 보고서 파일도 같은 위치
|
||||
- 실행 시 항상 `tests/matching/` 에서 (스크립트 내부 상대 경로 의존)
|
||||
|
||||
## 시스템 개요
|
||||
|
||||
MDX 콘텐츠 ↔ Figma Frame 32 개를 매칭하는 4 단계 파이프라인 (V1~V4).
|
||||
상세는 `README.md` / `PLAN.md` / `PROGRESS.md` 참조.
|
||||
|
||||
## 절대 규칙
|
||||
|
||||
### 1. 하드코딩 금지 (사용자 강조 사항)
|
||||
- 결과물을 직접 고치지 말고 **프로세스/코드를 고쳐라**
|
||||
- 임의 데이터 삽입 금지 (예: DECK 04 의 "제목·A 라벨·B 라벨·행 데이터" placeholder 사용 금지)
|
||||
- 모든 표시값은 실제 코드 결과 (yaml / 함수 출력) 에서 가져와야 함
|
||||
|
||||
### 2. 사용자 직접 수정 보존
|
||||
- 사용자가 HTML 파일을 직접 편집한 경우 **반드시 pipeline 코드에 반영** 후 재생성
|
||||
- 코드만 고치고 재실행하면 사용자 수정이 사라짐
|
||||
- 변경 시: 사용자 수정 8 개 모두 코드에 반영 → 재실행
|
||||
|
||||
### 3. 정직한 코드 동작 표시
|
||||
- 임원 보고용 deck 라도 **코드의 한계를 솔직히 표시**
|
||||
- 예: "MDX 자동 분석 결과 — 정책/요구사항 (사람이 보면 행렬형 비교)" 같은 표기
|
||||
- "이 축은 사실 frame 매칭에 영향 없음" 같은 ablation 결과는 임원용에는 빼지만, 내부 문서 (PROGRESS.md) 에는 명시
|
||||
|
||||
### 4. 임원 보고용 톤
|
||||
- 영문 enum 코드 (`policy_requirements`) 직접 노출 금지 — 한글 (정책/요구사항) 우선
|
||||
- 매칭 키워드는 5~10 개 + "등 N 개" 로 축약
|
||||
- 디자인: 그라데이션 / 화려한 카드 금지. 단순 표 + 흑백 + 강조 색 1~2 가지
|
||||
- 정의 / 부연 설명 최소화 (def 는 한 줄, 길게 풀어 쓰지 말 것)
|
||||
|
||||
## 명명 규칙
|
||||
|
||||
### 파이프라인 스크립트
|
||||
```
|
||||
pipeline_<숫자>_<이름>.py
|
||||
```
|
||||
- 01~07: 입력 추출 + 전처리 + 키워드
|
||||
- 08: V2 (semantic) / V3 (structure r2~r5) / V4 (template_fit, r1/r2)
|
||||
- 09: V2 진단
|
||||
- 10: Holdout 라벨링 / 평가
|
||||
- 11: templates_v1 감사
|
||||
- 12: templates_v2 생성 (r1, r2, r3, final, final_r2, promote_frame13)
|
||||
- 13: meeting docs / samples
|
||||
- 14: single sample
|
||||
- 15: bm25 / idf / logistic regression 비교
|
||||
- 16: deck 페이지 생성 (DECK 1~7)
|
||||
- 17: V4 full32 (32 frame 전체 평가)
|
||||
- 18: V4 slot 축 ablation
|
||||
|
||||
### 결과 파일
|
||||
```
|
||||
<단계>_<설명>_result.yaml
|
||||
```
|
||||
- `mdx_matching_result.yaml` — V1
|
||||
- `v2_semantic_rerank_result.yaml` — V2
|
||||
- `v3_structure_rerank_r5_result.yaml` — V3 (최종 r5)
|
||||
- `v4_full32_result.yaml` — V4 (32 frame 전체)
|
||||
- `structure_ontology_v2_final_r2.yaml` — Frame 32 DB
|
||||
|
||||
### 보고서
|
||||
```
|
||||
DECK_<번호>_<이름>.html — 임원 보고용 A4 페이지
|
||||
ATTACH_<번호>_<이름>.html — 부속 자료
|
||||
<NAME>_REPORT.html / .md — 분석 보고서
|
||||
```
|
||||
|
||||
## 자주 쓰는 명령어
|
||||
|
||||
```bash
|
||||
cd tests/matching/
|
||||
|
||||
# 매칭 시스템 전체 재실행
|
||||
python pipeline_06_2_mdx_matching.py
|
||||
python pipeline_08_v2_semantic_rerank.py
|
||||
python pipeline_08_v3_r5_structure_rerank.py
|
||||
python pipeline_17_v4_full32.py
|
||||
|
||||
# 보고서 재생성
|
||||
python pipeline_16_deck_4pages.py # DECK 1~7
|
||||
|
||||
# Ablation / 검증
|
||||
python pipeline_15_logistic_regression.py
|
||||
python pipeline_18_slot_axis_ablation.py
|
||||
```
|
||||
|
||||
## 파이프라인 핵심 가중치
|
||||
|
||||
### V1 키워드 매칭 (Logistic Regression 학습)
|
||||
```
|
||||
matching_score = 0.414 × 핵심 + 0.320 × 세트 + 0.265 × 연관
|
||||
```
|
||||
|
||||
### V3 구조 매칭
|
||||
```
|
||||
total = 0.40 × 레이아웃 일치 + 0.35 × 콘텐츠 성격 + 0.25 × 시각 의도
|
||||
```
|
||||
|
||||
### V4 종합 판정
|
||||
```
|
||||
confidence = 0.25 × anchor + 0.20 × cardinality + 0.20 × relation
|
||||
+ 0.15 × slot + 0.20 × content − penalty
|
||||
|
||||
라벨 임계값:
|
||||
≥ 0.90 → use_as_is (그대로 사용)
|
||||
≥ 0.75 → light_edit (가벼운 편집)
|
||||
≥ 0.60 → restructure (구조 재배치)
|
||||
< 0.60 → reject (사용 불가)
|
||||
```
|
||||
|
||||
## 데이터 소스
|
||||
|
||||
| 데이터 | 위치 | 용도 |
|
||||
|---|---|---|
|
||||
| Figma 텍스트 | `figma_to_html_agent/blocks/*/texts.md` | 32 frame 텍스트 추출 |
|
||||
| BEPS 마스터 | (별도 위치) | 키워드 보강용 |
|
||||
| MDX 검증 구간 | (`pipeline_01_extract_nodes.py` 의 `MDX_SECTIONS`) | 정답 매칭 검증 |
|
||||
| Frame 이미지 | `data/figma_previews/<프레임번호>.png` | DECK 시각화 |
|
||||
|
||||
## 테스트 픽스처 컨벤션 (F-5, INTEGRATION-AUDIT-01 §10.5.1)
|
||||
|
||||
테스트 데이터 / 샘플 참조의 정식 위치 규약. `tests/` 안에서만 적용되고 `src/**` 프로덕션 경로에는 적용되지 않음.
|
||||
|
||||
| 경로 | 상태 | 용도 | 비고 |
|
||||
|---|---|---|---|
|
||||
| `tests/phase_z2/fixtures/` | **존재 (정식)** | Phase Z 회귀 YAML 픽스처 | `test_fixtures_loader.py` 가 로드. 서브디렉토리 : `build_layout_css/`, `retry_gate/`. |
|
||||
| `tests/fixtures/` (루트) | **없음 (현재 미생성)** | 비-Phase-Z / 비-YAML 픽스처 미래 후보 | 샘플 인벤토리가 `tests/phase_z2/test_*.py` 인라인으로 감당 못 할 때만 별도 이슈로 신설. |
|
||||
| `samples/mdx_batch/**` , `samples/mdx/**` | 존재 | 통합 스모크 입력 | `tests/**` 에서만 참조 가능. `src/**` 런타임 경로 하드코딩 금지. |
|
||||
|
||||
규칙 :
|
||||
|
||||
- 테스트 코드에서는 `samples/mdx_batch/02.mdx` 같은 샘플 MDX 를 직접 참조해도 됨 (예 : `tests/phase_z2/test_pz2_vu_integration.py`). `src/**` 런타임 입력은 절대 샘플 파일명 / 콘텐츠를 핀하지 말 것.
|
||||
- 새 YAML 회귀 픽스처는 `tests/phase_z2/fixtures/` 아래 새 서브디렉토리로 추가. 루트 `tests/fixtures/` 신설은 금지 (별도 이슈 필요).
|
||||
- `src/**` 안에 등장하는 "BIM" / "건설산업 DX" / "재구성" 같은 sample-like 리터럴은 INTEGRATION-AUDIT-01 §10.4 (F-4) 에서 의도된 docstring / glossary / 예시 dict 로 분류 완료. annotation marker 가 붙어 있으면 의도된 example. 새 sample 리터럴을 `src/**` 에 도입하지 말 것.
|
||||
- 본 컨벤션의 anchor 정의는 `docs/architecture/INTEGRATION-AUDIT-01-REPORT.md` §10.5.1. 변경 시 anchor 부터 갱신.
|
||||
|
||||
## 자주 헷갈리는 것
|
||||
|
||||
### 영문 enum vs 한글 매핑
|
||||
- 코드 / yaml: 영문 enum (`comparative_matrix`, `cycle_interrelation`)
|
||||
- 보고서 표시: 한글 (`행렬형 비교`, `순환/상호 관계`)
|
||||
- DECK 05 의 키워드 사전 표는 양쪽 다 표시 (사용자 매칭 가능)
|
||||
|
||||
### 항목수 vs 슬롯 후보 개수
|
||||
- **동일** — `item_count = len(slot_candidates)` (표 / subsections / bullets 어떤 형태든)
|
||||
- V4 의 cardinality 축과 slot.within 부분은 **같은 신호의 중복 가중** (ablation 으로 확인)
|
||||
|
||||
### V3 vs V4 구조 점수
|
||||
- V3 = layout family + content_affinity + structure_intent (3 축)
|
||||
- V4 = anchor + cardinality + relation + slot + content (5 축)
|
||||
- **다른 모델**. V3 점수와 V4 confidence 는 별도 계산
|
||||
|
||||
## 사용자가 강조한 피드백
|
||||
|
||||
- "코드로 돌린 결과물이지 임의 데이터 아님" — 모든 표시 정직
|
||||
- "임원 보고용이야" — 부정적 부연 / 디테일 산식 빼기
|
||||
- "한가지만 해" — 한 번에 한 가지만 변경
|
||||
- "모든 변경은 pipeline 코드에 반영" — HTML 직접 수정은 일시적
|
||||
|
||||
## 진행 중 발견된 약점
|
||||
|
||||
`PROGRESS.md` 의 "발견된 약점" 표 참조. 8 개 모두 Phase E 작업 대상.
|
||||
|
||||
가장 시급:
|
||||
1. **02-2.2 매칭 실패** (E.5)
|
||||
2. **MDX 분석 LLM 화** (E.1, E.2)
|
||||
3. **슬롯 의미 매핑** (E.3, E.4)
|
||||
@@ -0,0 +1,90 @@
|
||||
# IMP-47A — mdx03 frontend stabilization manual e2e
|
||||
|
||||
Scope: frontend-only. Backend pipeline must NOT be modified during this test.
|
||||
Path under test: `mdx=03` (default sample loaded on page open).
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Backend running on `http://localhost:8001` (`uvicorn src.main:app --port 8001`).
|
||||
- Frontend dev server running (`cd Front && npm run dev`).
|
||||
- Working tree at IMP-47A Stage 3 HEAD (u1+u2+u3 applied to `Front/client/src/components/SlideCanvas.tsx`, `Front/client/src/services/designAgentApi.ts`, `Front/client/src/pages/Home.tsx`).
|
||||
- Browser opens `http://localhost:5173/?mdx=03` (or default route, which auto-loads `mdx=03`).
|
||||
|
||||
## Section 1 — iframe rendering (axis 1)
|
||||
|
||||
Goal: verify u1 sandbox change lets `slide_base.html` script apply `html.embedded` class so the slide is not clipped inside the iframe.
|
||||
|
||||
Steps:
|
||||
1. Open the app; wait for `mdx=03` auto-load and initial `final.html` render.
|
||||
2. Click the "슬라이드 플랜 생성하기" button; wait for `run "<id>" 완료` toast.
|
||||
3. Open browser DevTools → Elements; locate the iframe inside `SlideCanvas`; switch context to the iframe document.
|
||||
4. Confirm `<html class="embedded">` is present (not just `<html>`).
|
||||
5. Confirm the rendered slide content fills the 1280×720 frame with no top padding offset and no clipping at the bottom.
|
||||
|
||||
Pass: `html.embedded` class present AND no visible vertical shift/clipping.
|
||||
Fail signal: iframe content pushed downward, footer cut off, or `html` lacks `embedded` class (means script never ran → sandbox regression).
|
||||
|
||||
## Section 2 — multi-source frame candidates (axis 2)
|
||||
|
||||
Goal: verify u2 3-source merge surfaces candidates from `candidate_evidence`, `v4_all_judgments`, and `v4_candidates` with deterministic dedup and cap.
|
||||
|
||||
Steps:
|
||||
1. After Section 1 success, click any zone in the canvas; right panel switches to the "frame" tab.
|
||||
2. In the frame candidate list, count visible candidates. Expect ≤ `TOP_N_FRAMES` (=6).
|
||||
3. Open DevTools → Network → reload `/api/run/<id>`; inspect the JSON response and confirm at least two of `candidate_evidence`, `v4_all_judgments`, `v4_candidates` are non-empty.
|
||||
4. Cross-check: union of `template_id ?? id ?? frame_id` keys from all three arrays (deduped, capped at 6) equals the UI list count and order.
|
||||
5. Confirm the order respects LABEL_PRIORITY (`use_as_is` < `light_edit` < `restructure` < `reject`) then descending confidence.
|
||||
|
||||
Pass: union/dedup/cap/order all match.
|
||||
Fail signal: only candidates from a single source visible, duplicates by template_id, more than 6 items, or order violates LABEL_PRIORITY.
|
||||
|
||||
## Section 3 — frame / layout override regeneration (axis 3)
|
||||
|
||||
Goal: verify u3 5-dep `handleGenerate` callback delivers the latest override state to backend (no stale closure).
|
||||
|
||||
Steps:
|
||||
1. From Section 2, pick a non-default frame candidate (one whose label is not `use_as_is`); click "이 프레임 적용".
|
||||
2. Confirm bottom-left button transforms to "선택대로 재생성하기" with amber pulse dot (hasPendingChanges = true).
|
||||
3. Without any extra clicks, click "선택대로 재생성하기".
|
||||
4. Open DevTools → Network → inspect the POST `/api/pipeline` body; confirm `overrides.frames` contains the chosen `{unit_id: frame_id}` mapping.
|
||||
5. After success toast, confirm new `run_id` differs from the previous run, and the rendered iframe reflects the chosen frame (frame DOM class / id matches selection).
|
||||
6. Repeat with a layout-card "적용하기" → pending overlay enters → "선택대로 재생성하기"; confirm POST body includes `overrides.layout` with the chosen preset id.
|
||||
|
||||
Pass: every override (frames / layout / zoneSections / zoneGeometries when applicable) reaches backend on first click; new `run_id` returned.
|
||||
Fail signal: POST body lacks the override, or backend re-renders with the previous selection (stale closure regression).
|
||||
|
||||
## Section 4 — pending overlay enter / cancel / clear (axis 4)
|
||||
|
||||
Goal: verify pendingLayout overlay enters on "적용하기", exits on "취소", and auto-clears on successful regenerate.
|
||||
|
||||
Steps:
|
||||
1. Click any non-current layout card "적용하기" button.
|
||||
2. Confirm amber dashed overlay appears over `.slide-body` area with `PENDING BODY LAYOUT` label and chosen layout id.
|
||||
3. Confirm "취소" button (top-right of canvas) is visible.
|
||||
4. Click "취소"; confirm overlay disappears, `userSelection` resets, and `hasPendingChanges` indicator clears.
|
||||
5. Re-enter pending mode (apply a layout again), this time click "선택대로 재생성하기"; confirm overlay disappears on success (Home.tsx clears `pendingLayout` + `hasPendingChanges` before pipeline call) and the new `final.html` renders inline (no overlay).
|
||||
|
||||
Pass: enter → cancel → re-enter → regenerate cycle leaves no overlay, no stuck pending state, no leftover `hasPendingChanges` flag.
|
||||
Fail signal: overlay persists after regenerate, button stays in amber state, or cancel does not restore the canvas.
|
||||
|
||||
## Section 5 — mdx03 end-to-end pass (axis 5)
|
||||
|
||||
Goal: smoke run combining Sections 1–4 in one session to validate mdx03 demo path.
|
||||
|
||||
Steps:
|
||||
1. Fresh reload `http://localhost:5173/?mdx=03`.
|
||||
2. Click "슬라이드 플랜 생성하기" → wait for run completion → confirm iframe renders cleanly (Section 1 pass).
|
||||
3. Click any zone → inspect 2+ candidates in frame list (Section 2 pass).
|
||||
4. Apply a non-default frame → "선택대로 재생성하기" → new run id + iframe reflects override (Section 3 pass).
|
||||
5. Apply a non-default layout → confirm overlay → "선택대로 재생성하기" → overlay clears (Section 4 pass).
|
||||
6. Capture: run_id chain, final.html path under `data/runs/<run_id>/final.html`, and DOM screenshot of the rendered iframe content.
|
||||
|
||||
Pass: all five sections green, no console errors, no toast errors, three distinct `run_id`s produced across the session.
|
||||
Fail signal: any earlier section regression OR backend pipeline failure (out-of-scope for IMP-47A — log separately).
|
||||
|
||||
## Out of scope (not tested here)
|
||||
|
||||
- AI fallback activation in `Step 12` (`light_edit` / `restructure`) — IMP-47B.
|
||||
- Frame cache (#62 / IMP-46).
|
||||
- mdx04 / mdx05 path-specific axes.
|
||||
- Automated Playwright e2e replacement — future work.
|
||||
@@ -0,0 +1,401 @@
|
||||
"""P4 (2026-05-19) — audit-only mode verification.
|
||||
|
||||
Covers:
|
||||
- _is_audit_issue: title pattern detection (positive + negative)
|
||||
- _audit_mode: title-based + CLI override (AUDIT_ONLY_OVERRIDE)
|
||||
- _check_audit_only_violations: forbidden prefix detection via mocked git status
|
||||
- AUDIT_ONLY_NOTE injection into context pack (via build_context_pack contract)
|
||||
|
||||
Run: pytest -q tests/orchestrator_unit/test_audit_mode.py
|
||||
"""
|
||||
import sys
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import orchestrator # noqa: E402
|
||||
from orchestrator import ( # noqa: E402
|
||||
_is_audit_issue,
|
||||
_audit_mode,
|
||||
_check_audit_only_violations,
|
||||
_check_audit_commit_scope,
|
||||
_ensure_audit_baseline,
|
||||
_load_audit_baseline,
|
||||
_audit_baseline_path,
|
||||
AUDIT_ONLY_FORBIDDEN_PREFIXES,
|
||||
AUDIT_ONLY_NOTE,
|
||||
AUDIT_ALLOWED_COMMIT_GLOBS,
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# _is_audit_issue — title detection
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestIsAuditIssue:
|
||||
def test_integration_audit_bracket(self):
|
||||
assert _is_audit_issue("[INTEGRATION-AUDIT-01] cumulative review") is True
|
||||
assert _is_audit_issue("[INTEGRATION-AUDIT-02] something") is True
|
||||
assert _is_audit_issue("[INTEGRATION-AUDIT] no number") is True
|
||||
|
||||
def test_audit_only_bracket(self):
|
||||
assert _is_audit_issue("[AUDIT-ONLY] doc consistency check") is True
|
||||
|
||||
def test_case_insensitive(self):
|
||||
assert _is_audit_issue("[integration-audit-03] foo") is True
|
||||
assert _is_audit_issue("[Audit-Only] bar") is True
|
||||
|
||||
def test_plain_integration_audit_phrase(self):
|
||||
assert _is_audit_issue("Quarterly integration audit for closed issues") is True
|
||||
assert _is_audit_issue("Integration Audit Q2") is True
|
||||
|
||||
def test_execution_issue_not_audit(self):
|
||||
"""execution sub-issue 가 audit 로 잘못 감지되면 안 됨."""
|
||||
assert _is_audit_issue("[IMP-15 실행-1] image_aspect_mismatch") is False
|
||||
assert _is_audit_issue("[IMP-15 exec-2] table overflow") is False
|
||||
|
||||
def test_unrelated_issues(self):
|
||||
assert _is_audit_issue("IMP-19 I4 zone 비중 분배") is False
|
||||
assert _is_audit_issue("Fix overflow bug") is False
|
||||
assert _is_audit_issue("docs(IMP-06): Stage 4 fix") is False
|
||||
|
||||
def test_empty_or_none(self):
|
||||
assert _is_audit_issue("") is False
|
||||
assert _is_audit_issue(None) is False
|
||||
|
||||
def test_audit_word_in_random_position_no_match(self):
|
||||
"""'audit' 가 단독으로 나오는 건 안 잡아야 함 — 'integration audit' 만."""
|
||||
assert _is_audit_issue("audit some code") is False
|
||||
assert _is_audit_issue("security audit") is False
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# _audit_mode — combination with CLI override
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestAuditMode:
|
||||
def setup_method(self):
|
||||
# 각 테스트 전에 override 리셋.
|
||||
orchestrator.AUDIT_ONLY_OVERRIDE = False
|
||||
|
||||
def teardown_method(self):
|
||||
orchestrator.AUDIT_ONLY_OVERRIDE = False
|
||||
|
||||
def test_title_based_only(self):
|
||||
assert _audit_mode("[INTEGRATION-AUDIT-01] foo") is True
|
||||
assert _audit_mode("IMP-19 zone") is False
|
||||
|
||||
def test_cli_override_forces_audit(self):
|
||||
"""title 에 marker 없어도 CLI flag 가 audit mode 강제."""
|
||||
orchestrator.AUDIT_ONLY_OVERRIDE = True
|
||||
assert _audit_mode("IMP-19 zone") is True
|
||||
assert _audit_mode("any title") is True
|
||||
assert _audit_mode("") is True
|
||||
|
||||
def test_override_off_falls_back_to_title(self):
|
||||
orchestrator.AUDIT_ONLY_OVERRIDE = False
|
||||
assert _audit_mode("IMP-19 zone") is False
|
||||
assert _audit_mode("[INTEGRATION-AUDIT-01]") is True
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# _check_audit_only_violations — git status parsing
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class _FakeCompleted:
|
||||
def __init__(self, stdout, returncode=0):
|
||||
self.stdout = stdout
|
||||
self.stderr = ""
|
||||
self.returncode = returncode
|
||||
|
||||
|
||||
class TestCheckAuditOnlyViolations:
|
||||
"""subprocess.run 을 monkeypatch 해서 다양한 git status 출력 시나리오 검증."""
|
||||
|
||||
def test_clean_tree(self, monkeypatch):
|
||||
def fake_run(*args, **kwargs):
|
||||
return _FakeCompleted(stdout="")
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
assert _check_audit_only_violations() == []
|
||||
|
||||
def test_only_allowed_changes(self, monkeypatch):
|
||||
"""docs/architecture 변경만 있으면 violation 0."""
|
||||
stdout = (
|
||||
" M docs/architecture/INTEGRATION-AUDIT-01-REPORT.md\n"
|
||||
"?? docs/architecture/INTEGRATION-AUDIT-01-MATRIX.md\n"
|
||||
" M docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md\n"
|
||||
)
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
assert _check_audit_only_violations() == []
|
||||
|
||||
def test_src_change_detected(self, monkeypatch):
|
||||
stdout = (
|
||||
" M src/phase_z2_pipeline.py\n"
|
||||
" M docs/architecture/INTEGRATION-AUDIT-01-REPORT.md\n"
|
||||
)
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
v = _check_audit_only_violations()
|
||||
assert v == ["src/phase_z2_pipeline.py"]
|
||||
|
||||
def test_templates_change_detected(self, monkeypatch):
|
||||
stdout = " M templates/phase_z2/families/something.html\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
v = _check_audit_only_violations()
|
||||
assert v == ["templates/phase_z2/families/something.html"]
|
||||
|
||||
def test_tests_change_detected(self, monkeypatch):
|
||||
stdout = " M tests/phase_z2/test_overflow.py\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
v = _check_audit_only_violations()
|
||||
assert v == ["tests/phase_z2/test_overflow.py"]
|
||||
|
||||
def test_multiple_violations(self, monkeypatch):
|
||||
stdout = (
|
||||
" M src/a.py\n"
|
||||
"?? src/b.py\n"
|
||||
" M templates/c.html\n"
|
||||
" M tests/d.py\n"
|
||||
" M docs/architecture/INTEGRATION-AUDIT-01-REPORT.md\n" # allowed
|
||||
" M data/runs/run123.json\n" # allowed (not in forbidden)
|
||||
)
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
v = _check_audit_only_violations()
|
||||
assert set(v) == {"src/a.py", "src/b.py", "templates/c.html", "tests/d.py"}
|
||||
|
||||
def test_renamed_file_destination_checked(self, monkeypatch):
|
||||
"""rename 의 경우 destination 만 검사."""
|
||||
stdout = "R docs/old.md -> src/new.py\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
v = _check_audit_only_violations()
|
||||
assert v == ["src/new.py"]
|
||||
|
||||
def test_windows_backslash_path(self, monkeypatch):
|
||||
"""Windows backslash path 도 forward-slash 로 정규화돼서 매치."""
|
||||
stdout = " M src\\phase_z2_pipeline.py\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
v = _check_audit_only_violations()
|
||||
assert v == ["src/phase_z2_pipeline.py"]
|
||||
|
||||
def test_quoted_path_with_spaces(self, monkeypatch):
|
||||
"""공백/특수문자 포함 path 는 quoted — quote strip 후 검사."""
|
||||
stdout = ' M "src/some file.py"\n'
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
v = _check_audit_only_violations()
|
||||
assert v == ["src/some file.py"]
|
||||
|
||||
def test_git_error_fails_open(self, monkeypatch):
|
||||
"""git 자체 실패 → 가드 false positive 안 만들고 빈 list 반환."""
|
||||
monkeypatch.setattr(subprocess, "run",
|
||||
lambda *a, **kw: _FakeCompleted(stdout="", returncode=128))
|
||||
assert _check_audit_only_violations() == []
|
||||
|
||||
def test_subprocess_exception_fails_open(self, monkeypatch):
|
||||
"""subprocess.run 자체가 raise 해도 가드 false positive X."""
|
||||
def boom(*a, **kw): raise RuntimeError("git missing")
|
||||
monkeypatch.setattr(subprocess, "run", boom)
|
||||
assert _check_audit_only_violations() == []
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# AUDIT_ONLY_NOTE constants — sanity
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# P4a: baseline-aware violations
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestBaselineAwareViolations:
|
||||
def test_baseline_subtraction_removes_preexisting(self, monkeypatch):
|
||||
"""pre-existing forbidden path 는 baseline 에 있으면 violation 에서 제외."""
|
||||
stdout = (
|
||||
" M src/already_dirty.py\n" # baseline 안에 있음 — 제외돼야 함
|
||||
" M src/new_violation.py\n" # baseline 밖 — violation
|
||||
)
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
baseline = {"src/already_dirty.py"}
|
||||
v = _check_audit_only_violations(baseline=baseline)
|
||||
assert v == ["src/new_violation.py"]
|
||||
|
||||
def test_baseline_none_keeps_all(self, monkeypatch):
|
||||
"""baseline=None 이면 기존 동작 — 모든 forbidden 잡음."""
|
||||
stdout = " M src/a.py\n M src/b.py\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
v = _check_audit_only_violations(baseline=None)
|
||||
assert set(v) == {"src/a.py", "src/b.py"}
|
||||
|
||||
def test_baseline_empty_set_keeps_all(self, monkeypatch):
|
||||
"""baseline=set() 이면 모두 새 violation 으로 잡음."""
|
||||
stdout = " M src/a.py\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
v = _check_audit_only_violations(baseline=set())
|
||||
assert v == ["src/a.py"]
|
||||
|
||||
def test_baseline_filters_all_violations(self, monkeypatch):
|
||||
"""모든 violation 이 baseline 에 있으면 빈 list 반환 — clean 으로 판정."""
|
||||
stdout = " M src/a.py\n M templates/b.html\n M tests/c.py\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
baseline = {"src/a.py", "templates/b.html", "tests/c.py"}
|
||||
v = _check_audit_only_violations(baseline=baseline)
|
||||
assert v == []
|
||||
|
||||
def test_baseline_path_normalized_match(self, monkeypatch):
|
||||
"""baseline 의 path 는 forward-slash 정규화 형태. Windows backslash 도 매치."""
|
||||
stdout = " M src\\windows_path.py\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
baseline = {"src/windows_path.py"} # baseline 도 forward-slash 형태로 저장
|
||||
v = _check_audit_only_violations(baseline=baseline)
|
||||
assert v == []
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# P4a: _ensure_audit_baseline / _load_audit_baseline
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestAuditBaselinePersist:
|
||||
def test_save_and_load_roundtrip(self, monkeypatch, tmp_path):
|
||||
"""baseline 저장 → 로드 → 동일 path set 반환."""
|
||||
# Redirect ORCH_DIR to tmp_path for isolation.
|
||||
monkeypatch.setattr(orchestrator, "ORCH_DIR", tmp_path)
|
||||
# Mock git status output.
|
||||
stdout = " M src/a.py\n?? src/b.py\n M docs/c.md\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
_ensure_audit_baseline(999)
|
||||
loaded = _load_audit_baseline(999)
|
||||
assert loaded == {"src/a.py", "src/b.py", "docs/c.md"}
|
||||
|
||||
def test_ensure_does_not_overwrite_existing(self, monkeypatch, tmp_path):
|
||||
"""이미 baseline 파일 있으면 덮어쓰지 않음 — resumed run 의 가드 일관성."""
|
||||
monkeypatch.setattr(orchestrator, "ORCH_DIR", tmp_path)
|
||||
# First save with one set.
|
||||
stdout1 = " M src/original.py\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout1))
|
||||
_ensure_audit_baseline(999)
|
||||
# Second call with DIFFERENT git status — should NOT overwrite.
|
||||
stdout2 = " M src/different.py\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout2))
|
||||
_ensure_audit_baseline(999)
|
||||
loaded = _load_audit_baseline(999)
|
||||
# Original baseline preserved.
|
||||
assert loaded == {"src/original.py"}
|
||||
|
||||
def test_load_missing_returns_empty_set(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(orchestrator, "ORCH_DIR", tmp_path)
|
||||
assert _load_audit_baseline(8888) == set()
|
||||
|
||||
def test_load_corrupt_returns_empty_set(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(orchestrator, "ORCH_DIR", tmp_path)
|
||||
# Manually write corrupt JSON.
|
||||
p = tmp_path / "audit_baseline_7777.json"
|
||||
p.write_text("not valid json {{{", encoding="utf-8")
|
||||
assert _load_audit_baseline(7777) == set()
|
||||
|
||||
def test_load_non_list_returns_empty_set(self, monkeypatch, tmp_path):
|
||||
"""baseline 파일이 list 가 아닌 다른 JSON (예: dict) 이면 empty set."""
|
||||
monkeypatch.setattr(orchestrator, "ORCH_DIR", tmp_path)
|
||||
p = tmp_path / "audit_baseline_6666.json"
|
||||
p.write_text('{"unexpected": "shape"}', encoding="utf-8")
|
||||
assert _load_audit_baseline(6666) == set()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# P4a: _check_audit_commit_scope — Stage 5 guard
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestAuditCommitScope:
|
||||
def test_clean_commit_audit_report_only(self, monkeypatch):
|
||||
"""audit report 파일만 commit 되면 통과."""
|
||||
stdout = (
|
||||
"docs/architecture/INTEGRATION-AUDIT-01-REPORT.md\n"
|
||||
"docs/architecture/INTEGRATION-AUDIT-01-MATRIX.md\n"
|
||||
)
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
assert _check_audit_commit_scope() == []
|
||||
|
||||
def test_backlog_update_allowed(self, monkeypatch):
|
||||
stdout = (
|
||||
"docs/architecture/INTEGRATION-AUDIT-01-REPORT.md\n"
|
||||
"docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md\n"
|
||||
)
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
assert _check_audit_commit_scope() == []
|
||||
|
||||
def test_src_file_in_commit_detected(self, monkeypatch):
|
||||
"""audit commit 에 src/ 파일이 끼면 violation."""
|
||||
stdout = (
|
||||
"docs/architecture/INTEGRATION-AUDIT-01-REPORT.md\n"
|
||||
"src/phase_z2_pipeline.py\n"
|
||||
)
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
v = _check_audit_commit_scope()
|
||||
assert v == ["src/phase_z2_pipeline.py"]
|
||||
|
||||
def test_unrelated_doc_detected(self, monkeypatch):
|
||||
"""docs/ 라도 audit 관련 아닌 doc 은 violation."""
|
||||
stdout = (
|
||||
"docs/architecture/INTEGRATION-AUDIT-01-REPORT.md\n"
|
||||
"docs/some_other_doc.md\n" # 다른 doc
|
||||
"docs/architecture/PHASE-Z-PIPELINE-OVERVIEW.md\n" # audit 와 무관한 doc
|
||||
)
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
v = _check_audit_commit_scope()
|
||||
assert set(v) == {"docs/some_other_doc.md",
|
||||
"docs/architecture/PHASE-Z-PIPELINE-OVERVIEW.md"}
|
||||
|
||||
def test_git_error_fails_open(self, monkeypatch):
|
||||
"""git show 자체 실패 → 빈 list (가드가 false positive 만들지 않음)."""
|
||||
monkeypatch.setattr(subprocess, "run",
|
||||
lambda *a, **kw: _FakeCompleted(stdout="", returncode=128))
|
||||
assert _check_audit_commit_scope() == []
|
||||
|
||||
def test_windows_backslash_normalized(self, monkeypatch):
|
||||
"""Windows backslash path 도 forward-slash 정규화 후 glob 매치."""
|
||||
stdout = "docs\\architecture\\INTEGRATION-AUDIT-01-REPORT.md\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
assert _check_audit_commit_scope() == []
|
||||
|
||||
def test_empty_commit_passes(self, monkeypatch):
|
||||
"""commit 에 파일 변경 없음 (보통 안 일어나지만) — 위반 없음."""
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=""))
|
||||
assert _check_audit_commit_scope() == []
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# P4a: allowed-glob shape sanity
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestAuditCommitAllowedGlobs:
|
||||
def test_globs_have_audit_marker(self):
|
||||
"""모든 allowed glob 에 INTEGRATION-AUDIT 또는 BACKLOG 마커 존재."""
|
||||
for g in AUDIT_ALLOWED_COMMIT_GLOBS:
|
||||
assert ("INTEGRATION-AUDIT" in g) or ("BACKLOG" in g)
|
||||
|
||||
def test_globs_under_docs_architecture(self):
|
||||
"""모든 allowed path 가 docs/architecture/ 산하 — src/ 등 우발적 허용 차단."""
|
||||
for g in AUDIT_ALLOWED_COMMIT_GLOBS:
|
||||
assert g.startswith("docs/architecture/"), f"glob escapes docs/architecture/: {g}"
|
||||
|
||||
|
||||
class TestAuditOnlyConstants:
|
||||
def test_note_mentions_forbidden_prefixes(self):
|
||||
for p in AUDIT_ONLY_FORBIDDEN_PREFIXES:
|
||||
assert p in AUDIT_ONLY_NOTE, f"AUDIT_ONLY_NOTE missing prefix mention: {p}"
|
||||
|
||||
def test_note_mentions_allowed_paths(self):
|
||||
assert "INTEGRATION-AUDIT-*.md" in AUDIT_ONLY_NOTE
|
||||
assert "PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md" in AUDIT_ONLY_NOTE
|
||||
|
||||
def test_note_states_no_code_edit(self):
|
||||
# "report" 또는 "NOT code" 표현 명시 확인 (LLM 가독성 가드).
|
||||
lower = AUDIT_ONLY_NOTE.lower()
|
||||
assert "audit report" in lower or "report writing" in lower
|
||||
assert "not code" in lower or "no production" in lower
|
||||
|
||||
def test_forbidden_prefixes_no_trailing_slash_issues(self):
|
||||
"""블랙리스트는 startswith 매치 — 'src' (slash 없음) 면 'srcfoo.py' 도 매칭돼서 false positive.
|
||||
모든 prefix 가 '/' 로 끝나야 함."""
|
||||
for p in AUDIT_ONLY_FORBIDDEN_PREFIXES:
|
||||
assert p.endswith("/"), f"prefix '{p}' must end with '/' to avoid false matches"
|
||||
@@ -0,0 +1,492 @@
|
||||
"""P5 (2026-05-20) — Dormant trigger guard tests (issue #58, unit u4).
|
||||
|
||||
Covers the L3 dormant trigger layer end-to-end:
|
||||
|
||||
- u1 — docs/architecture/DORMANT-TRIGGERS.yaml schema + content for the
|
||||
IMP-16 / IMP-17 / IMP-18 / IMP-19 / IMP-20 axes.
|
||||
- u2 — scripts/check_dormant_triggers.py file-pattern + content-pattern
|
||||
matching, manual-evidence skip, followup-linked skip,
|
||||
false-positive guards, exit-0 standalone invocation.
|
||||
- u3 — orchestrator._check_dormant_triggers() helper fail-open contract
|
||||
and the Stage 4→5 _audit_mode() bypass predicate.
|
||||
- u5 — DORMANT-TRIGGERS.yaml self-documenting header
|
||||
(governance doc cross-reference test runs after u5 lands).
|
||||
|
||||
Each test names the IMP-# trigger it exercises (scope-qualified verification
|
||||
per the work-principles lock).
|
||||
|
||||
Run: pytest -q tests/orchestrator_unit/test_dormant_triggers.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
REGISTRY_PATH = ROOT / "docs" / "architecture" / "DORMANT-TRIGGERS.yaml"
|
||||
GOVERNANCE_PATH = ROOT / "docs" / "architecture" / "PROJECT-INTENT-AND-GOVERNANCE.md"
|
||||
CHECKER_PATH = ROOT / "scripts" / "check_dormant_triggers.py"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Shared fixtures
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def registry_entries() -> list[dict]:
|
||||
"""Parsed registry — used across schema + content tests."""
|
||||
with REGISTRY_PATH.open("r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
assert isinstance(data, list), "registry root must be a YAML list"
|
||||
return data
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chkmod():
|
||||
"""Fresh import of the standalone checker module (function-scoped so
|
||||
monkeypatched module attributes do not leak across tests)."""
|
||||
if "check_dormant_triggers" in sys.modules:
|
||||
return importlib.reload(sys.modules["check_dormant_triggers"])
|
||||
import check_dormant_triggers as m # noqa: WPS433 — runtime import by design
|
||||
return m
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def orch():
|
||||
"""Orchestrator module under test (u3 helper)."""
|
||||
import orchestrator as m # noqa: WPS433
|
||||
return m
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# u1 — registry yaml schema + content
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestRegistrySchema:
|
||||
"""u1 — DORMANT-TRIGGERS.yaml exists, parses, and shapes the 5 dormant axes."""
|
||||
|
||||
def test_registry_yaml_parses_with_pyyaml(self):
|
||||
"""Schema sanity (covers all of IMP-16/17/18/19/20): file parses as YAML list."""
|
||||
assert REGISTRY_PATH.exists(), f"registry missing: {REGISTRY_PATH}"
|
||||
with REGISTRY_PATH.open("r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
assert isinstance(data, list), "registry root must be a YAML list"
|
||||
|
||||
def test_registry_has_5_entries_4_active_plus_1_followup_linked(self, registry_entries):
|
||||
"""Stage 1/2 scope-lock: IMP-16 / IMP-17 / IMP-18 / IMP-19 + IMP-20 followup-linked."""
|
||||
assert len(registry_entries) == 5
|
||||
issues = sorted(e["issue"] for e in registry_entries)
|
||||
assert issues == [16, 17, 18, 19, 20]
|
||||
|
||||
def test_registry_required_fields_present(self, registry_entries):
|
||||
"""Every entry has issue / title / doc / status / trigger / on_trigger (covers IMP-16~20)."""
|
||||
for e in registry_entries:
|
||||
assert isinstance(e.get("issue"), int)
|
||||
assert isinstance(e.get("title"), str) and e["title"]
|
||||
assert isinstance(e.get("doc"), str) and e["doc"]
|
||||
assert "status" in e
|
||||
assert isinstance(e.get("trigger"), dict)
|
||||
assert isinstance(e.get("on_trigger"), dict)
|
||||
trig = e["trigger"]
|
||||
assert "description" in trig
|
||||
assert isinstance(trig.get("manual_evidence_required"), bool)
|
||||
|
||||
|
||||
class TestImp16Entry:
|
||||
"""IMP-16 (issue 16) — active watch on src/** reverse-path adapter."""
|
||||
|
||||
def test_imp16_active_src_glob_and_reverse_path_content_pattern(self, registry_entries):
|
||||
"""IMP-16 trigger: src/**/*.py glob + reverse_path / html_to_slide_mdx content."""
|
||||
e = next(x for x in registry_entries if x["issue"] == 16)
|
||||
assert e["status"] == "documented:dormant"
|
||||
assert not e.get("followup_issue"), "IMP-16 is active, not followup-linked"
|
||||
t = e["trigger"]
|
||||
assert t["manual_evidence_required"] is False
|
||||
assert any("src/**" in p for p in t["file_patterns"])
|
||||
cps = t["content_patterns"]
|
||||
assert any("reverse_path" in p or "html_to_slide_mdx" in p for p in cps)
|
||||
|
||||
|
||||
class TestImp17Entry:
|
||||
"""IMP-17 (issue 17) — manual-evidence gate (3-cond User-GO AND)."""
|
||||
|
||||
def test_imp17_manual_evidence_required_true(self, registry_entries):
|
||||
"""IMP-17 trigger gate: manual_evidence_required=true (User GO + B4 + IMP-04/05 live)."""
|
||||
e = next(x for x in registry_entries if x["issue"] == 17)
|
||||
assert e["trigger"]["manual_evidence_required"] is True
|
||||
|
||||
|
||||
class TestImp18Entry:
|
||||
"""IMP-18 (issue 18) — SVG partial under templates/phase_z2/."""
|
||||
|
||||
def test_imp18_active_watch_on_phase_z2_templates_with_svg_content(self, registry_entries):
|
||||
"""IMP-18 trigger: templates/phase_z2/{families,frames}/*.html + SVG signature."""
|
||||
e = next(x for x in registry_entries if x["issue"] == 18)
|
||||
assert e["trigger"]["manual_evidence_required"] is False
|
||||
fps = e["trigger"]["file_patterns"]
|
||||
assert any("templates/phase_z2/" in p for p in fps)
|
||||
cps = e["trigger"]["content_patterns"]
|
||||
assert any("svg" in p.lower() or "viewBox" in p for p in cps)
|
||||
|
||||
|
||||
class TestImp19Entry:
|
||||
"""IMP-19 (issue 19) — manual-evidence gate (IMP-09 owner sign-off)."""
|
||||
|
||||
def test_imp19_manual_evidence_required_true(self, registry_entries):
|
||||
"""IMP-19 trigger gate: manual_evidence_required=true (failing-case + IMP-09 sign-off)."""
|
||||
e = next(x for x in registry_entries if x["issue"] == 19)
|
||||
assert e["trigger"]["manual_evidence_required"] is True
|
||||
|
||||
|
||||
class TestImp20Entry:
|
||||
"""IMP-20 (issue 20) — followup-linked to open issue #55, note-only."""
|
||||
|
||||
def test_imp20_followup_linked_to_55_with_note_only_action(self, registry_entries):
|
||||
"""IMP-20 status: followup_issue=55, on_trigger.action=note_only (no checker watch)."""
|
||||
e = next(x for x in registry_entries if x["issue"] == 20)
|
||||
assert e.get("followup_issue") == 55
|
||||
assert e["on_trigger"]["action"] == "note_only"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# u2 — check_dormant_triggers.py
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestCheckerMatching:
|
||||
|
||||
def test_checker_clean_tree_no_alerts_covers_imp16_18(self, chkmod, monkeypatch):
|
||||
"""False-positive guard: empty change surface → no IMP-16 / IMP-18 alerts."""
|
||||
monkeypatch.setattr(chkmod, "collect_changed_files", lambda: [])
|
||||
entries = chkmod.load_registry()
|
||||
alerts = [a for a in (chkmod.check_entry(e, []) for e in entries) if a]
|
||||
assert alerts == []
|
||||
|
||||
def test_checker_imp16_alert_on_src_reverse_path_adapter(
|
||||
self, chkmod, monkeypatch, tmp_path
|
||||
):
|
||||
"""IMP-16 positive: src/foo/adapter.py with `reverse_path` content → alert."""
|
||||
fake_path = "src/foo/adapter.py"
|
||||
(tmp_path / "src" / "foo").mkdir(parents=True)
|
||||
(tmp_path / fake_path).write_text("def reverse_path(): pass\n", encoding="utf-8")
|
||||
monkeypatch.setattr(chkmod, "REPO_ROOT", tmp_path)
|
||||
entries = chkmod.load_registry()
|
||||
imp16 = next(e for e in entries if e["issue"] == 16)
|
||||
result = chkmod.check_entry(imp16, [fake_path])
|
||||
assert result is not None
|
||||
assert result["issue"] == 16
|
||||
assert fake_path in result["match"]["files"]
|
||||
|
||||
def test_checker_imp16_no_alert_on_tests_path_false_positive_guard(
|
||||
self, chkmod, monkeypatch, tmp_path
|
||||
):
|
||||
"""False-positive guard: IMP-16 must NOT fire on tests/foo.py even with matching content."""
|
||||
fake_path = "tests/foo.py"
|
||||
(tmp_path / "tests").mkdir()
|
||||
(tmp_path / fake_path).write_text("def reverse_path(): pass\n", encoding="utf-8")
|
||||
monkeypatch.setattr(chkmod, "REPO_ROOT", tmp_path)
|
||||
entries = chkmod.load_registry()
|
||||
imp16 = next(e for e in entries if e["issue"] == 16)
|
||||
assert chkmod.check_entry(imp16, [fake_path]) is None
|
||||
|
||||
def test_checker_imp18_alert_on_phase_z2_family_svg(
|
||||
self, chkmod, monkeypatch, tmp_path
|
||||
):
|
||||
"""IMP-18 positive: templates/phase_z2/families/new.html with <svg viewBox> → alert."""
|
||||
fake_path = "templates/phase_z2/families/new_partial.html"
|
||||
(tmp_path / "templates" / "phase_z2" / "families").mkdir(parents=True)
|
||||
(tmp_path / fake_path).write_text(
|
||||
'<svg viewBox="0 0 100 100"></svg>\n', encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setattr(chkmod, "REPO_ROOT", tmp_path)
|
||||
entries = chkmod.load_registry()
|
||||
imp18 = next(e for e in entries if e["issue"] == 18)
|
||||
result = chkmod.check_entry(imp18, [fake_path])
|
||||
assert result is not None
|
||||
assert result["issue"] == 18
|
||||
|
||||
def test_checker_imp18_flat_glob_boundary_nested_path_skipped(
|
||||
self, chkmod, monkeypatch, tmp_path
|
||||
):
|
||||
"""False-positive guard: IMP-18 flat glob `families/*.html` skips nested family path."""
|
||||
fake_path = "templates/phase_z2/families/nested/inner.html"
|
||||
(tmp_path / "templates" / "phase_z2" / "families" / "nested").mkdir(parents=True)
|
||||
(tmp_path / fake_path).write_text(
|
||||
'<svg viewBox="0 0 100 100"></svg>\n', encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setattr(chkmod, "REPO_ROOT", tmp_path)
|
||||
entries = chkmod.load_registry()
|
||||
imp18 = next(e for e in entries if e["issue"] == 18)
|
||||
assert chkmod.check_entry(imp18, [fake_path]) is None
|
||||
|
||||
def test_checker_skips_imp17_manual_evidence(self, chkmod):
|
||||
"""Guardrail: IMP-17 manual_evidence_required skips even with broad change surface."""
|
||||
entries = chkmod.load_registry()
|
||||
imp17 = next(e for e in entries if e["issue"] == 17)
|
||||
assert chkmod.check_entry(imp17, ["src/foo.py", "anything.html"]) is None
|
||||
|
||||
def test_checker_skips_imp19_manual_evidence(self, chkmod):
|
||||
"""Guardrail: IMP-19 manual_evidence_required skips even with broad change surface."""
|
||||
entries = chkmod.load_registry()
|
||||
imp19 = next(e for e in entries if e["issue"] == 19)
|
||||
assert chkmod.check_entry(imp19, ["src/foo.py"]) is None
|
||||
|
||||
def test_checker_skips_imp20_followup_linked(self, chkmod):
|
||||
"""Guardrail: IMP-20 followup_issue=55 skips (open issue #55 owns the watch)."""
|
||||
entries = chkmod.load_registry()
|
||||
imp20 = next(e for e in entries if e["issue"] == 20)
|
||||
assert chkmod.check_entry(imp20, ["anything", "src/a.py"]) is None
|
||||
|
||||
def test_checker_standalone_invocation_exit_0(self):
|
||||
"""Guardrail: standalone `python scripts/check_dormant_triggers.py` exits 0 always.
|
||||
|
||||
Exercises the IMP-16~20 informational-only contract — checker never blocks
|
||||
the orchestrator regardless of working-tree state.
|
||||
"""
|
||||
r = subprocess.run(
|
||||
[sys.executable, str(CHECKER_PATH)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
cwd=str(ROOT),
|
||||
timeout=30,
|
||||
)
|
||||
assert r.returncode == 0, f"checker exit={r.returncode} stderr={r.stderr!r}"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# u3 — orchestrator helper + Stage 4→5 hook
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestOrchestratorDormantHook:
|
||||
|
||||
def test_orchestrator_helper_returns_list_on_clean_tree(self, orch):
|
||||
"""u3 helper returns list[dict] (possibly empty) — never raises (IMP-16~20 informational)."""
|
||||
result = orch._check_dormant_triggers()
|
||||
assert isinstance(result, list)
|
||||
|
||||
def test_orchestrator_helper_fail_open_on_subprocess_error(self, orch, monkeypatch):
|
||||
"""u3 helper: subprocess raise → [] (fail-open, no false positives across all IMPs)."""
|
||||
def boom(*a, **kw):
|
||||
raise RuntimeError("subprocess unavailable")
|
||||
monkeypatch.setattr(orch.subprocess, "run", boom)
|
||||
assert orch._check_dormant_triggers() == []
|
||||
|
||||
def test_orchestrator_helper_fail_open_on_nonzero_exit(self, orch, monkeypatch):
|
||||
"""u3 helper: subprocess returncode != 0 → [] (fail-open)."""
|
||||
class _Err:
|
||||
returncode = 1
|
||||
stdout = ""
|
||||
stderr = "boom"
|
||||
monkeypatch.setattr(orch.subprocess, "run", lambda *a, **kw: _Err())
|
||||
assert orch._check_dormant_triggers() == []
|
||||
|
||||
def test_orchestrator_helper_fail_open_on_missing_alerts_file(
|
||||
self, orch, monkeypatch, tmp_path
|
||||
):
|
||||
"""u3 helper: subprocess OK but alert file absent → [] (fail-open)."""
|
||||
class _OK:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
monkeypatch.setattr(orch.subprocess, "run", lambda *a, **kw: _OK())
|
||||
monkeypatch.setattr(orch, "ORCH_DIR", tmp_path)
|
||||
assert orch._check_dormant_triggers() == []
|
||||
|
||||
def test_orchestrator_helper_parses_alerts_payload(self, orch, monkeypatch, tmp_path):
|
||||
"""u3 helper: reads `alerts` list out of .orchestrator/dormant_alerts.json payload."""
|
||||
class _OK:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
monkeypatch.setattr(orch.subprocess, "run", lambda *a, **kw: _OK())
|
||||
monkeypatch.setattr(orch, "ORCH_DIR", tmp_path)
|
||||
payload = {
|
||||
"alerts": [
|
||||
{"issue": 16, "title": "IMP-16 sample", "on_trigger": {"action": "create_runtime_issue"}},
|
||||
],
|
||||
}
|
||||
(tmp_path / "dormant_alerts.json").write_text(
|
||||
json.dumps(payload), encoding="utf-8"
|
||||
)
|
||||
result = orch._check_dormant_triggers()
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert result[0]["issue"] == 16
|
||||
|
||||
def test_orchestrator_helper_handles_non_list_alerts_payload(
|
||||
self, orch, monkeypatch, tmp_path
|
||||
):
|
||||
"""u3 helper: malformed `alerts` (non-list) → [] (fail-open, IMP-16~20 informational)."""
|
||||
class _OK:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
monkeypatch.setattr(orch.subprocess, "run", lambda *a, **kw: _OK())
|
||||
monkeypatch.setattr(orch, "ORCH_DIR", tmp_path)
|
||||
(tmp_path / "dormant_alerts.json").write_text(
|
||||
json.dumps({"alerts": "oops"}), encoding="utf-8"
|
||||
)
|
||||
assert orch._check_dormant_triggers() == []
|
||||
|
||||
def test_stage_4_to_5_hook_predicate_audit_bypass(self, orch):
|
||||
"""u3 Stage 4→5 hook guard: _audit_mode(title) True → dormant checker bypassed.
|
||||
|
||||
Mirrors P4a placement — the dormant hook condition is
|
||||
`sid == "test-verify" and not _audit_mode(title)`. This test asserts
|
||||
the gating predicate for the IMP-16/18 active watches.
|
||||
"""
|
||||
assert orch._audit_mode("[INTEGRATION-AUDIT-02] cumulative review") is True
|
||||
assert orch._audit_mode("[AUDIT-ONLY] doc consistency") is True
|
||||
assert orch._audit_mode("[P5][DORMANT-TRIGGER-GUARD] hook") is False
|
||||
assert orch._audit_mode("IMP-16 U2 wiring") is False
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# u3 — Stage 4→5 hook integration (focused static assertions on run_stage)
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestRunStageDormantHookIntegration:
|
||||
"""u3 — Stage 4→5 hook must be wired into ``orchestrator.run_stage``.
|
||||
|
||||
Codex #5 rewind: testing ``_audit_mode()`` in isolation is insufficient.
|
||||
These focused static-source assertions on ``inspect.getsource(run_stage)``
|
||||
fail if the dormant hook is silently removed or its audit-bypass predicate
|
||||
is weakened — guarding the IMP-16/17/18/19/20 informational alert wiring.
|
||||
"""
|
||||
|
||||
def _run_stage_src(self, orch) -> str:
|
||||
import inspect
|
||||
return inspect.getsource(orch.run_stage)
|
||||
|
||||
def test_run_stage_invokes_check_dormant_triggers_on_test_verify_non_audit(self, orch):
|
||||
"""u3 contract: run_stage body calls _check_dormant_triggers().
|
||||
|
||||
Exercises the Stage 4 (test-verify) non-audit invocation path for
|
||||
IMP-16/18 active watches. If the call disappears from run_stage,
|
||||
this test fails (catches the regression that Codex #5 flagged).
|
||||
"""
|
||||
src = self._run_stage_src(orch)
|
||||
assert "_check_dormant_triggers()" in src, (
|
||||
"run_stage must invoke _check_dormant_triggers() — the L3 wiring "
|
||||
"for IMP-16/17/18/19/20 dormant alerts. Silent removal breaks the "
|
||||
"Stage 4→5 informational hook."
|
||||
)
|
||||
|
||||
def test_run_stage_dormant_hook_gated_by_test_verify_and_non_audit(self, orch):
|
||||
"""u3 contract: dormant hook is gated by both sid==test-verify AND not _audit_mode(title).
|
||||
|
||||
The predicate is what makes audit-only Stage 4 bypass the IMP-16~20
|
||||
checker (Stage 1 scope-lock guardrail). Removing either conjunct
|
||||
would silently re-enable the checker on audit-only issues whose
|
||||
change surface is restricted to audit-report docs.
|
||||
"""
|
||||
import re
|
||||
src = self._run_stage_src(orch)
|
||||
m = re.search(
|
||||
r'sid\s*==\s*"test-verify"\s+and\s+not\s+_audit_mode\(title\)\s*:\s*\n'
|
||||
r'\s+alerts\s*=\s*_check_dormant_triggers\(\)',
|
||||
src,
|
||||
)
|
||||
assert m is not None, (
|
||||
"Stage 4→5 dormant hook must be gated by "
|
||||
'`sid == "test-verify" and not _audit_mode(title):` immediately '
|
||||
"before `alerts = _check_dormant_triggers()`. Audit-only bypass "
|
||||
"and Stage 4 placement are part of the IMP-16~20 contract."
|
||||
)
|
||||
|
||||
def test_run_stage_dormant_hook_is_informational_no_continue(self, orch):
|
||||
"""u3 contract: dormant hook block must NOT contain a `continue` statement.
|
||||
|
||||
IMP-16~20 alerts are informational only (Stage 1 guardrail). The hook
|
||||
block between the _check_dormant_triggers() call and the next
|
||||
Stage 4 PASS log line ("YES (evidence verified)") must not short-
|
||||
circuit Stage 5 entry via `continue`. Comments mentioning the word
|
||||
"continue" are allowed (they document the contract).
|
||||
"""
|
||||
import re
|
||||
src = self._run_stage_src(orch)
|
||||
start = src.find("_check_dormant_triggers()")
|
||||
assert start >= 0
|
||||
end = src.find("YES (evidence verified)", start)
|
||||
assert end > start, (
|
||||
"could not locate Stage 4 PASS log line after dormant hook — "
|
||||
"run_stage shape may have shifted; re-examine integration."
|
||||
)
|
||||
hook_block = src[start:end]
|
||||
# Strip line comments before checking for the `continue` keyword as
|
||||
# an actual statement — the source intentionally documents the
|
||||
# informational-only contract with a `# Never continue — ...` comment.
|
||||
stripped_lines = []
|
||||
for line in hook_block.splitlines():
|
||||
code = line.split("#", 1)[0]
|
||||
stripped_lines.append(code)
|
||||
code_only = "\n".join(stripped_lines)
|
||||
assert not re.search(r'(^|\s)continue\b', code_only), (
|
||||
"dormant hook block contains a `continue` statement — IMP-16~20 "
|
||||
"alerts must never block Stage 5 entry (informational-only contract)."
|
||||
)
|
||||
|
||||
def test_run_stage_dormant_hook_positioned_before_stage_pass_return(self, orch):
|
||||
"""u3 contract: dormant hook is positioned within the Stage 4 YES PASS path.
|
||||
|
||||
Verifies the hook sits between the P4a audit commit-scope guard and
|
||||
the Stage 4 success log+return (i.e., on the PASS path), not in an
|
||||
unreachable branch. Protects against accidental relocation during
|
||||
future refactors.
|
||||
"""
|
||||
src = self._run_stage_src(orch)
|
||||
hook_pos = src.find("_check_dormant_triggers()")
|
||||
pass_log_pos = src.find("YES (evidence verified)", hook_pos)
|
||||
return_true_pos = src.find("return True", hook_pos)
|
||||
assert hook_pos >= 0
|
||||
assert 0 < pass_log_pos - hook_pos < 4000, (
|
||||
"dormant hook is not adjacent to the Stage 4 PASS log line — "
|
||||
"run_stage shape may have shifted away from PASS-path placement."
|
||||
)
|
||||
assert return_true_pos > pass_log_pos, (
|
||||
"Stage 4 `return True` should follow the PASS log line; "
|
||||
"dormant hook must precede both."
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# u5 — registry self-documentation + governance cross-reference
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestRegistryHeaderAndGovernanceRef:
|
||||
|
||||
def test_registry_header_explains_schema_and_l3_purpose(self):
|
||||
"""u5 acceptance: yaml header explains schema + L3 informational-only purpose.
|
||||
|
||||
The header is the durable self-documentation surface that anchors the
|
||||
IMP-16/17/18/19/20 registry (per Stage 1 exit unit u5).
|
||||
"""
|
||||
text = REGISTRY_PATH.read_text(encoding="utf-8")
|
||||
assert "Schema" in text or "schema" in text
|
||||
assert "dormant" in text.lower()
|
||||
assert "L3" in text or "machine-readable" in text.lower()
|
||||
assert "Guardrails" in text or "informational" in text.lower()
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not GOVERNANCE_PATH.exists()
|
||||
or "DORMANT-TRIGGERS.yaml" not in GOVERNANCE_PATH.read_text(encoding="utf-8"),
|
||||
reason="u5 governance-doc reference line not yet appended (passes after u5 lands).",
|
||||
)
|
||||
def test_governance_doc_references_registry(self):
|
||||
"""u5 deliverable: PROJECT-INTENT-AND-GOVERNANCE.md cites DORMANT-TRIGGERS.yaml as L3.
|
||||
|
||||
Runs after u5 lands — IMP-16~20 registry surfaces in the governance
|
||||
anti-patterns row so future maintainers find it without rediscovery.
|
||||
"""
|
||||
text = GOVERNANCE_PATH.read_text(encoding="utf-8")
|
||||
assert "DORMANT-TRIGGERS.yaml" in text
|
||||
@@ -0,0 +1,33 @@
|
||||
"""IMP-17 u1 (2026-05-19) — comment anchor for src/phase_z2_pipeline.py route hint table.
|
||||
|
||||
Stage 1 finding: line 564 previously referenced a non-existent ID ("IMP-31").
|
||||
The legitimate slot is IMP-17 (Gitea #17, carve-out — AI fallback only, normal path 밖).
|
||||
Line 565 (IMP-29 frontend zone-level override) must remain untouched.
|
||||
|
||||
Anchor re-pin (2026-05-20, IMP-30 u1 follow-up): V4Match.provisional field added at
|
||||
src/phase_z2_pipeline.py:179-184 shifted the route-hint table down by six lines.
|
||||
Pinned line numbers updated from 564/565 → 570/571 to track the actual anchor location.
|
||||
|
||||
Run: pytest -q tests/orchestrator_unit/test_imp17_comment_anchor.py
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parent.parent.parent
|
||||
PIPELINE = ROOT / "src" / "phase_z2_pipeline.py"
|
||||
|
||||
|
||||
def _lines() -> list[str]:
|
||||
return PIPELINE.read_text(encoding="utf-8").splitlines()
|
||||
|
||||
|
||||
def test_line_570_references_imp17_not_imp31():
|
||||
line = _lines()[569] # 1-indexed line 570
|
||||
assert "restructure" in line, f"line 570 anchor drifted: {line!r}"
|
||||
assert "IMP-17" in line, f"line 570 must reference IMP-17 (carve-out): {line!r}"
|
||||
assert "IMP-31" not in line, f"line 570 must not reference non-existent IMP-31: {line!r}"
|
||||
|
||||
|
||||
def test_line_571_still_references_imp29():
|
||||
line = _lines()[570] # 1-indexed line 571
|
||||
assert "reject" in line, f"line 571 anchor drifted: {line!r}"
|
||||
assert "IMP-29" in line, f"line 571 must still reference IMP-29 frontend override: {line!r}"
|
||||
@@ -0,0 +1,348 @@
|
||||
"""P0-3 (2026-05-18) — orchestrator self-test minimum set.
|
||||
|
||||
Covers detect_agent (the bug that caused #45 infinite loop), parse_consensus,
|
||||
parse_remaining_units, IMPLEMENTATION_UNITS parsing, dual-write normalize.
|
||||
|
||||
Run: pytest -q tests/orchestrator_unit/
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add design_agent root to sys.path so we can import orchestrator.py
|
||||
ROOT = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from orchestrator import (
|
||||
detect_agent,
|
||||
parse_consensus,
|
||||
parse_remaining_units,
|
||||
_is_execution_issue,
|
||||
)
|
||||
import re
|
||||
|
||||
|
||||
class TestExecutionIssueDetection:
|
||||
"""P1-4 — execution sub-issue title detection."""
|
||||
|
||||
def test_execution_korean_pattern(self):
|
||||
assert _is_execution_issue("[IMP-15 실행-1] image_aspect_mismatch") is True
|
||||
assert _is_execution_issue("[IMP-15 실행-2] table overflow") is True
|
||||
assert _is_execution_issue("[IMP-15 실행 3] something") is True
|
||||
|
||||
def test_execution_english_pattern(self):
|
||||
assert _is_execution_issue("[IMP-15 exec-1] image") is True
|
||||
assert _is_execution_issue("[IMP-15 EXEC 2] table") is True
|
||||
|
||||
def test_non_execution_title(self):
|
||||
assert _is_execution_issue("IMP-15 Step 14 visual_check 보강") is False
|
||||
assert _is_execution_issue("IMP-09 B-4 다른 layout zone-geometry") is False
|
||||
|
||||
def test_empty_title(self):
|
||||
assert _is_execution_issue("") is False
|
||||
assert _is_execution_issue(None) is False
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# detect_agent — the bug that caused #45 infinite loop
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestDetectAgent:
|
||||
def test_claude_header(self):
|
||||
assert detect_agent("[Claude #1] Stage 1 ...") == "claude"
|
||||
|
||||
def test_codex_header(self):
|
||||
assert detect_agent("[Codex #1] Stage 1 review") == "codex"
|
||||
|
||||
def test_codex_body_with_claude_citation(self):
|
||||
"""The exact bug from #45 — Codex body contains [Claude #N] citation in
|
||||
EVIDENCE section. Old detect_agent returned 'claude' (wrong)."""
|
||||
body = """[Codex #2] Stage 2 Round #1 simulation-plan verification
|
||||
|
||||
Verdict: NO.
|
||||
|
||||
=== EVIDENCE ===
|
||||
- Read current-stage Gitea comment `[Claude #2] Stage 2 Round #1 - Plan` only
|
||||
"""
|
||||
assert detect_agent(body) == "codex", \
|
||||
"Codex body containing [Claude #N] citation must still detect as codex"
|
||||
|
||||
def test_claude_body_with_codex_citation(self):
|
||||
body = """[Claude #3] Stage 2 Round #2 - Plan
|
||||
|
||||
Addressing [Codex #2] findings ...
|
||||
"""
|
||||
assert detect_agent(body) == "claude"
|
||||
|
||||
def test_empty_body(self):
|
||||
assert detect_agent("") is None
|
||||
assert detect_agent(None) is None
|
||||
assert detect_agent(" \n ") is None
|
||||
|
||||
def test_no_agent_header(self):
|
||||
assert detect_agent("This is some random text without any agent marker") is None
|
||||
|
||||
def test_leading_whitespace_before_header(self):
|
||||
body = " \n[Codex #1] header after whitespace"
|
||||
assert detect_agent(body) == "codex"
|
||||
|
||||
def test_header_must_be_at_start(self):
|
||||
"""Body that doesn't start with [Agent header should return None."""
|
||||
body = "Some intro text.\n[Codex #1] header on second line"
|
||||
# P0-1 fix: only first non-empty line is checked.
|
||||
# First line = "Some intro text." → no match → None
|
||||
assert detect_agent(body) is None
|
||||
|
||||
def test_header_with_hash_immediately(self):
|
||||
"""[Codex#1] (no space) should still match per regex \\[Codex[\\s#]."""
|
||||
assert detect_agent("[Codex#1] hello") == "codex"
|
||||
assert detect_agent("[Claude#5] hi") == "claude"
|
||||
|
||||
def test_audit_anchor_preface_breaks_detection(self):
|
||||
"""P5 (2026-05-20) — regression: AUDIT-ONLY mode 의 'Audit anchor:' preface 가
|
||||
첫 줄에 박히면 detect_agent 는 None 반환 (P0-1 strict 의도된 동작).
|
||||
이게 #56 (INTEGRATION-AUDIT-02) 의 Stage 4 Round #14 infinite loop 의 직접 원인.
|
||||
해결책 = detect_agent 완화 X, AUDIT_ONLY_NOTE 가 agent header 를 first line 으로 강제."""
|
||||
body_anchor_first = (
|
||||
"Audit anchor: This audit verifies pipeline contracts...\n"
|
||||
"It does not implement runtime code.\n"
|
||||
"\n"
|
||||
"[Codex #14] Stage 4 (test-verify) Round #14 - INTEGRATION-AUDIT-02\n"
|
||||
"\n"
|
||||
"Verdict: PASS. Stage 3 satisfies all criteria.\n"
|
||||
"FINAL_CONSENSUS: YES\n"
|
||||
)
|
||||
assert detect_agent(body_anchor_first) is None, (
|
||||
"audit anchor preface as first line MUST cause detect_agent None "
|
||||
"(P0-1 strict). Fix path: comment format, not detect_agent."
|
||||
)
|
||||
|
||||
def test_audit_anchor_after_header_works(self):
|
||||
"""P5 (2026-05-20) — 올바른 format: agent header first line, anchor line 2+."""
|
||||
body_header_first = (
|
||||
"[Codex #14] Stage 4 (test-verify) Round #14 - INTEGRATION-AUDIT-02\n"
|
||||
"\n"
|
||||
"Audit anchor: This audit verifies pipeline contracts...\n"
|
||||
"\n"
|
||||
"Verdict: PASS.\n"
|
||||
"FINAL_CONSENSUS: YES\n"
|
||||
)
|
||||
assert detect_agent(body_header_first) == "codex"
|
||||
|
||||
# P5b (2026-05-20) — Stage 2 compact-plan first-line conflict regression.
|
||||
# #24 IMP-24 K6: Codex r1~r3 가 첫 줄을 '=== IMPLEMENTATION_UNITS ===' 로 시작 →
|
||||
# detect_agent None → orchestrator silent loop. fix path = comment format strict,
|
||||
# NOT detect_agent 완화 (P0-1 강화 그대로 유지).
|
||||
|
||||
def test_implementation_units_first_line_breaks_detection(self):
|
||||
"""=== IMPLEMENTATION_UNITS === 가 첫 줄이면 detect_agent None (P0-1 strict 정상 동작)."""
|
||||
body = (
|
||||
"=== IMPLEMENTATION_UNITS ===\n"
|
||||
"- id: u1\n"
|
||||
" summary: ...\n"
|
||||
" files:\n"
|
||||
" - docs/architecture/PHASE-Q-AUDIT.md\n"
|
||||
" tests:\n"
|
||||
" - pytest -q tests\n"
|
||||
" estimate_lines: 1\n"
|
||||
"\n"
|
||||
"FINAL_CONSENSUS: YES\n"
|
||||
)
|
||||
assert detect_agent(body) is None, (
|
||||
"=== IMPLEMENTATION_UNITS === as first line MUST cause detect_agent None "
|
||||
"(P0-1 strict). Fix path: enforce agent header first-line in prompt, not relax detect_agent."
|
||||
)
|
||||
|
||||
def test_compact_plan_with_header_first_works(self):
|
||||
"""올바른 Stage 2 compact format: [Codex #N] 첫 줄 → === IMPLEMENTATION_UNITS === 둘째 줄+."""
|
||||
body = (
|
||||
"[Codex #4] Stage 2 simulation-plan review - IMP-24 K6\n"
|
||||
"\n"
|
||||
"=== IMPLEMENTATION_UNITS ===\n"
|
||||
"- id: u1\n"
|
||||
" summary: ...\n"
|
||||
" tests:\n"
|
||||
" - pytest -q tests\n"
|
||||
"\n"
|
||||
"FINAL_CONSENSUS: YES\n"
|
||||
)
|
||||
assert detect_agent(body) == "codex"
|
||||
|
||||
def test_markdown_prefix_breaks_detection(self):
|
||||
"""P5b — `## [Codex #N]` 같은 markdown header prefix 도 detect_agent None.
|
||||
(#21 Stage 4 에서 관찰된 latent silent loop 원인.)"""
|
||||
body_hash = "## [Codex #1] Stage 4 test-verify Round #1\n\nVerdict: PASS\n"
|
||||
body_emoji = "📌 **[Claude #1] Stage 2 plan**\n\nbody\n"
|
||||
body_bold = "**[Codex #1] Stage 4**\n\nbody\n"
|
||||
assert detect_agent(body_hash) is None
|
||||
assert detect_agent(body_emoji) is None
|
||||
assert detect_agent(body_bold) is None
|
||||
|
||||
|
||||
class TestRulesAndCompactPlanFirstLineContract:
|
||||
"""P5b (2026-05-20) — RULES 와 COMPACT_PLAN_RULE 둘 다 first-line agent header
|
||||
rule 을 명시해야 함. wording 검증."""
|
||||
|
||||
def test_rules_has_first_line_strict(self):
|
||||
from orchestrator import RULES
|
||||
# RULES 안에 first-line strict + 모든 stage 적용 명시 있어야 함.
|
||||
assert "FIRST non-empty line" in RULES
|
||||
assert "[Claude #N]" in RULES and "[Codex #N]" in RULES
|
||||
# P5b OVERRIDES 키워드 — body rule 들이 first-line rule 보다 우선하지 않음을 강조
|
||||
assert "OVERRIDES" in RULES or "overrides" in RULES.lower()
|
||||
|
||||
def test_compact_plan_rule_carves_out_first_line(self):
|
||||
from orchestrator import COMPACT_PLAN_RULE
|
||||
# "body" 는 first-line agent header 다음부터 시작한다고 명시
|
||||
assert "FIRST non-empty line" in COMPACT_PLAN_RULE or "first-line agent header" in COMPACT_PLAN_RULE
|
||||
# "after the first-line" 같은 carve-out wording 검증
|
||||
body_lower = COMPACT_PLAN_RULE.lower()
|
||||
assert "after the first" in body_lower or "after the agent header" in body_lower
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# parse_consensus — YES/NO + rewind_target
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestParseConsensus:
|
||||
def test_yes_only(self):
|
||||
body = "Some text.\nFINAL_CONSENSUS: YES"
|
||||
assert parse_consensus(body) == ("YES", None)
|
||||
|
||||
def test_no_with_rewind_target(self):
|
||||
body = "Some text.\nrewind_target: stage_2_plan\nFINAL_CONSENSUS: NO"
|
||||
assert parse_consensus(body) == ("NO", "stage_2_plan")
|
||||
|
||||
def test_no_with_continue_same(self):
|
||||
body = "blah\nrewind_target: continue_same\nFINAL_CONSENSUS: NO"
|
||||
assert parse_consensus(body) == ("NO", "continue_same")
|
||||
|
||||
def test_no_target_only_in_last_10_lines(self):
|
||||
"""parse_consensus only scans last 10 lines."""
|
||||
body = "rewind_target: stage_1_review\n" + "\n".join(["filler"] * 20) + "\nFINAL_CONSENSUS: NO"
|
||||
status, target = parse_consensus(body)
|
||||
assert status == "NO"
|
||||
assert target is None # too far from end to be picked up
|
||||
|
||||
def test_no_consensus_marker(self):
|
||||
assert parse_consensus("just text, no marker") == (None, None)
|
||||
|
||||
def test_empty_body(self):
|
||||
assert parse_consensus("") == (None, None)
|
||||
assert parse_consensus(None) == (None, None)
|
||||
|
||||
def test_unknown_rewind_target_ignored(self):
|
||||
body = "rewind_target: bogus_target\nFINAL_CONSENSUS: NO"
|
||||
status, target = parse_consensus(body)
|
||||
assert status == "NO"
|
||||
assert target is None # bogus is not in REWIND_TARGET_TO_SID
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# parse_remaining_units — Stage 3 continue_same progress detection
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestParseRemainingUnits:
|
||||
def test_bracketed_list(self):
|
||||
body = "Remaining units: [u2, u3, u4]"
|
||||
assert parse_remaining_units(body) == {"u2", "u3", "u4"}
|
||||
|
||||
def test_comma_list_no_brackets(self):
|
||||
body = "Remaining units: u5, u6, u7"
|
||||
assert parse_remaining_units(body) == {"u5", "u6", "u7"}
|
||||
|
||||
def test_none_explicit(self):
|
||||
assert parse_remaining_units("Remaining units: none") == set()
|
||||
assert parse_remaining_units("Remaining units: []") == set()
|
||||
assert parse_remaining_units("Remaining units: (none)") == set()
|
||||
assert parse_remaining_units("Remaining units: -") == set()
|
||||
|
||||
def test_line_not_present(self):
|
||||
assert parse_remaining_units("no remaining units mentioned here") is None
|
||||
|
||||
def test_case_insensitive(self):
|
||||
body = "REMAINING UNITS: [U1, U2]"
|
||||
assert parse_remaining_units(body) == {"u1", "u2"}
|
||||
|
||||
def test_only_u_prefixed_digits(self):
|
||||
"""Sentence noise ignored — only u\\d+ pattern matched."""
|
||||
body = "Remaining units: I still need to do u3 and u7 work"
|
||||
assert parse_remaining_units(body) == {"u3", "u7"}
|
||||
|
||||
def test_empty_body(self):
|
||||
assert parse_remaining_units("") is None
|
||||
assert parse_remaining_units(None) is None
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# IMPLEMENTATION_UNITS block parsing (used in Stage 2 YES guard)
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestImplementationUnitsBlock:
|
||||
"""Reproduces the parser in run_stage Stage 2 YES guard (line ~810)."""
|
||||
|
||||
def _parse(self, body):
|
||||
iu_block_pat = re.compile(
|
||||
r"===\s*IMPLEMENTATION_UNITS\s*===\s*\n(.*?)(?=\n===\s|\Z)",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
iu_unit_pat = re.compile(r"^\s*-\s*id:\s*u\d+", re.IGNORECASE | re.MULTILINE)
|
||||
m = iu_block_pat.search(body or "")
|
||||
return bool(m and iu_unit_pat.search(m.group(1)))
|
||||
|
||||
def test_valid_block(self):
|
||||
body = """text
|
||||
|
||||
=== IMPLEMENTATION_UNITS ===
|
||||
- id: u1
|
||||
summary: ...
|
||||
- id: u2
|
||||
summary: ...
|
||||
"""
|
||||
assert self._parse(body) is True
|
||||
|
||||
def test_empty_block(self):
|
||||
body = "=== IMPLEMENTATION_UNITS ===\n(no entries)\n"
|
||||
assert self._parse(body) is False # header but no - id: uN entry
|
||||
|
||||
def test_block_missing(self):
|
||||
body = "just text, no implementation_units"
|
||||
assert self._parse(body) is False
|
||||
|
||||
def test_block_with_only_non_u_entries(self):
|
||||
body = """=== IMPLEMENTATION_UNITS ===
|
||||
- id: alpha
|
||||
summary: ...
|
||||
"""
|
||||
assert self._parse(body) is False # 'alpha' is not 'u\\d+'
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Direct integration check — the #45 bug case
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestRegressionForIssue45Bug:
|
||||
"""Verify the exact body shape that caused #45 infinite loop is now handled."""
|
||||
|
||||
def test_codex_no_with_claude_citation_full_flow(self):
|
||||
body = """[Codex #3] Stage 2 Round #2 simulation-plan verification for issue #45
|
||||
|
||||
Verdict: NO. The plan covers main axes but violates two Stage 2 requirements.
|
||||
|
||||
Findings:
|
||||
- Unit u1 declares tests: [] in === IMPLEMENTATION_UNITS ===
|
||||
- xfail-strict mechanism unclear
|
||||
|
||||
=== EVIDENCE ===
|
||||
Commands run:
|
||||
- git rev-parse HEAD
|
||||
- Read current-stage Gitea comment `[Claude #3] Stage 2 Round #2 - Plan`
|
||||
|
||||
rewind_target: stage_2_plan
|
||||
FINAL_CONSENSUS: NO
|
||||
"""
|
||||
# P0-1 fix: detect_agent reads only first line → "[Codex #3]" → codex
|
||||
assert detect_agent(body) == "codex", "P0-1 regression test"
|
||||
# parse_consensus: NO + rewind_target stage_2_plan
|
||||
status, target = parse_consensus(body)
|
||||
assert status == "NO"
|
||||
assert target == "stage_2_plan"
|
||||
@@ -0,0 +1,310 @@
|
||||
"""P3-5 (2026-05-18) — subprocess cleanup hardening verification.
|
||||
|
||||
Covers:
|
||||
C1: 정상 종료 → tree 잔류 0
|
||||
C2: timeout → TimeoutExpired raise + 자손 0
|
||||
C3: grandchild spawn 후 parent timeout → grandchild 정리
|
||||
C4: 외부 (orchestrator 가 spawn 안한) 프로세스 보호
|
||||
C5: _kill_process_tree(self.pid) 호출해도 orchestrator 자살 안 함
|
||||
C6 (CORE): parent 정상 종료 후 grandchild orphan 정리 — PID 2780 regression
|
||||
|
||||
Run: pytest -q tests/orchestrator_unit/test_subprocess_cleanup.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import psutil
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from orchestrator import (
|
||||
_kill_process_tree,
|
||||
_kill_tracked,
|
||||
_run_with_tree_kill,
|
||||
_proc_signature,
|
||||
_is_same_process,
|
||||
_SPAWNED,
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _py():
|
||||
"""Path to current Python interpreter — used to spawn dummy subprocesses."""
|
||||
return sys.executable
|
||||
|
||||
def _alive(pid):
|
||||
try:
|
||||
return psutil.Process(pid).is_running() and psutil.Process(pid).status() != psutil.STATUS_ZOMBIE
|
||||
except psutil.NoSuchProcess:
|
||||
return False
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Signature helpers
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestSignatureHelpers:
|
||||
def test_proc_signature_alive(self):
|
||||
p = psutil.Process(os.getpid())
|
||||
sig = _proc_signature(p)
|
||||
assert sig is not None
|
||||
assert sig[0] == os.getpid()
|
||||
assert isinstance(sig[1], float)
|
||||
|
||||
def test_is_same_process_orch_self_blocked(self):
|
||||
"""C5 prep — orchestrator 자기 자신은 절대 same-process true 안 됨."""
|
||||
p = psutil.Process(os.getpid())
|
||||
sig = _proc_signature(p)
|
||||
# _is_same_process 가 _ORCH_PID 체크로 False 반환해야 함.
|
||||
assert _is_same_process(sig[0], sig[1]) is False
|
||||
|
||||
def test_is_same_process_dead_pid(self):
|
||||
# 사용 가능성 낮은 PID 999999 — 거의 확실히 죽음.
|
||||
assert _is_same_process(999999, time.time()) is False
|
||||
|
||||
def test_is_same_process_wrong_create_time(self):
|
||||
"""PID 재사용 회피 검증 — 같은 PID 라도 create_time 안 맞으면 False."""
|
||||
# 살아있는 외부 프로세스 빌려서 일부러 어긋난 create_time 으로 호출.
|
||||
# System Idle 같은 특수 프로세스 (create_time=0) 회피 — 우리가 띄운 dummy 사용.
|
||||
dummy = subprocess.Popen(
|
||||
[_py(), "-c", "import time; time.sleep(5)"],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
try:
|
||||
# 실제 create_time 보다 1 년 전 시각 → 명백한 mismatch.
|
||||
far_past = time.time() - 365 * 24 * 3600
|
||||
assert _is_same_process(dummy.pid, far_past) is False
|
||||
# 맞는 create_time 으로는 True 여야 함 (sanity).
|
||||
real_ct = psutil.Process(dummy.pid).create_time()
|
||||
assert _is_same_process(dummy.pid, real_ct) is True
|
||||
finally:
|
||||
dummy.kill()
|
||||
dummy.wait(timeout=5)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# C1: 정상 종료 — tree 잔류 0
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestC1_NormalExit:
|
||||
def test_dummy_short_run_no_residue(self):
|
||||
r = _run_with_tree_kill(
|
||||
[_py(), "-c", "import time; time.sleep(0.3)"],
|
||||
timeout=10,
|
||||
)
|
||||
assert r.returncode == 0
|
||||
# 호출 후 _SPAWNED 에 우리 호출 잔재가 남으면 안 됨 (wrapper 가 discard).
|
||||
# 다른 테스트 영향 가능성 있어서 set 전체가 0 이 아니어도 됨, 단 우리 잔재 없으면 OK.
|
||||
# 보수적으로 — 우리 호출 직전에 _SPAWNED 가 비어있었으면 직후에도 비어있어야 함.
|
||||
assert len(_SPAWNED) == 0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# C2: Timeout — TimeoutExpired raise + 자손 정리
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestC2_Timeout:
|
||||
def test_dummy_long_sleep_times_out(self):
|
||||
with pytest.raises(subprocess.TimeoutExpired):
|
||||
_run_with_tree_kill(
|
||||
[_py(), "-c", "import time; time.sleep(60)"],
|
||||
timeout=1.5,
|
||||
)
|
||||
# raise 후에도 _SPAWNED 우리 잔재 없어야 함 (wrapper finally 가 discard).
|
||||
assert len(_SPAWNED) == 0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# C3: grandchild orphan 정리 — parent timeout path
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestC3_GrandchildTimeoutPath:
|
||||
def test_grandchild_killed_on_parent_timeout(self):
|
||||
# parent 가 grandchild 띄우고 자기는 sleep — timeout 으로 강제 종료.
|
||||
# grandchild 도 정리돼야 함.
|
||||
# PID 캡처를 위해 grandchild 가 자기 PID 를 파일에 기록.
|
||||
marker = ROOT / ".orchestrator" / "tmp" / "test_c3_gc_pid.txt"
|
||||
marker.parent.mkdir(parents=True, exist_ok=True)
|
||||
if marker.exists(): marker.unlink()
|
||||
|
||||
# grandchild 의 stdin/stdout/stderr 를 DEVNULL 로 분리 — production 의 claude.exe→python.exe -
|
||||
# 케이스와 동일 (grandchild 가 wrapper 의 pipe 핸들 안 상속). 안 그러면 pipe inheritance 로
|
||||
# communicate() 가 hang.
|
||||
spawn_code = (
|
||||
f"import subprocess, time, sys, os; "
|
||||
f"gc = subprocess.Popen("
|
||||
f" [sys.executable, '-c', 'import time; time.sleep(60)'], "
|
||||
f" stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL); "
|
||||
f"open(r'{marker}', 'w').write(str(gc.pid)); "
|
||||
f"time.sleep(60)"
|
||||
)
|
||||
|
||||
with pytest.raises(subprocess.TimeoutExpired):
|
||||
_run_with_tree_kill(
|
||||
[_py(), "-c", spawn_code],
|
||||
timeout=3,
|
||||
)
|
||||
|
||||
# marker 파일에서 grandchild PID 읽기.
|
||||
assert marker.exists(), "grandchild marker not written — parent died too early"
|
||||
gc_pid = int(marker.read_text().strip())
|
||||
|
||||
# 잠시 대기 (cleanup 비동기 가능성) 후 grandchild 죽었는지 확인.
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline and _alive(gc_pid):
|
||||
time.sleep(0.2)
|
||||
assert not _alive(gc_pid), f"grandchild PID {gc_pid} still alive after parent timeout"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# C4: 외부 프로세스 보호
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestC4_ExternalProcessProtection:
|
||||
def test_outsider_not_killed(self):
|
||||
# 사용자가 직접 띄운 척하는 외부 프로세스 (orchestrator 가 spawn 안 함).
|
||||
outsider = subprocess.Popen([_py(), "-c", "import time; time.sleep(10)"])
|
||||
try:
|
||||
# _kill_tracked 에 외부 PID 의 (잘못된) signature 넘기면 무시돼야 함.
|
||||
# signature 일치 안 하면 _is_same_process False → kill 안 됨.
|
||||
wrong_sig = [(outsider.pid, 0.0)] # create_time 안 맞음
|
||||
cleaned = _kill_tracked(wrong_sig)
|
||||
assert cleaned == 0
|
||||
assert _alive(outsider.pid), "outsider killed despite wrong create_time"
|
||||
finally:
|
||||
outsider.kill()
|
||||
outsider.wait(timeout=5)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# C5: orchestrator 자살 방지
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestC5_SelfKillProtection:
|
||||
def test_kill_process_tree_self_pid_noop(self):
|
||||
"""orchestrator(=pytest) PID 로 _kill_process_tree 호출해도 죽으면 안 됨."""
|
||||
result = _kill_process_tree(os.getpid())
|
||||
assert result == 0 # ORCH_PID 검사로 즉시 0 반환
|
||||
|
||||
def test_kill_tracked_with_orch_pid_noop(self):
|
||||
# 일부러 self signature 를 tracked 에 넣어도 _is_same_process False → skip.
|
||||
self_p = psutil.Process(os.getpid())
|
||||
self_sig = _proc_signature(self_p)
|
||||
cleaned = _kill_tracked([self_sig])
|
||||
assert cleaned == 0 # 자기 자신 보호
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# C6 (CORE): parent 정상 종료 후 grandchild orphan 정리
|
||||
# — PID 2780 regression test
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestC6_OrphanGrandchildAfterNormalExit:
|
||||
"""PID 2780 path: parent 가 정상 exit 했는데 grandchild 만 살아남는 케이스.
|
||||
monitor thread 가 parent 살아있을 때 grandchild 를 미리 추적해서 finally 에서 정리해야 함."""
|
||||
|
||||
def test_grandchild_killed_after_parent_normal_exit(self):
|
||||
marker = ROOT / ".orchestrator" / "tmp" / "test_c6_gc_pid.txt"
|
||||
marker.parent.mkdir(parents=True, exist_ok=True)
|
||||
if marker.exists(): marker.unlink()
|
||||
|
||||
# parent 가:
|
||||
# 1. grandchild 띄움 (DEVNULL 격리 — production claude.exe→python.exe - 과 동등).
|
||||
# 2. PID 마커에 기록.
|
||||
# 3. monitor 가 1초 polling 으로 catch 할 시간 확보 (2.5초 sleep).
|
||||
# 4. 정상 종료.
|
||||
spawn_code = (
|
||||
f"import subprocess, time, sys, os; "
|
||||
f"gc = subprocess.Popen("
|
||||
f" [sys.executable, '-c', 'import time; time.sleep(60)'], "
|
||||
f" stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL); "
|
||||
f"open(r'{marker}', 'w').write(str(gc.pid)); "
|
||||
f"time.sleep(2.5); "
|
||||
f"sys.exit(0)"
|
||||
)
|
||||
|
||||
# 정상 종료 (timeout 안 걸림) — wrapper 의 finally cleanup 만으로 grandchild 잡혀야 함.
|
||||
r = _run_with_tree_kill(
|
||||
[_py(), "-c", spawn_code],
|
||||
timeout=15,
|
||||
)
|
||||
assert r.returncode == 0, "parent did not exit normally"
|
||||
|
||||
# marker 에서 grandchild PID.
|
||||
assert marker.exists(), "grandchild marker missing"
|
||||
gc_pid = int(marker.read_text().strip())
|
||||
|
||||
# 정리 비동기 가능성 → 짧게 대기 후 확인.
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline and _alive(gc_pid):
|
||||
time.sleep(0.2)
|
||||
assert not _alive(gc_pid), (
|
||||
f"REGRESSION: grandchild PID {gc_pid} survived parent normal exit "
|
||||
f"(PID 2780 path not fixed)"
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# C7: input + encoding path — run_claude 가 실제 사용하는 호출 모드.
|
||||
# 2026-05-18 production bug: str input + encoding="utf-8" 일 때
|
||||
# wrapper 가 input 을 강제로 bytes 인코딩 → Popen text mode pipe 에
|
||||
# bytes 쓰려다 TypeError: write() argument must be str, not bytes.
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestC7_InputEncodingPath:
|
||||
def test_str_input_with_encoding_utf8(self):
|
||||
"""run_claude 와 동일한 호출 모드 — input=str + encoding='utf-8'."""
|
||||
# stdin 에서 읽은 그대로 stdout 으로 echo. 한글 포함해서 encoding 검증.
|
||||
r = _run_with_tree_kill(
|
||||
[_py(), "-c", "import sys; sys.stdout.write(sys.stdin.read())"],
|
||||
input="hello 안녕\n",
|
||||
encoding="utf-8",
|
||||
timeout=10,
|
||||
)
|
||||
assert r.returncode == 0
|
||||
# encoding= 모드면 stdout 는 str 이어야 함.
|
||||
assert isinstance(r.stdout, str)
|
||||
assert "hello" in r.stdout
|
||||
assert "안녕" in r.stdout
|
||||
|
||||
def test_bytes_input_without_encoding(self):
|
||||
"""encoding 없으면 binary mode — input=bytes 그대로 통과."""
|
||||
r = _run_with_tree_kill(
|
||||
[_py(), "-c", "import sys; sys.stdout.buffer.write(sys.stdin.buffer.read())"],
|
||||
input=b"raw bytes",
|
||||
timeout=10,
|
||||
)
|
||||
assert r.returncode == 0
|
||||
assert isinstance(r.stdout, bytes)
|
||||
assert r.stdout == b"raw bytes"
|
||||
|
||||
def test_str_input_without_encoding_auto_encoded(self):
|
||||
"""input=str 인데 encoding 없으면 wrapper 가 자동 utf-8 인코딩."""
|
||||
r = _run_with_tree_kill(
|
||||
[_py(), "-c", "import sys; sys.stdout.buffer.write(sys.stdin.buffer.read())"],
|
||||
input="auto encode 한글",
|
||||
timeout=10,
|
||||
)
|
||||
assert r.returncode == 0
|
||||
assert isinstance(r.stdout, bytes)
|
||||
assert r.stdout.decode("utf-8") == "auto encode 한글"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Bonus: _SPAWNED discipline — 다중 호출 후 누적 안 됨
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestSpawnedDiscipline:
|
||||
def test_spawned_drained_between_calls(self):
|
||||
for _ in range(3):
|
||||
_run_with_tree_kill([_py(), "-c", "pass"], timeout=10)
|
||||
# 3 회 호출 후에도 우리 잔재 없음 (wrapper finally 가 discard).
|
||||
assert len(_SPAWNED) == 0
|
||||
@@ -0,0 +1,43 @@
|
||||
input:
|
||||
layout_preset: grid-2x2
|
||||
zones_data:
|
||||
- position: top-left
|
||||
template_id: MOCK_top-left
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: top-right
|
||||
template_id: MOCK_top-right
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: bottom-left
|
||||
template_id: MOCK_bottom-left
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: bottom-right
|
||||
template_id: MOCK_bottom-right
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
override_zone_geometries: null
|
||||
expected_layout_css:
|
||||
areas: '"top-left top-right" "bottom-left bottom-right"'
|
||||
cols: 583px 583px
|
||||
rows: 286px 285px
|
||||
heights_px:
|
||||
- 286
|
||||
- 285
|
||||
widths_px:
|
||||
- 583
|
||||
- 583
|
||||
ratios:
|
||||
- 0.489
|
||||
- 0.487
|
||||
width_ratios:
|
||||
- 0.494
|
||||
- 0.494
|
||||
computation: 2d_dynamic_aggregated
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,63 @@
|
||||
input:
|
||||
layout_preset: grid-2x2
|
||||
zones_data:
|
||||
- position: top-left
|
||||
template_id: MOCK_top-left
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: top-right
|
||||
template_id: MOCK_top-right
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: bottom-left
|
||||
template_id: MOCK_bottom-left
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: bottom-right
|
||||
template_id: MOCK_bottom-right
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
override_zone_geometries:
|
||||
top-left:
|
||||
x: 0
|
||||
y: 0
|
||||
w: 0.55
|
||||
h: 0.4
|
||||
top-right:
|
||||
x: 0.55
|
||||
y: 0
|
||||
w: 0.45
|
||||
h: 0.4
|
||||
bottom-left:
|
||||
x: 0
|
||||
y: 0.4
|
||||
w: 0.55
|
||||
h: 0.6
|
||||
bottom-right:
|
||||
x: 0.55
|
||||
y: 0.4
|
||||
w: 0.45
|
||||
h: 0.6
|
||||
expected_layout_css:
|
||||
areas: '"top-left top-right" "bottom-left bottom-right"'
|
||||
cols: 641px 525px
|
||||
rows: 228px 343px
|
||||
heights_px:
|
||||
- 228
|
||||
- 343
|
||||
widths_px:
|
||||
- 641
|
||||
- 525
|
||||
ratios:
|
||||
- 0.4
|
||||
- 0.6
|
||||
width_ratios:
|
||||
- 0.55
|
||||
- 0.45
|
||||
computation: user_override_geometry
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,31 @@
|
||||
input:
|
||||
layout_preset: horizontal-2
|
||||
zones_data:
|
||||
- position: top
|
||||
template_id: MOCK_top
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
- position: bottom
|
||||
template_id: MOCK_bottom
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
override_zone_geometries: null
|
||||
expected_layout_css:
|
||||
areas: '"top" "bottom"'
|
||||
cols: 1fr
|
||||
rows: 286px 285px
|
||||
heights_px:
|
||||
- 286
|
||||
- 285
|
||||
widths_px:
|
||||
- 1180
|
||||
ratios:
|
||||
- 0.489
|
||||
- 0.487
|
||||
width_ratios:
|
||||
- 1.0
|
||||
computation: min_height_first + content_weight_distribution
|
||||
dynamic_rows: true
|
||||
dynamic_cols: false
|
||||
@@ -0,0 +1,41 @@
|
||||
input:
|
||||
layout_preset: horizontal-2
|
||||
zones_data:
|
||||
- position: top
|
||||
template_id: MOCK_top
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
- position: bottom
|
||||
template_id: MOCK_bottom
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
override_zone_geometries:
|
||||
top:
|
||||
x: 0
|
||||
y: 0
|
||||
w: 1.0
|
||||
h: 0.3
|
||||
bottom:
|
||||
x: 0
|
||||
y: 0.3
|
||||
w: 1.0
|
||||
h: 0.7
|
||||
expected_layout_css:
|
||||
areas: '"top" "bottom"'
|
||||
cols: 1fr
|
||||
rows: 176px 410px
|
||||
heights_px:
|
||||
- 176
|
||||
- 410
|
||||
widths_px:
|
||||
- 1180
|
||||
ratios:
|
||||
- 0.3
|
||||
- 0.7
|
||||
width_ratios:
|
||||
- 1.0
|
||||
computation: user_override_geometry
|
||||
dynamic_rows: true
|
||||
dynamic_cols: false
|
||||
@@ -0,0 +1,31 @@
|
||||
input:
|
||||
layout_preset: horizontal-2
|
||||
zones_data:
|
||||
- position: top
|
||||
template_id: MOCK_top
|
||||
content_weight:
|
||||
score: 0.8
|
||||
min_height_px: 200
|
||||
- position: bottom
|
||||
template_id: MOCK_bottom
|
||||
content_weight:
|
||||
score: 0.2
|
||||
min_height_px: 150
|
||||
override_zone_geometries: null
|
||||
expected_layout_css:
|
||||
areas: '"top" "bottom"'
|
||||
cols: 1fr
|
||||
rows: 377px 194px
|
||||
heights_px:
|
||||
- 377
|
||||
- 194
|
||||
widths_px:
|
||||
- 1180
|
||||
ratios:
|
||||
- 0.644
|
||||
- 0.332
|
||||
width_ratios:
|
||||
- 1.0
|
||||
computation: min_height_first + content_weight_distribution
|
||||
dynamic_rows: true
|
||||
dynamic_cols: false
|
||||
@@ -0,0 +1,38 @@
|
||||
input:
|
||||
layout_preset: left-1-right-2
|
||||
zones_data:
|
||||
- position: left
|
||||
template_id: MOCK_left
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
- position: right-top
|
||||
template_id: MOCK_right-top
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: right-bottom
|
||||
template_id: MOCK_right-bottom
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
override_zone_geometries: null
|
||||
expected_layout_css:
|
||||
areas: '"left right-top" "left right-bottom"'
|
||||
cols: 777px 389px
|
||||
rows: 286px 285px
|
||||
heights_px:
|
||||
- 286
|
||||
- 285
|
||||
widths_px:
|
||||
- 777
|
||||
- 389
|
||||
ratios:
|
||||
- 0.489
|
||||
- 0.487
|
||||
width_ratios:
|
||||
- 0.658
|
||||
- 0.33
|
||||
computation: 2d_dynamic_aggregated
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,53 @@
|
||||
input:
|
||||
layout_preset: left-1-right-2
|
||||
zones_data:
|
||||
- position: left
|
||||
template_id: MOCK_left
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
- position: right-top
|
||||
template_id: MOCK_right-top
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: right-bottom
|
||||
template_id: MOCK_right-bottom
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
override_zone_geometries:
|
||||
left:
|
||||
x: 0
|
||||
y: 0
|
||||
w: 0.4
|
||||
h: 1.0
|
||||
right-top:
|
||||
x: 0.4
|
||||
y: 0
|
||||
w: 0.6
|
||||
h: 0.5
|
||||
right-bottom:
|
||||
x: 0.4
|
||||
y: 0.5
|
||||
w: 0.6
|
||||
h: 0.5
|
||||
expected_layout_css:
|
||||
areas: '"left right-top" "left right-bottom"'
|
||||
cols: 466px 700px
|
||||
rows: 286px 285px
|
||||
heights_px:
|
||||
- 286
|
||||
- 285
|
||||
widths_px:
|
||||
- 466
|
||||
- 700
|
||||
ratios:
|
||||
- 0.5
|
||||
- 0.5
|
||||
width_ratios:
|
||||
- 0.4
|
||||
- 0.6
|
||||
computation: user_override_geometry
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,38 @@
|
||||
input:
|
||||
layout_preset: left-2-right-1
|
||||
zones_data:
|
||||
- position: left-top
|
||||
template_id: MOCK_left-top
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: left-bottom
|
||||
template_id: MOCK_left-bottom
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: right
|
||||
template_id: MOCK_right
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
override_zone_geometries: null
|
||||
expected_layout_css:
|
||||
areas: '"left-top right" "left-bottom right"'
|
||||
cols: 389px 777px
|
||||
rows: 286px 285px
|
||||
heights_px:
|
||||
- 286
|
||||
- 285
|
||||
widths_px:
|
||||
- 389
|
||||
- 777
|
||||
ratios:
|
||||
- 0.489
|
||||
- 0.487
|
||||
width_ratios:
|
||||
- 0.33
|
||||
- 0.658
|
||||
computation: 2d_dynamic_aggregated
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,53 @@
|
||||
input:
|
||||
layout_preset: left-2-right-1
|
||||
zones_data:
|
||||
- position: left-top
|
||||
template_id: MOCK_left-top
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: left-bottom
|
||||
template_id: MOCK_left-bottom
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: right
|
||||
template_id: MOCK_right
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
override_zone_geometries:
|
||||
left-top:
|
||||
x: 0
|
||||
y: 0
|
||||
w: 0.35
|
||||
h: 0.6
|
||||
left-bottom:
|
||||
x: 0
|
||||
y: 0.6
|
||||
w: 0.35
|
||||
h: 0.4
|
||||
right:
|
||||
x: 0.35
|
||||
y: 0
|
||||
w: 0.65
|
||||
h: 1.0
|
||||
expected_layout_css:
|
||||
areas: '"left-top right" "left-bottom right"'
|
||||
cols: 408px 758px
|
||||
rows: 343px 228px
|
||||
heights_px:
|
||||
- 343
|
||||
- 228
|
||||
widths_px:
|
||||
- 408
|
||||
- 758
|
||||
ratios:
|
||||
- 0.6
|
||||
- 0.4
|
||||
width_ratios:
|
||||
- 0.35
|
||||
- 0.65
|
||||
computation: user_override_geometry
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,38 @@
|
||||
input:
|
||||
layout_preset: top-1-bottom-2
|
||||
zones_data:
|
||||
- position: top
|
||||
template_id: MOCK_top
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
- position: bottom-left
|
||||
template_id: MOCK_bottom-left
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: bottom-right
|
||||
template_id: MOCK_bottom-right
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
override_zone_geometries: null
|
||||
expected_layout_css:
|
||||
areas: '"top top" "bottom-left bottom-right"'
|
||||
cols: 583px 583px
|
||||
rows: 314px 257px
|
||||
heights_px:
|
||||
- 314
|
||||
- 257
|
||||
widths_px:
|
||||
- 583
|
||||
- 583
|
||||
ratios:
|
||||
- 0.537
|
||||
- 0.439
|
||||
width_ratios:
|
||||
- 0.494
|
||||
- 0.494
|
||||
computation: 2d_dynamic_aggregated
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,53 @@
|
||||
input:
|
||||
layout_preset: top-1-bottom-2
|
||||
zones_data:
|
||||
- position: top
|
||||
template_id: MOCK_top
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
- position: bottom-left
|
||||
template_id: MOCK_bottom-left
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: bottom-right
|
||||
template_id: MOCK_bottom-right
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
override_zone_geometries:
|
||||
top:
|
||||
x: 0
|
||||
y: 0
|
||||
w: 1.0
|
||||
h: 0.3
|
||||
bottom-left:
|
||||
x: 0
|
||||
y: 0.3
|
||||
w: 0.5
|
||||
h: 0.7
|
||||
bottom-right:
|
||||
x: 0.5
|
||||
y: 0.3
|
||||
w: 0.5
|
||||
h: 0.7
|
||||
expected_layout_css:
|
||||
areas: '"top top" "bottom-left bottom-right"'
|
||||
cols: 583px 583px
|
||||
rows: 171px 400px
|
||||
heights_px:
|
||||
- 171
|
||||
- 400
|
||||
widths_px:
|
||||
- 583
|
||||
- 583
|
||||
ratios:
|
||||
- 0.3
|
||||
- 0.7
|
||||
width_ratios:
|
||||
- 0.5
|
||||
- 0.5
|
||||
computation: user_override_geometry
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,38 @@
|
||||
input:
|
||||
layout_preset: top-2-bottom-1
|
||||
zones_data:
|
||||
- position: top-left
|
||||
template_id: MOCK_top-left
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: top-right
|
||||
template_id: MOCK_top-right
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: bottom
|
||||
template_id: MOCK_bottom
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
override_zone_geometries: null
|
||||
expected_layout_css:
|
||||
areas: '"top-left top-right" "bottom bottom"'
|
||||
cols: 583px 583px
|
||||
rows: 257px 314px
|
||||
heights_px:
|
||||
- 257
|
||||
- 314
|
||||
widths_px:
|
||||
- 583
|
||||
- 583
|
||||
ratios:
|
||||
- 0.439
|
||||
- 0.537
|
||||
width_ratios:
|
||||
- 0.494
|
||||
- 0.494
|
||||
computation: 2d_dynamic_aggregated
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,53 @@
|
||||
input:
|
||||
layout_preset: top-2-bottom-1
|
||||
zones_data:
|
||||
- position: top-left
|
||||
template_id: MOCK_top-left
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: top-right
|
||||
template_id: MOCK_top-right
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: bottom
|
||||
template_id: MOCK_bottom
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
override_zone_geometries:
|
||||
top-left:
|
||||
x: 0
|
||||
y: 0
|
||||
w: 0.6
|
||||
h: 0.4
|
||||
top-right:
|
||||
x: 0.6
|
||||
y: 0
|
||||
w: 0.4
|
||||
h: 0.4
|
||||
bottom:
|
||||
x: 0
|
||||
y: 0.4
|
||||
w: 1.0
|
||||
h: 0.6
|
||||
expected_layout_css:
|
||||
areas: '"top-left top-right" "bottom bottom"'
|
||||
cols: 700px 466px
|
||||
rows: 228px 343px
|
||||
heights_px:
|
||||
- 228
|
||||
- 343
|
||||
widths_px:
|
||||
- 700
|
||||
- 466
|
||||
ratios:
|
||||
- 0.4
|
||||
- 0.6
|
||||
width_ratios:
|
||||
- 0.6
|
||||
- 0.4
|
||||
computation: user_override_geometry
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,31 @@
|
||||
input:
|
||||
layout_preset: vertical-2
|
||||
zones_data:
|
||||
- position: left
|
||||
template_id: MOCK_left
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
- position: right
|
||||
template_id: MOCK_right
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
override_zone_geometries: null
|
||||
expected_layout_css:
|
||||
areas: '"left right"'
|
||||
cols: 583px 583px
|
||||
rows: 1fr
|
||||
heights_px:
|
||||
- 585
|
||||
widths_px:
|
||||
- 583
|
||||
- 583
|
||||
ratios:
|
||||
- 1.0
|
||||
width_ratios:
|
||||
- 0.494
|
||||
- 0.494
|
||||
computation: content_weight_distribution_cols
|
||||
dynamic_rows: false
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,41 @@
|
||||
input:
|
||||
layout_preset: vertical-2
|
||||
zones_data:
|
||||
- position: left
|
||||
template_id: MOCK_left
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
- position: right
|
||||
template_id: MOCK_right
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
override_zone_geometries:
|
||||
left:
|
||||
x: 0
|
||||
y: 0
|
||||
w: 0.4
|
||||
h: 1.0
|
||||
right:
|
||||
x: 0.4
|
||||
y: 0
|
||||
w: 0.6
|
||||
h: 1.0
|
||||
expected_layout_css:
|
||||
areas: '"left right"'
|
||||
cols: 40.0fr 60.0fr
|
||||
rows: 1fr
|
||||
heights_px:
|
||||
- 585
|
||||
widths_px:
|
||||
- 466
|
||||
- 700
|
||||
ratios:
|
||||
- 1.0
|
||||
width_ratios:
|
||||
- 0.4
|
||||
- 0.6
|
||||
computation: user_override_geometry
|
||||
dynamic_rows: false
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,31 @@
|
||||
input:
|
||||
layout_preset: vertical-2
|
||||
zones_data:
|
||||
- position: left
|
||||
template_id: MOCK_left
|
||||
content_weight:
|
||||
score: 0.7
|
||||
min_height_px: 200
|
||||
- position: right
|
||||
template_id: MOCK_right
|
||||
content_weight:
|
||||
score: 0.3
|
||||
min_height_px: 200
|
||||
override_zone_geometries: null
|
||||
expected_layout_css:
|
||||
areas: '"left right"'
|
||||
cols: 816px 350px
|
||||
rows: 1fr
|
||||
heights_px:
|
||||
- 585
|
||||
widths_px:
|
||||
- 816
|
||||
- 350
|
||||
ratios:
|
||||
- 1.0
|
||||
width_ratios:
|
||||
- 0.692
|
||||
- 0.297
|
||||
computation: content_weight_distribution_cols
|
||||
dynamic_rows: false
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,24 @@
|
||||
case_id: grid-2x2_dynamic_2d
|
||||
description: |
|
||||
grid-2x2 (2x2 topology) is promoted to 2-D dynamic in IMP-09 PR 2.
|
||||
Row-axis retry MUST be skipped by the gate with the
|
||||
"dynamic_cols (2-D topology)" reason.
|
||||
input_layout_css:
|
||||
areas: '"top-left top-right" "bottom-left bottom-right"'
|
||||
cols: 583px 583px
|
||||
rows: 286px 285px
|
||||
heights_px: [286, 285]
|
||||
widths_px: [583, 583]
|
||||
ratios: [0.489, 0.487]
|
||||
width_ratios: [0.494, 0.494]
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
router_decision:
|
||||
router_active: true
|
||||
proposed_actions_summary: [zone_ratio_retry]
|
||||
expected_gate:
|
||||
retry_attempted: false
|
||||
retry_skipped_reason_contains:
|
||||
- "dynamic_cols"
|
||||
- "2-D"
|
||||
- "IMP-09"
|
||||
@@ -0,0 +1,24 @@
|
||||
case_id: horizontal2_dynamic_rows
|
||||
description: |
|
||||
horizontal-2 layout with dynamic_rows=True must pass the IMP-09 retry
|
||||
gate. The base trace should record retry_attempted=True (legacy
|
||||
plan/rerender path continues). retry_skipped_reason MUST NOT contain
|
||||
either of the IMP-09 gate skip strings.
|
||||
input_layout_css:
|
||||
areas: '"top" "bottom"'
|
||||
cols: 1fr
|
||||
rows: 333px 238px
|
||||
heights_px: [333, 238]
|
||||
widths_px: [1180]
|
||||
ratios: [0.569, 0.407]
|
||||
width_ratios: [1.0]
|
||||
dynamic_rows: true
|
||||
dynamic_cols: false
|
||||
router_decision:
|
||||
router_active: true
|
||||
proposed_actions_summary: [zone_ratio_retry]
|
||||
expected_gate:
|
||||
retry_attempted: true
|
||||
retry_skipped_reason_excludes:
|
||||
- "dynamic_cols"
|
||||
- "fr_default_from_preset"
|
||||
@@ -0,0 +1,24 @@
|
||||
case_id: left-1-right-2_dynamic_2d
|
||||
description: |
|
||||
left-1-right-2 (side-T-left topology) is promoted to 2-D dynamic in
|
||||
IMP-09 PR 2. Row-axis retry MUST be skipped by the gate with the
|
||||
"dynamic_cols (2-D topology)" reason.
|
||||
input_layout_css:
|
||||
areas: '"left right-top" "left right-bottom"'
|
||||
cols: 777px 389px
|
||||
rows: 286px 285px
|
||||
heights_px: [286, 285]
|
||||
widths_px: [777, 389]
|
||||
ratios: [0.489, 0.487]
|
||||
width_ratios: [0.658, 0.33]
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
router_decision:
|
||||
router_active: true
|
||||
proposed_actions_summary: [zone_ratio_retry]
|
||||
expected_gate:
|
||||
retry_attempted: false
|
||||
retry_skipped_reason_contains:
|
||||
- "dynamic_cols"
|
||||
- "2-D"
|
||||
- "IMP-09"
|
||||
@@ -0,0 +1,24 @@
|
||||
case_id: left-2-right-1_dynamic_2d
|
||||
description: |
|
||||
left-2-right-1 (side-T-right topology) is promoted to 2-D dynamic in
|
||||
IMP-09 PR 2. Row-axis retry MUST be skipped by the gate with the
|
||||
"dynamic_cols (2-D topology)" reason.
|
||||
input_layout_css:
|
||||
areas: '"left-top right" "left-bottom right"'
|
||||
cols: 389px 777px
|
||||
rows: 286px 285px
|
||||
heights_px: [286, 285]
|
||||
widths_px: [389, 777]
|
||||
ratios: [0.489, 0.487]
|
||||
width_ratios: [0.33, 0.658]
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
router_decision:
|
||||
router_active: true
|
||||
proposed_actions_summary: [zone_ratio_retry]
|
||||
expected_gate:
|
||||
retry_attempted: false
|
||||
retry_skipped_reason_contains:
|
||||
- "dynamic_cols"
|
||||
- "2-D"
|
||||
- "IMP-09"
|
||||
@@ -0,0 +1,23 @@
|
||||
case_id: single_fr_default
|
||||
description: |
|
||||
Any layout that fell through to fr_default_from_preset (single,
|
||||
T-shape, 2x2 in PR 1) has neither dynamic_rows nor dynamic_cols.
|
||||
Row-axis retry is a no-op and must be skipped by the IMP-09 gate
|
||||
with a fr_default_from_preset skip reason.
|
||||
input_layout_css:
|
||||
areas: '"top top" "bottom-left bottom-right"'
|
||||
cols: 1fr 1fr
|
||||
rows: 1fr 1fr
|
||||
heights_px: [285, 286]
|
||||
widths_px: [583, 583]
|
||||
ratios: [0.487, 0.489]
|
||||
width_ratios: [0.494, 0.494]
|
||||
dynamic_rows: false
|
||||
dynamic_cols: false
|
||||
router_decision:
|
||||
router_active: true
|
||||
proposed_actions_summary: [zone_ratio_retry]
|
||||
expected_gate:
|
||||
retry_attempted: false
|
||||
retry_skipped_reason_contains:
|
||||
- "fr_default_from_preset"
|
||||
@@ -0,0 +1,26 @@
|
||||
case_id: top-1-bottom-2_dynamic_2d
|
||||
description: |
|
||||
top-1-bottom-2 (T topology) is promoted to 2-D dynamic in IMP-09
|
||||
PR 2 (dynamic_rows=True, dynamic_cols=True). Row-axis retry MUST be
|
||||
skipped by the IMP-09 gate with the "dynamic_cols (2-D topology)"
|
||||
skip reason, because row-only redistribution cannot reconcile both
|
||||
axes simultaneously.
|
||||
input_layout_css:
|
||||
areas: '"top top" "bottom-left bottom-right"'
|
||||
cols: 583px 583px
|
||||
rows: 314px 257px
|
||||
heights_px: [314, 257]
|
||||
widths_px: [583, 583]
|
||||
ratios: [0.537, 0.439]
|
||||
width_ratios: [0.494, 0.494]
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
router_decision:
|
||||
router_active: true
|
||||
proposed_actions_summary: [zone_ratio_retry]
|
||||
expected_gate:
|
||||
retry_attempted: false
|
||||
retry_skipped_reason_contains:
|
||||
- "dynamic_cols"
|
||||
- "2-D"
|
||||
- "IMP-09"
|
||||
@@ -0,0 +1,24 @@
|
||||
case_id: top-2-bottom-1_dynamic_2d
|
||||
description: |
|
||||
top-2-bottom-1 (inverted-T topology) is promoted to 2-D dynamic in
|
||||
IMP-09 PR 2. Row-axis retry MUST be skipped by the gate with the
|
||||
"dynamic_cols (2-D topology)" reason.
|
||||
input_layout_css:
|
||||
areas: '"top-left top-right" "bottom bottom"'
|
||||
cols: 583px 583px
|
||||
rows: 257px 314px
|
||||
heights_px: [257, 314]
|
||||
widths_px: [583, 583]
|
||||
ratios: [0.439, 0.537]
|
||||
width_ratios: [0.494, 0.494]
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
router_decision:
|
||||
router_active: true
|
||||
proposed_actions_summary: [zone_ratio_retry]
|
||||
expected_gate:
|
||||
retry_attempted: false
|
||||
retry_skipped_reason_contains:
|
||||
- "dynamic_cols"
|
||||
- "2-D"
|
||||
- "IMP-09"
|
||||
@@ -0,0 +1,24 @@
|
||||
case_id: vertical2_dynamic_cols
|
||||
description: |
|
||||
vertical-2 layout with dynamic_cols=True must be skipped by the
|
||||
IMP-09 retry gate before plan/rerender, because the existing
|
||||
apply_retry_to_layout_css mutates only row-axis fields and would
|
||||
produce a misleading trace if it ran on a column-dynamic layout.
|
||||
input_layout_css:
|
||||
areas: '"left right"'
|
||||
cols: 583px 583px
|
||||
rows: 1fr
|
||||
heights_px: [585]
|
||||
widths_px: [583, 583]
|
||||
ratios: [1.0]
|
||||
width_ratios: [0.494, 0.494]
|
||||
dynamic_rows: false
|
||||
dynamic_cols: true
|
||||
router_decision:
|
||||
router_active: true
|
||||
proposed_actions_summary: [zone_ratio_retry]
|
||||
expected_gate:
|
||||
retry_attempted: false
|
||||
retry_skipped_reason_contains:
|
||||
- "dynamic_cols"
|
||||
- "IMP-09"
|
||||
@@ -0,0 +1,158 @@
|
||||
"""IMP-09 PR 1 — build_layout_css contract tests.
|
||||
|
||||
Verifies horizontal-2 byte-identity for the legacy grid strings
|
||||
(areas / cols / rows) and that every return path now carries the new
|
||||
length-locked col-axis keys (widths_px / width_ratios / dynamic_cols).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import (
|
||||
GRID_GAP,
|
||||
SLIDE_BODY_HEIGHT,
|
||||
SLIDE_BODY_WIDTH,
|
||||
build_layout_css,
|
||||
)
|
||||
|
||||
|
||||
def _zone(position: str, score: float, min_h: int = 100) -> dict:
|
||||
return {
|
||||
"position": position,
|
||||
"template_id": f"MOCK_{position}",
|
||||
"content_weight": {"score": score},
|
||||
"min_height_px": min_h,
|
||||
}
|
||||
|
||||
|
||||
# ────────────────────── new-key contract ──────────────────────
|
||||
|
||||
|
||||
NEW_KEYS = {"widths_px", "width_ratios", "dynamic_cols"}
|
||||
|
||||
|
||||
def test_all_presets_carry_new_col_axis_keys():
|
||||
"""Every PR 1 return path must include widths_px / width_ratios /
|
||||
dynamic_cols, and heights_px / widths_px must be length-locked to
|
||||
the catalog grid (R rows, C cols)."""
|
||||
cases = [
|
||||
("single", [_zone("primary", 1.0)]),
|
||||
("horizontal-2", [_zone("top", 0.6), _zone("bottom", 0.4)]),
|
||||
("vertical-2", [_zone("left", 0.5), _zone("right", 0.5)]),
|
||||
("top-1-bottom-2", [
|
||||
_zone("top", 0.5),
|
||||
_zone("bottom-left", 0.25),
|
||||
_zone("bottom-right", 0.25),
|
||||
]),
|
||||
("grid-2x2", [
|
||||
_zone("top-left", 0.25),
|
||||
_zone("top-right", 0.25),
|
||||
_zone("bottom-left", 0.25),
|
||||
_zone("bottom-right", 0.25),
|
||||
]),
|
||||
]
|
||||
for preset, zones in cases:
|
||||
result = build_layout_css(preset, zones)
|
||||
missing = NEW_KEYS - set(result)
|
||||
assert not missing, f"{preset} missing new keys: {missing}"
|
||||
# heights_px / widths_px never empty in PR 1 (length-locked).
|
||||
assert len(result["heights_px"]) > 0, f"{preset} empty heights_px"
|
||||
assert len(result["widths_px"]) > 0, f"{preset} empty widths_px"
|
||||
|
||||
|
||||
# ────────────────────── horizontal-2 byte-identity ──────────────────────
|
||||
|
||||
|
||||
def test_horizontal_2_grid_strings_match_legacy():
|
||||
zones = [_zone("top", 0.6), _zone("bottom", 0.4)]
|
||||
result = build_layout_css("horizontal-2", zones)
|
||||
|
||||
# Legacy contract: areas / cols / rows strings preserved.
|
||||
assert result["areas"] == '"top" "bottom"'
|
||||
assert result["cols"] == "1fr"
|
||||
assert result["rows"].count("px") == 2
|
||||
|
||||
# heights_px sum to body height; ratios consistent.
|
||||
assert sum(result["heights_px"]) == SLIDE_BODY_HEIGHT - GRID_GAP
|
||||
assert result["dynamic_rows"] is True
|
||||
assert result["dynamic_cols"] is False
|
||||
|
||||
# New col-axis defaults: full body width, ratio 1.0.
|
||||
assert result["widths_px"] == [SLIDE_BODY_WIDTH]
|
||||
assert result["width_ratios"] == [1.0]
|
||||
|
||||
|
||||
def test_horizontal_2_override_preserves_rows():
|
||||
zones = [_zone("top", 0.6), _zone("bottom", 0.4)]
|
||||
override = {
|
||||
"top": {"x": 0, "y": 0, "w": 1.0, "h": 0.3},
|
||||
"bottom": {"x": 0, "y": 0.3, "w": 1.0, "h": 0.7},
|
||||
}
|
||||
result = build_layout_css(
|
||||
"horizontal-2", zones, override_zone_geometries=override
|
||||
)
|
||||
assert result["computation"] == "user_override_geometry"
|
||||
assert result["dynamic_rows"] is True
|
||||
assert result["dynamic_cols"] is False
|
||||
assert result["heights_px"][0] < result["heights_px"][1]
|
||||
assert result["widths_px"] == [SLIDE_BODY_WIDTH]
|
||||
# Override ratio target.
|
||||
assert result["ratios"] == [0.3, 0.7]
|
||||
|
||||
|
||||
# ────────────────────── vertical-2 new dynamic ──────────────────────
|
||||
|
||||
|
||||
def test_vertical_2_normal_produces_dynamic_cols():
|
||||
zones = [_zone("left", 0.7), _zone("right", 0.3)]
|
||||
result = build_layout_css("vertical-2", zones)
|
||||
assert result["dynamic_rows"] is False
|
||||
assert result["dynamic_cols"] is True
|
||||
# cols string is px-based (no fr).
|
||||
assert "fr" not in result["cols"]
|
||||
assert result["cols"].count("px") == 2
|
||||
# Heights span full body in a single row.
|
||||
assert result["heights_px"] == [SLIDE_BODY_HEIGHT]
|
||||
# Widths reflect 70/30 weight split.
|
||||
assert result["widths_px"][0] > result["widths_px"][1]
|
||||
|
||||
|
||||
def test_vertical_2_override_keeps_fr_cols_legacy():
|
||||
"""PR 1 v-2 override path keeps legacy fr-string cols but now
|
||||
populates widths_px in pixels for downstream consumers."""
|
||||
zones = [_zone("left", 0.5), _zone("right", 0.5)]
|
||||
override = {
|
||||
"left": {"x": 0, "y": 0, "w": 0.4, "h": 1.0},
|
||||
"right": {"x": 0.4, "y": 0, "w": 0.6, "h": 1.0},
|
||||
}
|
||||
result = build_layout_css(
|
||||
"vertical-2", zones, override_zone_geometries=override
|
||||
)
|
||||
assert result["computation"] == "user_override_geometry"
|
||||
assert "fr" in result["cols"]
|
||||
assert result["dynamic_cols"] is True
|
||||
assert result["dynamic_rows"] is False
|
||||
# widths_px now populated.
|
||||
assert len(result["widths_px"]) == 2
|
||||
assert sum(result["widths_px"]) == SLIDE_BODY_WIDTH - GRID_GAP
|
||||
assert result["width_ratios"] == [0.4, 0.6]
|
||||
|
||||
|
||||
# ────────────────────── 2-D dynamic dispatch (PR 2) ──────────────────────
|
||||
|
||||
|
||||
def test_top_1_bottom_2_dynamic_2d_populates_geometry():
|
||||
"""T-shape (top-1-bottom-2) is dispatched through the 2-D dynamic
|
||||
builder in PR 2: heights_px / widths_px length-locked to grid
|
||||
R=2, C=2 with both dynamic flags True."""
|
||||
zones = [
|
||||
_zone("top", 0.5),
|
||||
_zone("bottom-left", 0.25),
|
||||
_zone("bottom-right", 0.25),
|
||||
]
|
||||
result = build_layout_css("top-1-bottom-2", zones)
|
||||
assert result["computation"] == "2d_dynamic_aggregated"
|
||||
assert result["dynamic_rows"] is True
|
||||
assert result["dynamic_cols"] is True
|
||||
assert len(result["heights_px"]) == 2 # R rows
|
||||
assert len(result["widths_px"]) == 2 # C cols
|
||||
@@ -0,0 +1,101 @@
|
||||
"""IMP-09 PR 1 — _compute_per_zone_geometry tests (1-D paths).
|
||||
|
||||
Verifies the unified per-zone geometry aggregator on horizontal-2 and
|
||||
vertical-2 (the two 1-D presets active in PR 1). 2-D spanning zone
|
||||
cases (T / 2x2) are exercised in PR 2.
|
||||
|
||||
The helper aggregates grid-track sizes into per-zone dimensions and
|
||||
must produce length-locked outputs:
|
||||
- layout_css["heights_px"] length == R (parsed css_areas rows)
|
||||
- layout_css["widths_px"] length == C (parsed css_areas cols)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import (
|
||||
GRID_GAP,
|
||||
SLIDE_BODY_HEIGHT,
|
||||
SLIDE_BODY_WIDTH,
|
||||
_compute_per_zone_geometry,
|
||||
build_layout_css,
|
||||
)
|
||||
|
||||
|
||||
def _zone(position: str, score: float) -> dict:
|
||||
return {
|
||||
"position": position,
|
||||
"template_id": f"MOCK_{position}",
|
||||
"content_weight": {"score": score},
|
||||
"min_height_px": 100,
|
||||
}
|
||||
|
||||
|
||||
def test_horizontal_2_per_zone_widths_match_slide_body():
|
||||
zones = [_zone("top", 0.6), _zone("bottom", 0.4)]
|
||||
layout_css = build_layout_css("horizontal-2", zones)
|
||||
debug_zones = [{"position": "top"}, {"position": "bottom"}]
|
||||
per_zone = _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
|
||||
|
||||
# Both zones share the single column => width == SLIDE_BODY_WIDTH.
|
||||
assert per_zone[0]["zone_width_px"] == SLIDE_BODY_WIDTH
|
||||
assert per_zone[1]["zone_width_px"] == SLIDE_BODY_WIDTH
|
||||
# Heights mirror layout_css.heights_px.
|
||||
assert per_zone[0]["zone_height_px"] == layout_css["heights_px"][0]
|
||||
assert per_zone[1]["zone_height_px"] == layout_css["heights_px"][1]
|
||||
|
||||
|
||||
def test_vertical_2_per_zone_heights_match_slide_body():
|
||||
zones = [_zone("left", 0.5), _zone("right", 0.5)]
|
||||
layout_css = build_layout_css("vertical-2", zones)
|
||||
debug_zones = [{"position": "left"}, {"position": "right"}]
|
||||
per_zone = _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
|
||||
|
||||
# Both zones share the single row => height == SLIDE_BODY_HEIGHT.
|
||||
assert per_zone[0]["zone_height_px"] == SLIDE_BODY_HEIGHT
|
||||
assert per_zone[1]["zone_height_px"] == SLIDE_BODY_HEIGHT
|
||||
# Widths mirror layout_css.widths_px.
|
||||
assert per_zone[0]["zone_width_px"] == layout_css["widths_px"][0]
|
||||
assert per_zone[1]["zone_width_px"] == layout_css["widths_px"][1]
|
||||
|
||||
|
||||
def test_heights_px_length_mismatch_raises():
|
||||
layout_css = {
|
||||
"areas": '"top" "bottom"',
|
||||
"heights_px": [300], # wrong length, expected 2
|
||||
"widths_px": [SLIDE_BODY_WIDTH],
|
||||
}
|
||||
with pytest.raises(ValueError, match="heights_px length"):
|
||||
_compute_per_zone_geometry(
|
||||
layout_css, [{"position": "top"}], GRID_GAP
|
||||
)
|
||||
|
||||
|
||||
def test_widths_px_length_mismatch_raises():
|
||||
layout_css = {
|
||||
"areas": '"left right"',
|
||||
"heights_px": [SLIDE_BODY_HEIGHT],
|
||||
"widths_px": [600], # wrong length, expected 2
|
||||
}
|
||||
with pytest.raises(ValueError, match="widths_px length"):
|
||||
_compute_per_zone_geometry(
|
||||
layout_css, [{"position": "left"}], GRID_GAP
|
||||
)
|
||||
|
||||
|
||||
def test_unknown_position_raises():
|
||||
zones = [_zone("top", 0.5), _zone("bottom", 0.5)]
|
||||
layout_css = build_layout_css("horizontal-2", zones)
|
||||
debug_zones = [{"position": "ghost"}]
|
||||
with pytest.raises(ValueError, match="not present in css_areas"):
|
||||
_compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
|
||||
|
||||
|
||||
def test_fr_default_single_returns_full_body():
|
||||
# 'single' is the fr_default sink in PR 1; widths_px / heights_px
|
||||
# must still be populated (length 1 each).
|
||||
layout_css = build_layout_css("single", [_zone("primary", 1.0)])
|
||||
debug_zones = [{"position": "primary"}]
|
||||
per_zone = _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
|
||||
assert per_zone[0]["zone_height_px"] == SLIDE_BODY_HEIGHT
|
||||
assert per_zone[0]["zone_width_px"] == SLIDE_BODY_WIDTH
|
||||
@@ -0,0 +1,76 @@
|
||||
"""IMP-09 PR 1 — compute_zone_layout_cols tests.
|
||||
|
||||
Column-axis weight-only solver. Mirrors compute_zone_layout for rows.
|
||||
No min_width_px contract exists in frame_contracts.yaml (verified
|
||||
during Stage 2), so column distribution is purely content_weight.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_pipeline import (
|
||||
GRID_GAP,
|
||||
SLIDE_BODY_WIDTH,
|
||||
compute_zone_layout_cols,
|
||||
)
|
||||
|
||||
|
||||
def _zone(position: str, score: float) -> dict:
|
||||
return {
|
||||
"position": position,
|
||||
"template_id": f"MOCK_{position}",
|
||||
"content_weight": {"score": score},
|
||||
}
|
||||
|
||||
|
||||
def test_empty_zones_returns_empty_result():
|
||||
result = compute_zone_layout_cols([])
|
||||
assert result["widths_px"] == []
|
||||
assert result["width_ratios"] == []
|
||||
|
||||
|
||||
def test_two_equal_zones_split_evenly():
|
||||
zones = [_zone("left", 0.5), _zone("right", 0.5)]
|
||||
result = compute_zone_layout_cols(zones)
|
||||
available = SLIDE_BODY_WIDTH - GRID_GAP # one gap between two zones
|
||||
assert sum(result["widths_px"]) == available
|
||||
assert result["widths_px"][0] == result["widths_px"][1]
|
||||
assert result["computation"] == "content_weight_distribution_cols"
|
||||
|
||||
|
||||
def test_asymmetric_weights_distribute_by_ratio():
|
||||
zones = [_zone("left", 0.8), _zone("right", 0.2)]
|
||||
result = compute_zone_layout_cols(zones)
|
||||
available = SLIDE_BODY_WIDTH - GRID_GAP
|
||||
assert sum(result["widths_px"]) == available
|
||||
# left should be ~4x right
|
||||
assert result["widths_px"][0] > result["widths_px"][1] * 3
|
||||
|
||||
|
||||
def test_zero_weight_guard_equal_split():
|
||||
zones = [_zone("left", 0.0), _zone("right", 0.0)]
|
||||
result = compute_zone_layout_cols(zones)
|
||||
available = SLIDE_BODY_WIDTH - GRID_GAP
|
||||
assert sum(result["widths_px"]) == available
|
||||
assert result["widths_px"][0] == result["widths_px"][1]
|
||||
# weight_shares fallback to equal share.
|
||||
assert result["weight_shares"] == [0.5, 0.5]
|
||||
|
||||
|
||||
def test_integer_rounding_absorbed_by_last_zone():
|
||||
# Three zones with weights that don't divide evenly.
|
||||
zones = [
|
||||
_zone("a", 0.333333),
|
||||
_zone("b", 0.333333),
|
||||
_zone("c", 0.333334),
|
||||
]
|
||||
result = compute_zone_layout_cols(zones)
|
||||
available = SLIDE_BODY_WIDTH - 2 * GRID_GAP
|
||||
assert sum(result["widths_px"]) == available
|
||||
|
||||
|
||||
def test_width_ratios_match_total_width():
|
||||
zones = [_zone("left", 0.6), _zone("right", 0.4)]
|
||||
result = compute_zone_layout_cols(zones)
|
||||
# width_ratios should be widths_px / SLIDE_BODY_WIDTH (not / available)
|
||||
assert abs(
|
||||
result["width_ratios"][0] - result["widths_px"][0] / SLIDE_BODY_WIDTH
|
||||
) < 1e-3
|
||||
@@ -0,0 +1,81 @@
|
||||
"""IMP-15 실행-4 (Gitea issue #48) — debug.json top-level event surfacing.
|
||||
|
||||
Verifies ``write_debug_json`` lifts ``image_events`` + ``table_events`` out of
|
||||
``visual_runtime_check`` and exposes them as top-level keys, mirroring the
|
||||
existing ``zone_geometries_px`` precedent (src/phase_z2_pipeline.py:2739).
|
||||
|
||||
Two scenarios:
|
||||
|
||||
* Populated — ``visual_runtime_check`` carries non-empty event lists; the
|
||||
written debug dict surfaces both at the top level with identical payloads.
|
||||
* None — ``visual_runtime_check is None``; both top-level keys default to ``[]``
|
||||
(no KeyError, no propagated None).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from src.phase_z2_pipeline import write_debug_json
|
||||
|
||||
|
||||
def _read_debug(run_dir: Path) -> dict:
|
||||
return json.loads((run_dir / "debug.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_write_debug_json_surfaces_image_and_table_events(tmp_path: Path) -> None:
|
||||
image_events = [
|
||||
{
|
||||
"src": "img/a.png",
|
||||
"zone_position": "primary",
|
||||
"zone_template_id": "tid-1",
|
||||
"natural_w": 200,
|
||||
"natural_h": 100,
|
||||
"rendered_w": 200,
|
||||
"rendered_h": 200,
|
||||
"delta": 1.0,
|
||||
}
|
||||
]
|
||||
table_events = [
|
||||
{
|
||||
"zone_position": "secondary",
|
||||
"zone_template_id": "tid-2",
|
||||
"clientWidth": 300,
|
||||
"scrollWidth": 360,
|
||||
"excess_x": 60,
|
||||
"wrapper_clipped_index": 0,
|
||||
}
|
||||
]
|
||||
visual_runtime_check = {
|
||||
"image_events": image_events,
|
||||
"table_events": table_events,
|
||||
"zone_geometries_px": [],
|
||||
}
|
||||
|
||||
write_debug_json(
|
||||
run_dir=tmp_path,
|
||||
layout_preset="single",
|
||||
debug_zones=[],
|
||||
layout_css={},
|
||||
visual_runtime_check=visual_runtime_check,
|
||||
)
|
||||
|
||||
debug = _read_debug(tmp_path)
|
||||
assert "image_events" in debug, "image_events must be a top-level key"
|
||||
assert "table_events" in debug, "table_events must be a top-level key"
|
||||
assert debug["image_events"] == image_events
|
||||
assert debug["table_events"] == table_events
|
||||
|
||||
|
||||
def test_write_debug_json_defaults_when_visual_runtime_check_none(tmp_path: Path) -> None:
|
||||
write_debug_json(
|
||||
run_dir=tmp_path,
|
||||
layout_preset="single",
|
||||
debug_zones=[],
|
||||
layout_css={},
|
||||
visual_runtime_check=None,
|
||||
)
|
||||
|
||||
debug = _read_debug(tmp_path)
|
||||
assert debug["image_events"] == []
|
||||
assert debug["table_events"] == []
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user