Add Phase Z Layer A planning scaffold

- add Internal Region model to Phase Z architecture docs and specs
- add frame contract content type and Frame Slot declarations
- add dormant content object extractor and internal region planner
This commit is contained in:
2026-05-04 08:21:50 +09:00
parent e7848b602d
commit 2ec8fc5a77
7 changed files with 2604 additions and 0 deletions

View File

@@ -0,0 +1,323 @@
"""Phase Z-2 Content Object extractor (B1 v0 — dormant module).
SPEC v1 §1 의 typed content_object schema 만족하는 dedicated extractor.
v0 minimal :
- 지원 type : text_block, transform_table 2 개 만 (table / image / diagram / details 제외)
- role : 모두 "summary" (v0 default — role 정밀화는 별 axis)
- dormant — runtime path 미연결 (pipeline / composition / mapper 미터치)
- mapper 미수정, 기존 helper move / promote / copy 없음
- transform_table 은 *arrow column 보존* 위해 B1 *local helper* 로 구현
(regex / parsing 일부가 mapper helper 와 유사 — 단 mapper helper 는 arrow 폐기.
향후 helper promote / 통합 refactor 는 별 axis)
v0 흐름 :
section.raw_content
→ 3-column markdown table 감지 (arrow glyph 포함) → transform_table
→ 나머지 content → text_block (format / bullet_count / has_emphasis 분석)
→ list[ContentObject]
검증 :
- dormancy : MDX 03 final.html SHA = canonical 유지 (runtime path 미연결)
- correctness : __main__ self-test (text_block 1 case + transform_table 1 case)
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
# B1 v0 helper 처리 정직 기록 (기존 보고 정정 — 2026-04-30) :
# - `phase_z2_mapper` 미수정. 기존 mapper helper (`_extract_markdown_table` 등) move /
# promote / copy 없음.
# - 단 SPEC v1 §1.2 transform_table.rows = [{from, arrow, to}] schema 가
# mapper 의 helper 출력 (from/to 만, arrow 폐기) 와 호환 안 됨.
# - 따라서 *arrow column 보존* 이 필요한 transform_table 추출 부분은 본 module 의
# *layer-agnostic local helper* (`_capture_3col_transform_table`) 로 *별도 구현*.
# - mapper helper 와 regex / parsing 일부 유사 — 향후 *promote / 통합 refactor* 는
# 별 axis (B1 안정 후 layer-agnostic helper module 통합 검토 가능).
# ─── ContentObject schema (SPEC v1 §1.1) ────────────────────────
@dataclass
class ContentObject:
"""SPEC v1 §1.1 base schema. v0 = text_block + transform_table 만 지원.
Fields :
id : section 내 unique id (예: '03-2.transform-1' / '03-2.text-1')
type : "text_block" | "transform_table"
role : v0 = "summary" 만 (정밀화는 별 axis)
raw_payload : 원본 markdown (자름 / 변형 X — 원문 보존 룰)
size_estimate : type 별 (line_count / rows 등)
type_specific : type 별 detail (SPEC v1 §1.2)
"""
id: str
type: str
role: str
raw_payload: str
size_estimate: dict = field(default_factory=dict)
type_specific: dict = field(default_factory=dict)
# ─── Transform table extraction ─────────────────────────────────
_ARROW_GLYPHS = ("", "", "", "->", "=>")
_TABLE_PATTERN = re.compile(
r"(^[ \t]*\|[^\n]+\|\n[ \t]*\|[\s\-:|]+\|\n(?:[ \t]*\|[^\n]+\|\n?)+)",
re.MULTILINE,
)
def _capture_3col_transform_table(content: str) -> tuple[dict | None, str]:
"""3-column markdown table 에서 (from / arrow / to) 캡처 → transform_table.
본 함수 = B1 v0 의 *layer-agnostic extractor helper*. mapper 의
`_extract_markdown_table` 와 regex / parsing 의 일부가 유사하나, mapper helper 는
arrow column 을 폐기 (from/to 만 추출) — SPEC v1 §1.2 의
`transform_table.rows = [{from, arrow, to}]` schema 를 직접 만족 못 함.
따라서 arrow column 보존 필요해 본 module 안에 *별도 구현*. mapper 미수정 유지.
*향후 helper promote / 통합 refactor 는 별 axis* — B1 안정 후 mapper 와
*layer-agnostic helper module* 통합 검토 가능.
arrow column 에 arrow glyph 가 있어야 transform 으로 인정.
Returns :
({"type_specific": ..., "raw_payload": <table markdown>}, content_without_table)
또는 (None, original_content) — transform 패턴 미감지 시
"""
m = _TABLE_PATTERN.search(content)
if not m:
return None, content
raw_lines = [r.strip() for r in m.group(1).strip().splitlines() if r.strip()]
if len(raw_lines) < 3: # header + separator + ≥1 data row
return None, content
data_rows = raw_lines[2:] # skip header + separator
pairs: list[dict] = []
arrow_glyph = ""
for r in data_rows:
cells = [c.strip() for c in r.strip("|").split("|")]
if len(cells) < 3:
continue
f = re.sub(r"\*\*(.+?)\*\*", r"\1", cells[0])
a = re.sub(r"\*\*(.+?)\*\*", r"\1", cells[1])
t = re.sub(r"\*\*(.+?)\*\*", r"\1", cells[2])
if not arrow_glyph:
for g in _ARROW_GLYPHS:
if g in a:
arrow_glyph = g
break
pairs.append({"from": f, "arrow": a, "to": t})
if not pairs:
return None, content
# transform 인지 검증 — arrow glyph 가 *어느 row 든* 등장해야
has_arrow = any(any(g in p["arrow"] for g in _ARROW_GLYPHS) for p in pairs)
if not has_arrow:
return None, content
type_specific = {
"pair_count": len(pairs),
"arrow_glyph": arrow_glyph,
"rows": pairs,
}
raw_table = m.group(1)
remaining = content[: m.start()] + content[m.end() :]
return ({"type_specific": type_specific, "raw_payload": raw_table}, remaining)
# ─── Text block extraction ──────────────────────────────────────
def _detect_text_block_specific(content: str) -> tuple[dict, int]:
"""text_block 의 type_specific + line_count 추출.
format 결정 :
- top bullet 0 → paragraph
- top bullet 있음, nested 0 → bullet_list
- top bullet + nested → nested_list
Returns :
(type_specific dict, line_count)
"""
lines = content.splitlines()
top_bullets = sum(1 for l in lines if re.match(r"^[\*\-]\s", l))
nested_bullets = sum(1 for l in lines if re.match(r"^\s+[\*\-]\s", l))
# max_indent_level (2-space indent 단위)
max_indent = 0
for l in lines:
mm = re.match(r"^( *)[\*\-]\s", l)
if mm:
level = len(mm.group(1)) // 2
max_indent = max(max_indent, level)
if top_bullets == 0:
fmt = "paragraph"
elif nested_bullets > 0:
fmt = "nested_list"
else:
fmt = "bullet_list"
has_emphasis = bool(
re.search(r"\*\*[^*\n]+\*\*", content)
or re.search(r"(?<!\*)\*[^*\n]+\*(?!\*)", content)
)
line_count = sum(1 for l in lines if l.strip())
type_specific = {
"format": fmt,
"bullet_count": top_bullets,
"max_indent_level": max_indent,
"has_emphasis": has_emphasis,
}
return type_specific, line_count
# ─── Public entry ───────────────────────────────────────────────
def extract_content_objects(section) -> list[ContentObject]:
"""MDX section.raw_content → typed content_object list (SPEC v1 §1).
v0 minimal :
- 1 section → 1~2 ContentObject (transform_table + text_block 또는 text_block 만)
- role = "summary" (모두 — v0 default)
- 미지원 type (table / image / diagram / details) = 무시 (별 axis)
- 원문 (raw_payload) = 자름 / 변형 X (원문 보존 룰)
Args :
section : MdxSection-like 객체 (section_id, raw_content 필드 필요)
Returns :
list[ContentObject] — 0 ~ 2 개 (content 비어 있으면 0, transform-only 면 1, mixed 면 2)
"""
content = section.raw_content
section_id = section.section_id
objects: list[ContentObject] = []
# 1. transform_table 추출 시도 (3-col with arrow)
transform_result, remaining = _capture_3col_transform_table(content)
if transform_result is not None:
objects.append(
ContentObject(
id=f"{section_id}.transform-1",
type="transform_table",
role="summary",
raw_payload=transform_result["raw_payload"],
size_estimate={"rows": transform_result["type_specific"]["pair_count"]},
type_specific=transform_result["type_specific"],
)
)
# 2. text_block 추출 (transform 추출 후 남은 content, 또는 transform 없으면 전체)
text_remainder = remaining if transform_result is not None else content
if text_remainder.strip():
text_specific, line_count = _detect_text_block_specific(text_remainder)
objects.append(
ContentObject(
id=f"{section_id}.text-1",
type="text_block",
role="summary",
raw_payload=text_remainder.strip(),
size_estimate={"line_count": line_count},
type_specific=text_specific,
)
)
return objects
# ─── Self-test (B1 v0 correctness 검증) ─────────────────────────
def _run_self_test():
"""v0 unit test : text_block 1 case + transform_table 1 case.
scope-lock 의 검증 (b) correctness — 추출기 정확성 확인.
fixed input 기반, MDX 01/02/04 미사용.
"""
class MockSection:
def __init__(self, section_id: str, raw_content: str):
self.section_id = section_id
self.raw_content = raw_content
# ─── Test 1 : text_block (nested_list 형태, F13 style) ───────
text_section = MockSection(
"test-1",
"* **기술 부족**\n"
" * 디지털 도구 미숙\n"
" * BIM 활용 제한\n"
"* **인력 부족**\n"
" * 전문가 부재\n"
"* **자연 환경**\n"
" * 지역적 제약\n",
)
objs1 = extract_content_objects(text_section)
assert len(objs1) == 1, f"text-only section → 1 obj 기대, got {len(objs1)}"
o = objs1[0]
assert o.type == "text_block", f"type=text_block 기대, got {o.type}"
assert o.role == "summary"
assert o.id == "test-1.text-1"
assert o.type_specific["format"] == "nested_list", f"format=nested_list 기대, got {o.type_specific['format']}"
assert o.type_specific["bullet_count"] == 3, f"top bullet=3 기대, got {o.type_specific['bullet_count']}"
assert o.type_specific["max_indent_level"] >= 1, "nested 가 있으니 max_indent ≥ 1"
assert o.type_specific["has_emphasis"] is True, "**bold** 존재 → has_emphasis=True"
assert o.size_estimate["line_count"] >= 6
assert "기술 부족" in o.raw_payload, "원문 보존 — '기술 부족' 잔존 필요"
print("[OK] Test 1 (text_block) passed.")
# ─── Test 2 : transform_table (3-col, arrow 포함) + 잔여 text ─
transform_section = MockSection(
"test-2",
"**프로세스 변환**\n"
"\n"
"| AS-IS | ➜ | TO-BE |\n"
"|---|---|---|\n"
"| 도면 중심 | ➜ | BIM 모델 중심 |\n"
"| 단계별 분리 | ➜ | 통합 협업 |\n"
"| 사후 검토 | ➜ | 실시간 검증 |\n"
"\n"
"추가 설명 : 위 변환이 핵심.\n",
)
objs2 = extract_content_objects(transform_section)
assert len(objs2) == 2, f"transform+text → 2 obj 기대, got {len(objs2)}"
# transform_table 검증
t = objs2[0]
assert t.type == "transform_table", f"첫 obj=transform_table 기대, got {t.type}"
assert t.role == "summary"
assert t.id == "test-2.transform-1"
assert t.type_specific["pair_count"] == 3, f"pair_count=3 기대, got {t.type_specific['pair_count']}"
assert t.type_specific["arrow_glyph"] == "", f"arrow_glyph=➜ 기대, got {t.type_specific['arrow_glyph']}"
assert len(t.type_specific["rows"]) == 3
assert t.type_specific["rows"][0]["from"] == "도면 중심"
assert t.type_specific["rows"][0]["to"] == "BIM 모델 중심"
assert t.size_estimate["rows"] == 3
assert "도면 중심" in t.raw_payload, "raw_payload 에 원본 table 보존"
# text_block 검증 (transform 제거 후 남은 content)
tb = objs2[1]
assert tb.type == "text_block", f"두번째 obj=text_block 기대, got {tb.type}"
assert tb.id == "test-2.text-1"
assert "프로세스 변환" in tb.raw_payload, "transform 제거 후 surrounding text 보존 — '프로세스 변환'"
assert "추가 설명" in tb.raw_payload, "transform 뒤 잔여 text 보존 — '추가 설명'"
print("[OK] Test 2 (transform_table + text_block) passed.")
print("\n=== B1 v0 self-test PASS ===")
if __name__ == "__main__":
_run_self_test()

View File

@@ -0,0 +1,366 @@
"""Phase Z-2 Internal Region planner (B2 v0 — dormant module).
SPEC v1 §2 의 Layer A planner — ContentObject[] → InternalRegion[] + region_layout.
v0 minimal :
- 지원 case : text_block only / text_block + transform_table 2 가지
- 3-way decision : whole + split (group merge 미지원 — 별 axis)
- topology vocabulary 출력 : `region-single` + `region-vertical-stack` 2 entry 만
- SPEC v1 §2.5 algorithm = rule 1 + rule 6 만 구현 (rules 2~5 명시 deferred)
- role 할당 : type 기반 (text_block → primary / transform_table → supporting)
- split 결정 : distinct content type 기준 (같은 type 만 → single region)
- frame_match_strategy : kind="frame_match" / frame_id=None (Step 9 / B4 책임)
- dormant — runtime path 미연결 (pipeline / composition / mapper 미터치)
책임 boundary :
- B2 = region 생성 (split / role / ratio / topology)
- Step 9 / B4 = frame compatibility / frame selection / display strategy
- accepted_content_types 기반 compatibility 판단은 B2 책임 *아님*
frame_contracts 인자 :
- signature 에 둠 (future hook). v0 에서는 *output 결정에 사용 X*.
- 향후 display_only 활성화 / B4 통합 시 hook 자리.
검증 :
- dormancy : MDX 03 final.html SHA = canonical 유지 (runtime path 미연결)
- correctness : __main__ self-test (text-only 1 case + text+transform 1 case)
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Optional
# B2 v0 input contract = B1 의 ContentObject (phase_z2_content_extractor).
# 두 module 모두 dormant — runtime path 와 무관한 *layer-agnostic 의존*.
from phase_z2_content_extractor import ContentObject
# ─── Constants (B2 v0 lock) ──────────────────────────────────────
# transform_table 의 size proxy 환산 계수 (SPEC v1 §2.4 size proxy).
# pair 1 개 = 1.5 line 등가 (heuristic, content 기반 ratio 산정용).
# 정밀화는 향후 axis (visual_hints / content density signal).
_PAIR_HEIGHT_FACTOR = 1.5
# ─── Output schema (SPEC v1 §2.1 + §2.5) ─────────────────────────
@dataclass
class InternalRegion:
"""SPEC v1 §2.1 Internal Region entity schema.
Fields :
region_id : zone 내 unique id (예: '{section_id}.region-1')
role : 'primary' | 'supporting' | (B2 v0 = 두 개만 사용)
content_type : 'text_block' | 'transform_table' (v0 supported)
ratio_estimate : zone 내 비율 (sum normalize = 1.0)
content_unit_ids : 본 region 에 묶인 content_object id list
frame_match_strategy : {kind, frame_id, display_strategy}
— B2 v0 에서 kind="frame_match" / frame_id=None /
display_strategy="inline_full" 고정.
실제 frame 결정은 Step 9 / B4 책임.
"""
region_id: str
role: str
content_type: str
ratio_estimate: float
content_unit_ids: list[str]
frame_match_strategy: dict
@dataclass
class RegionLayout:
"""SPEC v1 §2.5 region_layout — zone 내 region 들의 *공간 배치 패턴*.
B2 v0 가 출력하는 vocabulary :
- 'region-single' (rule 1 — region_count == 1)
- 'region-vertical-stack' (rule 6 fallback — 그 외)
rules 2~5 (region-preview-details / region-grid-2x2 / region-main-support /
region-horizontal-split) 는 SPEC 정의 있으나 *B2 v0 deferred*.
"""
region_layout_type: str # 'region-single' | 'region-vertical-stack'
region_order: list[str] # region_id 의 배치 순서
region_placement: str # 'single' | 'vertical'
@dataclass
class ZoneRegionPlan:
"""B2 v0 의 출력 — 1 zone 의 region 분할 결과 + layout.
Fields :
internal_regions : list[InternalRegion]
region_layout : RegionLayout
"""
internal_regions: list[InternalRegion] = field(default_factory=list)
region_layout: Optional[RegionLayout] = None
# ─── Helpers ─────────────────────────────────────────────────────
# B2 v0 지원 type 별 (role, size proxy 추출 함수).
# - text_block : role=primary, size = size_estimate.line_count
# - transform_table : role=supporting, size = size_estimate.rows × _PAIR_HEIGHT_FACTOR
_TYPE_ROLE: dict[str, str] = {
"text_block": "primary",
"transform_table": "supporting",
}
def _size_proxy(obj: ContentObject) -> float:
"""Content object 의 *공간 크기 proxy* (SPEC v1 §2.4).
text_block : line_count
transform_table : rows × _PAIR_HEIGHT_FACTOR
그 외 : 0 (B2 v0 미지원 type)
"""
if obj.type == "text_block":
return float(obj.size_estimate.get("line_count", 0))
if obj.type == "transform_table":
return float(obj.size_estimate.get("rows", 0)) * _PAIR_HEIGHT_FACTOR
return 0.0
def _group_by_type_preserving_order(
content_objects: list[ContentObject],
) -> dict[str, list[ContentObject]]:
"""content_objects 를 type 별로 grouping. 등장 순서 보존 (dict 의 ordered 특성)."""
groups: dict[str, list[ContentObject]] = {}
for obj in content_objects:
groups.setdefault(obj.type, []).append(obj)
return groups
# region_order 결정 시 type 우선순위 — primary 먼저, supporting 다음.
# B2 v0 type 만 등록. 향후 axis 에서 secondary / reference 추가 가능.
_TYPE_ORDER_PRIORITY: dict[str, int] = {
"text_block": 0, # primary
"transform_table": 1, # supporting
}
# ─── Public entry ────────────────────────────────────────────────
def plan_internal_regions(
content_objects: list[ContentObject],
frame_contracts: Optional[list[dict[str, Any]]] = None, # v0 unused, future hook
section_id: str = "",
) -> ZoneRegionPlan:
"""ContentObject[] → ZoneRegionPlan (region 분할 + topology + ratio + role).
B2 v0 algorithm :
1. content_objects 를 type 별로 grouping (등장 순서 보존)
2. distinct type 수 → region_count 결정 (split 결정)
3. region 별 :
- role = type 기반 (_TYPE_ROLE)
- ratio_estimate = type 내 size proxy 합 / 전체 합 (normalize=1.0)
- frame_match_strategy = {kind: 'frame_match', frame_id: None,
display_strategy: 'inline_full'} (Step 9 / B4 영역)
4. topology vocabulary 결정 — SPEC v1 §2.5 :
- rule 1 : region_count == 1 → region-single
- rules 2~5 : *deferred* (SPEC 정의만, B2 v0 미구현)
- rule 6 fallback : 그 외 → region-vertical-stack
5. region_order = type priority (primary → supporting) 순.
Args :
content_objects : list[ContentObject] — B1 v0 extractor 출력
frame_contracts : v0 unused (future hook). signature 에 두되 output 결정에 미사용.
section_id : region_id 생성용 prefix
Returns :
ZoneRegionPlan (1 zone 의 plan, singular).
Note :
- frame_contracts 무시 — 본 v0 는 *frame compatibility 판단 안 함*.
compatibility 판단은 Step 9 / B4 책임.
- empty content_objects → empty plan (region_layout=None) — caller 가 사전 차단 권장.
"""
if not content_objects:
return ZoneRegionPlan()
# 1. type 별 grouping
groups = _group_by_type_preserving_order(content_objects)
# 2. region 별 size proxy 합 + 전체 합
type_sizes: dict[str, float] = {}
for ctype, objs in groups.items():
type_sizes[ctype] = sum(_size_proxy(o) for o in objs)
total_size = sum(type_sizes.values())
if total_size <= 0:
# 모든 size proxy = 0 인 edge case (예: 빈 content) → equal split fallback
equal_share = 1.0 / max(len(groups), 1)
for ctype in groups:
type_sizes[ctype] = equal_share
total_size = sum(type_sizes.values()) or 1.0
# 3. region 생성 (type 우선순위 순으로)
sorted_types = sorted(
groups.keys(),
key=lambda t: _TYPE_ORDER_PRIORITY.get(t, 99),
)
regions: list[InternalRegion] = []
for idx, ctype in enumerate(sorted_types, start=1):
objs = groups[ctype]
ratio = type_sizes[ctype] / total_size
regions.append(
InternalRegion(
region_id=f"{section_id}.region-{idx}",
role=_TYPE_ROLE.get(ctype, "primary"), # 미지원 type fallback = primary
content_type=ctype,
ratio_estimate=round(ratio, 4),
content_unit_ids=[o.id for o in objs],
frame_match_strategy={
"kind": "frame_match",
"frame_id": None, # Step 9 / B4 영역
"display_strategy": "inline_full", # v0 default
},
)
)
# 4. topology vocabulary 결정 (SPEC v1 §2.5 algorithm — rule 1 + rule 6 만)
region_count = len(regions)
if region_count == 1:
# rule 1
layout_type = "region-single"
placement = "single"
else:
# rules 2~5 = B2 v0 deferred (SPEC 정의만, 미구현) :
# - rule 2 region-preview-details : details_presence path 미구현
# - rule 3 region-grid-2x2 : 4 region 미지원
# - rule 4 region-main-support : role asymmetric trigger 미구현
# - rule 5 region-horizontal-split : visual element type 미지원
# rule 6 fallback
layout_type = "region-vertical-stack"
placement = "vertical"
# 5. region_order = 위 sorted_types 순 (primary → supporting)
region_order = [r.region_id for r in regions]
return ZoneRegionPlan(
internal_regions=regions,
region_layout=RegionLayout(
region_layout_type=layout_type,
region_order=region_order,
region_placement=placement,
),
)
# ─── Self-test (B2 v0 correctness 검증) ─────────────────────────
def _run_self_test():
"""v0 unit test : text-only 1 case + text+transform 1 case.
scope-lock 의 검증 (b) correctness — planner 정확성 확인.
fixed input 기반, MDX 01/02/04 미사용.
"""
# ─── Test 1 : text-only (1 ContentObject) ────────────────────
text_obj = ContentObject(
id="test-1.text-1",
type="text_block",
role="summary",
raw_payload="* 본문\n * nested",
size_estimate={"line_count": 6},
type_specific={"format": "nested_list", "bullet_count": 1, "max_indent_level": 1, "has_emphasis": False},
)
plan1 = plan_internal_regions([text_obj], section_id="test-1")
assert plan1.region_layout is not None
assert plan1.region_layout.region_layout_type == "region-single", \
f"text-only → region-single 기대, got {plan1.region_layout.region_layout_type}"
assert plan1.region_layout.region_placement == "single"
assert len(plan1.internal_regions) == 1, f"1 region 기대, got {len(plan1.internal_regions)}"
r = plan1.internal_regions[0]
assert r.region_id == "test-1.region-1"
assert r.role == "primary", f"text-only role=primary 기대, got {r.role}"
assert r.content_type == "text_block"
assert r.ratio_estimate == 1.0, f"단일 region ratio=1.0 기대, got {r.ratio_estimate}"
assert r.content_unit_ids == ["test-1.text-1"]
assert r.frame_match_strategy["kind"] == "frame_match"
assert r.frame_match_strategy["frame_id"] is None, "B2 v0 frame_id=None lock"
assert r.frame_match_strategy["display_strategy"] == "inline_full"
assert plan1.region_layout.region_order == ["test-1.region-1"]
print("[OK] Test 1 (text-only) passed.")
# ─── Test 2 : text + transform_table (2 ContentObject) ────────
text_obj2 = ContentObject(
id="test-2.text-1",
type="text_block",
role="summary",
raw_payload="* 본문",
size_estimate={"line_count": 6},
type_specific={"format": "bullet_list", "bullet_count": 1, "max_indent_level": 0, "has_emphasis": False},
)
transform_obj = ContentObject(
id="test-2.transform-1",
type="transform_table",
role="summary",
raw_payload="| AS-IS | ➜ | TO-BE |\n|---|---|---|\n| a | ➜ | b |\n| c | ➜ | d |",
size_estimate={"rows": 2},
type_specific={"pair_count": 2, "arrow_glyph": "", "rows": [
{"from": "a", "arrow": "", "to": "b"},
{"from": "c", "arrow": "", "to": "d"},
]},
)
plan2 = plan_internal_regions([text_obj2, transform_obj], section_id="test-2")
assert plan2.region_layout is not None
assert plan2.region_layout.region_layout_type == "region-vertical-stack", \
f"text+transform → region-vertical-stack (rule 6 fallback) 기대, got {plan2.region_layout.region_layout_type}"
assert plan2.region_layout.region_placement == "vertical"
assert len(plan2.internal_regions) == 2, f"2 region 기대, got {len(plan2.internal_regions)}"
# region_order = primary first (text), supporting second (transform)
assert plan2.region_layout.region_order == ["test-2.region-1", "test-2.region-2"]
# text region (region-1, primary)
text_r = plan2.internal_regions[0]
assert text_r.region_id == "test-2.region-1"
assert text_r.role == "primary"
assert text_r.content_type == "text_block"
# ratio : 6 / (6 + 2*1.5) = 6/9 ≈ 0.667
expected_text_ratio = 6.0 / (6.0 + 2.0 * 1.5)
assert abs(text_r.ratio_estimate - expected_text_ratio) < 0.001, \
f"text ratio {expected_text_ratio:.4f} 기대, got {text_r.ratio_estimate}"
assert text_r.content_unit_ids == ["test-2.text-1"]
assert text_r.frame_match_strategy["kind"] == "frame_match"
assert text_r.frame_match_strategy["frame_id"] is None
# transform region (region-2, supporting)
tr_r = plan2.internal_regions[1]
assert tr_r.region_id == "test-2.region-2"
assert tr_r.role == "supporting", f"transform_table role=supporting 기대, got {tr_r.role}"
assert tr_r.content_type == "transform_table"
expected_tr_ratio = (2.0 * 1.5) / (6.0 + 2.0 * 1.5)
assert abs(tr_r.ratio_estimate - expected_tr_ratio) < 0.001, \
f"transform ratio {expected_tr_ratio:.4f} 기대, got {tr_r.ratio_estimate}"
assert tr_r.content_unit_ids == ["test-2.transform-1"]
assert tr_r.frame_match_strategy["frame_id"] is None
# ratio sum normalize = 1.0
ratio_sum = text_r.ratio_estimate + tr_r.ratio_estimate
assert abs(ratio_sum - 1.0) < 0.01, f"ratio sum=1.0 기대, got {ratio_sum}"
# frame_contracts 인자 unused 검증 — None 으로 호출 / dict 으로 호출 결과 동일해야 함
plan2_with_contracts = plan_internal_regions(
[text_obj2, transform_obj],
frame_contracts=[{"template_id": "dummy", "accepted_content_types": ["text_block"]}],
section_id="test-2",
)
assert plan2_with_contracts.region_layout.region_layout_type == plan2.region_layout.region_layout_type
assert len(plan2_with_contracts.internal_regions) == len(plan2.internal_regions)
print("[OK] Test 2 (text+transform, vertical-stack, ratio 6:3) passed.")
print("\n=== B2 v0 self-test PASS ===")
if __name__ == "__main__":
_run_self_test()