Compare commits

Author SHA1 Message Date
Kyeongmin 6a0b0b282f On main: stage5_r2_predirty 2026-05-15 22:58:18 +09:00
Kyeongmin 1264e92e75 index on main: ab2764c feat(IMP-08): U3 — frontend wire (zoneSections override) 2026-05-15 22:58:18 +09:00
KyeongminandClaude Opus 4.7 ab2764c8d0 feat(IMP-08): U3 — frontend wire (zoneSections override)
Wires the frontend drag/drop zone assignment through to the backend
--override-section-assignment CLI flag.

PipelineOverrides gains an optional zoneSections field
(Record<string, string[]>) carrying canonical ordinal section ids
(e.g., "top": ["04-2-sub-1"]).

Vite middleware /api/run accepts overrides.zoneSections and forwards
each non-empty zone as `--override-section-assignment ZONE=sid[,sid]`.
Empty arrays and non-string sids are filtered to avoid bogus
assignments from a partially-built UI state.

Home.tsx builds the override with a diff-vs-default guard per Codex
Stage 3 R3 B3 fix : createInitialUserSelection seeds zone_sections with
the auto plan, so a literal copy would pollute backend assignment-source
provenance even on a fresh re-render. The diff compares each zone's
section list against sourcePlan.zones[].section_ids and only emits zones
that differ. Toast summary now reports zoneSections=N when forwarded.

Smoke verification : python -m src.phase_z2_pipeline samples/mdx_batch/04.mdx
test_imp08_smoke --override-section-assignment primary=04-2-sub-1 produces
section_assignment_plan with assignment_source=cli_override and
v4_selector_trace.candidates populated via the U1 alias resolver
(04-2-sub-1 -> 04-2.1 V4 entry).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:36:16 +09:00
KyeongminandClaude Opus 4.7 5191acad85 feat(IMP-08): U2 — aligner canonical sub-id + N-R5 decimal alias guard
align_sections_to_v4_granularity now emits canonical sub-section ids
of the form ${section_id}-sub-${ordinal} (e.g., "04-2-sub-1"), matching
the frontend drag/drop schema. Each drilled sub-section populates
heading_number (decimal "2.1" / integer "1" / None for undecorated)
and v4_alias_keys for legacy V4 keys.

