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:
323
src/phase_z2_content_extractor.py
Normal file
323
src/phase_z2_content_extractor.py
Normal 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()
|
||||
366
src/phase_z2_internal_region_planner.py
Normal file
366
src/phase_z2_internal_region_planner.py
Normal 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()
|
||||
Reference in New Issue
Block a user