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:
2026-07-02 17:03:42 +09:00
co-authored by Claude Opus 4.8
parent 97b7833a1b
commit b836e79ee1
527 changed files with 673036 additions and 717 deletions
+150
View File
@@ -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())
+82
View File
@@ -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:]))
+16
View File
@@ -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)
+179
View File
@@ -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()