N-R5 decimal-only alias guard : v4_alias_keys is populated only when
heading_number matches re.fullmatch(r"\d+\.\d+", ...). Integer-only
H3 headings (e.g., MDX 05's "### 1", "### 2") and bare H3 headings
produce no alias to avoid sibling-parent V4 collisions (RULE 0
generalization — applies to all 32-frame MDX, not MDX 05-specific).

The drill regex is broadened from r"^###\s+(\d+\.\d+)\s+..." to
r"^###\s+(?:(\d+(?:\.\d+)?)\s+)?(.+?)$" so integer-only and bare H3
headings are now recognised as sub-sections; they previously failed
the regex and were silently kept under the parent section.

Tests : 7 new cases (MdxSection default 4-positional callers, V4 exact
passthrough, decimal drill with alias, integer-only no-alias guard,
bare H3 no-alias, no-H3 passthrough, end-to-end aligner -> resolver
round-trip with legacy V4 alias). 15/15 in test_phase_z2_subsection_schema
+ 14 override + 8 fallback baseline = 37/37 PASS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:33:49 +09:00
KyeongminandClaude Opus 4.7 a422d72c0b feat(IMP-08): U1 — schema helper + V4 alias resolver (4 lookup sites)
Adds sub-section schema fields (heading_number / v4_alias_keys /
sub_sections) to MdxSection with defaults so existing 4-positional
constructions remain valid. Introduces _resolve_v4_section_key helper
that resolves a V4 mdx_sections key in exact > alias > None order with
no parent/sibling promotion (axis 7 hybrid lock).

Rewires four runtime V4 lookup sites (lookup_v4_match,
lookup_v4_match_with_fallback, lookup_v4_all_judgments,
lookup_v4_candidates) to accept an optional alias_keys kwarg and go
through the resolver. U1 callers pass empty alias lists so behaviour
is byte-identical to the previous exact-match path; U2 will populate
aliases from MDX heading_number metadata.

Closure callers in run_phase_z2 build section_alias_by_id from
MdxSection.v4_alias_keys and forward into lookup_fn /
candidates_lookup_fn / lookup_v4_all_judgments (Step 7-A trace) and
into _select_template_for_overrides single-section selector.

Step 9 candidate report (post-decision diagnostic) is marked with an
inline English exemption comment per N-R6 — runtime selection goes
through _resolve_v4_section_key, the report path stays a direct
dict-shape lookup to avoid debug_zones schema plumbing.

derive_parent_id now recognises canonical ordinal ids
("03-1-sub-2" -> "03-1") first and keeps the legacy decimal fallback
("04-2.1" -> "04-2") for V4 alias compatibility.

Tests : 8 synthetic cases in tests/test_phase_z2_subsection_schema.py
covering derive_parent_id ordinal/decimal/none and the resolver
exact/alias/no-promote/miss cases. 30/30 PASS combined with the 14
override + 8 fallback baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:28:59 +09:00
6 changed files with 557 additions and 46 deletions
+31
View File
@@ -300,6 +300,35 @@ export default function Home() {
if (zoneGeometries && Object.keys(zoneGeometries).length > 0) {
overrides.zoneGeometries = zoneGeometries;
}
// IMP-08 B-3 : zoneSections forward only when the user diverged from
// the auto plan. Codex Stage 3 R3 B3 fix : `createInitialUserSelection`
// seeds `zone_sections` with the default placement, so a literal copy
// would pollute backend assignment-source provenance even on a fresh
// re-render. Diff against `sourcePlan.zones[].section_ids` per zone and
// only emit zones whose section list differs.
const userZoneSections = state.userSelection.overrides.zone_sections;
if (userZoneSections) {
const defaultByZone = new Map<string, string[]>();
sourcePlan.zones.forEach((z) => {
defaultByZone.set(z.zone_id, z.section_ids);
});
const zoneSectionsDiff: Record<string, string[]> = {};
for (const [zoneId, sids] of Object.entries(userZoneSections)) {
if (!Array.isArray(sids)) continue;
const cleaned = sids.filter((s) => typeof s === "string" && s.trim());
const defaults = defaultByZone.get(zoneId) ?? [];
const sameAsDefault =
cleaned.length === defaults.length &&
cleaned.every((sid, i) => sid === defaults[i]);
if (!sameAsDefault) {
zoneSectionsDiff[zoneId] = cleaned;
}
}
if (Object.keys(zoneSectionsDiff).length > 0) {
overrides.zoneSections = zoneSectionsDiff;
}
}
}
setState((p) => ({ ...p, isLoading: true }));
@@ -310,6 +339,8 @@ export default function Home() {
? `(overrides: ${[
overrides.layout && `layout=${overrides.layout}`,
overrides.frames && `frames=${Object.keys(overrides.frames).length}`,
overrides.zoneSections &&
`zoneSections=${Object.keys(overrides.zoneSections).length}`,
]
.filter(Boolean)
.join(", ")})`
@@ -251,6 +251,11 @@ export interface PipelineOverrides {
/** zone_id (top/bottom/left/right/...) → slide-body 내부 0~1 비율.
* backend 의 build_layout_css 가 horizontal-2 / vertical-2 만 처리. */
zoneGeometries?: Record<string, { x: number; y: number; w: number; h: number }>;
/** IMP-08 B-3 : zone_id -> list of section_id assignments
* (canonical ordinal `${parent}-sub-${n}`). Only forwarded when the
* user explicitly diverges from the auto plan; default placements
* are not echoed back to avoid polluting override provenance. */
zoneSections?: Record<string, string[]>;
}
export async function runPipeline(
+18
View File
@@ -241,6 +241,9 @@ function vitePluginPhaseZApi(): Plugin {
layout?: string;
frames?: Record<string, string>; // unit_id → template_id
zoneGeometries?: Record<string, { x: number; y: number; w: number; h: number }>; // zone_id → bbox (slide-body 내부 0~1)
// IMP-08 B-3 : zone_id -> list of canonical section_id assignments
// (e.g., "top": ["03-1-sub-1"]). Forwarded as --override-section-assignment.
zoneSections?: Record<string, string[]>;
};
};
try {
@@ -322,6 +325,21 @@ function vitePluginPhaseZApi(): Plugin {
}
}
}
// IMP-08 B-3 — zoneSections override forward to CLI.
// Each entry becomes `--override-section-assignment ZONE=sid[,sid]`.
// Empty arrays and non-string sids are filtered out so the backend
// never receives bogus assignments from a partially-built UI state.
if (overrides?.zoneSections && typeof overrides.zoneSections === "object") {
for (const [zoneId, sids] of Object.entries(overrides.zoneSections)) {
if (!Array.isArray(sids)) continue;
const cleaned = sids.filter((s) => typeof s === "string" && s.trim());
if (cleaned.length === 0) continue;
cliArgs.push(
"--override-section-assignment",
`${zoneId}=${cleaned.join(",")}`
);
}
}
console.log(
`[phase-z-api] spawn pipeline: run_id=${runId}, mdx=${mdxPath}, args=${JSON.stringify(cliArgs.slice(2))}`
);
+19 -5
View File
@@ -21,6 +21,7 @@ Pipeline 의 빠진 layer = MDX 덩어리들을 *최종 zone unit* 으로 묶는
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
@@ -371,13 +372,20 @@ class CompositionUnit:
# ─── Heading Tree ──────────────────────────────────────────────
def derive_parent_id(section_id: str) -> Optional[str]:
"""section_id 에서 parent 도출 — V4 키 컨벤션 기반.
"""Section id -> parent id derivation by V4 key convention.
예시 (코멘트, 룰 X) :
- "04-2.1" → "04-2" (decimal suffix → strip)
- "04-1" → None (top-level, no parent)
- "04" → None
IMP-08 B-3 : canonical ordinal `${parent}-sub-${n}` recognised first;
legacy decimal `04-2.1` kept as fallback alias path.
Examples (illustrative, not rules) :
- "03-1-sub-2" -> "03-1" (canonical ordinal, IMP-08)
- "04-2.1" -> "04-2" (decimal suffix, legacy V4 key style)
- "04-1" -> None (top-level, no parent)
- "04" -> None
"""
m = re.fullmatch(r"(.+?)-sub-(\d+)", section_id)
if m:
return m.group(1)
parts = section_id.split("-", 1)
if len(parts) != 2:
return None
@@ -693,6 +701,12 @@ def select_composition_units(candidates, allowed_statuses: set[str]) -> list[Com
selected.append(c)
covered.update(c.source_section_ids)
# 2026-05-14 — MDX 자연 순서 (section_id 오름차순) 로 재정렬.
# 사용자 룰 (CLAUDE.md "정보 계층: 위 → 아래") + 04-1/04-2 score 차이로 인한
# zone 거꾸로 배치 catch. score 는 viable selection 에만, position 은 MDX 순서.
# source_section_ids 첫 element 기준 lexicographic sort — "04-1" < "04-2" < "04-2.1" < "04-2.2".
selected.sort(key=lambda c: c.source_section_ids[0] if c.source_section_ids else "")
return selected
+245 -41
View File
@@ -31,7 +31,7 @@ import re
import shutil
import sys
import time
from dataclasses import asdict, dataclass
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Optional
@@ -41,6 +41,7 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape
from phase_z2_composition import (
LAYOUT_PRESETS,
CompositionUnit,
derive_parent_id,
plan_composition,
select_display_strategy_candidates,
select_layout_candidates,
@@ -85,6 +86,19 @@ V4_LABEL_TO_PHASE_Z_STATUS = {
}
MVP1_ALLOWED_STATUSES = {"matched_zone", "adapt_matched_zone"}
# Env toggle PHASE_Z_ALLOW_RESTRUCTURE (default OFF) — when "1/true/yes" 도 restructure
# (= extract_matched_zone) 통과시킴. AI fallback 대행 (사용자가 콘텐츠를 frame 구조에 맞게
# 재정리한 mdx 를 제공) 시나리오용. MVP1 정책 자체는 무변 — env 켜진 세션만 영향.
if os.environ.get("PHASE_Z_ALLOW_RESTRUCTURE", "").strip().lower() in {"1", "true", "yes"}:
MVP1_ALLOWED_STATUSES = MVP1_ALLOWED_STATUSES | {"extract_matched_zone"}
# Env toggle PHASE_Z_ALLOW_REJECT (default OFF) — when "1/true/yes" 도 reject
# (= fallback_candidate) 통과시킴. 사용자 룰 : "매칭점수 가장 높은 frame 의 구조,
# 요소, 색상 활용" — V4 가 의미적으로 reject 해도 structure 매칭 충분하면 강제 사용.
# 04-1 같은 all-reject section + 신규 등록 frame 강제 매핑 시나리오용.
if os.environ.get("PHASE_Z_ALLOW_REJECT", "").strip().lower() in {"1", "true", "yes"}:
MVP1_ALLOWED_STATUSES = MVP1_ALLOWED_STATUSES | {"fallback_candidate"}
# Step 9 v0 (사용자 lock 2026-05-08) — V4 label → application_mode 변환.
# tuple = (application_mode, auto_applicable, delegated_to).
# status.md §2 Q3 / Q7 lock 따라.
@@ -136,6 +150,13 @@ class MdxSection:
section_num: int
title: str
raw_content: str
# IMP-08 B-3 sub-section schema (additive, defaults preserve 4-positional callers).
# heading_number: decimal "2.1" from MDX `### 2.1 Title` capture (U2-populated).
# v4_alias_keys: legacy V4 keys to try when canonical ordinal id misses (e.g. "04-2.1").
# sub_sections: raw child payloads from section_parser (Stage 0 adapter consumes).
heading_number: Optional[str] = None
v4_alias_keys: list = field(default_factory=list)
sub_sections: list = field(default_factory=list)
@dataclass
@@ -177,7 +198,15 @@ def parse_mdx(mdx_path: Path) -> tuple[str, list[MdxSection], Optional[str]]:
if footer_match:
body = footer_match.group(1)
bullet_match = re.search(r"\*\s*\*\*([^*]+)\*\*", body)
footer_text = (bullet_match.group(1).strip() if bullet_match else body.strip())
if bullet_match:
footer_text = bullet_match.group(1).strip()
else:
# 2026-05-14 Q5 — markdown bullet marker `*` 시작 시 제거 (사용자 lock).
# `* 검증 없는 정책...` → `검증 없는 정책...`
plain = body.strip()
if plain.startswith("*"):
plain = plain[1:].lstrip()
footer_text = plain
text = text[:footer_match.start()] + text[footer_match.end():]
sections = []
@@ -365,47 +394,111 @@ def load_v4_result() -> dict:
return yaml.safe_load(V4_RESULT_PATH.read_text(encoding="utf-8"))
def align_sections_to_v4_granularity(sections: list[MdxSection], v4: dict) -> list[MdxSection]:
"""V4 section granularity 에 맞춰 sections 조정.
def align_sections_to_v4_granularity(
sections: list[MdxSection],
v4: dict,
*,
override_target_section_ids: Optional[list[str]] = None,
) -> list[MdxSection]:
"""Align MDX sections to canonical sub-section granularity.
각 section 에 대해 :
- V4 에 section.section_id 키 있음 → 그대로 유지 (## level 매칭)
- V4 에 키 없고 raw_content 에 ### sub-section 존재 → ### 로 drill
- V4 에 키 없고 ### 도 없음 → 원본 그대로 (V4 lookup 단계에서 자연스럽게 abort)
Default behaviour (V4-driven granularity, backward compatible) :
- V4 has section_id exact key -> keep section unchanged (parent
granularity rendering, parent-level V4 evidence applies).
- V4 missing + H3 sub-sections -> drill into sub-sections, emit
canonical ids ``${section_id}-sub-${ordinal}`` with optional
decimal alias for legacy V4 keys (e.g. ``04-2.1``).
- V4 missing + no H3 -> pass through (downstream V4 lookup
will naturally abort with no_v4_section).
설계 원칙 :
- parser (parse_mdx) = MDX 만 앎 (V4 무관)
- aligner (이 함수) = V4 키 기준 granularity 결정
- runtime parser 가 matching artifact 의 granularity 를 *따라가는* 구조
IMP-08 B-3 / Stage 5 R2 blocker-fix — ``override_target_section_ids``
is the list of section ids that drag/drop override CLI flags target.
When any override target matches ``${section_id}-sub-N`` for a section
whose parent is otherwise V4-aligned, that section is force-drilled so
sub-section ids become addressable. This keeps the default rendering
path on V4 granularity while making drag/drop deterministic regardless
of whether V4 carries a parent exact key.
Each drilled sub-section carries :
- heading_number : decimal "2.1" / integer "1" / None (bare H3 title).
- v4_alias_keys : legacy V4 keys to try when the canonical ordinal
id misses. Populated only when ``heading_number`` matches the
decimal pattern ``\\d+\\.\\d+`` (N-R5 guard) — integer-only or
bare H3 produces no alias to avoid sibling-parent V4 collisions.
Design boundary :
- parser (``parse_mdx``) = MDX-only knowledge (V4-agnostic).
- aligner (this function) = canonical sub-id schema, MDX-driven on
force_drill, V4-driven otherwise.
- resolver (``_resolve_v4_section_key``) = exact > alias > None,
never auto-promotes to parent/sibling (axis 7 hybrid lock).
"""
v4_keys = set(v4.get("mdx_sections", {}).keys())
# Build the set of parent ids whose sub-ids are explicitly targeted by
# an override. These sections must be drilled even if V4 also carries
# the parent key exactly. Parents derived from canonical "X-sub-N" ids
# only — non-sub ids (top-level overrides) do not trigger drilling.
force_drill_parents: set[str] = set()
if override_target_section_ids:
for sid in override_target_section_ids:
parent = derive_parent_id(sid)
if parent and sid != parent:
force_drill_parents.add(parent)
aligned: list[MdxSection] = []
# Capture optional heading-number prefix (decimal "2.1" or integer "1")
# plus the heading title. None group = bare "### Title".
sub_pattern = re.compile(
r"^###\s+(?:(\d+(?:\.\d+)?)\s+)?(.+?)$", re.MULTILINE
)
decimal_re = re.compile(r"\d+\.\d+")
for section in sections:
if section.section_id in v4_keys:
force_drill = section.section_id in force_drill_parents
if section.section_id in v4_keys and not force_drill:
# V4 carries this section exactly and no override targets a
# sub-id under it: keep parent granularity (backward compat).
aligned.append(section)
continue
# ### drill 시도
sub_pattern = re.compile(r"^###\s+(\d+\.\d+)\s+(.+?)$", re.MULTILINE)
sub_matches = list(sub_pattern.finditer(section.raw_content))
if not sub_matches:
aligned.append(section) # drill 불가, V4 lookup 에서 abort 됨
# No H3 sub-sections: cannot drill. Pass section through;
# downstream V4 lookup aborts with no_v4_section when needed.
aligned.append(section)
continue
# ### sub-section 추출
mdx_id = section.section_id.split("-")[0] # e.g., "04"
for i, m in enumerate(sub_matches):
subnum = m.group(1) # e.g., "2.1"
for ordinal, m in enumerate(sub_matches, start=1):
heading_number = m.group(1) # decimal "2.1" / integer "1" / None
sub_title = m.group(2).strip()
start = m.end()
end = sub_matches[i + 1].start() if i + 1 < len(sub_matches) else len(section.raw_content)
end = (
sub_matches[ordinal].start()
if ordinal < len(sub_matches)
else len(section.raw_content)
)
raw = section.raw_content[start:end].strip()
# N-R5 : alias only for decimal heading numbers. integer-only
# H3 (`### 1`) or undecorated H3 produce no alias to avoid
# sibling-parent V4 collisions (e.g., 05.mdx integer H3s).
alias_keys: list[str] = []
if heading_number and decimal_re.fullmatch(heading_number):
alias_keys.append(f"{mdx_id}-{heading_number}")
title = (
f"{heading_number} {sub_title}" if heading_number else sub_title
)
aligned.append(MdxSection(
section_id=f"{mdx_id}-{subnum}", # e.g., "04-2.1"
section_id=f"{section.section_id}-sub-{ordinal}",
section_num=section.section_num,
title=f"{subnum} {sub_title}",
title=title,
raw_content=raw,
heading_number=heading_number,
v4_alias_keys=alias_keys,
))
return aligned
@@ -424,8 +517,39 @@ def _v4_match_from_judgment(section_id: str, judgment: dict, rank: Optional[int]
)
def lookup_v4_match(v4: dict, section_id: str) -> Optional[V4Match]:
sec = v4.get("mdx_sections", {}).get(section_id)
def _resolve_v4_section_key(
v4: dict,
section_id: str,
*,
alias_keys: Optional[list] = None,
) -> Optional[str]:
"""Resolve a V4 ``mdx_sections`` key for *section_id*.
Resolution order :
1. exact match (canonical ordinal id wins)
2. alias_keys in given order (e.g. legacy decimal ``04-2.1`` for ``04-2-sub-1``)
3. None on miss.
Never promotes to parent or sibling — that would reinterpret V4 evidence
(axis 7 hybrid lock, RULE 0). U1 callers pass alias_keys=None so the
function is byte-identical to the previous exact-match lookup; U2 populates
aliases from MDX heading_number metadata.
"""
keys = v4.get("mdx_sections", {})
if section_id in keys:
return section_id
if alias_keys:
for a in alias_keys:
if a and a in keys:
return a
return None
def lookup_v4_match(
v4: dict, section_id: str, *, alias_keys: Optional[list] = None
) -> Optional[V4Match]:
resolved = _resolve_v4_section_key(v4, section_id, alias_keys=alias_keys)
sec = v4.get("mdx_sections", {}).get(resolved) if resolved else None
if not sec:
return None
judgments = sec.get("judgments_full32", [])
@@ -461,14 +585,23 @@ def lookup_v4_match_with_fallback(
section_id: str,
*,
raw_content: Optional[str] = None,
max_rank: int = 3,
max_rank: Optional[int] = None,
alias_keys: Optional[list] = None,
) -> tuple[Optional[V4Match], dict]:
"""Select V4 rank-1, or promote rank-2/3 when rank-1 is not auto-renderable.
This is an IMP-05 selector only. It uses existing V4 labels, frame-contract
presence, and the Phase Z capacity precheck; it does not call calculate_fit.
"""
sec = v4.get("mdx_sections", {}).get(section_id)
# 2026-05-14 — max_rank env toggle PHASE_Z_MAX_RANK (default 3).
# 보고용 : 등록 frame rank 가 4+ 인 경우 (예: mdx05-2 의 rank 10) 도 통과시킴.
if max_rank is None:
try:
max_rank = int(os.environ.get("PHASE_Z_MAX_RANK", "3"))
except ValueError:
max_rank = 3
resolved = _resolve_v4_section_key(v4, section_id, alias_keys=alias_keys)
sec = v4.get("mdx_sections", {}).get(resolved) if resolved else None
trace = {
"section_id": section_id,
"max_rank": max_rank,
@@ -485,7 +618,20 @@ def lookup_v4_match_with_fallback(
trace["fallback_reason"] = "no_v4_section"
return None, trace
judgments = (sec.get("judgments_full32") or [])[:max_rank]
# 2026-05-14 — fallback chain sort = label priority + confidence (frontend 와 동일).
# 사용자 룰 : "reject 외 다른 label 있으면 reject 는 ranking 상단 X".
# judgments_full32 는 confidence desc only. 그대로 iterate 시 reject (conf 높은) 가
# light_edit (conf 낮은) 보다 먼저 선택될 수 있음. label priority 우선 정렬.
_LABEL_PRIORITY = {"use_as_is": 0, "light_edit": 1, "restructure": 2, "reject": 3}
all_judgments = sec.get("judgments_full32") or []
judgments_sorted = sorted(
all_judgments,
key=lambda j: (
_LABEL_PRIORITY.get(j.get("label"), 99),
-float(j.get("confidence") or 0),
),
)
judgments = judgments_sorted[:max_rank]
if not judgments:
trace["fallback_reason"] = "empty_v4_judgments"
return None, trace
@@ -571,7 +717,9 @@ def lookup_v4_match_with_fallback(
return None, trace
def lookup_v4_all_judgments(v4: dict, section_id: str) -> list[V4Match]:
def lookup_v4_all_judgments(
v4: dict, section_id: str, *, alias_keys: Optional[list] = None
) -> list[V4Match]:
"""V4 raw 32 entry 그대로 반환 — reject 포함, max_n filter 없음.
Step 7-A axis 보강 (사용자 lock 2026-05-08) — 사용자 UI 가 모든 frame 의
@@ -581,7 +729,8 @@ def lookup_v4_all_judgments(v4: dict, section_id: str) -> list[V4Match]:
Returns :
list[V4Match] — 0~32 길이. raw judgments_full32 순서 (= V4 score desc) 보존.
"""
sec = v4.get("mdx_sections", {}).get(section_id)
resolved = _resolve_v4_section_key(v4, section_id, alias_keys=alias_keys)
sec = v4.get("mdx_sections", {}).get(resolved) if resolved else None
if not sec:
return []
judgments = sec.get("judgments_full32", [])
@@ -592,7 +741,11 @@ def lookup_v4_all_judgments(v4: dict, section_id: str) -> list[V4Match]:
def lookup_v4_candidates(
v4: dict, section_id: str, max_n: int = 6
v4: dict,
section_id: str,
max_n: int = 6,
*,
alias_keys: Optional[list] = None,
) -> list[V4Match]:
"""V4 non-reject 후보 list 반환 (Step 5 보완 axis — 사용자 lock 2026-05-08).
@@ -612,7 +765,8 @@ def lookup_v4_candidates(
호출처 무변. 본 함수는 Step 5 artifact + Step 9 application_plan input
위한 새 entry point.
"""
sec = v4.get("mdx_sections", {}).get(section_id)
resolved = _resolve_v4_section_key(v4, section_id, alias_keys=alias_keys)
sec = v4.get("mdx_sections", {}).get(resolved) if resolved else None
if not sec:
return []
judgments = sec.get("judgments_full32", [])
@@ -743,14 +897,19 @@ def build_layout_css(layout_preset: str, zones_data: list[dict],
# ── Step D-ext : user override 처리 ──
if override_zone_geometries:
if layout_preset == "horizontal-2":
# heights_px override — zone 의 h 비율로 SLIDE_BODY_HEIGHT 분배.
# heights_px override — zone 의 h 비율로 (SLIDE_BODY_HEIGHT - gap) 분배.
# 2026-05-14 BUGFIX (Axis A) — 원래 SLIDE_BODY_HEIGHT 만 사용해서 zone
# heights 합 + gap 이 slide-body 보다 +gap px overflow. gap 빼고
# 분배 (compute_zone_layout 의 normal path 와 동일 logic).
ratios = []
for pos in positions:
geom = override_zone_geometries.get(pos)
ratios.append(float(geom["h"]) if geom else 0.0)
total = sum(ratios)
if total > 0:
heights_px = [int(round(r / total * SLIDE_BODY_HEIGHT)) for r in ratios]
n = len(ratios)
available = SLIDE_BODY_HEIGHT - gap * (n - 1)
heights_px = [int(round(r / total * available)) for r in ratios]
rows = " ".join(f"{h}px" for h in heights_px)
return {
"areas": preset["css_areas"],
@@ -932,7 +1091,12 @@ def _build_position_assignment_plan(
if v4 is None or section is None:
return None, "no_v4_section", None
raw_content = getattr(section, "raw_content", None)
match, trace = lookup_v4_match_with_fallback(v4, sid, raw_content=raw_content)
# IMP-08 B-3 : forward sub-section V4 aliases (decimal heading_number)
# when canonical ordinal id misses; safe for top-level sids (empty list).
alias_keys = list(getattr(section, "v4_alias_keys", []) or [])
match, trace = lookup_v4_match_with_fallback(
v4, sid, raw_content=raw_content, alias_keys=alias_keys
)
if match is None:
return None, "no_direct_render_template", trace
return match.template_id, None, trace
@@ -1306,7 +1470,13 @@ def render_slide(slide_title: str, slide_footer: Optional[str],
zone["partial_html"] = ""
continue
partial = env.get_template(f"families/{zone['template_id']}.html")
zone["partial_html"] = partial.render(slot_payload=zone["slot_payload"])
# 2026-05-14 — partial 에 assets_dir 전달. figma asset PNG/SVG 참조 가능.
# assets_dir = "assets/<template_id>" (run-relative). final.html 의 <img src>
# 상대 path 와 matching — frontend `/data/runs/.../assets/...` serve.
zone["partial_html"] = partial.render(
slot_payload=zone["slot_payload"],
assets_dir=zone.get("assets_dir") or "",
)
base = env.get_template("slide_base.html")
return base.render(
@@ -1992,8 +2162,21 @@ def run_phase_z2_mvp1(
# 2. Load V4
v4 = load_v4_result()
# 3. Align sections to V4 granularity (### drill if needed)
sections = align_sections_to_v4_granularity(sections, v4)
# 3. Align sections to V4 granularity (### drill if needed).
# IMP-08 B-3 / Stage 5 R2 : forward override target ids so sub-id
# drag/drop targets force-drill their parent section even when V4
# carries the parent exact key (deterministic drag/drop addressing).
_override_target_sids: list[str] = []
if override_section_assignments:
for _sids in override_section_assignments.values():
for _sid in _sids:
if isinstance(_sid, str) and _sid:
_override_target_sids.append(_sid)
sections = align_sections_to_v4_granularity(
sections,
v4,
override_target_section_ids=_override_target_sids or None,
)
print(f" aligned : sections={len(sections)} ({[s.section_id for s in sections]})")
# ─── Step 5: V4 매칭 evidence (non-reject max-6 후보 list — 사용자 lock 2026-05-08) ───
@@ -2039,14 +2222,21 @@ def run_phase_z2_mvp1(
# candidate (separate / parent_merged) → score → greedy non-overlapping select →
# layout preset (count-based v0).
section_content_by_id = {s.section_id: s.raw_content for s in sections}
# IMP-08 B-3 : sub-section ordinal id -> legacy V4 key aliases (e.g. "04-2.1").
# Empty list for canonical (top-level) sections — U1 baseline path is exact-only.
section_alias_by_id: dict[str, list] = {
s.section_id: list(getattr(s, "v4_alias_keys", []) or []) for s in sections
}
v4_fallback_traces: dict[str, dict] = {}
def lookup_fn(sid: str) -> Optional[V4Match]:
# max_rank None → lookup_v4_match_with_fallback 가 PHASE_Z_MAX_RANK env (default 3) 사용.
match, trace = lookup_v4_match_with_fallback(
v4,
sid,
raw_content=section_content_by_id.get(sid),
max_rank=3,
max_rank=None,
alias_keys=section_alias_by_id.get(sid),
)
v4_fallback_traces[sid] = trace
return match
@@ -2054,7 +2244,7 @@ def run_phase_z2_mvp1(
# Step 6-A axis (사용자 lock 2026-05-08) — V4 raw dict 흡수 fn.
# composition module 은 V4 yaml shape 모름. 본 fn 만 통해 후보 list 받음.
def candidates_lookup_fn(sid: str) -> list[V4Match]:
return lookup_v4_candidates(v4, sid)
return lookup_v4_candidates(v4, sid, alias_keys=section_alias_by_id.get(sid))
units, layout_preset, comp_debug = plan_composition(
sections, lookup_fn, V4_LABEL_TO_PHASE_Z_STATUS, MVP1_ALLOWED_STATUSES,
@@ -2553,6 +2743,10 @@ def run_phase_z2_mvp1(
"slot_payload": slot_payload,
"content_weight": content_weight,
"min_height_px": min_height_px,
# 2026-05-14 — partial.render() 가 assets_dir 을 zone.get() 으로 읽으므로
# zones_data 에도 포함해야 figma asset PNG 경로가 final.html 에 박힘.
# as_posix() — Windows 에서 str(Path) 는 backslash 라 url() 에서 404.
"assets_dir": assets_dir.relative_to(run_dir).as_posix() if assets_dir else None,
"assignment_source": plan_assignment_source,
"section_assignment_override": plan_section_override,
})
@@ -2581,7 +2775,7 @@ def run_phase_z2_mvp1(
"min_height_px": min_height_px,
"slot_payload_keys": sorted(slot_payload.keys()),
"content_truncated_count": truncated_count, # None / N (builder 가 N 개 자름)
"assets_dir": str(assets_dir.relative_to(run_dir)) if assets_dir else None,
"assets_dir": assets_dir.relative_to(run_dir).as_posix() if assets_dir else None,
"content_weight": content_weight,
# trace-only runtime 연결 v0 — B1 → B2 → B4 chain 결과 (render 미영향).
"placement_trace": placement_trace,
@@ -2777,6 +2971,11 @@ def run_phase_z2_mvp1(
note="V4 evidence 와 B4 통합 미완 — 별 axis. 현재 = composition planner 의 V4 rank-1 채택.",
)
# Step 9 HTML — V4 top candidates per zone (rank 1~4)
# IMP-08 N-R6 diagnostic exemption : this report path is post-decision
# reporting only. Runtime selection goes through _resolve_v4_section_key
# (4 sites). Direct dict lookup here is intentional — debug_zones carries
# dict-shape entries without v4_alias_keys plumbing, and a miss here only
# yields a "V4 entry missing" report line (runtime impact zero).
try:
with open(V4_RESULT_PATH, encoding="utf-8") as _vf:
_v4_full = yaml.safe_load(_vf)
@@ -3263,7 +3462,12 @@ def run_phase_z2_mvp1(
# 모든 frame 의 png 를 카드로 보여주기 위함).
# unit_id = source_section_ids join. parent_merged 는 첫 section 의
# judgments 사용 (parent V4 entry 가 그 section 에 있으므로).
v4_all_for_unit = lookup_v4_all_judgments(v4, unit.source_section_ids[0])
# IMP-08 B-3 : forward sub-section V4 aliases (decimal heading_number)
# when canonical ordinal id misses; U1 default = empty list (no change).
_first_sid = unit.source_section_ids[0]
v4_all_for_unit = lookup_v4_all_judgments(
v4, _first_sid, alias_keys=section_alias_by_id.get(_first_sid)
)
# application_candidates : V4 후보 zip 으로 application_mode 변환
app_candidates = []
+239
View File
@@ -0,0 +1,239 @@
"""IMP-08 B-3 sub-section drag/drop — schema + V4 alias resolver tests.
Fully synthetic per Codex #7 generalization guardrail:
NO real catalog template_id / frame_id, NO ``v4_full32_result.yaml`` dependency,
NO MDX-specific section ids beyond canonical id format.
Locked scope (Stage 3 R8) :
A. ``derive_parent_id`` canonical ordinal recognition + legacy decimal fallback.
B. ``_resolve_v4_section_key`` exact > alias > None (no parent/sibling promotion).
C. ``align_sections_to_v4_granularity`` canonical ordinal id emit + N-R5
decimal-only alias guard + MdxSection default-construction stability.
"""
from __future__ import annotations
from src.phase_z2_composition import derive_parent_id
from src.phase_z2_pipeline import (
MdxSection,
_resolve_v4_section_key,
align_sections_to_v4_granularity,
)
# ─── A. derive_parent_id ────────────────────────────────────────────────────
def test_derive_parent_id_ordinal_sub():
assert derive_parent_id("03-1-sub-2") == "03-1"
assert derive_parent_id("04-2-sub-1") == "04-2"
def test_derive_parent_id_decimal_legacy_alias():
# Legacy V4 decimal id retains existing behaviour for alias path.
assert derive_parent_id("04-2.1") == "04-2"
def test_derive_parent_id_top_level_none():
assert derive_parent_id("04-1") is None
assert derive_parent_id("04") is None
assert derive_parent_id("nonsense") is None
# ─── B. _resolve_v4_section_key ─────────────────────────────────────────────
def _fake_v4(*keys):
return {"mdx_sections": {k: {"judgments_full32": []} for k in keys}}
def test_alias_resolver_exact_match_wins():
v4 = _fake_v4("04-2-sub-1", "04-2.1")
assert _resolve_v4_section_key(v4, "04-2-sub-1") == "04-2-sub-1"
assert (
_resolve_v4_section_key(v4, "04-2-sub-1", alias_keys=["04-2.1"])
== "04-2-sub-1"
)
def test_alias_resolver_decimal_alias_when_metadata_present():
v4 = _fake_v4("04-2.1")
assert (
_resolve_v4_section_key(v4, "04-2-sub-1", alias_keys=["04-2.1"])
== "04-2.1"
)
def test_alias_resolver_no_parent_promotion():
# parent V4 entry must not be promoted into a sibling sub-section lookup.
v4 = _fake_v4("04-2")
assert _resolve_v4_section_key(v4, "04-2-sub-1") is None
assert (
_resolve_v4_section_key(v4, "04-2-sub-1", alias_keys=["04-2"])
== "04-2"
) # alias is opt-in; only resolves when caller explicitly provides it
def test_alias_resolver_no_sibling_promotion():
# sibling sub-section entry must not be auto-promoted without an alias.
v4 = _fake_v4("04-2-sub-2")
assert _resolve_v4_section_key(v4, "04-2-sub-1") is None
def test_alias_resolver_miss_returns_none():
v4 = _fake_v4("99-1")
assert _resolve_v4_section_key(v4, "04-2-sub-1") is None
assert (
_resolve_v4_section_key(v4, "04-2-sub-1", alias_keys=["04-2.1"])
is None
)
# ─── C. align_sections_to_v4_granularity ────────────────────────────────────
def _section(section_id, num, title, raw_content):
"""Build an MdxSection with default sub-section schema fields."""
return MdxSection(
section_id=section_id,
section_num=num,
title=title,
raw_content=raw_content,
)
def test_mdx_section_default_construction_preserves_4_positional_callers():
# IMP-08 B-3 : MdxSection still accepts the legacy 4-positional shape
# (defaults for heading_number / v4_alias_keys / sub_sections).
s = MdxSection("04-1", 1, "1. Top", "body")
assert s.heading_number is None
assert s.v4_alias_keys == []
assert s.sub_sections == []
def test_align_passthrough_when_v4_key_exact_match():
# Section already aligned to V4 key (no H3 sub-sections, no override
# target): aligner keeps it untouched. Parent-level V4 evidence
# flows via exact-match lookup.
sections = [_section("04-1", 1, "1. Top", "body")]
v4 = {"mdx_sections": {"04-1": {"judgments_full32": []}}}
out = align_sections_to_v4_granularity(sections, v4)
assert len(out) == 1
assert out[0].section_id == "04-1"
def test_align_parent_v4_exact_keeps_section_when_no_override_targets_sub():
# Backward-compat axis: when V4 carries the parent exact key and no
# drag/drop override targets a sub-id of this section, the aligner
# MUST keep the parent (preserves V4 evidence at parent granularity).
raw = "### 2.1 First\nbody1\n### 2.2 Second\nbody2\n"
sections = [_section("03-2", 2, "2. Parent", raw)]
v4 = {"mdx_sections": {"03-2": {"judgments_full32": []}}}
out = align_sections_to_v4_granularity(sections, v4)
assert [s.section_id for s in out] == ["03-2"]
def test_align_force_drills_when_override_targets_sub_id_with_parent_in_v4():
# Stage 5 R2 blocker-fix regression: when V4 has the parent exact key
# AND an override targets a sub-id of that section, the aligner MUST
# drill regardless of V4 parent presence. This makes drag/drop
# addressing deterministic across all V4 yaml shapes.
raw = "### 2.1 First\nbody1\n### 2.2 Second\nbody2\n"
sections = [_section("04-2", 2, "2. Parent", raw)]
v4 = {
"mdx_sections": {
"04-2": {"judgments_full32": []}, # parent V4 entry present
"04-2.1": {"judgments_full32": []}, # plus decimal sub entries
"04-2.2": {"judgments_full32": []},
}
}
out = align_sections_to_v4_granularity(
sections, v4, override_target_section_ids=["04-2-sub-1"]
)
# Force-drill: parent id MUST be replaced by canonical sub-ids.
assert [s.section_id for s in out] == ["04-2-sub-1", "04-2-sub-2"]
# Decimal aliases preserved (N-R5: decimal heading_number).
assert out[0].v4_alias_keys == ["04-2.1"]
assert out[1].v4_alias_keys == ["04-2.2"]
def test_align_top_level_override_target_does_not_force_drill_other_sections():
# Top-level override target ("primary=03-1") has no derive_parent_id,
# so it MUST NOT force-drill any section. Only "X-sub-N" targets
# trigger force-drill on parent X.
raw = "### 2.1 First\nbody1\n"
sections = [
_section("03-1", 1, "1. Top", "body"),
_section("03-2", 2, "2. Parent", raw),
]
v4 = {
"mdx_sections": {
"03-1": {"judgments_full32": []},
"03-2": {"judgments_full32": []},
}
}
out = align_sections_to_v4_granularity(
sections, v4, override_target_section_ids=["03-1"]
)
# No sub-id target -> both sections kept at parent granularity.
assert [s.section_id for s in out] == ["03-1", "03-2"]
def test_align_drill_emits_canonical_ordinal_id_with_decimal_alias():
# Decimal H3 headings -> canonical ordinal id + decimal alias (legacy V4 key).
raw = "### 2.1 First\nbody1\n### 2.2 Second\nbody2\n"
sections = [_section("04-2", 2, "2. Parent", raw)]
v4 = {"mdx_sections": {}} # forces drill (no exact key)
out = align_sections_to_v4_granularity(sections, v4)
assert [s.section_id for s in out] == ["04-2-sub-1", "04-2-sub-2"]
assert [s.heading_number for s in out] == ["2.1", "2.2"]
# N-R5 : decimal headings -> alias emitted.
assert out[0].v4_alias_keys == ["04-2.1"]
assert out[1].v4_alias_keys == ["04-2.2"]
def test_align_drill_integer_only_h3_emits_no_alias_n_r5_guard():
# N-R5 : integer-only H3 (e.g., "### 1 Title") must NOT generate an alias,
# otherwise it would collide with sibling parent V4 entries (`{mdx_id}-1`).
raw = "### 1 Alpha\nbody1\n### 2 Beta\nbody2\n"
sections = [_section("05-2", 2, "2. Parent", raw)]
v4 = {"mdx_sections": {}}
out = align_sections_to_v4_granularity(sections, v4)
assert [s.section_id for s in out] == ["05-2-sub-1", "05-2-sub-2"]
assert [s.heading_number for s in out] == ["1", "2"]
assert out[0].v4_alias_keys == []
assert out[1].v4_alias_keys == []
def test_align_drill_undecorated_h3_emits_no_alias():
# Plain `### Title` without numeric prefix -> heading_number=None, no alias.
raw = "### Alpha\nbody1\n### Beta\nbody2\n"
sections = [_section("03-3", 3, "3. Parent", raw)]
v4 = {"mdx_sections": {}}
out = align_sections_to_v4_granularity(sections, v4)
assert [s.section_id for s in out] == ["03-3-sub-1", "03-3-sub-2"]
assert [s.heading_number for s in out] == [None, None]
assert all(s.v4_alias_keys == [] for s in out)
def test_align_no_h3_passes_section_through_unchanged():
# No H3 sub-headings in raw_content -> aligner keeps the section.
sections = [_section("04-1", 1, "1. Top", "no subheadings here\njust prose")]
v4 = {"mdx_sections": {}}
out = align_sections_to_v4_granularity(sections, v4)
assert len(out) == 1
assert out[0].section_id == "04-1"
def test_align_resolver_round_trip_with_legacy_v4_alias():
# End-to-end : aligner emits canonical id + alias keys; resolver finds the
# legacy decimal key in V4 via alias path (no parent promotion).
raw = "### 2.1 First\nbody1\n"
sections = [_section("04-2", 2, "2. Parent", raw)]
v4 = {"mdx_sections": {"04-2.1": {"judgments_full32": []}}}
out = align_sections_to_v4_granularity(sections, v4)
sub = out[0]
assert sub.section_id == "04-2-sub-1"
resolved = _resolve_v4_section_key(
v4, sub.section_id, alias_keys=sub.v4_alias_keys
)
assert resolved == "04-2.1"