wip: phase_z2 evidence 파이프라인 + matching 실험(phase2~26) + 프론트 trace 패널 진행분 스냅샷
- src: phase_z2 composition/mapper/pipeline/placement_planner/retry, ai_fallback(prompts/schema/validate), mdx_text_atoms 신규 - Front: PipelineTracePanel 신규, FramePanel/SlideCanvas/Home/designAgentApi 등 갱신 + 테스트 4종 추가 - templates/phase_z2: catalog(component_expansion_registry, node_slot_mapping 신규), frames, families, slide_base 갱신 - tests/matching: phase2~26 매칭 실험 스크립트·리포트·온톨로지 전체 (미커밋 진행분) - tests: b4_v4 evidence, task5~28.5 시리즈, regression(imp95 baseline) 등 신규 테스트 대량 추가 - docs/reference: MDX 구조 인벤토리, MDX→Frame 구조 계약 문서 - scripts: mdx 계약/parity/coverage/viewport 체크, gitea comment, run sync 유틸 - .gitignore: tmp*.json, chromedriver, .orchestrator, *.pkl, Front_test* 등 임시/스냅샷 제외 미완성 작업의 보존용 스냅샷 커밋 (2026-07-02) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
"""Extract content-preserving text atoms from MDX-like source.
|
||||
|
||||
The extractor is intentionally conservative: it ignores markdown/HTML/JSX
|
||||
syntax, style declarations, imports, and frontmatter, but preserves semantic
|
||||
text that a slide conversion pipeline must not add, drop, or rewrite.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import html
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
_FRONTMATTER_RE = re.compile(r"\A---\s*\n.*?\n---\s*\n", re.DOTALL)
|
||||
_TAG_RE = re.compile(r"<[^>]+>")
|
||||
_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\([^)]+\)")
|
||||
_SUMMARY_TAG_RE = re.compile(r"<summary\b[^>]*>(.*?)</summary>", re.IGNORECASE)
|
||||
_NOTE_RE = re.compile(r"^:::\s*(?:note|tip|info|warning|danger)(?:\[(.*?)\])?\s*$", re.IGNORECASE)
|
||||
_TABLE_SEPARATOR_RE = re.compile(r"^\s*\|?\s*:?-{2,}:?\s*(?:\|\s*:?-{2,}:?\s*)+\|?\s*$")
|
||||
_HEADING_RE = re.compile(r"^\s{0,3}#{1,6}\s+")
|
||||
_LIST_MARKER_RE = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s+")
|
||||
_EMPHASIS_RE = re.compile(r"[*_`~]+")
|
||||
_BR_RE = re.compile(r"<br\s*/?>", re.IGNORECASE)
|
||||
_STYLE_PROP_RE = re.compile(r"^\s*[A-Za-z][A-Za-z0-9_-]*\s*:\s*['\"#]?[A-Za-z0-9#().,% -]+['\"]?\s*,?\s*$")
|
||||
_JS_EVENT_RE = re.compile(r"^\s*(?:on[A-Z][A-Za-z]+|style|className)\s*=", re.ASCII)
|
||||
_IMPORT_EXPORT_RE = re.compile(r"^\s*(?:import|export)\s+")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TextAtom:
|
||||
source: str
|
||||
line_no: int
|
||||
kind: str
|
||||
text: str
|
||||
normalized: str
|
||||
digest: str
|
||||
|
||||
|
||||
def normalize_text_atom(text: str) -> str:
|
||||
"""Normalize syntax-only variation while preserving semantic content."""
|
||||
|
||||
text = html.unescape(text)
|
||||
text = text.replace("\u00a0", " ")
|
||||
text = text.replace("<br/>", " ").replace("<br />", " ").replace("<br>", " ")
|
||||
text = _TAG_RE.sub(" ", text)
|
||||
text = _EMPHASIS_RE.sub("", text)
|
||||
text = text.replace("<", "<").replace(">", ">").replace("&", "&")
|
||||
text = re.sub(r"\s+", " ", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def atom_digest(normalized: str) -> str:
|
||||
return hashlib.sha1(normalized.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def extract_text_atoms(path_or_text: str | Path, *, source_name: str | None = None) -> list[TextAtom]:
|
||||
if isinstance(path_or_text, Path):
|
||||
text = path_or_text.read_text(encoding="utf-8")
|
||||
source = source_name or path_or_text.name
|
||||
else:
|
||||
maybe_path = Path(path_or_text)
|
||||
if "\n" not in path_or_text and maybe_path.exists():
|
||||
text = maybe_path.read_text(encoding="utf-8")
|
||||
source = source_name or maybe_path.name
|
||||
else:
|
||||
text = path_or_text
|
||||
source = source_name or "<memory>"
|
||||
|
||||
text = _FRONTMATTER_RE.sub("", text)
|
||||
atoms: list[TextAtom] = []
|
||||
in_fenced_code = False
|
||||
|
||||
for line_no, raw_line in enumerate(text.splitlines(), start=1):
|
||||
line = raw_line.rstrip()
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
if stripped.startswith("```"):
|
||||
in_fenced_code = not in_fenced_code
|
||||
continue
|
||||
if in_fenced_code:
|
||||
continue
|
||||
if _IMPORT_EXPORT_RE.match(stripped):
|
||||
continue
|
||||
if stripped in {"---", "<br/>", "<br />", "<br>", "</div>", "</details>"}:
|
||||
continue
|
||||
if stripped in {"{", "}", "});", "}}", "}}>", ">" }:
|
||||
continue
|
||||
if stripped.startswith(("e.currentTarget.", "<div style", "<p style", "<ul style", "<li style", "<h3 style")):
|
||||
continue
|
||||
if _JS_EVENT_RE.match(stripped) or _STYLE_PROP_RE.match(stripped):
|
||||
continue
|
||||
if stripped.startswith(("<div", "<p", "<ul", "<li", "<h")) and stripped.endswith(">") and not _TAG_RE.sub("", stripped).strip():
|
||||
continue
|
||||
if stripped.startswith("</"):
|
||||
continue
|
||||
|
||||
for alt in _IMAGE_RE.findall(stripped):
|
||||
_append_atom(atoms, source, line_no, "image_alt", alt)
|
||||
stripped = _IMAGE_RE.sub("", stripped).strip()
|
||||
if not stripped:
|
||||
continue
|
||||
|
||||
summary_match = _SUMMARY_TAG_RE.search(stripped)
|
||||
if summary_match:
|
||||
_append_atom(atoms, source, line_no, "details_summary", summary_match.group(1))
|
||||
stripped = _SUMMARY_TAG_RE.sub("", stripped).strip()
|
||||
if not stripped:
|
||||
continue
|
||||
|
||||
note_match = _NOTE_RE.match(stripped)
|
||||
if note_match:
|
||||
if note_match.group(1):
|
||||
_append_atom(atoms, source, line_no, "admonition_title", note_match.group(1))
|
||||
continue
|
||||
if stripped.startswith(":::"):
|
||||
continue
|
||||
|
||||
if "|" in stripped and stripped.count("|") >= 2:
|
||||
if _TABLE_SEPARATOR_RE.match(stripped):
|
||||
continue
|
||||
cells = [normalize_text_atom(c) for c in stripped.strip("|").split("|")]
|
||||
for cell in cells:
|
||||
if cell:
|
||||
_append_atom(atoms, source, line_no, "table_cell", cell)
|
||||
continue
|
||||
|
||||
kind = "paragraph"
|
||||
if _HEADING_RE.match(stripped):
|
||||
kind = "heading"
|
||||
stripped = _HEADING_RE.sub("", stripped)
|
||||
elif _LIST_MARKER_RE.match(stripped):
|
||||
kind = "list_item"
|
||||
stripped = _LIST_MARKER_RE.sub("", stripped)
|
||||
|
||||
stripped = re.sub(r"^\s*}+\s*>?\s*", "", stripped)
|
||||
stripped = _BR_RE.sub(" ", stripped)
|
||||
stripped = _TAG_RE.sub(" ", stripped)
|
||||
_append_atom(atoms, source, line_no, kind, stripped)
|
||||
|
||||
return atoms
|
||||
|
||||
|
||||
def compare_atom_sets(original_atoms: Iterable[TextAtom], standardized_atoms: Iterable[TextAtom]) -> dict[str, list[TextAtom]]:
|
||||
original_by_digest = {a.digest: a for a in original_atoms}
|
||||
standardized_by_digest = {a.digest: a for a in standardized_atoms}
|
||||
missing = [a for digest, a in original_by_digest.items() if digest not in standardized_by_digest]
|
||||
added = [a for digest, a in standardized_by_digest.items() if digest not in original_by_digest]
|
||||
return {"missing_from_standardized": missing, "added_in_standardized": added}
|
||||
|
||||
|
||||
def _append_atom(atoms: list[TextAtom], source: str, line_no: int, kind: str, text: str) -> None:
|
||||
normalized = normalize_text_atom(text)
|
||||
if not normalized:
|
||||
return
|
||||
if _looks_like_syntax_only(normalized):
|
||||
return
|
||||
atoms.append(
|
||||
TextAtom(
|
||||
source=source,
|
||||
line_no=line_no,
|
||||
kind=kind,
|
||||
text=text.strip(),
|
||||
normalized=normalized,
|
||||
digest=atom_digest(normalized),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _looks_like_syntax_only(text: str) -> bool:
|
||||
if text in {"/", "/>", ">", "{", "}", "});"}:
|
||||
return True
|
||||
if text.startswith(("style=", "onMouse", "cursor:", "fontWeight:", "color:", "margin", "padding")):
|
||||
return True
|
||||
if re.fullmatch(r"[{}(),;:'\"#.\-\s]+", text):
|
||||
return True
|
||||
return False
|
||||
@@ -29,8 +29,12 @@ SYSTEM_PROMPT = (
|
||||
f" 3. proposal_kind MUST be one of: {_ALLOWED_KINDS}.\n"
|
||||
f" 4. Do NOT propose any of: {_FORBIDDEN_KINDS}.\n"
|
||||
" 5. Do NOT change frame_id — V4 rank-1 frame is locked.\n"
|
||||
" 6. Keep declared frame slots (text/table/image/details) populated.\n"
|
||||
" 7. Respect Internal Region containment; place content units within "
|
||||
" 6. Preferred Task-12 output is proposal_kind='design_adaptation_plan': "
|
||||
"plan frame/layout/repeat changes only; do NOT output final text slots.\n"
|
||||
" 7. Do NOT create titles, labels, summaries, body text, or slot content. "
|
||||
"The code layer will place MDX verbatim text.\n"
|
||||
" 8. Keep declared frame slots (text/table/image/details) accounted for.\n"
|
||||
" 9. Respect Internal Region containment; place content units within "
|
||||
"the declared region only."
|
||||
)
|
||||
|
||||
@@ -73,6 +77,27 @@ def build_ai_fallback_prompt(
|
||||
"figma_partial_json": figma_partial_json,
|
||||
"internal_region": internal_region,
|
||||
"mdx_text_READ_ONLY": mdx_text,
|
||||
"task12_output_contract": {
|
||||
"preferred_proposal_kind": ProposalKind.DESIGN_ADAPTATION_PLAN.value,
|
||||
"allowed_operation_examples": [
|
||||
"increase_repeat_count",
|
||||
"split_group_across_repeat_blocks",
|
||||
"rebalance_zone_ratio",
|
||||
"compact_spacing",
|
||||
"use_repeatable_frame",
|
||||
],
|
||||
"forbidden_content_fields": [
|
||||
"text",
|
||||
"title",
|
||||
"label",
|
||||
"body",
|
||||
"slots",
|
||||
"mdx_text",
|
||||
"summary",
|
||||
"raw_html",
|
||||
"raw_css",
|
||||
],
|
||||
},
|
||||
}
|
||||
return {
|
||||
"system": SYSTEM_PROMPT,
|
||||
|
||||
@@ -4,6 +4,7 @@ Whitelisted proposal kinds (Stage 2 plan):
|
||||
- builder_options_patch : zone/frame builder option overrides
|
||||
- partial_overrides : Internal Region / Frame Slot content overrides
|
||||
- slot_mapping_proposal : restructuring proposal (content unit mapping)
|
||||
- design_adaptation_plan : structure-only plan; code applies verbatim MDX
|
||||
|
||||
Forbidden output forms (rejected by validator):
|
||||
- mdx_text (MDX read-only — `feedback_ai_isolation_contract`)
|
||||
@@ -23,6 +24,7 @@ class ProposalKind(str, Enum):
|
||||
BUILDER_OPTIONS_PATCH = "builder_options_patch"
|
||||
PARTIAL_OVERRIDES = "partial_overrides"
|
||||
SLOT_MAPPING_PROPOSAL = "slot_mapping_proposal"
|
||||
DESIGN_ADAPTATION_PLAN = "design_adaptation_plan"
|
||||
|
||||
|
||||
FORBIDDEN_KINDS: frozenset[str] = frozenset(
|
||||
|
||||
@@ -27,6 +27,43 @@ class AiFallbackValidationError(ValueError):
|
||||
|
||||
_SLOT_KINDS = (ProposalKind.PARTIAL_OVERRIDES, ProposalKind.SLOT_MAPPING_PROPOSAL)
|
||||
|
||||
_DESIGN_PLAN_FORBIDDEN_CONTENT_KEYS: frozenset[str] = frozenset(
|
||||
{
|
||||
"body",
|
||||
"label",
|
||||
"labels",
|
||||
"mdx_text",
|
||||
"new_text",
|
||||
"raw_css",
|
||||
"raw_html",
|
||||
"slot_payload",
|
||||
"slots",
|
||||
"summary",
|
||||
"text",
|
||||
"title",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _find_forbidden_design_plan_keys(value: Any, path: str = "payload") -> list[str]:
|
||||
"""Return content-bearing keys that would let AI generate text.
|
||||
|
||||
Task 12 contract: design_adaptation_plan may describe structure changes,
|
||||
but code remains the sole layer that maps MDX verbatim text into slots.
|
||||
"""
|
||||
hits: list[str] = []
|
||||
if isinstance(value, dict):
|
||||
for key, nested in value.items():
|
||||
key_str = str(key)
|
||||
nested_path = f"{path}.{key_str}"
|
||||
if key_str in _DESIGN_PLAN_FORBIDDEN_CONTENT_KEYS:
|
||||
hits.append(nested_path)
|
||||
hits.extend(_find_forbidden_design_plan_keys(nested, nested_path))
|
||||
elif isinstance(value, list):
|
||||
for idx, nested in enumerate(value):
|
||||
hits.extend(_find_forbidden_design_plan_keys(nested, f"{path}[{idx}]"))
|
||||
return hits
|
||||
|
||||
|
||||
def validate_proposal(
|
||||
proposal: AiFallbackProposal,
|
||||
@@ -73,6 +110,19 @@ def validate_proposal(
|
||||
"from payload.slots (text/table/image/details must remain populated)."
|
||||
)
|
||||
|
||||
if proposal.proposal_kind is ProposalKind.DESIGN_ADAPTATION_PLAN:
|
||||
operations = payload.get("operations")
|
||||
if not isinstance(operations, list) or not operations:
|
||||
raise AiFallbackValidationError(
|
||||
"design adaptation plan: payload.operations must be a non-empty list."
|
||||
)
|
||||
forbidden_paths = _find_forbidden_design_plan_keys(payload)
|
||||
if forbidden_paths:
|
||||
raise AiFallbackValidationError(
|
||||
"design adaptation plan: AI must not emit content-bearing fields "
|
||||
f"{forbidden_paths}; code maps MDX verbatim text."
|
||||
)
|
||||
|
||||
region_id = payload.get("region_id")
|
||||
if region_id is not None and internal_region is not None:
|
||||
declared_region_id = internal_region.get("id")
|
||||
|
||||
@@ -1039,10 +1039,22 @@ def select_composition_units(
|
||||
Args:
|
||||
candidates: full candidate pool from collect_candidates().
|
||||
allowed_statuses: phase_z_status set considered auto-renderable.
|
||||
all_section_ids: ordered section id list (only consulted when
|
||||
allow_provisional_fill=True; required for coverage check).
|
||||
all_section_ids: ordered section id list. Used for final source-order
|
||||
emission; also consulted for provisional coverage fill.
|
||||
allow_provisional_fill: opt-in for last-resort provisional fill.
|
||||
"""
|
||||
source_order_index = {sid: i for i, sid in enumerate(all_section_ids or [])}
|
||||
|
||||
def _source_order_key(c: CompositionUnit) -> tuple[int, int, str]:
|
||||
order_values = [
|
||||
source_order_index[sid]
|
||||
for sid in c.source_section_ids
|
||||
if sid in source_order_index
|
||||
]
|
||||
if not order_values:
|
||||
return (10**9, 10**9, "+".join(c.source_section_ids))
|
||||
return (min(order_values), max(order_values), "+".join(c.source_section_ids))
|
||||
|
||||
scored = [score_candidate(c) for c in candidates]
|
||||
viable = [
|
||||
c for c in scored
|
||||
@@ -1080,6 +1092,11 @@ def select_composition_units(
|
||||
selected.append(c)
|
||||
covered.update(c.source_section_ids)
|
||||
|
||||
# Task 9 (2026-05-28): frame score decides the covering frame, but final
|
||||
# unit emission must preserve MDX source order.
|
||||
if source_order_index:
|
||||
selected.sort(key=_source_order_key)
|
||||
|
||||
return selected
|
||||
|
||||
|
||||
@@ -1163,7 +1180,7 @@ def plan_composition(sections, v4_lookup_fn, v4_label_to_status: dict,
|
||||
units = select_composition_units(
|
||||
candidates,
|
||||
allowed_statuses,
|
||||
all_section_ids=[s.section_id for s in sections] if allow_provisional_fill else None,
|
||||
all_section_ids=[s.section_id for s in sections],
|
||||
allow_provisional_fill=allow_provisional_fill,
|
||||
)
|
||||
preset = select_layout_preset(units)
|
||||
|
||||
+172
-31
@@ -172,11 +172,46 @@ def _split_label_for_bar(label: str) -> tuple[str, str]:
|
||||
return label.strip(), ""
|
||||
|
||||
|
||||
def normalize_markdown_inline_text(value: str | None) -> str:
|
||||
"""Strip authoring-only Markdown/HTML markers without rewriting words."""
|
||||
if value is None:
|
||||
return ""
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
if re.fullmatch(r"[-:|\s]+", text):
|
||||
return ""
|
||||
|
||||
text = re.sub(r"<br\s*/?>", " ", text, flags=re.IGNORECASE)
|
||||
text = text.replace(" ", " ")
|
||||
text = re.sub(r"!\[([^\]]*)\]\([^)]+\)", r"\1", text)
|
||||
text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
|
||||
text = re.sub(r"`([^`]+)`", r"\1", text)
|
||||
text = re.sub(r"^\s*(?:[-*+]|\u2022)\s+", "", text)
|
||||
|
||||
if text.startswith("|") and text.endswith("|"):
|
||||
cells = [c.strip() for c in text.strip("|").split("|") if c.strip()]
|
||||
if all(re.fullmatch(r"[-: ]+", c) for c in cells):
|
||||
return ""
|
||||
text = " / ".join(cells)
|
||||
|
||||
previous = None
|
||||
while previous != text:
|
||||
previous = text
|
||||
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
|
||||
text = re.sub(r"__(.+?)__", r"\1", text)
|
||||
text = re.sub(r"(?<!\*)\*([^*\n]+?)\*(?!\*)", r"\1", text)
|
||||
text = re.sub(r"(?<!_)_([^_\n]+?)_(?!_)", r"\1", text)
|
||||
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
|
||||
def _extract_bold_or_plain(top_line: str) -> str:
|
||||
bold = re.search(r"\*\*(.+?)\*\*", top_line)
|
||||
if bold:
|
||||
return bold.group(1).strip()
|
||||
return top_line.strip().lstrip("*-").strip()
|
||||
return normalize_markdown_inline_text(bold.group(1))
|
||||
return normalize_markdown_inline_text(top_line)
|
||||
|
||||
|
||||
def _text_lines_with_indent(nested_lines: list[str], base_indent: int = 0) -> list[dict]:
|
||||
@@ -193,8 +228,9 @@ def _text_lines_with_indent(nested_lines: list[str], base_indent: int = 0) -> li
|
||||
rel = max(0, indent - base_indent)
|
||||
indent_level = max(0, rel // 2)
|
||||
text = re.sub(r"^[\*\-]\s+", "", s)
|
||||
text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
|
||||
text_lines.append({"text": text, "indent": indent_level})
|
||||
text = normalize_markdown_inline_text(text)
|
||||
if text:
|
||||
text_lines.append({"text": text, "indent": indent_level})
|
||||
return text_lines
|
||||
|
||||
|
||||
@@ -213,12 +249,23 @@ def _extract_markdown_table(content: str) -> tuple[list[dict] | None, str]:
|
||||
return None, content
|
||||
rows = [r.strip() for r in m.group(1).strip().splitlines() if r.strip()]
|
||||
transforms = []
|
||||
header_cells = [c.strip() for c in rows[0].strip("|").split("|")]
|
||||
if len(header_cells) >= 3:
|
||||
header_from = normalize_markdown_inline_text(header_cells[0])
|
||||
header_mid = normalize_markdown_inline_text(header_cells[1])
|
||||
header_to = normalize_markdown_inline_text(header_cells[2])
|
||||
if header_from or header_mid or header_to:
|
||||
transforms.append({
|
||||
"from": " / ".join(x for x in [header_from, header_mid] if x),
|
||||
"to": header_to,
|
||||
})
|
||||
for r in rows[2:]:
|
||||
cells = [c.strip() for c in r.strip("|").split("|")]
|
||||
if len(cells) >= 3:
|
||||
f = re.sub(r"\*\*(.+?)\*\*", r"\1", cells[0])
|
||||
t = re.sub(r"\*\*(.+?)\*\*", r"\1", cells[2])
|
||||
transforms.append({"from": f, "to": t})
|
||||
f = normalize_markdown_inline_text(cells[0])
|
||||
t = normalize_markdown_inline_text(cells[2])
|
||||
if f or t:
|
||||
transforms.append({"from": f, "to": t})
|
||||
remaining = content[:m.start()] + content[m.end():]
|
||||
return (transforms or None), remaining
|
||||
|
||||
@@ -244,16 +291,18 @@ def _parse_nested_pillar_sections(nested_lines: list[str]) -> list[dict]:
|
||||
if cur_heading is not None:
|
||||
sections.append({"heading": cur_heading, "text_lines": cur_text_lines})
|
||||
bold = re.search(r"\*\*(.+?)\*\*", stripped)
|
||||
cur_heading = (bold.group(1).strip() if bold
|
||||
else stripped.lstrip("*-").strip())
|
||||
cur_heading = normalize_markdown_inline_text(
|
||||
bold.group(1) if bold else stripped
|
||||
)
|
||||
cur_text_lines = []
|
||||
section_base_indent = indent
|
||||
else:
|
||||
rel_indent = indent - section_base_indent
|
||||
indent_level = max(0, (rel_indent - 2) // 2)
|
||||
text = re.sub(r"^[\*\-]\s+", "", stripped)
|
||||
text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
|
||||
cur_text_lines.append({"text": text, "indent": indent_level})
|
||||
text = normalize_markdown_inline_text(text)
|
||||
if text:
|
||||
cur_text_lines.append({"text": text, "indent": indent_level})
|
||||
|
||||
if cur_heading is not None:
|
||||
sections.append({"heading": cur_heading, "text_lines": cur_text_lines})
|
||||
@@ -291,6 +340,39 @@ def parse_quadrant_item(unit: tuple[str, list[str]]) -> dict:
|
||||
return {"label": label, "body": body}
|
||||
|
||||
|
||||
def _group_parsed_items_for_slots(parsed: list[dict], slot_count: int) -> list[dict]:
|
||||
"""Keep every source item while avoiding one visual card per tiny bullet.
|
||||
|
||||
Long sections can arrive as many sibling bullets even though the target
|
||||
frame has a small fixed data axis. In that case, preserve order and text by
|
||||
distributing the items across the existing slots instead of creating dozens
|
||||
of clipped cards.
|
||||
"""
|
||||
if slot_count <= 0 or len(parsed) <= slot_count * 2:
|
||||
return parsed
|
||||
|
||||
grouped: list[dict] = []
|
||||
for slot_index in range(slot_count):
|
||||
start = (len(parsed) * slot_index) // slot_count
|
||||
end = (len(parsed) * (slot_index + 1)) // slot_count
|
||||
bucket = parsed[start:end]
|
||||
if not bucket:
|
||||
grouped.append({"label": "", "body": []})
|
||||
continue
|
||||
|
||||
body: list[dict] = []
|
||||
first = bucket[0]
|
||||
body.extend(first.get("body") or [])
|
||||
for item in bucket[1:]:
|
||||
label = item.get("label") or ""
|
||||
if label:
|
||||
body.append({"text": label, "indent": 0})
|
||||
body.extend(item.get("body") or [])
|
||||
grouped.append({"label": first.get("label") or "", "body": body})
|
||||
|
||||
return grouped
|
||||
|
||||
|
||||
def parse_compare_row_2col_item(unit: tuple[str, list[str]]) -> dict:
|
||||
"""F18-style — bold = category label, nested 2 bullets = col_a / col_b values.
|
||||
|
||||
@@ -309,8 +391,9 @@ def parse_compare_row_2col_item(unit: tuple[str, list[str]]) -> dict:
|
||||
l_strip = l.strip()
|
||||
if re.match(r"^[\*\-]\s", l_strip):
|
||||
txt = re.sub(r"^[\*\-]\s+", "", l_strip)
|
||||
txt = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", txt)
|
||||
nested.append(txt)
|
||||
txt = normalize_markdown_inline_text(txt)
|
||||
if txt:
|
||||
nested.append(txt)
|
||||
col_a = nested[0] if len(nested) > 0 else ""
|
||||
col_b = nested[1] if len(nested) > 1 else ""
|
||||
return {"label": label, "col_a": col_a, "col_b": col_b}
|
||||
@@ -331,22 +414,57 @@ def _parse_column_sections(body: str, transform_first: bool) -> list[dict]:
|
||||
transform_first=True 면 첫 top-bullet 의 nested 안에 markdown table 이 있으면
|
||||
text_lines 대신 transforms 로 산출 (AS-IS/TO-BE).
|
||||
"""
|
||||
groups = _split_top_bullets(body)
|
||||
sections = []
|
||||
for i, (top_line, nested_lines) in enumerate(groups):
|
||||
title = _extract_bold_or_plain(top_line)
|
||||
if i == 0 and transform_first:
|
||||
nested_text = "\n".join(nested_lines)
|
||||
transforms, _ = _extract_markdown_table(nested_text)
|
||||
current_title: str | None = None
|
||||
current_lines: list[str] = []
|
||||
|
||||
def flush() -> None:
|
||||
nonlocal current_title, current_lines
|
||||
if current_title is None:
|
||||
return
|
||||
raw_body = "\n".join(current_lines)
|
||||
if transform_first and not sections:
|
||||
transforms, remaining = _extract_markdown_table(raw_body)
|
||||
if transforms:
|
||||
sections.append({"title": title, "transforms": transforms})
|
||||
continue
|
||||
non_empty = [l for l in nested_lines if l.strip()]
|
||||
base = min((len(l) - len(l.lstrip()) for l in non_empty), default=0)
|
||||
sections.append({
|
||||
"title": current_title,
|
||||
"transforms": transforms,
|
||||
"text_lines": _text_lines_with_indent(
|
||||
remaining.splitlines(),
|
||||
base_indent=0,
|
||||
),
|
||||
})
|
||||
current_title = None
|
||||
current_lines = []
|
||||
return
|
||||
sections.append({
|
||||
"title": title,
|
||||
"text_lines": _text_lines_with_indent(nested_lines, base_indent=base),
|
||||
"title": current_title,
|
||||
"text_lines": _text_lines_with_indent(current_lines, base_indent=0),
|
||||
})
|
||||
current_title = None
|
||||
current_lines = []
|
||||
|
||||
for line in body.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped == "---":
|
||||
continue
|
||||
if re.match(r"^[\*\-]\s+", stripped):
|
||||
text = re.sub(r"^[\*\-]\s+", "", stripped)
|
||||
text_norm = normalize_markdown_inline_text(text)
|
||||
bold = re.search(r"\*\*(.+?)\*\*", text)
|
||||
if bold:
|
||||
flush()
|
||||
current_title = normalize_markdown_inline_text(bold.group(1))
|
||||
current_lines = []
|
||||
elif current_title is not None and text_norm:
|
||||
current_lines.append(f"- {text_norm}")
|
||||
elif text_norm:
|
||||
current_title = text_norm
|
||||
current_lines = []
|
||||
elif current_title is not None:
|
||||
current_lines.append(line)
|
||||
|
||||
flush()
|
||||
return sections
|
||||
|
||||
|
||||
@@ -375,7 +493,7 @@ def _resolve_title(section, payload_spec: dict, contract: dict) -> dict:
|
||||
if src is None:
|
||||
return {}
|
||||
if src == "section.title":
|
||||
return {"title": section.title}
|
||||
return {"title": normalize_markdown_inline_text(section.title)}
|
||||
raise ValueError(
|
||||
f"Contract '{contract['template_id']}' has unsupported title source "
|
||||
f"'{src}'. v0 supports 'section.title' only."
|
||||
@@ -439,6 +557,10 @@ def _build_process_product_pair(section, units, contract) -> dict:
|
||||
|
||||
payload: dict = {}
|
||||
payload.update(_resolve_title(section, contract["payload"], contract))
|
||||
subheadings = [
|
||||
normalize_markdown_inline_text(m.group(1))
|
||||
for m in re.finditer(r"(?m)^###\s+(.+?)\s*$", getattr(section, "raw_content", "") or "")
|
||||
]
|
||||
|
||||
for i, col in enumerate(cols):
|
||||
sub_title, sub_body = units[i]
|
||||
@@ -455,7 +577,7 @@ def _build_process_product_pair(section, units, contract) -> dict:
|
||||
while len(sections_list) < pad_to:
|
||||
sections_list.append(dict(empty_template))
|
||||
sections_list = sections_list[:pad_to]
|
||||
payload[col["title_to"]] = sub_title
|
||||
payload[col["title_to"]] = subheadings[i] if i < len(subheadings) else sub_title
|
||||
payload[col["body_to"]] = {"sections": sections_list}
|
||||
|
||||
return payload
|
||||
@@ -487,6 +609,7 @@ def _build_quadrant_flat_slots(section, units, contract) -> dict:
|
||||
|
||||
pad_to = options.get("pad_to", 4)
|
||||
truncate_at = options.get("truncate_at", pad_to)
|
||||
max_dynamic_slots = options.get("max_dynamic_slots")
|
||||
label_key = options.get("label_key_pattern", "quadrant_{n}_label")
|
||||
body_key = options.get("body_key_pattern", "quadrant_{n}_body")
|
||||
empty_label = options.get("empty_label", "")
|
||||
@@ -495,10 +618,29 @@ def _build_quadrant_flat_slots(section, units, contract) -> dict:
|
||||
payload: dict = {}
|
||||
payload.update(_resolve_title(section, contract["payload"], contract))
|
||||
|
||||
visible_units = list(units[:truncate_at])
|
||||
visible_units = list(units)
|
||||
parsed = [parser(u) for u in visible_units]
|
||||
if max_dynamic_slots is None:
|
||||
# Keep the original fixed-card frame readable. Long content is grouped
|
||||
# into the declared slots rather than silently truncated or exploded
|
||||
# into dozens of clipped visual cards.
|
||||
max_dynamic_slots = pad_to
|
||||
dynamic_count = min(max(pad_to, len(parsed)), max_dynamic_slots)
|
||||
parsed = _group_parsed_items_for_slots(parsed, dynamic_count)
|
||||
subheadings = [
|
||||
normalize_markdown_inline_text(m.group(1))
|
||||
for m in re.finditer(r"(?m)^###\s+(.+?)\s*$", getattr(section, "raw_content", "") or "")
|
||||
]
|
||||
if subheadings and parsed:
|
||||
for i, heading in enumerate(subheadings):
|
||||
if not heading:
|
||||
continue
|
||||
target_index = min(len(parsed) - 1, (len(parsed) * i) // len(subheadings))
|
||||
body = parsed[target_index].setdefault("body", [])
|
||||
if not any(line.get("text") == heading for line in body if isinstance(line, dict)):
|
||||
body.insert(0, {"text": heading, "indent": 0})
|
||||
|
||||
for i in range(pad_to):
|
||||
for i in range(dynamic_count):
|
||||
n = i + 1
|
||||
if i < len(parsed):
|
||||
payload[label_key.format(n=n)] = parsed[i]["label"]
|
||||
@@ -508,8 +650,7 @@ def _build_quadrant_flat_slots(section, units, contract) -> dict:
|
||||
# list / dict default 는 항상 새 객체 — shared reference 방지
|
||||
payload[body_key.format(n=n)] = list(empty_body) if isinstance(empty_body, list) else empty_body
|
||||
|
||||
if len(units) > truncate_at:
|
||||
payload["_truncated_count"] = len(units) - truncate_at
|
||||
payload["_slot_count"] = dynamic_count
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
+3255
-166
File diff suppressed because it is too large
Load Diff
@@ -27,8 +27,9 @@ v0 minimal :
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
# B4 v0 input contract = B1 (ContentObject) + B2 (InternalRegion / ZoneRegionPlan).
|
||||
# 세 module 모두 dormant — runtime path 와 무관한 layer-agnostic 의존.
|
||||
@@ -40,6 +41,43 @@ from phase_z2_internal_region_planner import (
|
||||
)
|
||||
|
||||
|
||||
# ─── IMP-95 u1 — V4 evidence flag + trace key constants ─────────
|
||||
#
|
||||
# u1 scope-lock : env flag reader + trace key constant names only.
|
||||
# Selector wiring (u2), plan_placement signature (u3), Step 11 wiring
|
||||
# (u4), gatekeeper short-circuit (u5), partial_exists precheck (u6),
|
||||
# regression tests (u7~u10), and status-board markers (u11) are out of
|
||||
# scope for u1.
|
||||
|
||||
PHASE_Z_B4_V4_EVIDENCE_ENV = "PHASE_Z_B4_V4_EVIDENCE"
|
||||
|
||||
TRACE_KEY_FRAME_SELECTION_BASIS = "frame_selection_basis"
|
||||
TRACE_KEY_V4_EVIDENCE_CONSUMED = "v4_evidence_consumed"
|
||||
TRACE_KEY_B4_V0_FALLBACK_REASON = "b4_v0_fallback_reason"
|
||||
TRACE_KEY_V4_RANK_USED = "v4_rank_used"
|
||||
TRACE_KEY_V4_B4_FRAME_MATCH = "v4_b4_frame_match"
|
||||
TRACE_KEY_B4_PARTIAL_MISSING_SKIP = "b4_partial_missing_skip"
|
||||
|
||||
FRAME_SELECTION_BASIS_DECLARATION_ORDER = "declaration_order"
|
||||
FRAME_SELECTION_BASIS_V4_RANKED = "v4_ranked"
|
||||
|
||||
|
||||
def _b4_v4_evidence_enabled() -> bool:
|
||||
"""IMP-95 u1 — PHASE_Z_B4_V4_EVIDENCE env flag reader (default OFF).
|
||||
|
||||
Gates V4-aware frame selection in _select_frame (u2) and Step 11 wiring
|
||||
(u4). Default OFF preserves declaration-order behavior and final.html
|
||||
SHA parity. Independent of PHASE_Z_B4_MAPPER_SOURCE (IMP-89 89-a) and
|
||||
PHASE_Z_B4_GATEKEEPER. Truthy values: '1', 'true', 'yes' (case-insensitive,
|
||||
trimmed) — mirrors the contract at src/phase_z2_pipeline.py:_b4_mapper_source_enabled.
|
||||
"""
|
||||
return os.environ.get(PHASE_Z_B4_V4_EVIDENCE_ENV, "").strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
}
|
||||
|
||||
|
||||
# ─── Output schema (SPEC v1 §4.1) ────────────────────────────────
|
||||
|
||||
|
||||
@@ -71,6 +109,11 @@ class PlacementPlan:
|
||||
slot_assignments : Stage B 결과 (region.content_unit → Frame Slot)
|
||||
overflow_buffer : v0 = 빈 list (preview/details path 미활성)
|
||||
rejection : 매칭 안 된 / cardinality 초과 등 — 자동 렌더 X 신호
|
||||
selection_trace : IMP-95 u3 additive — frame selection telemetry.
|
||||
Always populated. Default = declaration_order
|
||||
(legacy behavior); V4-aware when env flag ON.
|
||||
Keys mirror u1 TRACE_KEY_* constants — Step 11
|
||||
wiring (u4) reads from this field, never inlines names.
|
||||
"""
|
||||
|
||||
section_id: str
|
||||
@@ -80,6 +123,7 @@ class PlacementPlan:
|
||||
slot_assignments: list[SlotAssignment] = field(default_factory=list)
|
||||
overflow_buffer: list[dict] = field(default_factory=list)
|
||||
rejection: list[dict] = field(default_factory=list)
|
||||
selection_trace: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
# ─── Frame selection ─────────────────────────────────────────────
|
||||
@@ -107,6 +151,138 @@ def _select_frame(
|
||||
return None
|
||||
|
||||
|
||||
# ─── IMP-95 u2 — V4 evidence-aware selector ──────────────────────
|
||||
#
|
||||
# Stage 2 u2 scope-lock : add a V4-aware selector that ranks eligible
|
||||
# contracts under the existing accepted_content_types ⊇ constraint,
|
||||
# then falls back to declaration order. plan_placement wiring (u3),
|
||||
# Step 11 trace exposure (u4), gatekeeper short-circuit (u5), and
|
||||
# partial_exists precheck (u6) are out of scope for u2.
|
||||
#
|
||||
# Behavior contract (IMP-95 issue body + Stage 1 guardrails) :
|
||||
# - V4 only re-orders among contracts that already satisfy
|
||||
# accepted_content_types ⊇ content_type_set.
|
||||
# - V4 ranks are tried in order; an ineligible / unmatched rank
|
||||
# falls through to the next rank (Stage 1 unresolved Q1 lock).
|
||||
# - When V4 evidence is empty / None / fails to resolve any rank,
|
||||
# fall back to declaration-order _select_frame() (preserves
|
||||
# final.html SHA parity under flag OFF when this helper is unused).
|
||||
# - Returns (Optional[frame_contract], selection_metadata dict)
|
||||
# using the u1 TRACE_KEY_* constants so u3 cannot drift names.
|
||||
|
||||
_FALLBACK_REASON_V4_EVIDENCE_EMPTY = "v4_evidence_empty"
|
||||
_FALLBACK_REASON_NO_V4_RANK_ELIGIBLE = "no_v4_rank_eligible"
|
||||
|
||||
|
||||
def _select_frame_v4_aware(
|
||||
content_objects: list[ContentObject],
|
||||
frame_contracts: list[dict[str, Any]],
|
||||
v4_candidates: Optional[list[Any]] = None,
|
||||
partial_exists: Optional[Callable[[str], bool]] = None,
|
||||
) -> tuple[Optional[dict[str, Any]], dict[str, Any]]:
|
||||
"""V4 evidence-aware variant of :func:`_select_frame`.
|
||||
|
||||
Algorithm :
|
||||
1. content_type_set = {obj.type for obj in content_objects} (re-uses
|
||||
the legacy ⊇ semantics — V4 only re-orders among eligible contracts).
|
||||
2. When ``v4_candidates`` is non-empty, iterate rank-ordered :
|
||||
a. Match candidate to a frame_contract by ``template_id`` first,
|
||||
``str(frame_id)`` second (duck-typed — composition.py:678-684).
|
||||
b. If the matched contract satisfies the ⊇ constraint, return it
|
||||
with basis = 'v4_ranked' + ``v4_rank_used`` = current index.
|
||||
c. Otherwise skip the candidate and continue to the next rank.
|
||||
3. Fall back to declaration-order selection via :func:`_select_frame`.
|
||||
The trace records why V4 evidence did not resolve
|
||||
('v4_evidence_empty' | 'no_v4_rank_eligible').
|
||||
|
||||
Returns :
|
||||
(frame_contract | None, selection_metadata dict)
|
||||
|
||||
Selection metadata keys (use u1 TRACE_KEY_* constants) :
|
||||
- frame_selection_basis : 'v4_ranked' | 'declaration_order'
|
||||
- v4_evidence_consumed : bool — True iff a V4 rank resolved.
|
||||
- v4_rank_used : int | None — 0-based rank index, else None.
|
||||
- v4_b4_frame_match : bool — True iff returned contract came from V4.
|
||||
- b4_v0_fallback_reason : str | None — set only when basis = declaration_order.
|
||||
- b4_partial_missing_skip : list[dict] — IMP-95 u6 ordered record of V4
|
||||
ranks skipped because their template_id had
|
||||
no partial HTML (contract-only template, 19
|
||||
in catalog as of 2026-05-25). Always present;
|
||||
empty when ``partial_exists`` is not supplied,
|
||||
no V4 evidence consulted, or no rank skipped.
|
||||
|
||||
IMP-95 u6 — ``partial_exists`` callable :
|
||||
When supplied and a V4 candidate resolves to a frame_contract by
|
||||
template_id/frame_id, the matched contract's ``template_id`` is passed
|
||||
to ``partial_exists`` BEFORE the accepted_content_types ⊇ check. If the
|
||||
callable returns False (no partial HTML on disk), the rank is recorded
|
||||
into ``b4_partial_missing_skip`` and the loop continues to the next
|
||||
rank. None (default) disables the precheck entirely — preserves the
|
||||
pre-u6 V4-aware selector behavior verbatim.
|
||||
"""
|
||||
content_type_set = {obj.type for obj in content_objects}
|
||||
partial_missing_skips: list[dict[str, Any]] = []
|
||||
|
||||
if v4_candidates:
|
||||
for rank_idx, cand in enumerate(v4_candidates):
|
||||
cand_tid = getattr(cand, "template_id", None)
|
||||
cand_fid = getattr(cand, "frame_id", None)
|
||||
matched: Optional[dict[str, Any]] = None
|
||||
# Two-pass match — template_id first across ALL contracts, then frame_id.
|
||||
# Single-pass per contract would let an earlier contract whose frame_id
|
||||
# matches the candidate win over a later contract whose template_id matches,
|
||||
# contradicting the documented precedence (composition.py:678-684).
|
||||
if cand_tid:
|
||||
for fc in frame_contracts:
|
||||
if fc.get("template_id") == cand_tid:
|
||||
matched = fc
|
||||
break
|
||||
if matched is None and cand_fid is not None:
|
||||
cand_fid_str = str(cand_fid)
|
||||
for fc in frame_contracts:
|
||||
fc_fid = fc.get("frame_id")
|
||||
if fc_fid is not None and str(fc_fid) == cand_fid_str:
|
||||
matched = fc
|
||||
break
|
||||
if matched is None:
|
||||
continue
|
||||
# IMP-95 u6 — partial_exists precheck. Skip contract-only templates
|
||||
# (no partial HTML on disk) before accepting the V4 rank. Trace the
|
||||
# skip in declaration order so downstream consumers can audit the
|
||||
# rank fall-through. Precheck is gated on partial_exists callable;
|
||||
# None (default) preserves the pre-u6 selector behavior.
|
||||
matched_tid = matched.get("template_id")
|
||||
if partial_exists is not None and matched_tid is not None:
|
||||
if not partial_exists(matched_tid):
|
||||
partial_missing_skips.append({
|
||||
"rank": rank_idx,
|
||||
"template_id": matched_tid,
|
||||
})
|
||||
continue
|
||||
accepted = set(matched.get("accepted_content_types") or [])
|
||||
if content_type_set <= accepted:
|
||||
return matched, {
|
||||
TRACE_KEY_FRAME_SELECTION_BASIS: FRAME_SELECTION_BASIS_V4_RANKED,
|
||||
TRACE_KEY_V4_EVIDENCE_CONSUMED: True,
|
||||
TRACE_KEY_V4_RANK_USED: rank_idx,
|
||||
TRACE_KEY_V4_B4_FRAME_MATCH: True,
|
||||
TRACE_KEY_B4_V0_FALLBACK_REASON: None,
|
||||
TRACE_KEY_B4_PARTIAL_MISSING_SKIP: partial_missing_skips,
|
||||
}
|
||||
fallback_reason = _FALLBACK_REASON_NO_V4_RANK_ELIGIBLE
|
||||
else:
|
||||
fallback_reason = _FALLBACK_REASON_V4_EVIDENCE_EMPTY
|
||||
|
||||
return _select_frame(content_objects, frame_contracts), {
|
||||
TRACE_KEY_FRAME_SELECTION_BASIS: FRAME_SELECTION_BASIS_DECLARATION_ORDER,
|
||||
TRACE_KEY_V4_EVIDENCE_CONSUMED: False,
|
||||
TRACE_KEY_V4_RANK_USED: None,
|
||||
TRACE_KEY_V4_B4_FRAME_MATCH: False,
|
||||
TRACE_KEY_B4_V0_FALLBACK_REASON: fallback_reason,
|
||||
TRACE_KEY_B4_PARTIAL_MISSING_SKIP: partial_missing_skips,
|
||||
}
|
||||
|
||||
|
||||
# ─── Sub_zone assignment (Stage B) ───────────────────────────────
|
||||
|
||||
|
||||
@@ -161,16 +337,43 @@ def _assign_region_to_sub_zone(
|
||||
# ─── Public entry ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _declaration_order_selection_trace() -> dict[str, Any]:
|
||||
"""IMP-95 u3 — selection_trace default (legacy declaration-order path).
|
||||
|
||||
Returned when ``PHASE_Z_B4_V4_EVIDENCE`` is OFF or when no V4 evidence
|
||||
is offered. Keys are stable u1 ``TRACE_KEY_*`` constants so u4 (Step 11
|
||||
wiring) cannot drift names. ``b4_v0_fallback_reason`` is ``None`` here
|
||||
because the V4 path is not attempted at all under this branch — the
|
||||
fallback-reason enum is reserved for the V4-aware selector's own
|
||||
fall-through bookkeeping (u2 contract).
|
||||
"""
|
||||
return {
|
||||
TRACE_KEY_FRAME_SELECTION_BASIS: FRAME_SELECTION_BASIS_DECLARATION_ORDER,
|
||||
TRACE_KEY_V4_EVIDENCE_CONSUMED: False,
|
||||
TRACE_KEY_V4_RANK_USED: None,
|
||||
TRACE_KEY_V4_B4_FRAME_MATCH: False,
|
||||
TRACE_KEY_B4_V0_FALLBACK_REASON: None,
|
||||
TRACE_KEY_B4_PARTIAL_MISSING_SKIP: [],
|
||||
}
|
||||
|
||||
|
||||
def plan_placement(
|
||||
content_objects: list[ContentObject],
|
||||
frame_contracts: list[dict[str, Any]],
|
||||
section_id: str = "",
|
||||
v4_candidates: Optional[list[Any]] = None,
|
||||
partial_exists: Optional[Callable[[str], bool]] = None,
|
||||
) -> PlacementPlan:
|
||||
"""ContentObject[] + frame_contracts → PlacementPlan (Stage A + Stage B 통합).
|
||||
|
||||
v0 algorithm :
|
||||
1. Stage A = B2 plan_internal_regions() 호출 → internal_regions 획득
|
||||
2. frame 선택 : accepted_content_types cover + 입력 순서 first
|
||||
2. frame 선택 : accepted_content_types cover + 입력 순서 first.
|
||||
IMP-95 u3 — when ``PHASE_Z_B4_V4_EVIDENCE`` env flag is ON,
|
||||
delegates to :func:`_select_frame_v4_aware` with the optional
|
||||
``v4_candidates`` evidence. Flag OFF (default) preserves the
|
||||
declaration-order ``_select_frame`` call site (final.html SHA
|
||||
parity guarantee, Stage 2 A1 + A10).
|
||||
- cover 실패 시 → rejection + early return
|
||||
3. selected_frame 의 sub_zones 읽음 (B3 catalog)
|
||||
4. Stage B (region 1:1 sub_zone 매핑) :
|
||||
@@ -186,11 +389,31 @@ def plan_placement(
|
||||
frame_contracts : list[dict] — frame_contracts.yaml 의 contract dict list
|
||||
(YAML declaration order = list 순서로 입력 권고)
|
||||
section_id : region_id / 결과 식별자 prefix
|
||||
v4_candidates : optional V4 rank-ordered evidence (duck-typed —
|
||||
``template_id`` + ``frame_id`` per
|
||||
``src/phase_z2_composition.py:678-684``). Consumed
|
||||
ONLY when ``PHASE_Z_B4_V4_EVIDENCE`` flag is ON
|
||||
(default OFF → ignored, declaration order preserved).
|
||||
Step 11 wiring (u4) forwards ``unit.v4_candidates``;
|
||||
existing call sites that omit the kwarg keep
|
||||
identical legacy behavior.
|
||||
partial_exists : optional ``Callable[[str], bool]`` — IMP-95 u6
|
||||
contract-only / no-partial precheck. When supplied
|
||||
AND ``PHASE_Z_B4_V4_EVIDENCE`` is ON, V4 ranks whose
|
||||
resolved ``template_id`` returns False are skipped
|
||||
(recorded into ``b4_partial_missing_skip``) and the
|
||||
loop falls through to the next rank. None (default)
|
||||
disables the precheck — pre-u6 selector behavior.
|
||||
Declaration-order legacy path (flag OFF) is NEVER
|
||||
pre-checked, preserving final.html SHA parity.
|
||||
|
||||
Returns :
|
||||
PlacementPlan
|
||||
PlacementPlan — ``selection_trace`` (additive) records which path
|
||||
(declaration_order vs v4_ranked) selected the frame and why a V4
|
||||
candidate was / was not consumed.
|
||||
"""
|
||||
plan = PlacementPlan(section_id=section_id)
|
||||
plan.selection_trace = _declaration_order_selection_trace()
|
||||
|
||||
if not content_objects:
|
||||
return plan
|
||||
@@ -203,8 +426,22 @@ def plan_placement(
|
||||
)
|
||||
plan.internal_regions = list(zone_plan.internal_regions)
|
||||
|
||||
# 2. frame 선택
|
||||
selected_frame = _select_frame(content_objects, frame_contracts)
|
||||
# 2. frame 선택 — IMP-95 u3 flag-gated V4-aware path.
|
||||
# Flag OFF preserves declaration-order _select_frame call (legacy parity).
|
||||
# Flag ON routes to _select_frame_v4_aware (u2) with optional V4 evidence;
|
||||
# when v4_candidates is empty/None the selector itself emits the
|
||||
# 'v4_evidence_empty' fallback reason and returns the same declaration-order
|
||||
# frame, so flag-ON + no-evidence remains behaviorally equivalent for
|
||||
# selected_frame_id / selected_template_id.
|
||||
if _b4_v4_evidence_enabled():
|
||||
selected_frame, plan.selection_trace = _select_frame_v4_aware(
|
||||
content_objects,
|
||||
frame_contracts,
|
||||
v4_candidates,
|
||||
partial_exists=partial_exists,
|
||||
)
|
||||
else:
|
||||
selected_frame = _select_frame(content_objects, frame_contracts)
|
||||
if selected_frame is None:
|
||||
plan.rejection.append({
|
||||
"reason": "no_frame_covers_content_types",
|
||||
|
||||
+61
-48
@@ -47,36 +47,29 @@ def plan_zone_ratio_retry(
|
||||
fit_classification: dict,
|
||||
router_decision: dict,
|
||||
safety_margin_px: int = DEFAULT_SAFETY_MARGIN_PX,
|
||||
override_zone_geometries: Optional[dict[str, dict]] = None,
|
||||
) -> Optional[dict]:
|
||||
"""zone_ratio_retry 의 redistribution plan 을 산출.
|
||||
"""Build a deterministic zone-height redistribution plan.
|
||||
|
||||
*plan 만*. 실제 height 적용 / rerender X (caller 가 처리).
|
||||
|
||||
Returns:
|
||||
None : retry 시도 자체가 불필요 (router 가 zone_ratio_retry 제안 X)
|
||||
dict : retry attempt 정보 (feasible 여부 + 상세)
|
||||
|
||||
feasible=True 이면 caller 가 zones_after 로 layout_css 재구성 + rerender 시도.
|
||||
feasible=False 이면 caller 는 retry 포기 (original final.html 유지).
|
||||
T28.5b: zones listed in ``override_zone_geometries`` are manual/frozen.
|
||||
A frozen zone can be neither the overflow target nor a donor.
|
||||
"""
|
||||
if not router_decision.get("router_active"):
|
||||
return None
|
||||
|
||||
# zone_ratio_retry 가 router 제안에 포함된 첫 classification 을 target 으로
|
||||
target_cls = None
|
||||
for cls in fit_classification.get("classifications", []) or []:
|
||||
if cls.get("proposed_action") == "zone_ratio_retry":
|
||||
target_cls = cls
|
||||
break
|
||||
if target_cls is None:
|
||||
return None # 다른 action (popup / reselect) — 본 retry 대상 아님
|
||||
return None
|
||||
|
||||
target_zone_position = target_cls.get("zone_position")
|
||||
target_excess_y = float(target_cls.get("inputs", {}).get("excess_y", 0))
|
||||
# round up to integer (subpixel 끼면 부족할 수 있음)
|
||||
target_added_px = int(math.ceil(target_excess_y)) + int(safety_margin_px)
|
||||
manual_positions = set((override_zone_geometries or {}).keys())
|
||||
|
||||
# zones_before — debug_zones 의 height_px 를 모음
|
||||
zones_before: dict[str, int] = {}
|
||||
zone_min_by_pos: dict[str, int] = {}
|
||||
for dz in debug_zones:
|
||||
@@ -90,40 +83,52 @@ def plan_zone_ratio_retry(
|
||||
zones_before[pos] = int(h)
|
||||
zone_min_by_pos[pos] = int(m)
|
||||
|
||||
# overflow zone 별 visual fail 정보
|
||||
overflow_zone_status: dict[str, dict] = {}
|
||||
for z in overflow.get("zones", []) or []:
|
||||
overflow_zone_status[z.get("position")] = z
|
||||
overflow_zone_status = {
|
||||
z.get("position"): z for z in overflow.get("zones", []) or []
|
||||
}
|
||||
base_plan = {
|
||||
"target_zone_position": target_zone_position,
|
||||
"target_excess_y": target_excess_y,
|
||||
"target_added_px": target_added_px,
|
||||
"safety_margin_px_used": int(safety_margin_px),
|
||||
"zones_before": dict(zones_before),
|
||||
"manual_zone_positions": sorted(manual_positions),
|
||||
"manual_geometry_policy": "per_zone_freeze",
|
||||
}
|
||||
|
||||
if target_zone_position in manual_positions:
|
||||
return {
|
||||
**base_plan,
|
||||
"feasible": False,
|
||||
"manual_target_blocked": True,
|
||||
"donor_zone_position": None,
|
||||
"donor_reduced_px": 0,
|
||||
"donor_candidates_considered": [],
|
||||
"zones_after": dict(zones_before),
|
||||
"failure_reason": (
|
||||
f"manual target zone '{target_zone_position}' is frozen by "
|
||||
"override_zone_geometries; emit warning/recommended_geometry only"
|
||||
),
|
||||
}
|
||||
|
||||
# donor 후보 식별
|
||||
donor_candidates: list[dict] = []
|
||||
for dz in debug_zones:
|
||||
pos = dz.get("position")
|
||||
if pos is None or pos == target_zone_position:
|
||||
continue
|
||||
# rule 4-(a) sibling 확인은 layout 내 sibling = 같은 zones list 안에 있으면 OK
|
||||
# (본 함수는 1 layout 내 zones 만 받음)
|
||||
if pos in manual_positions:
|
||||
continue
|
||||
|
||||
# rule 4-(b) visual_check 통과 — 이 zone 에 자체 overflow / clipped_inner 없음
|
||||
zinfo = overflow_zone_status.get(pos, {})
|
||||
zone_self_overflow = bool(zinfo.get("overflowed"))
|
||||
zone_inner_clipped = bool(zinfo.get("clipped_inner"))
|
||||
if zone_self_overflow or zone_inner_clipped:
|
||||
if bool(zinfo.get("overflowed")) or bool(zinfo.get("clipped_inner")):
|
||||
continue
|
||||
|
||||
# rule 4-(c) capacity_fit 가 ok
|
||||
cap_status = (
|
||||
(dz.get("composition_rationale") or {}).get("capacity_fit", {}).get("fit_status")
|
||||
)
|
||||
# 'ok' 아니거나 missing/unknown 이면 보수적으로 제외 (no_contract 는 허용 — capacity_fit 자체 부재)
|
||||
if cap_status not in {"ok", "no_contract", None}:
|
||||
continue
|
||||
|
||||
# rule 4-(d) 현재 height > min_height
|
||||
# IMP-34 u1: donor capacity bounded by measured empty space
|
||||
# (clientHeight - scrollHeight from Step 14) when both fields are present,
|
||||
# falling back to static contract slack when absent. Prevents the donor
|
||||
# from being over-allocated when it is already full but not overflowing.
|
||||
height = zones_before.get(pos)
|
||||
min_h = zone_min_by_pos.get(pos)
|
||||
if height is None or min_h is None:
|
||||
@@ -157,18 +162,8 @@ def plan_zone_ratio_retry(
|
||||
"slack_bound_source": slack_bound_source,
|
||||
})
|
||||
|
||||
# rule 4-(f) 여러 후보면 slack 가장 큰 것부터
|
||||
donor_candidates.sort(key=lambda d: d["slack"], reverse=True)
|
||||
|
||||
# base plan dict (failure / success 공용)
|
||||
base_plan = {
|
||||
"target_zone_position": target_zone_position,
|
||||
"target_excess_y": target_excess_y,
|
||||
"target_added_px": target_added_px,
|
||||
"safety_margin_px_used": int(safety_margin_px),
|
||||
"donor_candidates_considered": donor_candidates,
|
||||
"zones_before": dict(zones_before),
|
||||
}
|
||||
base_plan["donor_candidates_considered"] = donor_candidates
|
||||
|
||||
if not donor_candidates:
|
||||
return {
|
||||
@@ -178,12 +173,11 @@ def plan_zone_ratio_retry(
|
||||
"donor_reduced_px": 0,
|
||||
"zones_after": dict(zones_before),
|
||||
"failure_reason": (
|
||||
f"no donor candidates eligible (sibling visual_check OK + "
|
||||
f"capacity_fit ok/no_contract + slack > 0)"
|
||||
"no donor candidates eligible (sibling visual_check OK + "
|
||||
"capacity_fit ok/no_contract + slack > 0 + not manual/frozen)"
|
||||
),
|
||||
}
|
||||
|
||||
# IMP-12 u1 : multi-donor greedy aggregation (slack-desc 순서대로 합산)
|
||||
aggregate_slack_available = sum(d["slack"] for d in donor_candidates)
|
||||
if aggregate_slack_available < target_added_px:
|
||||
return {
|
||||
@@ -205,7 +199,6 @@ def plan_zone_ratio_retry(
|
||||
),
|
||||
}
|
||||
|
||||
# feasible — greedy aggregation: 각 donor 에서 필요한 만큼만 차감
|
||||
zones_after = dict(zones_before)
|
||||
zones_after[target_zone_position] = zones_before[target_zone_position] + target_added_px
|
||||
donors_used: list[dict] = []
|
||||
@@ -270,6 +263,7 @@ def plan_cross_zone_redistribute(
|
||||
fit_analysis,
|
||||
containers: dict,
|
||||
min_margin_px: float | None = None,
|
||||
manual_zone_positions: set[str] | list[str] | tuple[str, ...] | None = None,
|
||||
) -> dict:
|
||||
"""Cross-zone (intra-zone role-to-role) redistribute plan.
|
||||
|
||||
@@ -279,19 +273,38 @@ def plan_cross_zone_redistribute(
|
||||
from copy import deepcopy
|
||||
from src.fit_verifier import redistribute as _fv_redistribute
|
||||
|
||||
manual_positions = set(manual_zone_positions or [])
|
||||
role_heights_before = {
|
||||
role: float(rf.allocated_px) for role, rf in (fit_analysis.roles or {}).items()
|
||||
role: float(rf.allocated_px)
|
||||
for role, rf in (fit_analysis.roles or {}).items()
|
||||
if role not in manual_positions
|
||||
}
|
||||
base_plan = {
|
||||
"action": "cross_zone_redistribute",
|
||||
"role_heights_before": role_heights_before,
|
||||
"manual_zone_positions": sorted(manual_positions),
|
||||
"manual_geometry_policy": "per_zone_freeze",
|
||||
}
|
||||
if not role_heights_before:
|
||||
return {**base_plan, "feasible": False, "role_heights_after": {},
|
||||
"can_redistribute": False,
|
||||
"failure_reason": "no roles in fit_analysis — cannot redistribute."}
|
||||
|
||||
result = _fv_redistribute(deepcopy(fit_analysis), containers, min_margin_px=min_margin_px)
|
||||
fit_for_redistribute = deepcopy(fit_analysis)
|
||||
fit_for_redistribute.roles = {
|
||||
role: rf
|
||||
for role, rf in (fit_for_redistribute.roles or {}).items()
|
||||
if role not in manual_positions
|
||||
}
|
||||
automatic_containers = {
|
||||
role: value for role, value in (containers or {}).items()
|
||||
if role not in manual_positions
|
||||
}
|
||||
result = _fv_redistribute(
|
||||
fit_for_redistribute,
|
||||
automatic_containers,
|
||||
min_margin_px=min_margin_px,
|
||||
)
|
||||
redistribution = dict(result.redistribution or {})
|
||||
can_redistribute = bool(result.can_redistribute)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user