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,150 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.phase_z2_mapper import _extract_bold_or_plain, split_source
|
||||
from src.phase_z2_pipeline import parse_mdx
|
||||
|
||||
MDX_ROOT = ROOT / "samples" / "mdx"
|
||||
|
||||
|
||||
CONTRACT: dict[str, dict[str, Any]] = {
|
||||
"01. 건설산업 DX의 올바른 이해(0127).mdx": {
|
||||
"top_sections": ["1. 용어 정의", "2. 용어간 상호관계"],
|
||||
"internal_labels": {
|
||||
"01-1": ["건설산업", "BIM(Building Information Modeling) : 디지털 전환을 위한 핵심 기술", "DX(Digital Transformation) : 산업 패러다임의 변화"],
|
||||
"01-2": ["DX는 BIM과 같은 디지털기술을 기반으로 산업 전반의 프로세스를 혁신하는 상위개념", "DX와 BIM의 구분"],
|
||||
},
|
||||
},
|
||||
"02. DX의 시행 목표 및 기대효과.mdx": {
|
||||
"top_sections": ["1. DX의 궁극적 목표", "2. DX 기반 Process 혁신에 따른 주체별 기대효과"],
|
||||
"h3": {
|
||||
"02-2": ["2.1 업무 수행 과정(Process)의 변화", "2.2 DX 시행 주체별 기대효과"],
|
||||
},
|
||||
"internal_labels": {
|
||||
"02-1": ["안전과 품질", "생산성 향상", "소통과 신뢰"],
|
||||
},
|
||||
},
|
||||
"03. DX 시행을 위한 필수 요건 및 혁신 방안.mdx": {
|
||||
"top_sections": ["1. DX 시행을 위한 필수 요건", "2. Process의 혁신과 Product의 변화"],
|
||||
"h3": {
|
||||
"03-2": ["2.1 과정(Process)의 혁신", "2.2 결과(Product)의 변화"],
|
||||
},
|
||||
"internal_labels": {
|
||||
"03-1": ["기술(디지털)", "사람(역량)", "자연(여건)"],
|
||||
},
|
||||
},
|
||||
"04. DX 지연 요인.mdx": {
|
||||
"top_sections": ["1. DX에 대한 인식", "2. DX 추진의 실태"],
|
||||
"h3": {
|
||||
"04-1": ["기술 및 소프트웨어 이해도", "효과와 효율성", "인력 및 교육", "경제적 부담", "실무 및 적용성"],
|
||||
"04-2": ["2.1 정책 및 발주 체계", "2.2 조직 및 수행 역량"],
|
||||
},
|
||||
},
|
||||
"05. 설계 방식의 왜곡.mdx": {
|
||||
"top_sections": ["1. 설계의 자동화", "2. S/W 중심 설계 방식"],
|
||||
"h3": {
|
||||
"05-2": ["1 기능·구조적 한계", "2 기술 역량의 왜곡"],
|
||||
},
|
||||
"internal_labels": {
|
||||
"05-1": ["설계의 개념", "설계의 특성", "'설계 자동화'라는 용어의 오용과 모순"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _h3_titles(raw_content: str) -> list[str]:
|
||||
return [
|
||||
m.group(1).strip()
|
||||
for m in re.finditer(r"(?m)^###\s+(.+?)\s*$", raw_content or "")
|
||||
]
|
||||
|
||||
|
||||
def _top_bullet_labels(raw_content: str) -> list[str]:
|
||||
return [
|
||||
_extract_bold_or_plain(top_line)
|
||||
for top_line, _nested in split_source("top_bullets", raw_content or "")
|
||||
]
|
||||
|
||||
|
||||
def _contains_label(labels: list[str], expected: str) -> bool:
|
||||
return any(expected == label or expected in label for label in labels)
|
||||
|
||||
|
||||
def check_one(filename: str, spec: dict[str, Any]) -> dict[str, Any]:
|
||||
path = MDX_ROOT / filename
|
||||
title, sections, footer = parse_mdx(path)
|
||||
section_titles = [s.title for s in sections]
|
||||
errors: list[str] = []
|
||||
|
||||
expected_top = spec.get("top_sections", [])
|
||||
if section_titles != expected_top:
|
||||
errors.append(
|
||||
f"top_sections mismatch: expected={expected_top!r}, actual={section_titles!r}"
|
||||
)
|
||||
|
||||
section_reports = []
|
||||
for section in sections:
|
||||
h3_titles = _h3_titles(section.raw_content)
|
||||
bullet_labels = _top_bullet_labels(section.raw_content)
|
||||
expected_h3 = (spec.get("h3") or {}).get(section.section_id)
|
||||
if expected_h3 is not None and h3_titles != expected_h3:
|
||||
errors.append(
|
||||
f"{section.section_id} h3 mismatch: expected={expected_h3!r}, actual={h3_titles!r}"
|
||||
)
|
||||
expected_labels = (spec.get("internal_labels") or {}).get(section.section_id, [])
|
||||
missing_labels = [
|
||||
label for label in expected_labels if not _contains_label(bullet_labels, label)
|
||||
]
|
||||
if missing_labels:
|
||||
errors.append(
|
||||
f"{section.section_id} internal labels missing: {missing_labels!r}"
|
||||
)
|
||||
section_reports.append({
|
||||
"section_id": section.section_id,
|
||||
"title": section.title,
|
||||
"h3_titles": h3_titles,
|
||||
"top_bullet_labels_count": len(bullet_labels),
|
||||
"top_bullet_labels_sample": bullet_labels[:12],
|
||||
})
|
||||
|
||||
return {
|
||||
"filename": filename,
|
||||
"title": title,
|
||||
"has_footer": bool(footer),
|
||||
"passed": not errors,
|
||||
"errors": errors,
|
||||
"sections": section_reports,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--write-report", type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
reports = [check_one(filename, spec) for filename, spec in CONTRACT.items()]
|
||||
result = {
|
||||
"passed": all(report["passed"] for report in reports),
|
||||
"reports": reports,
|
||||
}
|
||||
text = json.dumps(result, ensure_ascii=False, indent=2)
|
||||
print(text)
|
||||
if args.write_report:
|
||||
args.write_report.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.write_report.write_text(text + "\n", encoding="utf-8")
|
||||
return 0 if result["passed"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from src.mdx_text_atoms import compare_atom_sets, extract_text_atoms
|
||||
|
||||
|
||||
DEFAULT_PAIRS = [
|
||||
(
|
||||
"samples/mdx/01. 건설산업 DX의 올바른 이해(0127)(원본).mdx",
|
||||
"samples/mdx/01. 건설산업 DX의 올바른 이해(0127).mdx",
|
||||
),
|
||||
(
|
||||
"samples/mdx/02. DX의 시행 목표 및 기대효과(원본).mdx",
|
||||
"samples/mdx/02. DX의 시행 목표 및 기대효과.mdx",
|
||||
),
|
||||
(
|
||||
"samples/mdx/04. DX 지연 요인(원본).mdx",
|
||||
"samples/mdx/04. DX 지연 요인.mdx",
|
||||
),
|
||||
(
|
||||
"samples/mdx/05. 설계 방식의 왜곡(원본).mdx",
|
||||
"samples/mdx/05. 설계 방식의 왜곡.mdx",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Compare original MDX text atoms with standardized MDX text atoms.")
|
||||
parser.add_argument("--root", default=".", help="Repository root. Defaults to current directory.")
|
||||
parser.add_argument("--max-items", type=int, default=8, help="Max examples per mismatch category.")
|
||||
args = parser.parse_args()
|
||||
|
||||
root = Path(args.root).resolve()
|
||||
any_failed = False
|
||||
for original_rel, standardized_rel in DEFAULT_PAIRS:
|
||||
original = root / original_rel
|
||||
standardized = root / standardized_rel
|
||||
print(f"=== {standardized.name} ===")
|
||||
if not original.exists():
|
||||
print(f" ERROR original missing: {original_rel}")
|
||||
any_failed = True
|
||||
continue
|
||||
if not standardized.exists():
|
||||
print(f" ERROR standardized missing: {standardized_rel}")
|
||||
any_failed = True
|
||||
continue
|
||||
|
||||
original_atoms = extract_text_atoms(original)
|
||||
standardized_atoms = extract_text_atoms(standardized)
|
||||
comparison = compare_atom_sets(original_atoms, standardized_atoms)
|
||||
missing = comparison["missing_from_standardized"]
|
||||
added = comparison["added_in_standardized"]
|
||||
print(f" original_atoms : {len(original_atoms)}")
|
||||
print(f" standardized_atoms : {len(standardized_atoms)}")
|
||||
print(f" missing : {len(missing)}")
|
||||
print(f" added : {len(added)}")
|
||||
if missing or added:
|
||||
any_failed = True
|
||||
_print_examples("missing_from_standardized", missing, args.max_items)
|
||||
_print_examples("added_in_standardized", added, args.max_items)
|
||||
else:
|
||||
print(" OK text atom parity")
|
||||
print()
|
||||
|
||||
return 1 if any_failed else 0
|
||||
|
||||
|
||||
def _print_examples(label: str, atoms, max_items: int) -> None:
|
||||
if not atoms:
|
||||
return
|
||||
print(f" {label} examples:")
|
||||
for atom in atoms[:max_items]:
|
||||
print(f" L{atom.line_no} {atom.kind}: {atom.normalized}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,92 @@
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from html import unescape
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.mdx_text_atoms import compare_atom_sets, extract_text_atoms, normalize_text_atom
|
||||
|
||||
|
||||
def _compact(text: str) -> str:
|
||||
normalized = normalize_text_atom(text or "")
|
||||
return re.sub(r"[^0-9A-Za-z가-힣]+", "", normalized)
|
||||
|
||||
|
||||
def _strip_html_to_text(html: str) -> str:
|
||||
html = re.sub(r"(?is)<script.*?</script>", "\n", html)
|
||||
html = re.sub(r"(?is)<style.*?</style>", "\n", html)
|
||||
html = re.sub(r"(?is)<[^>]+>", "\n", html)
|
||||
return unescape(html)
|
||||
|
||||
|
||||
def _load_source_text(run_dir: Path) -> str:
|
||||
step02 = run_dir / "phase_z2" / "steps" / "step02_normalized.json"
|
||||
data = json.loads(step02.read_text(encoding="utf-8"))["data"]
|
||||
parts: list[str] = []
|
||||
if data.get("slide_title"):
|
||||
parts.append(str(data["slide_title"]))
|
||||
for section in data.get("sections") or []:
|
||||
if section.get("title"):
|
||||
parts.append(str(section["title"]))
|
||||
if section.get("raw_content"):
|
||||
parts.append(str(section["raw_content"]))
|
||||
if data.get("slide_footer"):
|
||||
parts.append(str(data["slide_footer"]))
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def check_run(run_dir: Path) -> dict:
|
||||
final_html = run_dir / "phase_z2" / "final.html"
|
||||
source_text = _load_source_text(run_dir)
|
||||
rendered_text = _strip_html_to_text(final_html.read_text(encoding="utf-8"))
|
||||
source_atoms = extract_text_atoms(source_text)
|
||||
rendered_atoms = extract_text_atoms(rendered_text)
|
||||
diff = compare_atom_sets(source_atoms, rendered_atoms)
|
||||
rendered_compact = _compact(" ".join(a.normalized for a in rendered_atoms))
|
||||
compact_missing = [
|
||||
atom for atom in source_atoms
|
||||
if _compact(atom.normalized) and _compact(atom.normalized) not in rendered_compact
|
||||
]
|
||||
return {
|
||||
"run_id": run_dir.name,
|
||||
"source_atoms": len(source_atoms),
|
||||
"rendered_atoms": len(rendered_atoms),
|
||||
"missing_count": len(compact_missing),
|
||||
"added_count": len(diff["added_in_standardized"]),
|
||||
"missing": compact_missing,
|
||||
"added": diff["added_in_standardized"],
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("run_ids", nargs="+")
|
||||
parser.add_argument("--runs-root", default="data/runs")
|
||||
parser.add_argument("--max-items", type=int, default=8)
|
||||
args = parser.parse_args()
|
||||
|
||||
ok = True
|
||||
for run_id in args.run_ids:
|
||||
result = check_run(Path(args.runs_root) / run_id)
|
||||
if result["missing_count"]:
|
||||
ok = False
|
||||
print(f"=== {result['run_id']} ===")
|
||||
print(f" source_atoms : {result['source_atoms']}")
|
||||
print(f" rendered_atoms : {result['rendered_atoms']}")
|
||||
print(f" missing : {result['missing_count']}")
|
||||
print(f" added : {result['added_count']}")
|
||||
for item in result["missing"][: args.max_items]:
|
||||
print(f" - missing: {item.text}")
|
||||
for item in result["added"][: args.max_items]:
|
||||
print(f" + added: {item.text}")
|
||||
print()
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,179 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _open_driver(width: int, height: int):
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
|
||||
options = Options()
|
||||
options.add_argument("--headless=new")
|
||||
options.add_argument("--no-sandbox")
|
||||
options.add_argument("--disable-dev-shm-usage")
|
||||
options.add_argument(f"--window-size={width},{height}")
|
||||
|
||||
candidates = [
|
||||
PROJECT_ROOT / "chromedriver",
|
||||
PROJECT_ROOT / "chromedriver.exe",
|
||||
]
|
||||
last_err: Exception | None = None
|
||||
for path in candidates:
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
driver = webdriver.Chrome(service=Service(str(path)), options=options)
|
||||
break
|
||||
except Exception as exc: # pragma: no cover - environment dependent
|
||||
last_err = exc
|
||||
else:
|
||||
try:
|
||||
driver = webdriver.Chrome(options=options)
|
||||
except Exception as exc: # pragma: no cover - environment dependent
|
||||
raise RuntimeError(f"selenium init failed: {last_err or exc}") from exc
|
||||
|
||||
driver.execute_cdp_cmd(
|
||||
"Emulation.setDeviceMetricsOverride",
|
||||
{
|
||||
"width": width,
|
||||
"height": height,
|
||||
"deviceScaleFactor": 1,
|
||||
"mobile": False,
|
||||
},
|
||||
)
|
||||
return driver
|
||||
|
||||
|
||||
def check_final_html(path: Path, *, width: int, height: int, tolerance: int) -> dict:
|
||||
driver = _open_driver(width, height)
|
||||
try:
|
||||
driver.get(path.resolve().as_uri())
|
||||
result = driver.execute_script(
|
||||
r"""
|
||||
const de = document.documentElement;
|
||||
const body = document.body;
|
||||
const slide = document.querySelector('.slide');
|
||||
const slideRect = slide ? slide.getBoundingClientRect() : null;
|
||||
const measure = (el) => ({
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
scrollWidth: el.scrollWidth,
|
||||
scrollHeight: el.scrollHeight,
|
||||
});
|
||||
return {
|
||||
viewport: {
|
||||
innerWidth: window.innerWidth,
|
||||
innerHeight: window.innerHeight,
|
||||
},
|
||||
documentElement: measure(de),
|
||||
body: measure(body),
|
||||
slide: slideRect ? {
|
||||
x: slideRect.x,
|
||||
y: slideRect.y,
|
||||
width: slideRect.width,
|
||||
height: slideRect.height,
|
||||
right: slideRect.right,
|
||||
bottom: slideRect.bottom,
|
||||
} : null,
|
||||
};
|
||||
"""
|
||||
)
|
||||
finally:
|
||||
driver.quit()
|
||||
|
||||
failures: list[str] = []
|
||||
vp = result["viewport"]
|
||||
doc = result["documentElement"]
|
||||
body = result["body"]
|
||||
slide = result.get("slide")
|
||||
|
||||
if doc["scrollWidth"] > vp["innerWidth"] + tolerance:
|
||||
failures.append(
|
||||
f"document horizontal scroll: {doc['scrollWidth']} > {vp['innerWidth']}"
|
||||
)
|
||||
if doc["scrollHeight"] > vp["innerHeight"] + tolerance:
|
||||
failures.append(
|
||||
f"document vertical scroll: {doc['scrollHeight']} > {vp['innerHeight']}"
|
||||
)
|
||||
if body["scrollWidth"] > vp["innerWidth"] + tolerance:
|
||||
failures.append(f"body horizontal scroll: {body['scrollWidth']} > {vp['innerWidth']}")
|
||||
if body["scrollHeight"] > vp["innerHeight"] + tolerance:
|
||||
failures.append(f"body vertical scroll: {body['scrollHeight']} > {vp['innerHeight']}")
|
||||
if not slide:
|
||||
failures.append(".slide not found")
|
||||
else:
|
||||
if abs(slide["width"] - width) > tolerance or abs(slide["height"] - height) > tolerance:
|
||||
failures.append(
|
||||
f"slide size is {slide['width']}x{slide['height']}, expected {width}x{height}"
|
||||
)
|
||||
if slide["x"] < -tolerance or slide["y"] < -tolerance:
|
||||
failures.append(f"slide origin out of viewport: x={slide['x']}, y={slide['y']}")
|
||||
if slide["right"] > vp["innerWidth"] + tolerance:
|
||||
failures.append(f"slide right edge exceeds viewport: {slide['right']} > {vp['innerWidth']}")
|
||||
if slide["bottom"] > vp["innerHeight"] + tolerance:
|
||||
failures.append(f"slide bottom edge exceeds viewport: {slide['bottom']} > {vp['innerHeight']}")
|
||||
|
||||
return {
|
||||
"path": str(path),
|
||||
"passed": not failures,
|
||||
"failures": failures,
|
||||
"metrics": result,
|
||||
}
|
||||
|
||||
|
||||
def _run_id_to_final_html(run_id: str) -> Path:
|
||||
return PROJECT_ROOT / "data" / "runs" / run_id / "phase_z2" / "final.html"
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("run_ids", nargs="+")
|
||||
parser.add_argument("--width", type=int, default=1280)
|
||||
parser.add_argument("--height", type=int, default=720)
|
||||
parser.add_argument("--tolerance", type=int, default=1)
|
||||
parser.add_argument("--write-report", type=Path)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
reports = []
|
||||
exit_code = 0
|
||||
for run_id in args.run_ids:
|
||||
path = _run_id_to_final_html(run_id)
|
||||
if not path.is_file():
|
||||
report = {
|
||||
"path": str(path),
|
||||
"passed": False,
|
||||
"failures": ["final.html not found"],
|
||||
"metrics": None,
|
||||
}
|
||||
else:
|
||||
report = check_final_html(
|
||||
path,
|
||||
width=args.width,
|
||||
height=args.height,
|
||||
tolerance=args.tolerance,
|
||||
)
|
||||
reports.append({"run_id": run_id, **report})
|
||||
status = "PASS" if report["passed"] else "FAIL"
|
||||
print(f"{run_id}: {status}")
|
||||
for failure in report["failures"]:
|
||||
print(f" - {failure}")
|
||||
if not report["passed"]:
|
||||
exit_code = 1
|
||||
|
||||
if args.write_report:
|
||||
args.write_report.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.write_report.write_text(
|
||||
json.dumps({"reports": reports}, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return exit_code
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,16 @@
|
||||
import os
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
token = os.environ.get("GITEA_TOKEN")
|
||||
url = "https://gitea.hmac.kr/api/v1/repos/Kyeongmin/C.E.L_Slide_test2/issues/35/comments"
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
headers={"Authorization": f"token {token}"},
|
||||
json={"body": "Codex Test Hello from CLI"},
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
print(response.status_code)
|
||||
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Phase Z run 산출물을 frontend prototype 의 client/public/data/ 로 정적 export.
|
||||
|
||||
Usage:
|
||||
python scripts/sync_phase_z_run_to_frontend.py [run_id]
|
||||
python scripts/sync_phase_z_run_to_frontend.py mdx03_f29_fix_check
|
||||
python scripts/sync_phase_z_run_to_frontend.py --list # 사용 가능한 runs 목록
|
||||
|
||||
성격:
|
||||
- Static export (Phase 1 — 보고용 read-only viewer prototype).
|
||||
- 향후 Phase 2 에서 Express endpoint (server/index.ts) 로 대체 가능.
|
||||
- Frontend 의 designAgentApi.ts 의 loadRun(runId) 가 본 산출물을 fetch.
|
||||
|
||||
복사 대상 (Phase Z runtime 결과 → frontend type 매핑에 필요한 최소 set):
|
||||
root: final.html / preview.png / debug.json
|
||||
steps: step01_mdx_source.md (MDX 원문)
|
||||
step01_mdx_upload.json (run metadata)
|
||||
step02_normalized.json (sections)
|
||||
step05_v4_evidence.json (V4 후보 list)
|
||||
step06_composition_plan.json (composition units)
|
||||
step07_layout.json (layout preset + candidates)
|
||||
step08_zone_region_ratios.json (zone/region/display)
|
||||
step09_application_plan.json (적용 계획 — 핵심)
|
||||
step20_slide_status.json (최종 상태)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
RUNS_DIR = PROJECT_ROOT / "data" / "runs"
|
||||
|
||||
FRONTEND_DATA_DIR = Path(
|
||||
"D:/ad-hoc/kei/design_agent_front/design-agent/client/public/data/runs"
|
||||
)
|
||||
|
||||
ROOT_FILES = ["final.html", "preview.png", "debug.json"]
|
||||
|
||||
STEP_FILES = [
|
||||
"step01_mdx_source.md",
|
||||
"step01_mdx_upload.json",
|
||||
"step02_normalized.json",
|
||||
"step05_v4_evidence.json",
|
||||
"step06_composition_plan.json",
|
||||
"step07_layout.json",
|
||||
"step08_zone_region_ratios.json",
|
||||
"step09_application_plan.json",
|
||||
"step20_slide_status.json",
|
||||
]
|
||||
|
||||
|
||||
def list_runs() -> list[Path]:
|
||||
"""data/runs/*/phase_z2 가 있는 run dir 목록."""
|
||||
if not RUNS_DIR.exists():
|
||||
return []
|
||||
return sorted(
|
||||
p for p in RUNS_DIR.iterdir()
|
||||
if p.is_dir() and (p / "phase_z2").exists()
|
||||
)
|
||||
|
||||
|
||||
def sync_run(run_id: str, *, verbose: bool = True) -> int:
|
||||
"""run_id 의 phase_z2 산출물을 frontend 로 복사. 복사된 파일 수 반환."""
|
||||
src = RUNS_DIR / run_id / "phase_z2"
|
||||
if not src.exists():
|
||||
print(f"\n[error] source not found: {src}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
dst = FRONTEND_DATA_DIR / run_id
|
||||
dst.mkdir(parents=True, exist_ok=True)
|
||||
(dst / "steps").mkdir(exist_ok=True)
|
||||
|
||||
copied = 0
|
||||
missing: list[str] = []
|
||||
|
||||
for fname in ROOT_FILES:
|
||||
src_f = src / fname
|
||||
if src_f.exists():
|
||||
shutil.copy2(src_f, dst / fname)
|
||||
copied += 1
|
||||
else:
|
||||
missing.append(fname)
|
||||
|
||||
for fname in STEP_FILES:
|
||||
src_f = src / "steps" / fname
|
||||
if src_f.exists():
|
||||
shutil.copy2(src_f, dst / "steps" / fname)
|
||||
copied += 1
|
||||
else:
|
||||
missing.append(f"steps/{fname}")
|
||||
|
||||
if verbose:
|
||||
print(f"\n[ok] {run_id}")
|
||||
print(f" src : {src}")
|
||||
print(f" dst : {dst}")
|
||||
print(f" copied : {copied} files")
|
||||
if missing:
|
||||
print(f" missing: {len(missing)} files (산출물 없음 — 정상일 수 있음)")
|
||||
for m in missing:
|
||||
print(f" - {m}")
|
||||
return copied
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Phase Z run → frontend prototype 정적 export"
|
||||
)
|
||||
parser.add_argument(
|
||||
"run_id",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help="복사할 run id. 미지정 시 가장 최근 mdx03_* run 사용.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--list",
|
||||
action="store_true",
|
||||
help="사용 가능한 run 목록만 출력 후 종료.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--all-mdx03",
|
||||
action="store_true",
|
||||
help="mdx03_* 로 시작하는 모든 run 복사.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.list:
|
||||
runs = list_runs()
|
||||
print(f"\nAvailable runs ({len(runs)}):")
|
||||
for r in runs:
|
||||
print(f" - {r.name}")
|
||||
return
|
||||
|
||||
# client/public 자체는 존재해야 (frontend repo 가 정상이라는 sanity check)
|
||||
frontend_public = FRONTEND_DATA_DIR.parent.parent # = client/public
|
||||
if not frontend_public.exists():
|
||||
print(
|
||||
f"\n[error] frontend client/public 폴더 없음: {frontend_public}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
" FRONTEND_DATA_DIR 경로 본 script 안에서 확인 필요.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
# data/runs 가 없으면 자동 생성 (정적 export 첫 실행 시)
|
||||
FRONTEND_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if args.all_mdx03:
|
||||
runs = [r for r in list_runs() if r.name.startswith("mdx03_")]
|
||||
print(f"\nSyncing {len(runs)} mdx03_* runs ...")
|
||||
total = 0
|
||||
for r in runs:
|
||||
total += sync_run(r.name, verbose=True)
|
||||
print(f"\n[done] total: {total} files across {len(runs)} runs")
|
||||
return
|
||||
|
||||
if args.run_id is None:
|
||||
# default: 가장 최근 mdx03_* run
|
||||
candidates = [r for r in list_runs() if r.name.startswith("mdx03_")]
|
||||
if not candidates:
|
||||
print(
|
||||
"\n[error] mdx03_* run 없음. 명시적으로 run_id 지정 필요.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
# mtime 기준 최신
|
||||
latest = max(candidates, key=lambda p: p.stat().st_mtime)
|
||||
args.run_id = latest.name
|
||||
print(f"\n[default] 가장 최근 mdx03_* run = {args.run_id}")
|
||||
|
||||
sync_run(args.run_id, verbose=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user