Compare commits
2
Commits
ab2764c8d0
...
6a0b0b282f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a0b0b282f | ||
|
|
1264e92e75 |
@@ -701,6 +701,12 @@ def select_composition_units(candidates, allowed_statuses: set[str]) -> list[Com
|
|||||||
selected.append(c)
|
selected.append(c)
|
||||||
covered.update(c.source_section_ids)
|
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
|
return selected
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+137
-33
@@ -41,6 +41,7 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|||||||
from phase_z2_composition import (
|
from phase_z2_composition import (
|
||||||
LAYOUT_PRESETS,
|
LAYOUT_PRESETS,
|
||||||
CompositionUnit,
|
CompositionUnit,
|
||||||
|
derive_parent_id,
|
||||||
plan_composition,
|
plan_composition,
|
||||||
select_display_strategy_candidates,
|
select_display_strategy_candidates,
|
||||||
select_layout_candidates,
|
select_layout_candidates,
|
||||||
@@ -85,6 +86,19 @@ V4_LABEL_TO_PHASE_Z_STATUS = {
|
|||||||
}
|
}
|
||||||
MVP1_ALLOWED_STATUSES = {"matched_zone", "adapt_matched_zone"}
|
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 변환.
|
# Step 9 v0 (사용자 lock 2026-05-08) — V4 label → application_mode 변환.
|
||||||
# tuple = (application_mode, auto_applicable, delegated_to).
|
# tuple = (application_mode, auto_applicable, delegated_to).
|
||||||
# status.md §2 Q3 / Q7 lock 따라.
|
# status.md §2 Q3 / Q7 lock 따라.
|
||||||
@@ -184,7 +198,15 @@ def parse_mdx(mdx_path: Path) -> tuple[str, list[MdxSection], Optional[str]]:
|
|||||||
if footer_match:
|
if footer_match:
|
||||||
body = footer_match.group(1)
|
body = footer_match.group(1)
|
||||||
bullet_match = re.search(r"\*\s*\*\*([^*]+)\*\*", body)
|
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():]
|
text = text[:footer_match.start()] + text[footer_match.end():]
|
||||||
|
|
||||||
sections = []
|
sections = []
|
||||||
@@ -372,47 +394,80 @@ def load_v4_result() -> dict:
|
|||||||
return yaml.safe_load(V4_RESULT_PATH.read_text(encoding="utf-8"))
|
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]:
|
def align_sections_to_v4_granularity(
|
||||||
"""V4 section granularity 에 맞춰 sections 조정.
|
sections: list[MdxSection],
|
||||||
|
v4: dict,
|
||||||
|
*,
|
||||||
|
override_target_section_ids: Optional[list[str]] = None,
|
||||||
|
) -> list[MdxSection]:
|
||||||
|
"""Align MDX sections to canonical sub-section granularity.
|
||||||
|
|
||||||
IMP-08 B-3 : canonical sub-section id ``${section_id}-sub-${ordinal}``
|
Default behaviour (V4-driven granularity, backward compatible) :
|
||||||
(예 : ``04-2-sub-1``) 를 emit 하고, legacy V4 키 (``04-2.1``) 는
|
- V4 has section_id exact key -> keep section unchanged (parent
|
||||||
``v4_alias_keys`` 로 보존하여 ``_resolve_v4_section_key`` 가 alias 경로로
|
granularity rendering, parent-level V4 evidence applies).
|
||||||
매칭한다. canonical ordinal id 는 frontend drag/drop override 와 동일
|
- V4 missing + H3 sub-sections -> drill into sub-sections, emit
|
||||||
schema (`section_id-sub-N`).
|
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).
|
||||||
|
|
||||||
N-R5 alias guard : heading_number 가 decimal (``2.1``) 일 때만 alias
|
IMP-08 B-3 / Stage 5 R2 blocker-fix — ``override_target_section_ids``
|
||||||
emit. integer-only (``1``) / non-numeric heading 은 alias 0 — sibling
|
is the list of section ids that drag/drop override CLI flags target.
|
||||||
parent V4 evidence 로 잘못 promote 되는 collision 방지 (RULE 0).
|
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.
|
||||||
|
|
||||||
각 section 에 대해 :
|
Each drilled sub-section carries :
|
||||||
- V4 에 section.section_id 키 있음 → 그대로 유지 (## level 매칭)
|
- heading_number : decimal "2.1" / integer "1" / None (bare H3 title).
|
||||||
- V4 에 키 없고 raw_content 에 ### sub-section 존재 → ### 로 drill
|
- v4_alias_keys : legacy V4 keys to try when the canonical ordinal
|
||||||
- V4 에 키 없고 ### 도 없음 → 원본 그대로 (V4 lookup 단계에서 자연스럽게 abort)
|
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 만 앎 (V4 무관)
|
- parser (``parse_mdx``) = MDX-only knowledge (V4-agnostic).
|
||||||
- aligner (이 함수) = V4 키 기준 granularity 결정
|
- aligner (this function) = canonical sub-id schema, MDX-driven on
|
||||||
- runtime parser 가 matching artifact 의 granularity 를 *따라가는* 구조
|
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())
|
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] = []
|
aligned: list[MdxSection] = []
|
||||||
|
|
||||||
# IMP-08 B-3 : capture optional heading-number prefix (decimal "2.1" or
|
# Capture optional heading-number prefix (decimal "2.1" or integer "1")
|
||||||
# integer "1") + heading title. None group = bare "### Title".
|
# plus the heading title. None group = bare "### Title".
|
||||||
sub_pattern = re.compile(
|
sub_pattern = re.compile(
|
||||||
r"^###\s+(?:(\d+(?:\.\d+)?)\s+)?(.+?)$", re.MULTILINE
|
r"^###\s+(?:(\d+(?:\.\d+)?)\s+)?(.+?)$", re.MULTILINE
|
||||||
)
|
)
|
||||||
decimal_re = re.compile(r"\d+\.\d+")
|
decimal_re = re.compile(r"\d+\.\d+")
|
||||||
|
|
||||||
for section in sections:
|
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)
|
aligned.append(section)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
sub_matches = list(sub_pattern.finditer(section.raw_content))
|
sub_matches = list(sub_pattern.finditer(section.raw_content))
|
||||||
if not sub_matches:
|
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
|
continue
|
||||||
|
|
||||||
mdx_id = section.section_id.split("-")[0] # e.g., "04"
|
mdx_id = section.section_id.split("-")[0] # e.g., "04"
|
||||||
@@ -530,7 +585,7 @@ def lookup_v4_match_with_fallback(
|
|||||||
section_id: str,
|
section_id: str,
|
||||||
*,
|
*,
|
||||||
raw_content: Optional[str] = None,
|
raw_content: Optional[str] = None,
|
||||||
max_rank: int = 3,
|
max_rank: Optional[int] = None,
|
||||||
alias_keys: Optional[list] = None,
|
alias_keys: Optional[list] = None,
|
||||||
) -> tuple[Optional[V4Match], dict]:
|
) -> tuple[Optional[V4Match], dict]:
|
||||||
"""Select V4 rank-1, or promote rank-2/3 when rank-1 is not auto-renderable.
|
"""Select V4 rank-1, or promote rank-2/3 when rank-1 is not auto-renderable.
|
||||||
@@ -538,6 +593,13 @@ def lookup_v4_match_with_fallback(
|
|||||||
This is an IMP-05 selector only. It uses existing V4 labels, frame-contract
|
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.
|
presence, and the Phase Z capacity precheck; it does not call calculate_fit.
|
||||||
"""
|
"""
|
||||||
|
# 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)
|
resolved = _resolve_v4_section_key(v4, section_id, alias_keys=alias_keys)
|
||||||
sec = v4.get("mdx_sections", {}).get(resolved) if resolved else None
|
sec = v4.get("mdx_sections", {}).get(resolved) if resolved else None
|
||||||
trace = {
|
trace = {
|
||||||
@@ -556,7 +618,20 @@ def lookup_v4_match_with_fallback(
|
|||||||
trace["fallback_reason"] = "no_v4_section"
|
trace["fallback_reason"] = "no_v4_section"
|
||||||
return None, trace
|
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:
|
if not judgments:
|
||||||
trace["fallback_reason"] = "empty_v4_judgments"
|
trace["fallback_reason"] = "empty_v4_judgments"
|
||||||
return None, trace
|
return None, trace
|
||||||
@@ -822,14 +897,19 @@ def build_layout_css(layout_preset: str, zones_data: list[dict],
|
|||||||
# ── Step D-ext : user override 처리 ──
|
# ── Step D-ext : user override 처리 ──
|
||||||
if override_zone_geometries:
|
if override_zone_geometries:
|
||||||
if layout_preset == "horizontal-2":
|
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 = []
|
ratios = []
|
||||||
for pos in positions:
|
for pos in positions:
|
||||||
geom = override_zone_geometries.get(pos)
|
geom = override_zone_geometries.get(pos)
|
||||||
ratios.append(float(geom["h"]) if geom else 0.0)
|
ratios.append(float(geom["h"]) if geom else 0.0)
|
||||||
total = sum(ratios)
|
total = sum(ratios)
|
||||||
if total > 0:
|
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)
|
rows = " ".join(f"{h}px" for h in heights_px)
|
||||||
return {
|
return {
|
||||||
"areas": preset["css_areas"],
|
"areas": preset["css_areas"],
|
||||||
@@ -1390,7 +1470,13 @@ def render_slide(slide_title: str, slide_footer: Optional[str],
|
|||||||
zone["partial_html"] = ""
|
zone["partial_html"] = ""
|
||||||
continue
|
continue
|
||||||
partial = env.get_template(f"families/{zone['template_id']}.html")
|
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")
|
base = env.get_template("slide_base.html")
|
||||||
return base.render(
|
return base.render(
|
||||||
@@ -2076,8 +2162,21 @@ def run_phase_z2_mvp1(
|
|||||||
# 2. Load V4
|
# 2. Load V4
|
||||||
v4 = load_v4_result()
|
v4 = load_v4_result()
|
||||||
|
|
||||||
# 3. Align sections to V4 granularity (### drill if needed)
|
# 3. Align sections to V4 granularity (### drill if needed).
|
||||||
sections = align_sections_to_v4_granularity(sections, v4)
|
# 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]})")
|
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) ───
|
# ─── Step 5: V4 매칭 evidence (non-reject max-6 후보 list — 사용자 lock 2026-05-08) ───
|
||||||
@@ -2131,11 +2230,12 @@ def run_phase_z2_mvp1(
|
|||||||
v4_fallback_traces: dict[str, dict] = {}
|
v4_fallback_traces: dict[str, dict] = {}
|
||||||
|
|
||||||
def lookup_fn(sid: str) -> Optional[V4Match]:
|
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(
|
match, trace = lookup_v4_match_with_fallback(
|
||||||
v4,
|
v4,
|
||||||
sid,
|
sid,
|
||||||
raw_content=section_content_by_id.get(sid),
|
raw_content=section_content_by_id.get(sid),
|
||||||
max_rank=3,
|
max_rank=None,
|
||||||
alias_keys=section_alias_by_id.get(sid),
|
alias_keys=section_alias_by_id.get(sid),
|
||||||
)
|
)
|
||||||
v4_fallback_traces[sid] = trace
|
v4_fallback_traces[sid] = trace
|
||||||
@@ -2643,6 +2743,10 @@ def run_phase_z2_mvp1(
|
|||||||
"slot_payload": slot_payload,
|
"slot_payload": slot_payload,
|
||||||
"content_weight": content_weight,
|
"content_weight": content_weight,
|
||||||
"min_height_px": min_height_px,
|
"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,
|
"assignment_source": plan_assignment_source,
|
||||||
"section_assignment_override": plan_section_override,
|
"section_assignment_override": plan_section_override,
|
||||||
})
|
})
|
||||||
@@ -2671,7 +2775,7 @@ def run_phase_z2_mvp1(
|
|||||||
"min_height_px": min_height_px,
|
"min_height_px": min_height_px,
|
||||||
"slot_payload_keys": sorted(slot_payload.keys()),
|
"slot_payload_keys": sorted(slot_payload.keys()),
|
||||||
"content_truncated_count": truncated_count, # None / N (builder 가 N 개 자름)
|
"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,
|
"content_weight": content_weight,
|
||||||
# trace-only runtime 연결 v0 — B1 → B2 → B4 chain 결과 (render 미영향).
|
# trace-only runtime 연결 v0 — B1 → B2 → B4 chain 결과 (render 미영향).
|
||||||
"placement_trace": placement_trace,
|
"placement_trace": placement_trace,
|
||||||
@@ -2871,7 +2975,7 @@ def run_phase_z2_mvp1(
|
|||||||
# reporting only. Runtime selection goes through _resolve_v4_section_key
|
# reporting only. Runtime selection goes through _resolve_v4_section_key
|
||||||
# (4 sites). Direct dict lookup here is intentional — debug_zones carries
|
# (4 sites). Direct dict lookup here is intentional — debug_zones carries
|
||||||
# dict-shape entries without v4_alias_keys plumbing, and a miss here only
|
# dict-shape entries without v4_alias_keys plumbing, and a miss here only
|
||||||
# yields a "V4 entry 없음" report line (runtime impact zero).
|
# yields a "V4 entry missing" report line (runtime impact zero).
|
||||||
try:
|
try:
|
||||||
with open(V4_RESULT_PATH, encoding="utf-8") as _vf:
|
with open(V4_RESULT_PATH, encoding="utf-8") as _vf:
|
||||||
_v4_full = yaml.safe_load(_vf)
|
_v4_full = yaml.safe_load(_vf)
|
||||||
|
|||||||
@@ -111,7 +111,9 @@ def test_mdx_section_default_construction_preserves_4_positional_callers():
|
|||||||
|
|
||||||
|
|
||||||
def test_align_passthrough_when_v4_key_exact_match():
|
def test_align_passthrough_when_v4_key_exact_match():
|
||||||
# Section already aligned to V4 key — aligner keeps it untouched.
|
# 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")]
|
sections = [_section("04-1", 1, "1. Top", "body")]
|
||||||
v4 = {"mdx_sections": {"04-1": {"judgments_full32": []}}}
|
v4 = {"mdx_sections": {"04-1": {"judgments_full32": []}}}
|
||||||
out = align_sections_to_v4_granularity(sections, v4)
|
out = align_sections_to_v4_granularity(sections, v4)
|
||||||
@@ -119,6 +121,63 @@ def test_align_passthrough_when_v4_key_exact_match():
|
|||||||
assert out[0].section_id == "04-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():
|
def test_align_drill_emits_canonical_ordinal_id_with_decimal_alias():
|
||||||
# Decimal H3 headings -> canonical ordinal id + decimal alias (legacy V4 key).
|
# Decimal H3 headings -> canonical ordinal id + decimal alias (legacy V4 key).
|
||||||
raw = "### 2.1 First\nbody1\n### 2.2 Second\nbody2\n"
|
raw = "### 2.1 First\nbody1\n### 2.2 Second\nbody2\n"
|
||||||
|
|||||||
Reference in New Issue
Block a user