Compare commits
3 Commits
0f0d3fa91f
...
ab2764c8d0
| Author | SHA1 | Date | |
|---|---|---|---|
| ab2764c8d0 | |||
| 5191acad85 | |||
| a422d72c0b |
@@ -300,6 +300,35 @@ export default function Home() {
|
|||||||
if (zoneGeometries && Object.keys(zoneGeometries).length > 0) {
|
if (zoneGeometries && Object.keys(zoneGeometries).length > 0) {
|
||||||
overrides.zoneGeometries = zoneGeometries;
|
overrides.zoneGeometries = zoneGeometries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IMP-08 B-3 : zoneSections forward only when the user diverged from
|
||||||
|
// the auto plan. Codex Stage 3 R3 B3 fix : `createInitialUserSelection`
|
||||||
|
// seeds `zone_sections` with the default placement, so a literal copy
|
||||||
|
// would pollute backend assignment-source provenance even on a fresh
|
||||||
|
// re-render. Diff against `sourcePlan.zones[].section_ids` per zone and
|
||||||
|
// only emit zones whose section list differs.
|
||||||
|
const userZoneSections = state.userSelection.overrides.zone_sections;
|
||||||
|
if (userZoneSections) {
|
||||||
|
const defaultByZone = new Map<string, string[]>();
|
||||||
|
sourcePlan.zones.forEach((z) => {
|
||||||
|
defaultByZone.set(z.zone_id, z.section_ids);
|
||||||
|
});
|
||||||
|
const zoneSectionsDiff: Record<string, string[]> = {};
|
||||||
|
for (const [zoneId, sids] of Object.entries(userZoneSections)) {
|
||||||
|
if (!Array.isArray(sids)) continue;
|
||||||
|
const cleaned = sids.filter((s) => typeof s === "string" && s.trim());
|
||||||
|
const defaults = defaultByZone.get(zoneId) ?? [];
|
||||||
|
const sameAsDefault =
|
||||||
|
cleaned.length === defaults.length &&
|
||||||
|
cleaned.every((sid, i) => sid === defaults[i]);
|
||||||
|
if (!sameAsDefault) {
|
||||||
|
zoneSectionsDiff[zoneId] = cleaned;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(zoneSectionsDiff).length > 0) {
|
||||||
|
overrides.zoneSections = zoneSectionsDiff;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setState((p) => ({ ...p, isLoading: true }));
|
setState((p) => ({ ...p, isLoading: true }));
|
||||||
@@ -310,6 +339,8 @@ export default function Home() {
|
|||||||
? `(overrides: ${[
|
? `(overrides: ${[
|
||||||
overrides.layout && `layout=${overrides.layout}`,
|
overrides.layout && `layout=${overrides.layout}`,
|
||||||
overrides.frames && `frames=${Object.keys(overrides.frames).length}`,
|
overrides.frames && `frames=${Object.keys(overrides.frames).length}`,
|
||||||
|
overrides.zoneSections &&
|
||||||
|
`zoneSections=${Object.keys(overrides.zoneSections).length}`,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(", ")})`
|
.join(", ")})`
|
||||||
|
|||||||
@@ -251,6 +251,11 @@ export interface PipelineOverrides {
|
|||||||
/** zone_id (top/bottom/left/right/...) → slide-body 내부 0~1 비율.
|
/** zone_id (top/bottom/left/right/...) → slide-body 내부 0~1 비율.
|
||||||
* backend 의 build_layout_css 가 horizontal-2 / vertical-2 만 처리. */
|
* backend 의 build_layout_css 가 horizontal-2 / vertical-2 만 처리. */
|
||||||
zoneGeometries?: Record<string, { x: number; y: number; w: number; h: number }>;
|
zoneGeometries?: Record<string, { x: number; y: number; w: number; h: number }>;
|
||||||
|
/** IMP-08 B-3 : zone_id -> list of section_id assignments
|
||||||
|
* (canonical ordinal `${parent}-sub-${n}`). Only forwarded when the
|
||||||
|
* user explicitly diverges from the auto plan; default placements
|
||||||
|
* are not echoed back to avoid polluting override provenance. */
|
||||||
|
zoneSections?: Record<string, string[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runPipeline(
|
export async function runPipeline(
|
||||||
|
|||||||
@@ -241,6 +241,9 @@ function vitePluginPhaseZApi(): Plugin {
|
|||||||
layout?: string;
|
layout?: string;
|
||||||
frames?: Record<string, string>; // unit_id → template_id
|
frames?: Record<string, string>; // unit_id → template_id
|
||||||
zoneGeometries?: Record<string, { x: number; y: number; w: number; h: number }>; // zone_id → bbox (slide-body 내부 0~1)
|
zoneGeometries?: Record<string, { x: number; y: number; w: number; h: number }>; // zone_id → bbox (slide-body 내부 0~1)
|
||||||
|
// IMP-08 B-3 : zone_id -> list of canonical section_id assignments
|
||||||
|
// (e.g., "top": ["03-1-sub-1"]). Forwarded as --override-section-assignment.
|
||||||
|
zoneSections?: Record<string, string[]>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
@@ -322,6 +325,21 @@ function vitePluginPhaseZApi(): Plugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// IMP-08 B-3 — zoneSections override forward to CLI.
|
||||||
|
// Each entry becomes `--override-section-assignment ZONE=sid[,sid]`.
|
||||||
|
// Empty arrays and non-string sids are filtered out so the backend
|
||||||
|
// never receives bogus assignments from a partially-built UI state.
|
||||||
|
if (overrides?.zoneSections && typeof overrides.zoneSections === "object") {
|
||||||
|
for (const [zoneId, sids] of Object.entries(overrides.zoneSections)) {
|
||||||
|
if (!Array.isArray(sids)) continue;
|
||||||
|
const cleaned = sids.filter((s) => typeof s === "string" && s.trim());
|
||||||
|
if (cleaned.length === 0) continue;
|
||||||
|
cliArgs.push(
|
||||||
|
"--override-section-assignment",
|
||||||
|
`${zoneId}=${cleaned.join(",")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
console.log(
|
console.log(
|
||||||
`[phase-z-api] spawn pipeline: run_id=${runId}, mdx=${mdxPath}, args=${JSON.stringify(cliArgs.slice(2))}`
|
`[phase-z-api] spawn pipeline: run_id=${runId}, mdx=${mdxPath}, args=${JSON.stringify(cliArgs.slice(2))}`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ Pipeline 의 빠진 layer = MDX 덩어리들을 *최종 zone unit* 으로 묶는
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -371,13 +372,20 @@ class CompositionUnit:
|
|||||||
# ─── Heading Tree ──────────────────────────────────────────────
|
# ─── Heading Tree ──────────────────────────────────────────────
|
||||||
|
|
||||||
def derive_parent_id(section_id: str) -> Optional[str]:
|
def derive_parent_id(section_id: str) -> Optional[str]:
|
||||||
"""section_id 에서 parent 도출 — V4 키 컨벤션 기반.
|
"""Section id -> parent id derivation by V4 key convention.
|
||||||
|
|
||||||
예시 (코멘트, 룰 X) :
|
IMP-08 B-3 : canonical ordinal `${parent}-sub-${n}` recognised first;
|
||||||
- "04-2.1" → "04-2" (decimal suffix → strip)
|
legacy decimal `04-2.1` kept as fallback alias path.
|
||||||
- "04-1" → None (top-level, no parent)
|
|
||||||
- "04" → None
|
Examples (illustrative, not rules) :
|
||||||
|
- "03-1-sub-2" -> "03-1" (canonical ordinal, IMP-08)
|
||||||
|
- "04-2.1" -> "04-2" (decimal suffix, legacy V4 key style)
|
||||||
|
- "04-1" -> None (top-level, no parent)
|
||||||
|
- "04" -> None
|
||||||
"""
|
"""
|
||||||
|
m = re.fullmatch(r"(.+?)-sub-(\d+)", section_id)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
parts = section_id.split("-", 1)
|
parts = section_id.split("-", 1)
|
||||||
if len(parts) != 2:
|
if len(parts) != 2:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import re
|
|||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -136,6 +136,13 @@ class MdxSection:
|
|||||||
section_num: int
|
section_num: int
|
||||||
title: str
|
title: str
|
||||||
raw_content: str
|
raw_content: str
|
||||||
|
# IMP-08 B-3 sub-section schema (additive, defaults preserve 4-positional callers).
|
||||||
|
# heading_number: decimal "2.1" from MDX `### 2.1 Title` capture (U2-populated).
|
||||||
|
# v4_alias_keys: legacy V4 keys to try when canonical ordinal id misses (e.g. "04-2.1").
|
||||||
|
# sub_sections: raw child payloads from section_parser (Stage 0 adapter consumes).
|
||||||
|
heading_number: Optional[str] = None
|
||||||
|
v4_alias_keys: list = field(default_factory=list)
|
||||||
|
sub_sections: list = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -368,6 +375,16 @@ def load_v4_result() -> dict:
|
|||||||
def align_sections_to_v4_granularity(sections: list[MdxSection], v4: dict) -> list[MdxSection]:
|
def align_sections_to_v4_granularity(sections: list[MdxSection], v4: dict) -> list[MdxSection]:
|
||||||
"""V4 section granularity 에 맞춰 sections 조정.
|
"""V4 section granularity 에 맞춰 sections 조정.
|
||||||
|
|
||||||
|
IMP-08 B-3 : canonical sub-section id ``${section_id}-sub-${ordinal}``
|
||||||
|
(예 : ``04-2-sub-1``) 를 emit 하고, legacy V4 키 (``04-2.1``) 는
|
||||||
|
``v4_alias_keys`` 로 보존하여 ``_resolve_v4_section_key`` 가 alias 경로로
|
||||||
|
매칭한다. canonical ordinal id 는 frontend drag/drop override 와 동일
|
||||||
|
schema (`section_id-sub-N`).
|
||||||
|
|
||||||
|
N-R5 alias guard : heading_number 가 decimal (``2.1``) 일 때만 alias
|
||||||
|
emit. integer-only (``1``) / non-numeric heading 은 alias 0 — sibling
|
||||||
|
parent V4 evidence 로 잘못 promote 되는 collision 방지 (RULE 0).
|
||||||
|
|
||||||
각 section 에 대해 :
|
각 section 에 대해 :
|
||||||
- V4 에 section.section_id 키 있음 → 그대로 유지 (## level 매칭)
|
- V4 에 section.section_id 키 있음 → 그대로 유지 (## level 매칭)
|
||||||
- V4 에 키 없고 raw_content 에 ### sub-section 존재 → ### 로 drill
|
- V4 에 키 없고 raw_content 에 ### sub-section 존재 → ### 로 drill
|
||||||
@@ -381,31 +398,52 @@ def align_sections_to_v4_granularity(sections: list[MdxSection], v4: dict) -> li
|
|||||||
v4_keys = set(v4.get("mdx_sections", {}).keys())
|
v4_keys = set(v4.get("mdx_sections", {}).keys())
|
||||||
aligned: list[MdxSection] = []
|
aligned: list[MdxSection] = []
|
||||||
|
|
||||||
|
# IMP-08 B-3 : capture optional heading-number prefix (decimal "2.1" or
|
||||||
|
# integer "1") + heading title. None group = bare "### Title".
|
||||||
|
sub_pattern = re.compile(
|
||||||
|
r"^###\s+(?:(\d+(?:\.\d+)?)\s+)?(.+?)$", re.MULTILINE
|
||||||
|
)
|
||||||
|
decimal_re = re.compile(r"\d+\.\d+")
|
||||||
|
|
||||||
for section in sections:
|
for section in sections:
|
||||||
if section.section_id in v4_keys:
|
if section.section_id in v4_keys:
|
||||||
aligned.append(section)
|
aligned.append(section)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# ### drill 시도
|
|
||||||
sub_pattern = re.compile(r"^###\s+(\d+\.\d+)\s+(.+?)$", re.MULTILINE)
|
|
||||||
sub_matches = list(sub_pattern.finditer(section.raw_content))
|
sub_matches = list(sub_pattern.finditer(section.raw_content))
|
||||||
if not sub_matches:
|
if not sub_matches:
|
||||||
aligned.append(section) # drill 불가, V4 lookup 에서 abort 됨
|
aligned.append(section) # drill 불가, V4 lookup 에서 abort 됨
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# ### sub-section 추출
|
|
||||||
mdx_id = section.section_id.split("-")[0] # e.g., "04"
|
mdx_id = section.section_id.split("-")[0] # e.g., "04"
|
||||||
for i, m in enumerate(sub_matches):
|
for ordinal, m in enumerate(sub_matches, start=1):
|
||||||
subnum = m.group(1) # e.g., "2.1"
|
heading_number = m.group(1) # decimal "2.1" / integer "1" / None
|
||||||
sub_title = m.group(2).strip()
|
sub_title = m.group(2).strip()
|
||||||
start = m.end()
|
start = m.end()
|
||||||
end = sub_matches[i + 1].start() if i + 1 < len(sub_matches) else len(section.raw_content)
|
end = (
|
||||||
|
sub_matches[ordinal].start()
|
||||||
|
if ordinal < len(sub_matches)
|
||||||
|
else len(section.raw_content)
|
||||||
|
)
|
||||||
raw = section.raw_content[start:end].strip()
|
raw = section.raw_content[start:end].strip()
|
||||||
|
|
||||||
|
# N-R5 : alias only for decimal heading numbers. integer-only
|
||||||
|
# H3 (`### 1`) or undecorated H3 produce no alias to avoid
|
||||||
|
# sibling-parent V4 collisions (e.g., 05.mdx integer H3s).
|
||||||
|
alias_keys: list[str] = []
|
||||||
|
if heading_number and decimal_re.fullmatch(heading_number):
|
||||||
|
alias_keys.append(f"{mdx_id}-{heading_number}")
|
||||||
|
|
||||||
|
title = (
|
||||||
|
f"{heading_number} {sub_title}" if heading_number else sub_title
|
||||||
|
)
|
||||||
aligned.append(MdxSection(
|
aligned.append(MdxSection(
|
||||||
section_id=f"{mdx_id}-{subnum}", # e.g., "04-2.1"
|
section_id=f"{section.section_id}-sub-{ordinal}",
|
||||||
section_num=section.section_num,
|
section_num=section.section_num,
|
||||||
title=f"{subnum} {sub_title}",
|
title=title,
|
||||||
raw_content=raw,
|
raw_content=raw,
|
||||||
|
heading_number=heading_number,
|
||||||
|
v4_alias_keys=alias_keys,
|
||||||
))
|
))
|
||||||
|
|
||||||
return aligned
|
return aligned
|
||||||
@@ -424,8 +462,39 @@ def _v4_match_from_judgment(section_id: str, judgment: dict, rank: Optional[int]
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def lookup_v4_match(v4: dict, section_id: str) -> Optional[V4Match]:
|
def _resolve_v4_section_key(
|
||||||
sec = v4.get("mdx_sections", {}).get(section_id)
|
v4: dict,
|
||||||
|
section_id: str,
|
||||||
|
*,
|
||||||
|
alias_keys: Optional[list] = None,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Resolve a V4 ``mdx_sections`` key for *section_id*.
|
||||||
|
|
||||||
|
Resolution order :
|
||||||
|
1. exact match (canonical ordinal id wins)
|
||||||
|
2. alias_keys in given order (e.g. legacy decimal ``04-2.1`` for ``04-2-sub-1``)
|
||||||
|
3. None on miss.
|
||||||
|
|
||||||
|
Never promotes to parent or sibling — that would reinterpret V4 evidence
|
||||||
|
(axis 7 hybrid lock, RULE 0). U1 callers pass alias_keys=None so the
|
||||||
|
function is byte-identical to the previous exact-match lookup; U2 populates
|
||||||
|
aliases from MDX heading_number metadata.
|
||||||
|
"""
|
||||||
|
keys = v4.get("mdx_sections", {})
|
||||||
|
if section_id in keys:
|
||||||
|
return section_id
|
||||||
|
if alias_keys:
|
||||||
|
for a in alias_keys:
|
||||||
|
if a and a in keys:
|
||||||
|
return a
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_v4_match(
|
||||||
|
v4: dict, section_id: str, *, alias_keys: Optional[list] = None
|
||||||
|
) -> Optional[V4Match]:
|
||||||
|
resolved = _resolve_v4_section_key(v4, section_id, alias_keys=alias_keys)
|
||||||
|
sec = v4.get("mdx_sections", {}).get(resolved) if resolved else None
|
||||||
if not sec:
|
if not sec:
|
||||||
return None
|
return None
|
||||||
judgments = sec.get("judgments_full32", [])
|
judgments = sec.get("judgments_full32", [])
|
||||||
@@ -462,13 +531,15 @@ def lookup_v4_match_with_fallback(
|
|||||||
*,
|
*,
|
||||||
raw_content: Optional[str] = None,
|
raw_content: Optional[str] = None,
|
||||||
max_rank: int = 3,
|
max_rank: int = 3,
|
||||||
|
alias_keys: Optional[list] = None,
|
||||||
) -> tuple[Optional[V4Match], dict]:
|
) -> tuple[Optional[V4Match], dict]:
|
||||||
"""Select V4 rank-1, or promote rank-2/3 when rank-1 is not auto-renderable.
|
"""Select V4 rank-1, or promote rank-2/3 when rank-1 is not auto-renderable.
|
||||||
|
|
||||||
This is an IMP-05 selector only. It uses existing V4 labels, frame-contract
|
This is an IMP-05 selector only. It uses existing V4 labels, frame-contract
|
||||||
presence, and the Phase Z capacity precheck; it does not call calculate_fit.
|
presence, and the Phase Z capacity precheck; it does not call calculate_fit.
|
||||||
"""
|
"""
|
||||||
sec = v4.get("mdx_sections", {}).get(section_id)
|
resolved = _resolve_v4_section_key(v4, section_id, alias_keys=alias_keys)
|
||||||
|
sec = v4.get("mdx_sections", {}).get(resolved) if resolved else None
|
||||||
trace = {
|
trace = {
|
||||||
"section_id": section_id,
|
"section_id": section_id,
|
||||||
"max_rank": max_rank,
|
"max_rank": max_rank,
|
||||||
@@ -571,7 +642,9 @@ def lookup_v4_match_with_fallback(
|
|||||||
return None, trace
|
return None, trace
|
||||||
|
|
||||||
|
|
||||||
def lookup_v4_all_judgments(v4: dict, section_id: str) -> list[V4Match]:
|
def lookup_v4_all_judgments(
|
||||||
|
v4: dict, section_id: str, *, alias_keys: Optional[list] = None
|
||||||
|
) -> list[V4Match]:
|
||||||
"""V4 raw 32 entry 그대로 반환 — reject 포함, max_n filter 없음.
|
"""V4 raw 32 entry 그대로 반환 — reject 포함, max_n filter 없음.
|
||||||
|
|
||||||
Step 7-A axis 보강 (사용자 lock 2026-05-08) — 사용자 UI 가 모든 frame 의
|
Step 7-A axis 보강 (사용자 lock 2026-05-08) — 사용자 UI 가 모든 frame 의
|
||||||
@@ -581,7 +654,8 @@ def lookup_v4_all_judgments(v4: dict, section_id: str) -> list[V4Match]:
|
|||||||
Returns :
|
Returns :
|
||||||
list[V4Match] — 0~32 길이. raw judgments_full32 순서 (= V4 score desc) 보존.
|
list[V4Match] — 0~32 길이. raw judgments_full32 순서 (= V4 score desc) 보존.
|
||||||
"""
|
"""
|
||||||
sec = v4.get("mdx_sections", {}).get(section_id)
|
resolved = _resolve_v4_section_key(v4, section_id, alias_keys=alias_keys)
|
||||||
|
sec = v4.get("mdx_sections", {}).get(resolved) if resolved else None
|
||||||
if not sec:
|
if not sec:
|
||||||
return []
|
return []
|
||||||
judgments = sec.get("judgments_full32", [])
|
judgments = sec.get("judgments_full32", [])
|
||||||
@@ -592,7 +666,11 @@ def lookup_v4_all_judgments(v4: dict, section_id: str) -> list[V4Match]:
|
|||||||
|
|
||||||
|
|
||||||
def lookup_v4_candidates(
|
def lookup_v4_candidates(
|
||||||
v4: dict, section_id: str, max_n: int = 6
|
v4: dict,
|
||||||
|
section_id: str,
|
||||||
|
max_n: int = 6,
|
||||||
|
*,
|
||||||
|
alias_keys: Optional[list] = None,
|
||||||
) -> list[V4Match]:
|
) -> list[V4Match]:
|
||||||
"""V4 non-reject 후보 list 반환 (Step 5 보완 axis — 사용자 lock 2026-05-08).
|
"""V4 non-reject 후보 list 반환 (Step 5 보완 axis — 사용자 lock 2026-05-08).
|
||||||
|
|
||||||
@@ -612,7 +690,8 @@ def lookup_v4_candidates(
|
|||||||
호출처 무변. 본 함수는 Step 5 artifact + Step 9 application_plan input
|
호출처 무변. 본 함수는 Step 5 artifact + Step 9 application_plan input
|
||||||
위한 새 entry point.
|
위한 새 entry point.
|
||||||
"""
|
"""
|
||||||
sec = v4.get("mdx_sections", {}).get(section_id)
|
resolved = _resolve_v4_section_key(v4, section_id, alias_keys=alias_keys)
|
||||||
|
sec = v4.get("mdx_sections", {}).get(resolved) if resolved else None
|
||||||
if not sec:
|
if not sec:
|
||||||
return []
|
return []
|
||||||
judgments = sec.get("judgments_full32", [])
|
judgments = sec.get("judgments_full32", [])
|
||||||
@@ -932,7 +1011,12 @@ def _build_position_assignment_plan(
|
|||||||
if v4 is None or section is None:
|
if v4 is None or section is None:
|
||||||
return None, "no_v4_section", None
|
return None, "no_v4_section", None
|
||||||
raw_content = getattr(section, "raw_content", None)
|
raw_content = getattr(section, "raw_content", None)
|
||||||
match, trace = lookup_v4_match_with_fallback(v4, sid, raw_content=raw_content)
|
# IMP-08 B-3 : forward sub-section V4 aliases (decimal heading_number)
|
||||||
|
# when canonical ordinal id misses; safe for top-level sids (empty list).
|
||||||
|
alias_keys = list(getattr(section, "v4_alias_keys", []) or [])
|
||||||
|
match, trace = lookup_v4_match_with_fallback(
|
||||||
|
v4, sid, raw_content=raw_content, alias_keys=alias_keys
|
||||||
|
)
|
||||||
if match is None:
|
if match is None:
|
||||||
return None, "no_direct_render_template", trace
|
return None, "no_direct_render_template", trace
|
||||||
return match.template_id, None, trace
|
return match.template_id, None, trace
|
||||||
@@ -2039,6 +2123,11 @@ def run_phase_z2_mvp1(
|
|||||||
# candidate (separate / parent_merged) → score → greedy non-overlapping select →
|
# candidate (separate / parent_merged) → score → greedy non-overlapping select →
|
||||||
# layout preset (count-based v0).
|
# layout preset (count-based v0).
|
||||||
section_content_by_id = {s.section_id: s.raw_content for s in sections}
|
section_content_by_id = {s.section_id: s.raw_content for s in sections}
|
||||||
|
# IMP-08 B-3 : sub-section ordinal id -> legacy V4 key aliases (e.g. "04-2.1").
|
||||||
|
# Empty list for canonical (top-level) sections — U1 baseline path is exact-only.
|
||||||
|
section_alias_by_id: dict[str, list] = {
|
||||||
|
s.section_id: list(getattr(s, "v4_alias_keys", []) or []) for s in sections
|
||||||
|
}
|
||||||
v4_fallback_traces: dict[str, dict] = {}
|
v4_fallback_traces: dict[str, dict] = {}
|
||||||
|
|
||||||
def lookup_fn(sid: str) -> Optional[V4Match]:
|
def lookup_fn(sid: str) -> Optional[V4Match]:
|
||||||
@@ -2047,6 +2136,7 @@ def run_phase_z2_mvp1(
|
|||||||
sid,
|
sid,
|
||||||
raw_content=section_content_by_id.get(sid),
|
raw_content=section_content_by_id.get(sid),
|
||||||
max_rank=3,
|
max_rank=3,
|
||||||
|
alias_keys=section_alias_by_id.get(sid),
|
||||||
)
|
)
|
||||||
v4_fallback_traces[sid] = trace
|
v4_fallback_traces[sid] = trace
|
||||||
return match
|
return match
|
||||||
@@ -2054,7 +2144,7 @@ def run_phase_z2_mvp1(
|
|||||||
# Step 6-A axis (사용자 lock 2026-05-08) — V4 raw dict 흡수 fn.
|
# Step 6-A axis (사용자 lock 2026-05-08) — V4 raw dict 흡수 fn.
|
||||||
# composition module 은 V4 yaml shape 모름. 본 fn 만 통해 후보 list 받음.
|
# composition module 은 V4 yaml shape 모름. 본 fn 만 통해 후보 list 받음.
|
||||||
def candidates_lookup_fn(sid: str) -> list[V4Match]:
|
def candidates_lookup_fn(sid: str) -> list[V4Match]:
|
||||||
return lookup_v4_candidates(v4, sid)
|
return lookup_v4_candidates(v4, sid, alias_keys=section_alias_by_id.get(sid))
|
||||||
|
|
||||||
units, layout_preset, comp_debug = plan_composition(
|
units, layout_preset, comp_debug = plan_composition(
|
||||||
sections, lookup_fn, V4_LABEL_TO_PHASE_Z_STATUS, MVP1_ALLOWED_STATUSES,
|
sections, lookup_fn, V4_LABEL_TO_PHASE_Z_STATUS, MVP1_ALLOWED_STATUSES,
|
||||||
@@ -2777,6 +2867,11 @@ def run_phase_z2_mvp1(
|
|||||||
note="V4 evidence 와 B4 통합 미완 — 별 axis. 현재 = composition planner 의 V4 rank-1 채택.",
|
note="V4 evidence 와 B4 통합 미완 — 별 axis. 현재 = composition planner 의 V4 rank-1 채택.",
|
||||||
)
|
)
|
||||||
# Step 9 HTML — V4 top candidates per zone (rank 1~4)
|
# Step 9 HTML — V4 top candidates per zone (rank 1~4)
|
||||||
|
# IMP-08 N-R6 diagnostic exemption : this report path is post-decision
|
||||||
|
# reporting only. Runtime selection goes through _resolve_v4_section_key
|
||||||
|
# (4 sites). Direct dict lookup here is intentional — debug_zones carries
|
||||||
|
# dict-shape entries without v4_alias_keys plumbing, and a miss here only
|
||||||
|
# yields a "V4 entry 없음" report line (runtime impact zero).
|
||||||
try:
|
try:
|
||||||
with open(V4_RESULT_PATH, encoding="utf-8") as _vf:
|
with open(V4_RESULT_PATH, encoding="utf-8") as _vf:
|
||||||
_v4_full = yaml.safe_load(_vf)
|
_v4_full = yaml.safe_load(_vf)
|
||||||
@@ -3263,7 +3358,12 @@ def run_phase_z2_mvp1(
|
|||||||
# 모든 frame 의 png 를 카드로 보여주기 위함).
|
# 모든 frame 의 png 를 카드로 보여주기 위함).
|
||||||
# unit_id = source_section_ids join. parent_merged 는 첫 section 의
|
# unit_id = source_section_ids join. parent_merged 는 첫 section 의
|
||||||
# judgments 사용 (parent V4 entry 가 그 section 에 있으므로).
|
# judgments 사용 (parent V4 entry 가 그 section 에 있으므로).
|
||||||
v4_all_for_unit = lookup_v4_all_judgments(v4, unit.source_section_ids[0])
|
# IMP-08 B-3 : forward sub-section V4 aliases (decimal heading_number)
|
||||||
|
# when canonical ordinal id misses; U1 default = empty list (no change).
|
||||||
|
_first_sid = unit.source_section_ids[0]
|
||||||
|
v4_all_for_unit = lookup_v4_all_judgments(
|
||||||
|
v4, _first_sid, alias_keys=section_alias_by_id.get(_first_sid)
|
||||||
|
)
|
||||||
|
|
||||||
# application_candidates : V4 후보 zip 으로 application_mode 변환
|
# application_candidates : V4 후보 zip 으로 application_mode 변환
|
||||||
app_candidates = []
|
app_candidates = []
|
||||||
|
|||||||
180
tests/test_phase_z2_subsection_schema.py
Normal file
180
tests/test_phase_z2_subsection_schema.py
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
"""IMP-08 B-3 sub-section drag/drop — schema + V4 alias resolver tests.
|
||||||
|
|
||||||
|
Fully synthetic per Codex #7 generalization guardrail:
|
||||||
|
NO real catalog template_id / frame_id, NO ``v4_full32_result.yaml`` dependency,
|
||||||
|
NO MDX-specific section ids beyond canonical id format.
|
||||||
|
|
||||||
|
Locked scope (Stage 3 R8) :
|
||||||
|
A. ``derive_parent_id`` canonical ordinal recognition + legacy decimal fallback.
|
||||||
|
B. ``_resolve_v4_section_key`` exact > alias > None (no parent/sibling promotion).
|
||||||
|
C. ``align_sections_to_v4_granularity`` canonical ordinal id emit + N-R5
|
||||||
|
decimal-only alias guard + MdxSection default-construction stability.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.phase_z2_composition import derive_parent_id
|
||||||
|
from src.phase_z2_pipeline import (
|
||||||
|
MdxSection,
|
||||||
|
_resolve_v4_section_key,
|
||||||
|
align_sections_to_v4_granularity,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── A. derive_parent_id ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_derive_parent_id_ordinal_sub():
|
||||||
|
assert derive_parent_id("03-1-sub-2") == "03-1"
|
||||||
|
assert derive_parent_id("04-2-sub-1") == "04-2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_derive_parent_id_decimal_legacy_alias():
|
||||||
|
# Legacy V4 decimal id retains existing behaviour for alias path.
|
||||||
|
assert derive_parent_id("04-2.1") == "04-2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_derive_parent_id_top_level_none():
|
||||||
|
assert derive_parent_id("04-1") is None
|
||||||
|
assert derive_parent_id("04") is None
|
||||||
|
assert derive_parent_id("nonsense") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── B. _resolve_v4_section_key ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_v4(*keys):
|
||||||
|
return {"mdx_sections": {k: {"judgments_full32": []} for k in keys}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_alias_resolver_exact_match_wins():
|
||||||
|
v4 = _fake_v4("04-2-sub-1", "04-2.1")
|
||||||
|
assert _resolve_v4_section_key(v4, "04-2-sub-1") == "04-2-sub-1"
|
||||||
|
assert (
|
||||||
|
_resolve_v4_section_key(v4, "04-2-sub-1", alias_keys=["04-2.1"])
|
||||||
|
== "04-2-sub-1"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_alias_resolver_decimal_alias_when_metadata_present():
|
||||||
|
v4 = _fake_v4("04-2.1")
|
||||||
|
assert (
|
||||||
|
_resolve_v4_section_key(v4, "04-2-sub-1", alias_keys=["04-2.1"])
|
||||||
|
== "04-2.1"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_alias_resolver_no_parent_promotion():
|
||||||
|
# parent V4 entry must not be promoted into a sibling sub-section lookup.
|
||||||
|
v4 = _fake_v4("04-2")
|
||||||
|
assert _resolve_v4_section_key(v4, "04-2-sub-1") is None
|
||||||
|
assert (
|
||||||
|
_resolve_v4_section_key(v4, "04-2-sub-1", alias_keys=["04-2"])
|
||||||
|
== "04-2"
|
||||||
|
) # alias is opt-in; only resolves when caller explicitly provides it
|
||||||
|
|
||||||
|
|
||||||
|
def test_alias_resolver_no_sibling_promotion():
|
||||||
|
# sibling sub-section entry must not be auto-promoted without an alias.
|
||||||
|
v4 = _fake_v4("04-2-sub-2")
|
||||||
|
assert _resolve_v4_section_key(v4, "04-2-sub-1") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_alias_resolver_miss_returns_none():
|
||||||
|
v4 = _fake_v4("99-1")
|
||||||
|
assert _resolve_v4_section_key(v4, "04-2-sub-1") is None
|
||||||
|
assert (
|
||||||
|
_resolve_v4_section_key(v4, "04-2-sub-1", alias_keys=["04-2.1"])
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── C. align_sections_to_v4_granularity ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _section(section_id, num, title, raw_content):
|
||||||
|
"""Build an MdxSection with default sub-section schema fields."""
|
||||||
|
return MdxSection(
|
||||||
|
section_id=section_id,
|
||||||
|
section_num=num,
|
||||||
|
title=title,
|
||||||
|
raw_content=raw_content,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mdx_section_default_construction_preserves_4_positional_callers():
|
||||||
|
# IMP-08 B-3 : MdxSection still accepts the legacy 4-positional shape
|
||||||
|
# (defaults for heading_number / v4_alias_keys / sub_sections).
|
||||||
|
s = MdxSection("04-1", 1, "1. Top", "body")
|
||||||
|
assert s.heading_number is None
|
||||||
|
assert s.v4_alias_keys == []
|
||||||
|
assert s.sub_sections == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_align_passthrough_when_v4_key_exact_match():
|
||||||
|
# Section already aligned to V4 key — aligner keeps it untouched.
|
||||||
|
sections = [_section("04-1", 1, "1. Top", "body")]
|
||||||
|
v4 = {"mdx_sections": {"04-1": {"judgments_full32": []}}}
|
||||||
|
out = align_sections_to_v4_granularity(sections, v4)
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0].section_id == "04-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_align_drill_emits_canonical_ordinal_id_with_decimal_alias():
|
||||||
|
# Decimal H3 headings -> canonical ordinal id + decimal alias (legacy V4 key).
|
||||||
|
raw = "### 2.1 First\nbody1\n### 2.2 Second\nbody2\n"
|
||||||
|
sections = [_section("04-2", 2, "2. Parent", raw)]
|
||||||
|
v4 = {"mdx_sections": {}} # forces drill (no exact key)
|
||||||
|
out = align_sections_to_v4_granularity(sections, v4)
|
||||||
|
assert [s.section_id for s in out] == ["04-2-sub-1", "04-2-sub-2"]
|
||||||
|
assert [s.heading_number for s in out] == ["2.1", "2.2"]
|
||||||
|
# N-R5 : decimal headings -> alias emitted.
|
||||||
|
assert out[0].v4_alias_keys == ["04-2.1"]
|
||||||
|
assert out[1].v4_alias_keys == ["04-2.2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_align_drill_integer_only_h3_emits_no_alias_n_r5_guard():
|
||||||
|
# N-R5 : integer-only H3 (e.g., "### 1 Title") must NOT generate an alias,
|
||||||
|
# otherwise it would collide with sibling parent V4 entries (`{mdx_id}-1`).
|
||||||
|
raw = "### 1 Alpha\nbody1\n### 2 Beta\nbody2\n"
|
||||||
|
sections = [_section("05-2", 2, "2. Parent", raw)]
|
||||||
|
v4 = {"mdx_sections": {}}
|
||||||
|
out = align_sections_to_v4_granularity(sections, v4)
|
||||||
|
assert [s.section_id for s in out] == ["05-2-sub-1", "05-2-sub-2"]
|
||||||
|
assert [s.heading_number for s in out] == ["1", "2"]
|
||||||
|
assert out[0].v4_alias_keys == []
|
||||||
|
assert out[1].v4_alias_keys == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_align_drill_undecorated_h3_emits_no_alias():
|
||||||
|
# Plain `### Title` without numeric prefix -> heading_number=None, no alias.
|
||||||
|
raw = "### Alpha\nbody1\n### Beta\nbody2\n"
|
||||||
|
sections = [_section("03-3", 3, "3. Parent", raw)]
|
||||||
|
v4 = {"mdx_sections": {}}
|
||||||
|
out = align_sections_to_v4_granularity(sections, v4)
|
||||||
|
assert [s.section_id for s in out] == ["03-3-sub-1", "03-3-sub-2"]
|
||||||
|
assert [s.heading_number for s in out] == [None, None]
|
||||||
|
assert all(s.v4_alias_keys == [] for s in out)
|
||||||
|
|
||||||
|
|
||||||
|
def test_align_no_h3_passes_section_through_unchanged():
|
||||||
|
# No H3 sub-headings in raw_content -> aligner keeps the section.
|
||||||
|
sections = [_section("04-1", 1, "1. Top", "no subheadings here\njust prose")]
|
||||||
|
v4 = {"mdx_sections": {}}
|
||||||
|
out = align_sections_to_v4_granularity(sections, v4)
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0].section_id == "04-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_align_resolver_round_trip_with_legacy_v4_alias():
|
||||||
|
# End-to-end : aligner emits canonical id + alias keys; resolver finds the
|
||||||
|
# legacy decimal key in V4 via alias path (no parent promotion).
|
||||||
|
raw = "### 2.1 First\nbody1\n"
|
||||||
|
sections = [_section("04-2", 2, "2. Parent", raw)]
|
||||||
|
v4 = {"mdx_sections": {"04-2.1": {"judgments_full32": []}}}
|
||||||
|
out = align_sections_to_v4_granularity(sections, v4)
|
||||||
|
sub = out[0]
|
||||||
|
assert sub.section_id == "04-2-sub-1"
|
||||||
|
resolved = _resolve_v4_section_key(
|
||||||
|
v4, sub.section_id, alias_keys=sub.v4_alias_keys
|
||||||
|
)
|
||||||
|
assert resolved == "04-2.1"
|
||||||
Reference in New Issue
Block a user