docs + V4 catalog + samples + Phase Q legacy 보존
전체 26 files (20 추가 + 6 수정), 10507 insertions. Phase Z 문서 : - docs/architecture/PHASE-Z-CHANGE-LOG.md (신설) — axis-by-axis 의사결정 history (newest-on-top). Step 7-A 부터 6 entry 박힘 + 2026-05-08 / 2026-05-08 #2 (compat 매트릭스 폐기 / 6-B 폐기 / F14 표현 정정 / label gate policy 분리). - docs/architecture/PHASE-Z-PIPELINE-OVERVIEW.md (수정) — Step 5/6/9 Gap note append (구조 무변, append-only). 6-B 폐기 사실 + Refinement F. - docs/architecture/PHASE-Z-PIPELINE-STATUS-BOARD.md (수정) — snapshot date 2026-05-08 갱신. §3 핵심 missing item 5 (Step 5/6/9 boundary axis breakdown + 폐기 기록). §6 한 줄 갱신 — 다음 axis 후보 A~F. Project root docs : - PLAN.md / PROGRESS.md / README.md (수정) — 토큰 체계 / 폴더 구조 / 설계 문서 / 역할 분리 반영. - IMPROVEMENT-REDESIGN.md (신설) — Phase Z 설계 핵심 문서. - PROCESS_OVERVIEW.html (신설) — 파이프라인 개요 시각. - docs/tasks/* (신설) — Phase Z task 문서. V4 catalog (Phase Z runtime 필수 의존성) : - tests/matching/v4_full32_result.yaml (신설, 4888 줄) — V4 매칭 결과 32 frame × 10 MDX section. lookup_v4_match() / lookup_v4_candidates() 가 본 파일 read. Phase Z runtime 이 *없으면 즉시 abort* — clone 후 즉시 동작 가능 보장. Samples : - samples/mdx_batch/04.mdx (신설) — MDX04 기본 sample. - samples/mdx/04. DX 지연 요인.mdx (신설) — MDX04 원본. Phase Q legacy 보존 (별 axis "Phase Q audit & salvage" 영역) : - src/block_matcher_tfidf.py / catalog_blocks.py / frame_extractor.py / pipeline_v2.py — Phase Q (옛 파이프라인) src 신규 untracked 파일들. Phase Z runtime 와 의존성 0. Phase Q audit axis 에서 검토 예정. - scripts/eval_block_matcher.py / fetch_all_frame_screenshots.py / match_17_units_my_matcher.py / match_mdx_strict.py / match_mdx_to_frames_tfidf.py / ocr_augment_texts.py / run_pipeline_v2.py / previews/ — Phase Q 작업 시 사용한 옛 script. 같이 보존. - run_mdx03_pipeline.py (수정) — Phase Q 진입점 (no flag) + Phase Z 진입점 (--phase-z2 flag) 동시 wrapper. Phase Z 만 사용 시 `python -m src.phase_z2_pipeline samples/mdx_batch/03.mdx <run_id>` 직접 호출. 비-scope : - tests/matching/ (v4_full32_result.yaml 외 ~63MB) — V4 진화 history / reports / DECK / ATTACH. Phase Q audit axis 에서 검토. - tests/pipeline/ (~15MB) — pipeline data. Phase Q audit 영역. - templates/catalog/blocks.yaml — 옛 block catalog. Phase Q audit. - templates/phase_z2/frames/ — 옛 frame partial 위치. Phase Q audit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
82
scripts/eval_block_matcher.py
Normal file
82
scripts/eval_block_matcher.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""블록 매칭 비교 스크립트.
|
||||
|
||||
기존 tag/item_count 매칭과 새 TF-IDF 매칭을 나란히 비교.
|
||||
"진짜 좋아졌나?"를 판단하기 위한 도구.
|
||||
|
||||
사용법:
|
||||
python scripts/eval_block_matcher.py
|
||||
|
||||
출력:
|
||||
각 MDX의 중목차별로:
|
||||
- legacy 매칭 결과 (기존)
|
||||
- tfidf 매칭 결과 (새)
|
||||
- 일치 여부
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from src.mdx_normalizer import normalize_mdx_content as normalize_mdx
|
||||
from src.pipeline_v2 import match_blocks_for_sections
|
||||
|
||||
|
||||
def evaluate_mdx(mdx_path: Path):
|
||||
"""단일 MDX에 대해 TF-IDF 매칭 결과를 출력."""
|
||||
content = mdx_path.read_text(encoding="utf-8")
|
||||
result = normalize_mdx(content)
|
||||
sections = result.get("sections", [])
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"MDX: {mdx_path.name}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
v2_results = match_blocks_for_sections(sections)
|
||||
|
||||
for zone_name, info in v2_results.items():
|
||||
path = info["path"]
|
||||
match = info.get("match")
|
||||
sub_titles = info.get("sub_titles", [])
|
||||
candidates = info.get("candidates", [])
|
||||
|
||||
print(f"\n zone: {zone_name}")
|
||||
print(f" sub_titles: {sub_titles}")
|
||||
print(f" path: {path}")
|
||||
|
||||
if match:
|
||||
print(f" ✅ direct-fit: {match['block_id']} (score={match['score']})")
|
||||
else:
|
||||
print(f" → recipe 경로")
|
||||
if candidates:
|
||||
for i, c in enumerate(candidates):
|
||||
print(f" 후보 {i+1}: {c['block_id']} (score={c['score']})")
|
||||
else:
|
||||
print(f" 후보 없음")
|
||||
|
||||
|
||||
def main():
|
||||
mdx_dir = Path("samples/mdx")
|
||||
if not mdx_dir.exists():
|
||||
print(f"MDX 폴더 없음: {mdx_dir}")
|
||||
return
|
||||
|
||||
mdx_files = sorted(mdx_dir.glob("*.mdx"))
|
||||
if not mdx_files:
|
||||
print("MDX 파일 없음")
|
||||
return
|
||||
|
||||
print(f"블록 매칭 평가 ({len(mdx_files)}개 MDX)")
|
||||
print(f"catalog: templates/catalog/blocks.yaml")
|
||||
|
||||
for mdx_path in mdx_files:
|
||||
try:
|
||||
evaluate_mdx(mdx_path)
|
||||
except Exception as e:
|
||||
print(f"\n ❌ {mdx_path.name}: {e}")
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print("평가 완료")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
180
scripts/fetch_all_frame_screenshots.py
Normal file
180
scripts/fetch_all_frame_screenshots.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""모든 Figma 프레임의 스크린샷을 번호 붙은 단일 폴더로 정리.
|
||||
|
||||
결과:
|
||||
data/figma_previews/01.png, 02.png, ..., 32.png
|
||||
data/figma_previews/index.json ({number: {frame_id, node_id, title}})
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib import error, request
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from src.frame_extractor import extract_all_frames
|
||||
|
||||
MCP_URL = "http://127.0.0.1:3845/mcp"
|
||||
OUT_DIR = Path("data/figma_previews")
|
||||
|
||||
# frame_id → node_id (32개, metadata에서 추출)
|
||||
FRAME_NODE_MAP = {
|
||||
"1171281172": "145:8352",
|
||||
"1171281173": "182:2870",
|
||||
"1171281174": "182:2810",
|
||||
"1171281175": "182:2829",
|
||||
"1171281176": "182:3046",
|
||||
"1171281177": "182:3053",
|
||||
"1171281178": "145:8394",
|
||||
"1171281179": "182:3024",
|
||||
"1171281180": "112:87",
|
||||
"1171281181": "182:2572",
|
||||
"1171281182": "182:2523",
|
||||
"1171281189": "100:65",
|
||||
"1171281190": "51:99",
|
||||
"1171281191": "100:132",
|
||||
"1171281192": "182:2602",
|
||||
"1171281193": "106:205",
|
||||
"1171281194": "112:7",
|
||||
"1171281195": "106:252",
|
||||
"1171281197": "182:2727",
|
||||
"1171281198": "182:2766",
|
||||
"1171281201": "145:8310",
|
||||
"1171281202": "112:49",
|
||||
"1171281203": "145:8266",
|
||||
"1171281204": "145:8223",
|
||||
"1171281205": "182:2668",
|
||||
"1171281206": "182:2643",
|
||||
"1171281208": "145:8504",
|
||||
"1171281209": "145:8523",
|
||||
"1171281210": "181:2519",
|
||||
"1171281211": "181:2520",
|
||||
"1171281212": "181:2521",
|
||||
"1171281213": "181:2522",
|
||||
}
|
||||
|
||||
|
||||
def parse_sse(body: str) -> dict:
|
||||
for line in body.splitlines():
|
||||
if line.startswith("data: "):
|
||||
return json.loads(line[6:])
|
||||
raise RuntimeError(f"No data line in response: {body[:200]}")
|
||||
|
||||
|
||||
def post(payload: dict, session_id: str | None = None) -> tuple[dict, str | None]:
|
||||
data = json.dumps(payload).encode()
|
||||
req = request.Request(
|
||||
MCP_URL,
|
||||
data=data,
|
||||
method="POST",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/event-stream",
|
||||
},
|
||||
)
|
||||
if session_id:
|
||||
req.add_header("mcp-session-id", session_id)
|
||||
with request.urlopen(req, timeout=60) as resp:
|
||||
body = resp.read().decode()
|
||||
sid = resp.headers.get("mcp-session-id")
|
||||
return (parse_sse(body) if body.strip() else {}, sid)
|
||||
|
||||
|
||||
def initialize() -> str:
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "frame-dumper", "version": "1.0"},
|
||||
},
|
||||
}
|
||||
_, sid = post(payload)
|
||||
notify = {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}
|
||||
data = json.dumps(notify).encode()
|
||||
req = request.Request(
|
||||
MCP_URL,
|
||||
data=data,
|
||||
method="POST",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/event-stream",
|
||||
"mcp-session-id": sid or "",
|
||||
},
|
||||
)
|
||||
try:
|
||||
request.urlopen(req, timeout=10).read()
|
||||
except error.HTTPError:
|
||||
pass
|
||||
return sid or ""
|
||||
|
||||
|
||||
def get_screenshot(session_id: str, node_id: str, call_id: int) -> bytes:
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": call_id,
|
||||
"method": "tools/call",
|
||||
"params": {"name": "get_screenshot", "arguments": {"nodeId": node_id}},
|
||||
}
|
||||
resp, _ = post(payload, session_id=session_id)
|
||||
if "error" in resp:
|
||||
raise RuntimeError(f"MCP error for {node_id}: {resp['error']}")
|
||||
for item in resp.get("result", {}).get("content", []):
|
||||
if item.get("type") == "image":
|
||||
return base64.b64decode(item["data"])
|
||||
raise RuntimeError(f"No image in response for {node_id}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
# frame_id → title_text 맵
|
||||
frames = extract_all_frames("figma_to_html_agent/blocks")
|
||||
title_map = {f["frame_id"]: (f.get("title_text") or "").replace("\n", " ")[:80] for f in frames}
|
||||
|
||||
# 정렬된 frame_id 목록에 1부터 번호 매김
|
||||
frame_ids = sorted(FRAME_NODE_MAP.keys())
|
||||
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print("[init] MCP session...")
|
||||
sid = initialize()
|
||||
print(f"[init] session-id={sid}")
|
||||
|
||||
index: dict[str, dict] = {}
|
||||
for i, fid in enumerate(frame_ids, start=1):
|
||||
node_id = FRAME_NODE_MAP[fid]
|
||||
num = f"{i:02d}"
|
||||
out_path = OUT_DIR / f"{num}.png"
|
||||
title = title_map.get(fid, "")
|
||||
|
||||
if out_path.exists():
|
||||
print(f"[{num}] {fid} (node {node_id}) — 이미 있음, skip")
|
||||
else:
|
||||
print(f"[{num}] {fid} (node {node_id}) fetching...")
|
||||
try:
|
||||
png = get_screenshot(sid, node_id, 100 + i)
|
||||
out_path.write_bytes(png)
|
||||
print(f" saved {len(png)} bytes → {out_path}")
|
||||
except Exception as e:
|
||||
print(f" FAILED: {e}")
|
||||
continue
|
||||
|
||||
index[num] = {
|
||||
"frame_id": fid,
|
||||
"node_id": node_id,
|
||||
"title_text": title,
|
||||
"png": f"{num}.png",
|
||||
}
|
||||
|
||||
(OUT_DIR / "index.json").write_text(
|
||||
json.dumps(index, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
print(f"\n[done] {len(index)}개 저장, index: {OUT_DIR/'index.json'}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
334
scripts/match_17_units_my_matcher.py
Normal file
334
scripts/match_17_units_my_matcher.py
Normal file
@@ -0,0 +1,334 @@
|
||||
"""17개 콘텐츠 단위를 각각 추출하여 내 매처(src/block_matcher_tfidf.py, 32프레임 IDF 고정)로 매칭.
|
||||
|
||||
단위:
|
||||
1. MDX01-intro — 중목차 앞 본문
|
||||
2. MDX01-intro-details — 팝업: 혼용 대표 사례
|
||||
3. MDX01-1 — 중목차: 용어 정의
|
||||
4. MDX01-2 — 중목차: 용어간 상호관계 (본문, 표 제외)
|
||||
5. MDX01-2-image — 이미지 캡션: DX와 핵심기술간 상호관계
|
||||
6. MDX01-2-details — 팝업+표: DX와 BIM 구분 12행
|
||||
7. MDX02-1 — 중목차: DX의 궁극적 목표
|
||||
8. MDX02-1-image — 이미지 캡션
|
||||
9. MDX02-2 — 중목차(컨테이너): 타이틀 + 도입부만
|
||||
10. MDX02-2.1 — 소목차: 업무 수행 과정의 변화
|
||||
11. MDX02-2.2 — 소목차: 주체별 기대효과 (본문, 표 제외)
|
||||
12. MDX02-2.2-table — 표: 발주자/시공자/설계자
|
||||
13. MDX03-1 — 중목차: 필수 요건
|
||||
14. MDX03-2 — 중목차(컨테이너)
|
||||
15. MDX03-2.1 — 소목차: 과정의 혁신 (본문, 표 제외)
|
||||
16. MDX03-2.1-table — 표: As-is/To-be
|
||||
17. MDX03-2.2 — 소목차: 결과의 변화
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from src.block_matcher_tfidf import TfidfBlockMatcher
|
||||
|
||||
|
||||
TOP_K = 3
|
||||
|
||||
PREVIEW_DIR = Path("data/figma_previews")
|
||||
_INDEX: dict[str, dict] = json.loads((PREVIEW_DIR / "index.json").read_text(encoding="utf-8"))
|
||||
FRAME_TO_NUM: dict[str, str] = {v["frame_id"]: k for k, v in _INDEX.items()}
|
||||
|
||||
|
||||
def num_of(fid: str) -> str:
|
||||
return FRAME_TO_NUM.get(fid, f"?({fid})")
|
||||
|
||||
|
||||
def ftitle(matcher: TfidfBlockMatcher, fid: str) -> str:
|
||||
for f in matcher.frames:
|
||||
if f["frame_id"] == fid:
|
||||
return (f.get("title_text") or "").replace("\n", " ")[:50]
|
||||
return ""
|
||||
|
||||
|
||||
def strip_tags(t: str) -> str:
|
||||
t = re.sub(r"<[^>]+>", " ", t)
|
||||
t = re.sub(r"\{/\*.*?\*/\}", " ", t, flags=re.DOTALL)
|
||||
t = re.sub(r"\{[^{}]*\}", " ", t)
|
||||
t = re.sub(r"\s+", " ", t).strip()
|
||||
return t
|
||||
|
||||
|
||||
def extract_details(raw: str) -> list[dict]:
|
||||
out = []
|
||||
for m in re.finditer(r"<details\b[^>]*>([\s\S]*?)</details>", raw, re.IGNORECASE):
|
||||
body = m.group(1)
|
||||
sm = re.search(r"<summary\b[^>]*>([\s\S]*?)</summary>", body, re.IGNORECASE)
|
||||
summary = strip_tags(sm.group(1)) if sm else ""
|
||||
rest = body[sm.end():] if sm else body
|
||||
out.append({
|
||||
"summary": summary,
|
||||
"body": strip_tags(rest),
|
||||
"start": m.start(),
|
||||
"end": m.end(),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def split_h2(raw: str) -> list[dict]:
|
||||
"""raw → [{'title', 'body', 'start', 'end'}] for each ## section."""
|
||||
iters = list(re.finditer(r"^##\s+(.+?)$", raw, re.MULTILINE))
|
||||
out = []
|
||||
for i, m in enumerate(iters):
|
||||
end = iters[i+1].start() if i+1 < len(iters) else len(raw)
|
||||
out.append({
|
||||
"title": m.group(1).strip(),
|
||||
"start": m.start(),
|
||||
"end": end,
|
||||
"body_raw": raw[m.end():end],
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def split_h3(raw: str) -> list[dict]:
|
||||
iters = list(re.finditer(r"^###\s+(.+?)$", raw, re.MULTILINE))
|
||||
out = []
|
||||
for i, m in enumerate(iters):
|
||||
end = iters[i+1].start() if i+1 < len(iters) else len(raw)
|
||||
out.append({
|
||||
"title": m.group(1).strip(),
|
||||
"start": m.start(),
|
||||
"end": end,
|
||||
"body_raw": raw[m.end():end],
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def extract_tables(raw: str) -> list[str]:
|
||||
"""Markdown 표 ( | ... | ... | ) 블록을 각각 문자열로 반환."""
|
||||
lines = raw.splitlines()
|
||||
tables = []
|
||||
cur = []
|
||||
for ln in lines:
|
||||
if re.match(r"^\s*\|.*\|\s*$", ln):
|
||||
cur.append(ln.strip())
|
||||
else:
|
||||
if len(cur) >= 2:
|
||||
tables.append("\n".join(cur))
|
||||
cur = []
|
||||
if len(cur) >= 2:
|
||||
tables.append("\n".join(cur))
|
||||
return tables
|
||||
|
||||
|
||||
def extract_image_captions(raw: str) -> list[str]:
|
||||
""" + 그 근처의 이탤릭 [그림 N] 캡션 모음."""
|
||||
out = []
|
||||
for m in re.finditer(r"!\[([^\]]*)\]\(([^)]+)\)", raw):
|
||||
alt = m.group(1).strip()
|
||||
path = m.group(2).strip()
|
||||
# 뒤 300자 안에 *[그림 ...]* 캡션 찾기
|
||||
after = raw[m.end():m.end()+400]
|
||||
cap = re.search(r"\*\[그림[^\]]*\][^*]*\*", after)
|
||||
caption = cap.group(0).strip("*").strip() if cap else ""
|
||||
out.append(f"{alt} {path} {caption}".strip())
|
||||
return out
|
||||
|
||||
|
||||
def remove_details_and_tables(raw: str) -> str:
|
||||
t = re.sub(r"<details[\s\S]*?</details>", " ", raw, flags=re.IGNORECASE)
|
||||
# 표 라인 제거
|
||||
t = "\n".join(ln for ln in t.splitlines() if not re.match(r"^\s*\|.*\|\s*$", ln))
|
||||
return t
|
||||
|
||||
|
||||
def build_17_units() -> list[dict]:
|
||||
mdx_dir = Path("samples/mdx")
|
||||
raw01 = (mdx_dir / "01. 건설산업 DX의 올바른 이해(0127).mdx").read_text(encoding="utf-8")
|
||||
raw02 = (mdx_dir / "02. DX의 시행 목표 및 기대효과.mdx").read_text(encoding="utf-8")
|
||||
raw03 = (mdx_dir / "03. DX 시행을 위한 필수 요건 및 혁신 방안.mdx").read_text(encoding="utf-8")
|
||||
|
||||
units: list[dict] = []
|
||||
|
||||
# ─── MDX 01 ───
|
||||
h2_01 = split_h2(raw01)
|
||||
intro_01 = raw01[: h2_01[0]["start"]] if h2_01 else raw01
|
||||
details_01 = extract_details(raw01)
|
||||
images_01 = extract_image_captions(raw01)
|
||||
|
||||
# 1. MDX01-intro: 첫 ## 전 본문(details 제외)
|
||||
intro_text = remove_details_and_tables(intro_01)
|
||||
units.append({"id": "MDX01-intro", "kind": "중목차 앞 본문",
|
||||
"label": "용어 혼용 문제 제기",
|
||||
"text": strip_tags(intro_text)})
|
||||
# 2. MDX01-intro-details
|
||||
d0 = details_01[0] if details_01 else {"summary": "", "body": ""}
|
||||
units.append({"id": "MDX01-intro-details", "kind": "팝업",
|
||||
"label": "혼용 대표 사례",
|
||||
"text": f"{d0['summary']} {d0['body']}"})
|
||||
# 3. MDX01-1
|
||||
body_01_1 = h2_01[0]["body_raw"]
|
||||
units.append({"id": "MDX01-1", "kind": "중목차",
|
||||
"label": "용어 정의",
|
||||
"text": f"{h2_01[0]['title']} {strip_tags(remove_details_and_tables(body_01_1))}"})
|
||||
# 4. MDX01-2 (본문만, details/표 제거)
|
||||
body_01_2 = h2_01[1]["body_raw"]
|
||||
units.append({"id": "MDX01-2", "kind": "중목차",
|
||||
"label": "용어간 상호관계",
|
||||
"text": f"{h2_01[1]['title']} {strip_tags(remove_details_and_tables(body_01_2))}"})
|
||||
# 5. MDX01-2-image
|
||||
units.append({"id": "MDX01-2-image", "kind": "이미지",
|
||||
"label": "DX1.png",
|
||||
"text": images_01[0] if images_01 else ""})
|
||||
# 6. MDX01-2-details
|
||||
d1 = details_01[1] if len(details_01) >= 2 else {"summary": "", "body": ""}
|
||||
units.append({"id": "MDX01-2-details", "kind": "팝업+표",
|
||||
"label": "DX와 BIM의 구분 12행 비교표",
|
||||
"text": f"{d1['summary']} {d1['body']}"})
|
||||
|
||||
# ─── MDX 02 ───
|
||||
h2_02 = split_h2(raw02)
|
||||
images_02 = extract_image_captions(raw02)
|
||||
|
||||
# 7. MDX02-1
|
||||
body_02_1 = h2_02[0]["body_raw"]
|
||||
units.append({"id": "MDX02-1", "kind": "중목차",
|
||||
"label": "DX의 궁극적 목표",
|
||||
"text": f"{h2_02[0]['title']} {strip_tags(remove_details_and_tables(body_02_1))}"})
|
||||
# 8. MDX02-1-image
|
||||
units.append({"id": "MDX02-1-image", "kind": "이미지",
|
||||
"label": "궁극적목표.png",
|
||||
"text": images_02[0] if images_02 else ""})
|
||||
# 9. MDX02-2 컨테이너 (title + ### 이전 본문)
|
||||
body_02_2 = h2_02[1]["body_raw"]
|
||||
h3_02_2 = split_h3(body_02_2)
|
||||
pre_h3 = body_02_2[: h3_02_2[0]["start"]] if h3_02_2 else body_02_2
|
||||
units.append({"id": "MDX02-2", "kind": "중목차(컨테이너)",
|
||||
"label": "DX 기반 Process 혁신 기대효과",
|
||||
"text": f"{h2_02[1]['title']} {strip_tags(pre_h3)}"})
|
||||
# 10. MDX02-2.1 (표 제거)
|
||||
body_021 = h3_02_2[0]["body_raw"]
|
||||
units.append({"id": "MDX02-2.1", "kind": "소목차",
|
||||
"label": "업무 수행 과정의 변화",
|
||||
"text": f"{h3_02_2[0]['title']} {strip_tags(remove_details_and_tables(body_021))}"})
|
||||
# 11. MDX02-2.2 (표 제거)
|
||||
body_022 = h3_02_2[1]["body_raw"]
|
||||
units.append({"id": "MDX02-2.2", "kind": "소목차",
|
||||
"label": "주체별 기대효과",
|
||||
"text": f"{h3_02_2[1]['title']} {strip_tags(remove_details_and_tables(body_022))}"})
|
||||
# 12. MDX02-2.2-table
|
||||
tables_022 = extract_tables(body_022)
|
||||
units.append({"id": "MDX02-2.2-table", "kind": "표",
|
||||
"label": "발주자/시공자/설계자 4×3 표",
|
||||
"text": strip_tags(tables_022[0]) if tables_022 else ""})
|
||||
|
||||
# ─── MDX 03 ───
|
||||
h2_03 = split_h2(raw03)
|
||||
|
||||
# 13. MDX03-1
|
||||
body_03_1 = h2_03[0]["body_raw"]
|
||||
units.append({"id": "MDX03-1", "kind": "중목차",
|
||||
"label": "필수 요건 (기술/사람/자연)",
|
||||
"text": f"{h2_03[0]['title']} {strip_tags(remove_details_and_tables(body_03_1))}"})
|
||||
# 14. MDX03-2 컨테이너
|
||||
body_03_2 = h2_03[1]["body_raw"]
|
||||
h3_03_2 = split_h3(body_03_2)
|
||||
pre_h3_2 = body_03_2[: h3_03_2[0]["start"]] if h3_03_2 else body_03_2
|
||||
units.append({"id": "MDX03-2", "kind": "중목차(컨테이너)",
|
||||
"label": "Process/Product 혁신",
|
||||
"text": f"{h2_03[1]['title']} {strip_tags(pre_h3_2)}"})
|
||||
# 15. MDX03-2.1 (표 제거)
|
||||
body_031 = h3_03_2[0]["body_raw"]
|
||||
units.append({"id": "MDX03-2.1", "kind": "소목차",
|
||||
"label": "과정(Process)의 혁신",
|
||||
"text": f"{h3_03_2[0]['title']} {strip_tags(remove_details_and_tables(body_031))}"})
|
||||
# 16. MDX03-2.1-table
|
||||
tables_031 = extract_tables(body_031)
|
||||
units.append({"id": "MDX03-2.1-table", "kind": "표",
|
||||
"label": "As-is/To-be 3행 비교표",
|
||||
"text": strip_tags(tables_031[0]) if tables_031 else ""})
|
||||
# 17. MDX03-2.2
|
||||
body_032 = h3_03_2[1]["body_raw"]
|
||||
units.append({"id": "MDX03-2.2", "kind": "소목차",
|
||||
"label": "결과(Product)의 변화",
|
||||
"text": f"{h3_03_2[1]['title']} {strip_tags(remove_details_and_tables(body_032))}"})
|
||||
|
||||
return units
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("[init] TF-IDF 인덱스 로딩 (src/block_matcher_tfidf.py, 32프레임 IDF 고정)...")
|
||||
matcher = TfidfBlockMatcher()
|
||||
print(f"[init] 프레임 {len(matcher.frames)}개 인덱싱 완료")
|
||||
|
||||
units = build_17_units()
|
||||
|
||||
md_lines: list[str] = [
|
||||
"# 17개 콘텐츠 단위별 매칭 (내 매처: 32프레임 IDF 고정)",
|
||||
"",
|
||||
"엔진: `src/block_matcher_tfidf.py` (프레임 32개만으로 IDF 사전 계산, 확장어 주입).",
|
||||
"각 단위의 텍스트를 쿼리로 주입하고 top-3 프레임을 출력.",
|
||||
"",
|
||||
"| # | 단위 ID | 종류 | 라벨 | 텍스트 길이 | 1위 | 2위 | 3위 |",
|
||||
"|---|---|---|---|---|---|---|---|",
|
||||
]
|
||||
|
||||
rel = Path("..") / ".." / ".." / PREVIEW_DIR
|
||||
details_sections: list[str] = []
|
||||
|
||||
for i, u in enumerate(units, start=1):
|
||||
q = u["text"]
|
||||
print(f"\n[{i:02d}] {u['id']} ({u['kind']}) — {u['label']} 텍스트 {len(q)}자")
|
||||
if not q.strip():
|
||||
print(" (텍스트 비어 있음)")
|
||||
row = f"| {i} | {u['id']} | {u['kind']} | {u['label']} | 0 | — | — | — |"
|
||||
md_lines.append(row)
|
||||
continue
|
||||
top = matcher.match(q, sub_titles=None, d1_items=None, top_k=len(matcher.frames))
|
||||
top3 = [r for r in top[:TOP_K] if r["score"] > 0]
|
||||
for rank, r in enumerate(top3, start=1):
|
||||
num = num_of(r["frame_id"])
|
||||
print(f" {rank}. #{num} {r['score']*100:5.1f}% | frame {r['frame_id']} | {ftitle(matcher, r['frame_id'])}")
|
||||
|
||||
cells = []
|
||||
for slot in range(3):
|
||||
if slot < len(top3):
|
||||
r = top3[slot]
|
||||
n = num_of(r["frame_id"])
|
||||
cells.append(f"**#{n}** {r['score']*100:.1f}%")
|
||||
else:
|
||||
cells.append("—")
|
||||
row = (f"| {i} | {u['id']} | {u['kind']} | {u['label']} | {len(q)} | "
|
||||
+ " | ".join(cells) + " |")
|
||||
md_lines.append(row)
|
||||
|
||||
# 상세 섹션
|
||||
details_sections.append(f"\n### {i}. {u['id']} — {u['label']}")
|
||||
details_sections.append(f"- 종류: {u['kind']} · 텍스트 길이: {len(q)}자")
|
||||
preview = q[:120] + ("…" if len(q) > 120 else "")
|
||||
details_sections.append(f"- 쿼리 미리보기: _{preview}_\n")
|
||||
details_sections.append("| rank | # | preview | score | frame_id | title_text |")
|
||||
details_sections.append("|---|---|---|---|---|---|")
|
||||
for rank, r in enumerate(top3, start=1):
|
||||
n = num_of(r["frame_id"])
|
||||
prev = f").as_posix()})"
|
||||
details_sections.append(
|
||||
f"| {rank} | **#{n}** | {prev} | **{r['score']*100:.1f}%** | "
|
||||
f"`{r['frame_id']}` | {ftitle(matcher, r['frame_id'])} |"
|
||||
)
|
||||
if not top3:
|
||||
details_sections.append("| — | — | — | 0% | — | (매칭 없음) |")
|
||||
|
||||
md_lines.append("\n## 상세\n")
|
||||
md_lines.extend(details_sections)
|
||||
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
out_dir = Path("data/runs") / f"{ts}_17units_my_matcher"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
out = out_dir / "match_report.md"
|
||||
out.write_text("\n".join(md_lines), encoding="utf-8")
|
||||
print(f"\n[saved] {out}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
391
scripts/match_mdx_strict.py
Normal file
391
scripts/match_mdx_strict.py
Normal file
@@ -0,0 +1,391 @@
|
||||
"""MDX ↔ Figma Frame 매칭 (엄밀한 헤딩 구조 + 팝업 포함 버전).
|
||||
|
||||
mdx_normalizer 대신 raw MDX를 직접 파싱하여:
|
||||
- 중목차 = ## 헤딩 (오직 ## 만)
|
||||
- 소목차 = ### 헤딩 (오직 ### 만)
|
||||
- 팝업 = <details><summary>...</summary>...</details> (summary + body 분리 보존)
|
||||
|
||||
출력 레벨:
|
||||
L1 대목차 : MDX 전체 raw text (팝업 body 포함)
|
||||
L2 중목차 : 각 ## 섹션 본문 + 그 섹션 안 팝업 body 포함
|
||||
L3 소목차 : 각 ### 섹션 본문 (해당 섹션에 속한 팝업 body 포함)
|
||||
L4 팝업 : 각 <details> 의 summary + body 단독 쿼리
|
||||
|
||||
매칭 엔진은 src/block_matcher_tfidf.py 그대로 재사용.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from src.block_matcher_tfidf import TfidfBlockMatcher
|
||||
|
||||
|
||||
TOP_K = 3
|
||||
|
||||
PREVIEW_DIR = Path("data/figma_previews")
|
||||
INDEX_PATH = PREVIEW_DIR / "index.json"
|
||||
_INDEX: dict[str, dict] = json.loads(INDEX_PATH.read_text(encoding="utf-8"))
|
||||
FRAME_TO_NUM: dict[str, str] = {v["frame_id"]: k for k, v in _INDEX.items()}
|
||||
|
||||
MDX_FILES = [
|
||||
("01", Path("samples/mdx/01. 건설산업 DX의 올바른 이해(0127).mdx")),
|
||||
("02", Path("samples/mdx/02. DX의 시행 목표 및 기대효과.mdx")),
|
||||
("03", Path("samples/mdx/03. DX 시행을 위한 필수 요건 및 혁신 방안.mdx")),
|
||||
]
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# MDX 파서
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def strip_tags(text: str) -> str:
|
||||
"""HTML/JSX 태그 제거 + 마크다운 포맷 기호 살짝 정리."""
|
||||
text = re.sub(r"<[^>]+>", " ", text)
|
||||
text = re.sub(r"\{/\*.*?\*/\}", " ", text, flags=re.DOTALL)
|
||||
text = re.sub(r"\{[^{}]*\}", " ", text) # JSX prop {…}
|
||||
text = text.replace("\\", " ")
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
return text
|
||||
|
||||
|
||||
def parse_details(text: str) -> list[dict]:
|
||||
"""MDX 내 모든 <details> 블록을 추출. summary + body 반환.
|
||||
|
||||
각 엔트리: {"summary": str, "body": str, "raw_start": int, "raw_end": int}
|
||||
"""
|
||||
popups: list[dict] = []
|
||||
for m in re.finditer(r"<details\b[^>]*>([\s\S]*?)</details>", text, re.IGNORECASE):
|
||||
block = m.group(1)
|
||||
sm = re.search(r"<summary\b[^>]*>([\s\S]*?)</summary>", block, re.IGNORECASE)
|
||||
summary = strip_tags(sm.group(1)) if sm else ""
|
||||
body = block[sm.end():] if sm else block
|
||||
popups.append({
|
||||
"summary": summary,
|
||||
"body": strip_tags(body),
|
||||
"raw_start": m.start(),
|
||||
"raw_end": m.end(),
|
||||
})
|
||||
return popups
|
||||
|
||||
|
||||
def parse_mdx_structure(raw: str) -> dict:
|
||||
"""Raw MDX → 헤딩 트리 + 팝업 목록.
|
||||
|
||||
반환:
|
||||
{
|
||||
"doc_title": str, # frontmatter title 또는 None
|
||||
"intro": str, # 첫 ## 이전 텍스트
|
||||
"h2": [
|
||||
{
|
||||
"title": str,
|
||||
"body": str, # ## 섹션 본문 (### 이전까지)
|
||||
"h3": [ {"title": str, "body": str, "popups": [...]}, ... ],
|
||||
"popups": [ {"summary", "body"} ], # 이 ## 섹션에 직접 속한 팝업
|
||||
},
|
||||
...
|
||||
],
|
||||
"all_popups": [ ... ], # 전체 팝업
|
||||
}
|
||||
"""
|
||||
# frontmatter에서 title
|
||||
fm = re.match(r"---\s*\n([\s\S]*?)\n---\s*\n", raw)
|
||||
doc_title = None
|
||||
if fm:
|
||||
tm = re.search(r"^title:\s*(.+)$", fm.group(1), re.MULTILINE)
|
||||
if tm:
|
||||
doc_title = tm.group(1).strip().strip('"').strip("'")
|
||||
raw_body = raw[fm.end():]
|
||||
else:
|
||||
raw_body = raw
|
||||
|
||||
# ## 헤딩 찾기
|
||||
h2_iter = list(re.finditer(r"^##\s+(.+?)$", raw_body, re.MULTILINE))
|
||||
|
||||
intro = raw_body[: h2_iter[0].start()].strip() if h2_iter else raw_body.strip()
|
||||
|
||||
h2_nodes: list[dict] = []
|
||||
for i, m in enumerate(h2_iter):
|
||||
title = m.group(1).strip()
|
||||
start = m.end()
|
||||
end = h2_iter[i + 1].start() if i + 1 < len(h2_iter) else len(raw_body)
|
||||
section_raw = raw_body[start:end]
|
||||
|
||||
# ### 헤딩
|
||||
h3_iter = list(re.finditer(r"^###\s+(.+?)$", section_raw, re.MULTILINE))
|
||||
|
||||
body_before_h3 = section_raw[: h3_iter[0].start()] if h3_iter else section_raw
|
||||
|
||||
h3_nodes: list[dict] = []
|
||||
for j, m3 in enumerate(h3_iter):
|
||||
t3 = m3.group(1).strip()
|
||||
s3 = m3.end()
|
||||
e3 = h3_iter[j + 1].start() if j + 1 < len(h3_iter) else len(section_raw)
|
||||
sub_raw = section_raw[s3:e3]
|
||||
sub_popups = parse_details(sub_raw)
|
||||
# body = sub_raw + 팝업 본문 합친 문자열(쿼리용) — 팝업은 inline이므로 raw에 이미 포함되지만 태그 제거 후 텍스트로 강조
|
||||
h3_nodes.append({
|
||||
"title": t3,
|
||||
"body": strip_tags(sub_raw),
|
||||
"popups": sub_popups,
|
||||
})
|
||||
|
||||
# 이 ## 섹션 직속 팝업 (### 이전 부분에 있는 것)
|
||||
section_popups = parse_details(body_before_h3)
|
||||
|
||||
h2_nodes.append({
|
||||
"title": title,
|
||||
"body": strip_tags(body_before_h3),
|
||||
"h3": h3_nodes,
|
||||
"popups": section_popups,
|
||||
})
|
||||
|
||||
all_popups: list[dict] = []
|
||||
all_popups.extend(parse_details(intro))
|
||||
for h2 in h2_nodes:
|
||||
all_popups.extend(h2["popups"])
|
||||
for h3 in h2["h3"]:
|
||||
all_popups.extend(h3["popups"])
|
||||
|
||||
return {
|
||||
"doc_title": doc_title,
|
||||
"intro": strip_tags(intro),
|
||||
"intro_popups": parse_details(intro),
|
||||
"h2": h2_nodes,
|
||||
"all_popups": all_popups,
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 매칭 + 출력
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def num_of(frame_id: str) -> str:
|
||||
return FRAME_TO_NUM.get(frame_id, f"?({frame_id})")
|
||||
|
||||
|
||||
def frame_title(matcher: TfidfBlockMatcher, fid: str) -> str:
|
||||
for f in matcher.frames:
|
||||
if f["frame_id"] == fid:
|
||||
return (f.get("title_text") or "").replace("\n", " ")[:60]
|
||||
return ""
|
||||
|
||||
|
||||
def run_query(matcher: TfidfBlockMatcher, query_text: str) -> list[dict]:
|
||||
"""쿼리 텍스트 하나를 받아서 matcher로 돌린다.
|
||||
|
||||
block_matcher_tfidf.match는 (zone_title, sub_titles, d1_items) 인자를 받지만
|
||||
내부에서는 단순히 문자열로 합쳐 전처리 → TF-IDF 유사도. 여기서는 전체 쿼리를
|
||||
첫 인자(zone_title)로 넣어 동일한 전처리 경로를 탄다.
|
||||
"""
|
||||
return matcher.match(query_text, sub_titles=None, d1_items=None, top_k=len(matcher.frames))
|
||||
|
||||
|
||||
def print_ranking(top: list[dict], matcher: TfidfBlockMatcher, indent: str = " "):
|
||||
if not top or top[0]["score"] <= 0:
|
||||
print(f"{indent}(매칭 없음, score=0)")
|
||||
return
|
||||
for rank, r in enumerate(top[:TOP_K], start=1):
|
||||
if r["score"] <= 0:
|
||||
break
|
||||
num = num_of(r["frame_id"])
|
||||
print(
|
||||
f"{indent} {rank}. #{num} score={r['score']*100:5.1f}% "
|
||||
f"| frame {r['frame_id']} | {frame_title(matcher, r['frame_id'])}"
|
||||
)
|
||||
|
||||
|
||||
def md_ranking_table(top: list[dict], matcher: TfidfBlockMatcher, run_dir_depth: int = 3) -> list[str]:
|
||||
rel = Path(*[".." for _ in range(run_dir_depth)]) / PREVIEW_DIR
|
||||
lines = [
|
||||
"| rank | # | preview | score | frame_id | title_text |",
|
||||
"|---|---|---|---|---|---|",
|
||||
]
|
||||
for rank, r in enumerate(top[:TOP_K], start=1):
|
||||
if r["score"] <= 0:
|
||||
break
|
||||
num = num_of(r["frame_id"])
|
||||
preview = f").as_posix()})"
|
||||
lines.append(
|
||||
f"| {rank} | **#{num}** | {preview} | **{r['score']*100:.1f}%** | "
|
||||
f"`{r['frame_id']}` | {frame_title(matcher, r['frame_id'])} |"
|
||||
)
|
||||
if len(lines) == 2:
|
||||
lines.append("| — | — | — | 0% | — | (매칭 없음) |")
|
||||
return lines
|
||||
|
||||
|
||||
def evaluate_mdx(matcher: TfidfBlockMatcher, mdx_id: str, mdx_path: Path, md_lines: list[str]):
|
||||
raw = mdx_path.read_text(encoding="utf-8")
|
||||
parsed = parse_mdx_structure(raw)
|
||||
doc_title = parsed["doc_title"] or mdx_path.stem
|
||||
|
||||
print("\n" + "=" * 100)
|
||||
print(f"MDX {mdx_id}: {doc_title} ({mdx_path.name})")
|
||||
print(f" 중목차(##) {len(parsed['h2'])}개 · "
|
||||
f"소목차(###) {sum(len(h2['h3']) for h2 in parsed['h2'])}개 · "
|
||||
f"팝업(<details>) {len(parsed['all_popups'])}개")
|
||||
print("=" * 100)
|
||||
|
||||
md_lines.append(f"\n## MDX {mdx_id} — {doc_title}\n")
|
||||
md_lines.append(f"파일: `{mdx_path.as_posix()}`")
|
||||
md_lines.append(
|
||||
f"- 중목차(##) **{len(parsed['h2'])}개** · "
|
||||
f"소목차(###) **{sum(len(h2['h3']) for h2 in parsed['h2'])}개** · "
|
||||
f"팝업(<details>) **{len(parsed['all_popups'])}개**\n"
|
||||
)
|
||||
|
||||
# ─── L1 대목차: 전체 MDX ───
|
||||
full_text = strip_tags(raw)
|
||||
l1_top = run_query(matcher, full_text)
|
||||
print(f"\n┌─ L1 대목차 [전체 MDX, 팝업 포함]")
|
||||
print_ranking(l1_top, matcher, indent="│ ")
|
||||
md_lines.append("### 🟦 L1 — 대목차 (전체 MDX, 팝업 포함)\n")
|
||||
md_lines.extend(md_ranking_table(l1_top, matcher))
|
||||
md_lines.append("")
|
||||
|
||||
# ─── L2 중목차: 각 ## ───
|
||||
print(f"\n┌─ L2 중목차 [## 섹션별]")
|
||||
md_lines.append("\n### 🟩 L2 — 중목차 (각 ## 섹션, 팝업 body 포함)\n")
|
||||
|
||||
for zi, h2 in enumerate(parsed["h2"], start=1):
|
||||
# 쿼리: ## title + body(### 이전) + 직속 popup + 각 ### body/popup
|
||||
parts = [h2["title"], h2["body"]]
|
||||
for p in h2["popups"]:
|
||||
parts.append(p["summary"])
|
||||
parts.append(p["body"])
|
||||
for h3 in h2["h3"]:
|
||||
parts.append(h3["title"])
|
||||
parts.append(h3["body"])
|
||||
for p in h3["popups"]:
|
||||
parts.append(p["summary"])
|
||||
parts.append(p["body"])
|
||||
query = " ".join(parts)
|
||||
top = run_query(matcher, query)
|
||||
|
||||
pop_titles = [p["summary"] for p in h2["popups"]] + [
|
||||
p["summary"] for h3 in h2["h3"] for p in h3["popups"]
|
||||
]
|
||||
print(f"│\n│ [중 {zi}] ## {h2['title']}")
|
||||
print(f"│ 소목차: {[h3['title'] for h3 in h2['h3']] or '(없음)'}")
|
||||
print(f"│ 팝업: {pop_titles or '(없음)'}")
|
||||
print_ranking(top, matcher, indent="│ ")
|
||||
|
||||
md_lines.append(f"\n#### 중 {zi}: `## {h2['title']}`\n")
|
||||
md_lines.append(f"- 소목차(###): {[h3['title'] for h3 in h2['h3']] or '(없음)'}")
|
||||
md_lines.append(f"- 팝업: {pop_titles or '(없음)'}\n")
|
||||
md_lines.extend(md_ranking_table(top, matcher))
|
||||
md_lines.append("")
|
||||
|
||||
# ─── L3 소목차: 각 ### ───
|
||||
total_h3 = sum(len(h2["h3"]) for h2 in parsed["h2"])
|
||||
print(f"\n┌─ L3 소목차 [### 섹션별, 총 {total_h3}개]")
|
||||
if total_h3 == 0:
|
||||
print("│ (이 MDX에는 ### 소목차 없음)")
|
||||
md_lines.append(f"\n### 🟨 L3 — 소목차 (각 ### 섹션)\n")
|
||||
if total_h3 == 0:
|
||||
md_lines.append("_(이 MDX에는 ### 소목차 없음)_\n")
|
||||
|
||||
for h2 in parsed["h2"]:
|
||||
for h3 in h2["h3"]:
|
||||
parts = [h3["title"], h3["body"]]
|
||||
for p in h3["popups"]:
|
||||
parts.append(p["summary"])
|
||||
parts.append(p["body"])
|
||||
query = " ".join(parts)
|
||||
top = run_query(matcher, query)
|
||||
|
||||
pop_titles = [p["summary"] for p in h3["popups"]]
|
||||
print(f"│\n│ [소] ### {h3['title']} (상위 중목차: {h2['title']})")
|
||||
print(f"│ 팝업: {pop_titles or '(없음)'}")
|
||||
print_ranking(top, matcher, indent="│ ")
|
||||
|
||||
md_lines.append(
|
||||
f"\n#### 소: `### {h3['title']}` _(상위 중목차: `{h2['title']}`)_\n"
|
||||
)
|
||||
md_lines.append(f"- 팝업: {pop_titles or '(없음)'}\n")
|
||||
md_lines.extend(md_ranking_table(top, matcher))
|
||||
md_lines.append("")
|
||||
|
||||
# ─── L4 팝업: 각 <details> ───
|
||||
print(f"\n┌─ L4 팝업 [<details> 단독 매칭, 총 {len(parsed['all_popups'])}개]")
|
||||
if not parsed["all_popups"]:
|
||||
print("│ (팝업 없음)")
|
||||
md_lines.append(f"\n### 🟥 L4 — 팝업 (<details> 단독 매칭)\n")
|
||||
if not parsed["all_popups"]:
|
||||
md_lines.append("_(팝업 없음)_\n")
|
||||
|
||||
for pi, pop in enumerate(parsed["all_popups"], start=1):
|
||||
query = f"{pop['summary']} {pop['body']}"
|
||||
top = run_query(matcher, query)
|
||||
preview = pop["body"][:80] + ("…" if len(pop["body"]) > 80 else "")
|
||||
print(f"│\n│ [팝 {pi}] summary={pop['summary']!r} ({len(pop['body'])}자)")
|
||||
print(f"│ preview: {preview}")
|
||||
print_ranking(top, matcher, indent="│ ")
|
||||
|
||||
md_lines.append(f"\n#### 팝 {pi}: `<details>` — **{pop['summary']}**\n")
|
||||
md_lines.append(f"- 본문 길이: {len(pop['body'])}자 · preview: _{preview}_\n")
|
||||
md_lines.extend(md_ranking_table(top, matcher))
|
||||
md_lines.append("")
|
||||
|
||||
|
||||
def build_frame_legend(matcher: TfidfBlockMatcher, md_lines: list[str]) -> None:
|
||||
md_lines.append("\n## 프레임 번호 전체 색인 (01 ~ 32)\n")
|
||||
md_lines.append("| # | preview | frame_id | title_text |")
|
||||
md_lines.append("|---|---|---|---|")
|
||||
rel = Path("..") / ".." / ".." / PREVIEW_DIR
|
||||
for num in sorted(_INDEX.keys()):
|
||||
entry = _INDEX[num]
|
||||
preview = f").as_posix()})"
|
||||
md_lines.append(
|
||||
f"| **#{num}** | {preview} | `{entry['frame_id']}` | "
|
||||
f"{frame_title(matcher, entry['frame_id'])} |"
|
||||
)
|
||||
md_lines.append("")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("[init] TF-IDF 인덱스 로딩...")
|
||||
matcher = TfidfBlockMatcher()
|
||||
print(f"[init] 프레임 {len(matcher.frames)}개 인덱싱 완료")
|
||||
|
||||
md_lines: list[str] = [
|
||||
"# MDX ↔ Figma Frame 매칭 (엄밀한 구조 + 팝업 포함)",
|
||||
"",
|
||||
"raw MDX를 직접 파싱하여 **## 만 중목차**, **### 만 소목차**, `<details>` 을 팝업으로 분리.",
|
||||
"bullet 항목(`* **제목**`)은 헤딩이 아니므로 섹션 body에 포함되며 별도 레벨로 취급하지 않음.",
|
||||
"",
|
||||
"| 단계 | 입도 | 쿼리 구성 |",
|
||||
"|---|---|---|",
|
||||
"| 🟦 L1 대목차 | MDX 1개 | 전체 MDX raw text (팝업 body 포함) |",
|
||||
"| 🟩 L2 중목차 | 각 `##` | `## title + body + 하위 ### body + 팝업 body` |",
|
||||
"| 🟨 L3 소목차 | 각 `###` | `### title + body + 자기 팝업 body` |",
|
||||
"| 🟥 L4 팝업 | 각 `<details>` | `summary + body` |",
|
||||
"",
|
||||
"점수는 순수 TF-IDF cosine similarity × 100 (%). 판정 라벨 없음.",
|
||||
]
|
||||
|
||||
for mdx_id, p in MDX_FILES:
|
||||
if p.exists():
|
||||
evaluate_mdx(matcher, mdx_id, p, md_lines)
|
||||
else:
|
||||
print(f"[skip] 없음: {p}")
|
||||
|
||||
build_frame_legend(matcher, md_lines)
|
||||
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
out_dir = Path("data/runs") / f"{ts}_mdx_match_strict"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
out = out_dir / "match_report.md"
|
||||
out.write_text("\n".join(md_lines), encoding="utf-8")
|
||||
print(f"\n[saved] {out}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
238
scripts/match_mdx_to_frames_tfidf.py
Normal file
238
scripts/match_mdx_to_frames_tfidf.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""MDX → Figma Frame 매칭 (TF-IDF) — 대목차 / 중목차 / 소목차 3단계 모두 출력.
|
||||
|
||||
프레임은 data/figma_previews/index.json 의 번호(01~32)로 표기한다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from src.block_matcher_tfidf import TfidfBlockMatcher
|
||||
from src.mdx_normalizer import normalize_mdx_content
|
||||
from src.section_parser import extract_major_sections
|
||||
|
||||
|
||||
TOP_K = 3
|
||||
THRESHOLD = 0.15 # pipeline_v2 direct-fit 커트오프 (표기에 사용 안 함)
|
||||
|
||||
PREVIEW_DIR = Path("data/figma_previews")
|
||||
INDEX_PATH = PREVIEW_DIR / "index.json"
|
||||
|
||||
# index.json 로드: {"01": {"frame_id": "1171281172", ...}, ...}
|
||||
_INDEX: dict[str, dict] = json.loads(INDEX_PATH.read_text(encoding="utf-8"))
|
||||
FRAME_TO_NUM: dict[str, str] = {v["frame_id"]: k for k, v in _INDEX.items()}
|
||||
NUM_TO_FRAME: dict[str, str] = {k: v["frame_id"] for k, v in _INDEX.items()}
|
||||
|
||||
MDX_FILES = [
|
||||
("01", Path("samples/mdx/01. 건설산업 DX의 올바른 이해(0127).mdx")),
|
||||
("02", Path("samples/mdx/02. DX의 시행 목표 및 기대효과.mdx")),
|
||||
("03", Path("samples/mdx/03. DX 시행을 위한 필수 요건 및 혁신 방안.mdx")),
|
||||
]
|
||||
|
||||
|
||||
def num_of(frame_id: str) -> str:
|
||||
return FRAME_TO_NUM.get(frame_id, f"?({frame_id})")
|
||||
|
||||
|
||||
def extract_d1_items(content: str) -> list[str]:
|
||||
return [
|
||||
re.sub(r"\*+", "", d).strip()
|
||||
for d in re.findall(r"^D1:\s*(.*)", content, re.MULTILINE)
|
||||
]
|
||||
|
||||
|
||||
def frame_title(matcher: TfidfBlockMatcher, fid: str) -> str:
|
||||
for f in matcher.frames:
|
||||
if f["frame_id"] == fid:
|
||||
return (f.get("title_text") or "").replace("\n", " ")[:60]
|
||||
return ""
|
||||
|
||||
|
||||
def print_ranking(label: str, top: list[dict], matcher: TfidfBlockMatcher, indent: str = " "):
|
||||
if not top or top[0]["score"] <= 0:
|
||||
print(f"{indent}(매칭 없음, score=0)")
|
||||
return
|
||||
for rank, r in enumerate(top[:TOP_K], start=1):
|
||||
if r["score"] <= 0:
|
||||
break
|
||||
num = num_of(r["frame_id"])
|
||||
print(
|
||||
f"{indent} {rank}. #{num} score={r['score']*100:5.1f}% "
|
||||
f"| frame {r['frame_id']} | {frame_title(matcher, r['frame_id'])}"
|
||||
)
|
||||
|
||||
|
||||
def md_ranking_table(top: list[dict], matcher: TfidfBlockMatcher) -> list[str]:
|
||||
rel = Path("..") / ".." / ".." / PREVIEW_DIR # run dir 기준
|
||||
lines = [
|
||||
"| rank | # | preview | score | frame_id | title_text |",
|
||||
"|---|---|---|---|---|---|",
|
||||
]
|
||||
for rank, r in enumerate(top[:TOP_K], start=1):
|
||||
if r["score"] <= 0:
|
||||
break
|
||||
num = num_of(r["frame_id"])
|
||||
preview = f").as_posix()})"
|
||||
lines.append(
|
||||
f"| {rank} | **#{num}** | {preview} | **{r['score']*100:.1f}%** | "
|
||||
f"`{r['frame_id']}` | {frame_title(matcher, r['frame_id'])} |"
|
||||
)
|
||||
if len(lines) == 2:
|
||||
lines.append("| — | — | — | 0% | — | (매칭 없음) |")
|
||||
return lines
|
||||
|
||||
|
||||
def evaluate_mdx(
|
||||
matcher: TfidfBlockMatcher,
|
||||
mdx_id: str,
|
||||
mdx_path: Path,
|
||||
md_lines: list[str],
|
||||
) -> None:
|
||||
content = mdx_path.read_text(encoding="utf-8")
|
||||
norm = normalize_mdx_content(content)
|
||||
flat_sections = norm.get("sections", [])
|
||||
zones = extract_major_sections(flat_sections)
|
||||
doc_title = norm.get("title") or mdx_path.stem
|
||||
|
||||
print("\n" + "=" * 100)
|
||||
print(f"MDX {mdx_id}: {doc_title} ({mdx_path.name})")
|
||||
print(f"flat sections: {len(flat_sections)} | zones(중목차): {len(zones)}")
|
||||
print("=" * 100)
|
||||
|
||||
md_lines.append(f"\n## MDX {mdx_id} — {doc_title}\n")
|
||||
md_lines.append(
|
||||
f"파일: `{mdx_path.as_posix()}` · "
|
||||
f"평면 section {len(flat_sections)}개 · zone(중목차) {len(zones)}개\n"
|
||||
)
|
||||
|
||||
# ═══════════ L1: 대목차 (MDX 전체) ═══════════
|
||||
l1_subs = [z["title"] for z in zones] + [
|
||||
st for z in zones for st in z.get("sub_titles", [])
|
||||
]
|
||||
l1_top = matcher.match(doc_title, l1_subs, d1_items=None, top_k=len(matcher.frames))
|
||||
|
||||
print(f"\n┌─ L1 대목차 [전체 MDX] '{doc_title}'")
|
||||
print(f"│ zones: {[z['title'] for z in zones]}")
|
||||
print_ranking("L1", l1_top, matcher, indent="│ ")
|
||||
|
||||
md_lines.append("### 🟦 L1 — 대목차 (전체 MDX)\n")
|
||||
md_lines.append(f"- 쿼리: `{doc_title}` + 모든 zone/sub title")
|
||||
md_lines.append(f"- zone 목록: {[z['title'] for z in zones]}")
|
||||
md_lines.append("")
|
||||
md_lines.extend(md_ranking_table(l1_top, matcher))
|
||||
md_lines.append("")
|
||||
|
||||
# ═══════════ L2: 중목차 (zone 단위) ═══════════
|
||||
print(f"\n┌─ L2 중목차 [zone 단위]")
|
||||
md_lines.append("### 🟩 L2 — 중목차 (zone 단위)\n")
|
||||
|
||||
for zi, zone in enumerate(zones, start=1):
|
||||
z_title = zone["title"]
|
||||
sub_titles = zone.get("sub_titles", [])
|
||||
z_content = zone.get("content", "")
|
||||
d1 = extract_d1_items(z_content)
|
||||
top = matcher.match(z_title, sub_titles, d1, top_k=len(matcher.frames))
|
||||
|
||||
print(f"│\n│ [zone {zi}] {z_title}")
|
||||
print(f"│ sub_titles: {sub_titles}")
|
||||
print(f"│ d1_items: {len(d1)}개")
|
||||
print_ranking("L2", top, matcher, indent="│ ")
|
||||
|
||||
md_lines.append(f"\n#### zone {zi}: **{z_title}**")
|
||||
md_lines.append(f"- sub_titles: {sub_titles}")
|
||||
md_lines.append(f"- d1_items: {len(d1)}개")
|
||||
md_lines.append("")
|
||||
md_lines.extend(md_ranking_table(top, matcher))
|
||||
md_lines.append("")
|
||||
|
||||
# ═══════════ L3: 소목차 (평면 section 각각) ═══════════
|
||||
# normalize의 sections 중 content가 있는 것만 = 실제 소목차
|
||||
sub_sections = [s for s in flat_sections if s.get("content", "").strip()]
|
||||
print(f"\n┌─ L3 소목차 [개별 sub-section, {len(sub_sections)}개]")
|
||||
md_lines.append("### 🟨 L3 — 소목차 (개별 sub-section)\n")
|
||||
|
||||
for si, sec in enumerate(sub_sections, start=1):
|
||||
s_title = sec.get("title", "")
|
||||
s_content = sec.get("content", "")
|
||||
d1 = extract_d1_items(s_content)
|
||||
top = matcher.match(s_title, sub_titles=None, d1_items=d1, top_k=len(matcher.frames))
|
||||
|
||||
# 이 섹션이 어느 zone에 속하는지 찾기
|
||||
parent_zone = "—"
|
||||
for z in zones:
|
||||
if s_title in z.get("sub_titles", []):
|
||||
parent_zone = z["title"]
|
||||
break
|
||||
|
||||
print(f"│\n│ [sub {si}] {s_title} (zone: {parent_zone})")
|
||||
print(f"│ d1_items: {len(d1)}개")
|
||||
print_ranking("L3", top, matcher, indent="│ ")
|
||||
|
||||
md_lines.append(f"\n#### sub {si}: **{s_title}** _(zone: {parent_zone})_")
|
||||
md_lines.append(f"- d1_items: {len(d1)}개")
|
||||
md_lines.append("")
|
||||
md_lines.extend(md_ranking_table(top, matcher))
|
||||
md_lines.append("")
|
||||
|
||||
|
||||
def build_frame_legend(matcher: TfidfBlockMatcher, md_lines: list[str]) -> None:
|
||||
md_lines.append("\n## 프레임 번호 전체 색인 (01 ~ 32)\n")
|
||||
md_lines.append("| # | preview | frame_id | title_text |")
|
||||
md_lines.append("|---|---|---|---|")
|
||||
rel = Path("..") / ".." / ".." / PREVIEW_DIR
|
||||
for num in sorted(_INDEX.keys()):
|
||||
entry = _INDEX[num]
|
||||
preview = f").as_posix()})"
|
||||
md_lines.append(
|
||||
f"| **#{num}** | {preview} | `{entry['frame_id']}` | "
|
||||
f"{frame_title(matcher, entry['frame_id'])} |"
|
||||
)
|
||||
md_lines.append("")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("[init] TF-IDF 인덱스 로딩...")
|
||||
matcher = TfidfBlockMatcher()
|
||||
print(f"[init] 프레임 {len(matcher.frames)}개 인덱싱 완료")
|
||||
print(f"[init] direct-fit 임계값 = {THRESHOLD*100:.0f}%")
|
||||
|
||||
md_lines: list[str] = [
|
||||
"# MDX ↔ Figma Frame 매칭 (TF-IDF 순수 점수) — L1/L2/L3 3단계",
|
||||
"",
|
||||
"프레임은 `data/figma_previews/{번호}.png` 의 번호로 표기. 하단에 번호-프레임 색인.",
|
||||
"",
|
||||
"| 단계 | 입도 | 쿼리 구성 |",
|
||||
"|---|---|---|",
|
||||
"| 🟦 L1 대목차 | MDX 전체 1개 | doc title + 모든 zone/sub title |",
|
||||
"| 🟩 L2 중목차 | zone 단위 | zone title + sub_titles + d1_items |",
|
||||
"| 🟨 L3 소목차 | 개별 sub-section 각각 | sub title + 자기 content의 d1_items |",
|
||||
"",
|
||||
f"- 인덱싱된 프레임: {len(matcher.frames)}개",
|
||||
"- **각 표는 순수 TF-IDF cosine similarity × 100 을 %로 표시한 점수 랭킹.**",
|
||||
"- 판정/분기(recipe/direct-fit) 라벨은 출력하지 않음. 점수만 그대로 본다.",
|
||||
]
|
||||
|
||||
for mdx_id, p in MDX_FILES:
|
||||
if p.exists():
|
||||
evaluate_mdx(matcher, mdx_id, p, md_lines)
|
||||
else:
|
||||
print(f"[skip] 없음: {p}")
|
||||
|
||||
build_frame_legend(matcher, md_lines)
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
out_dir = Path("data/runs") / f"{timestamp}_mdx_match"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
out = out_dir / "match_report.md"
|
||||
out.write_text("\n".join(md_lines), encoding="utf-8")
|
||||
print(f"\n[saved] {out}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
398
scripts/ocr_augment_texts.py
Normal file
398
scripts/ocr_augment_texts.py
Normal file
@@ -0,0 +1,398 @@
|
||||
"""32개 프레임 preview PNG에 EasyOCR + 이미지 전처리를 돌려,
|
||||
기존 texts.md에 없는 '이미지 베이크 텍스트' 델타를 추출/보강.
|
||||
|
||||
흐름:
|
||||
1. 원본 PNG 로드
|
||||
2. 두 가지 변형을 OCR:
|
||||
(a) 원본 그대로
|
||||
(b) 2배 업스케일 + 대비 강화 (녹색/저대비 장식 텍스트 잡기용)
|
||||
3. 두 결과 합치고 confidence 컷 (low=0.15, high=0.5)
|
||||
4. 오인식 교정 사전 적용 (SIW→S/W, 움합의→융합의 등)
|
||||
5. 기존 texts.md 토큰과 비교하여 델타 추출
|
||||
6. 프레임별 통계(감지 수, 델타 수, 누락 여부) 리포트
|
||||
7. --apply 시 texts.md 파일들에 델타 추가
|
||||
|
||||
사용:
|
||||
python scripts/ocr_augment_texts.py # 드라이런 (리포트만)
|
||||
python scripts/ocr_augment_texts.py --apply # texts.md 수정
|
||||
python scripts/ocr_augment_texts.py --only 1171281172
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
PREVIEW_DIR = Path("data/figma_previews")
|
||||
INDEX_PATH = PREVIEW_DIR / "index.json"
|
||||
BLOCKS_DIR = Path("figma_to_html_agent/blocks")
|
||||
|
||||
APPEND_SECTION_HEADER = "## OCR 보강 (이미지 베이크 텍스트, 자동 추출)"
|
||||
APPEND_SECTION_MARKER = "<!-- OCR_AUGMENT_V1 -->"
|
||||
|
||||
# conf 기준
|
||||
CONF_HIGH = 0.5 # 이 이상은 그대로 채택
|
||||
CONF_LOW = 0.15 # 이 이하는 버림. 사이 구간은 교정 사전 거쳐야 채택
|
||||
|
||||
# 자주 틀리는 오인식 → 올바른 표현 (정확히 일치 시만 치환)
|
||||
OCR_CORRECTIONS: dict[str, str] = {
|
||||
"siw": "S/W",
|
||||
"sw": "S/W",
|
||||
"hiw": "H/W",
|
||||
"hw": "H/W",
|
||||
"움합의": "융합의",
|
||||
"(직관지 역할": "직관지 역할",
|
||||
"패텔입": "패러다임",
|
||||
"|말": "개발",
|
||||
"대발": "개발",
|
||||
"Civil": "Civil",
|
||||
"I/W": "S/W",
|
||||
"l/w": "S/W",
|
||||
}
|
||||
|
||||
# 버리고 싶은 노이즈 패턴 (OCR이 기호/잔여물 잡은 것)
|
||||
NOISE_PATTERNS = [
|
||||
re.compile(r"^[\W_]+$"), # 기호만
|
||||
re.compile(r"^\d{1,2}$"), # 숫자 1-2자리
|
||||
re.compile(r"^.$"), # 한 글자
|
||||
]
|
||||
|
||||
|
||||
def is_noise(text: str) -> bool:
|
||||
for p in NOISE_PATTERNS:
|
||||
if p.match(text):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def apply_corrections(text: str) -> str:
|
||||
"""교정 사전 적용. 대소문자 무시 완전 일치만."""
|
||||
key = text.strip().lower()
|
||||
if key in OCR_CORRECTIONS:
|
||||
return OCR_CORRECTIONS[key]
|
||||
# 부분 치환 (문구 안에 숨은 경우)
|
||||
result = text
|
||||
for bad, good in OCR_CORRECTIONS.items():
|
||||
pattern = re.compile(re.escape(bad), re.IGNORECASE)
|
||||
result = pattern.sub(good, result)
|
||||
return result
|
||||
|
||||
|
||||
def normalize_for_compare(text: str) -> str:
|
||||
t = text.lower()
|
||||
t = re.sub(r"[^\w가-힣]+", "", t)
|
||||
return t
|
||||
|
||||
|
||||
def load_existing_tokens(texts_md: Path) -> set[str]:
|
||||
if not texts_md.exists():
|
||||
return set()
|
||||
text = texts_md.read_text(encoding="utf-8")
|
||||
# 기존 OCR 섹션 제외
|
||||
if APPEND_SECTION_MARKER in text:
|
||||
idx = text.find(APPEND_SECTION_MARKER)
|
||||
header_idx = text.rfind(APPEND_SECTION_HEADER, 0, idx)
|
||||
if header_idx >= 0:
|
||||
text = text[:header_idx]
|
||||
lines = []
|
||||
for ln in text.splitlines():
|
||||
s = ln.strip()
|
||||
if s.startswith("#") or s.startswith(">"):
|
||||
continue
|
||||
lines.append(ln)
|
||||
body = " ".join(lines)
|
||||
tokens: set[str] = set()
|
||||
for tok in re.split(r"[\s\|\-·•/,.()\[\]:;!?#`'\"*~_+=<>&]+", body):
|
||||
if not tok:
|
||||
continue
|
||||
norm = normalize_for_compare(tok)
|
||||
if norm and len(norm) >= 2:
|
||||
tokens.add(norm)
|
||||
return tokens
|
||||
|
||||
|
||||
def preprocess_upscale(png_path: Path, scale: float = 2.0, contrast: float = 1.4):
|
||||
"""이미지를 업스케일 + 대비 강화해서 bytes 반환."""
|
||||
from PIL import Image, ImageEnhance
|
||||
img = Image.open(png_path).convert("RGB")
|
||||
w, h = img.size
|
||||
img = img.resize((int(w * scale), int(h * scale)), Image.LANCZOS)
|
||||
img = ImageEnhance.Contrast(img).enhance(contrast)
|
||||
import io
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def run_ocr_variants(reader, png_path: Path) -> list[tuple[str, float, tuple]]:
|
||||
"""원본 + 업스케일 두 번 OCR. (text, conf, bbox_center) 리스트."""
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
collected: list[tuple[str, float, tuple]] = []
|
||||
|
||||
# 1) 원본
|
||||
res1 = reader.readtext(str(png_path), detail=1, paragraph=False)
|
||||
for bbox, text, conf in res1:
|
||||
xs = [p[0] for p in bbox]
|
||||
ys = [p[1] for p in bbox]
|
||||
center = ((min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2)
|
||||
collected.append((text, float(conf), center))
|
||||
|
||||
# 2) 업스케일 + 대비 강화
|
||||
enhanced_bytes = preprocess_upscale(png_path)
|
||||
img = np.array(Image.open(__import__("io").BytesIO(enhanced_bytes)).convert("RGB"))
|
||||
res2 = reader.readtext(img, detail=1, paragraph=False)
|
||||
for bbox, text, conf in res2:
|
||||
xs = [p[0] for p in bbox]
|
||||
ys = [p[1] for p in bbox]
|
||||
# 원본 좌표계로 환산 (÷2)
|
||||
center = ((min(xs) + max(xs)) / 4, (min(ys) + max(ys)) / 4)
|
||||
collected.append((text, float(conf), center))
|
||||
|
||||
return collected
|
||||
|
||||
|
||||
def dedupe_by_position(items: list[tuple[str, float, tuple]]) -> list[tuple[str, float, tuple]]:
|
||||
"""같은 위치(±30px)에서 중복 감지된 것들을 confidence 높은 쪽으로 축약."""
|
||||
result: list[tuple[str, float, tuple]] = []
|
||||
for text, conf, center in sorted(items, key=lambda r: -r[1]):
|
||||
dupe = False
|
||||
for rt, rc, rcenter in result:
|
||||
if abs(rcenter[0] - center[0]) < 30 and abs(rcenter[1] - center[1]) < 30:
|
||||
# 텍스트 정규화 같으면 중복
|
||||
if normalize_for_compare(rt) == normalize_for_compare(text):
|
||||
dupe = True
|
||||
break
|
||||
# 같은 위치에서 더 긴 버전이 이미 있으면 중복으로 간주
|
||||
if normalize_for_compare(text) in normalize_for_compare(rt):
|
||||
dupe = True
|
||||
break
|
||||
if not dupe:
|
||||
result.append((text, conf, center))
|
||||
return result
|
||||
|
||||
|
||||
def extract_accepted(items: list[tuple[str, float, tuple]]) -> list[tuple[str, float]]:
|
||||
"""confidence + 교정 적용 후 최종 채택된 (text, conf) 리스트.
|
||||
|
||||
규칙:
|
||||
- 교정 사전에 명시된 오인식(예: '패텔입'→'패러다임')은 confidence 무관 채택
|
||||
- 그 외 conf < CONF_LOW는 노이즈로 버림
|
||||
- CONF_LOW ~ CONF_HIGH 사이: 한글 2자 이상 또는 교정 발생한 것만
|
||||
- CONF_HIGH 이상: 그대로 채택
|
||||
"""
|
||||
accepted: list[tuple[str, float]] = []
|
||||
for text, conf, _ in items:
|
||||
if is_noise(text):
|
||||
continue
|
||||
corrected = apply_corrections(text)
|
||||
was_corrected = corrected != text
|
||||
if is_noise(corrected):
|
||||
continue
|
||||
|
||||
if was_corrected:
|
||||
# 교정 사전 매칭 → conf 무관 채택 (신뢰도는 0.99로 덮어씀 — 사전 매칭 확신)
|
||||
accepted.append((corrected, max(conf, 0.99)))
|
||||
continue
|
||||
|
||||
if conf < CONF_LOW:
|
||||
continue
|
||||
|
||||
if conf < CONF_HIGH:
|
||||
if not re.search(r"[가-힣]{2,}", corrected):
|
||||
continue
|
||||
|
||||
accepted.append((corrected, conf))
|
||||
return accepted
|
||||
|
||||
|
||||
def find_delta(accepted: list[tuple[str, float]], existing: set[str]) -> list[tuple[str, float]]:
|
||||
delta: list[tuple[str, float]] = []
|
||||
seen: set[str] = set()
|
||||
for phrase, conf in accepted:
|
||||
n = normalize_for_compare(phrase)
|
||||
if not n or len(n) < 2:
|
||||
continue
|
||||
if n in seen:
|
||||
continue
|
||||
if n in existing:
|
||||
continue
|
||||
words = [w for w in re.split(r"[\s\|\-·•/,.()\[\]:;!?#`'\"*~_+=<>&]+", phrase) if w]
|
||||
word_norms = [normalize_for_compare(w) for w in words]
|
||||
has_new = any(wn and len(wn) >= 2 and wn not in existing for wn in word_norms)
|
||||
if not has_new and n not in existing:
|
||||
continue
|
||||
seen.add(n)
|
||||
delta.append((phrase, conf))
|
||||
return delta
|
||||
|
||||
|
||||
def strip_prev_ocr_section(text: str) -> str:
|
||||
marker = APPEND_SECTION_MARKER
|
||||
idx = text.find(marker)
|
||||
if idx < 0:
|
||||
return text
|
||||
header_idx = text.rfind(APPEND_SECTION_HEADER, 0, idx)
|
||||
cut = header_idx if header_idx >= 0 else idx
|
||||
return text[:cut].rstrip() + "\n"
|
||||
|
||||
|
||||
def append_delta(texts_md: Path, delta: list[tuple[str, float]]) -> str:
|
||||
original = texts_md.read_text(encoding="utf-8") if texts_md.exists() else ""
|
||||
cleaned = strip_prev_ocr_section(original)
|
||||
if not delta:
|
||||
return cleaned
|
||||
ts = datetime.now().strftime("%Y-%m-%d")
|
||||
lines = [
|
||||
"",
|
||||
APPEND_SECTION_HEADER,
|
||||
"",
|
||||
f"> EasyOCR(2x 업스케일 + 대비강화) 자동 추출 ({ts}). 기존 텍스트 레이어에 없던 단어/문구만.",
|
||||
APPEND_SECTION_MARKER,
|
||||
"",
|
||||
]
|
||||
for phrase, conf in delta:
|
||||
lines.append(f"- {phrase} _(conf={conf:.2f})_")
|
||||
lines.append("")
|
||||
return cleaned.rstrip() + "\n" + "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--apply", action="store_true", help="texts.md에 실제 반영")
|
||||
ap.add_argument("--only", type=str, default="")
|
||||
args = ap.parse_args()
|
||||
|
||||
idx: dict[str, dict] = json.loads(INDEX_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
print("[init] EasyOCR 로딩 (한/영, CPU)...")
|
||||
import easyocr
|
||||
reader = easyocr.Reader(["ko", "en"], gpu=False, verbose=False)
|
||||
print("[init] OK")
|
||||
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
out_dir = Path("data/runs") / f"{ts}_ocr_augment"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
numbers = sorted(idx.keys())
|
||||
summary_rows: list[dict] = []
|
||||
detail_lines: list[str] = []
|
||||
|
||||
for num in numbers:
|
||||
entry = idx[num]
|
||||
fid = entry["frame_id"]
|
||||
if args.only and fid != args.only:
|
||||
continue
|
||||
png = PREVIEW_DIR / f"{num}.png"
|
||||
texts_md = BLOCKS_DIR / fid / "texts.md"
|
||||
if not png.exists():
|
||||
continue
|
||||
|
||||
print(f"[{num}] {fid} OCR...", end="", flush=True)
|
||||
raw_items = run_ocr_variants(reader, png)
|
||||
deduped = dedupe_by_position(raw_items)
|
||||
accepted = extract_accepted(deduped)
|
||||
existing = load_existing_tokens(texts_md)
|
||||
delta = find_delta(accepted, existing)
|
||||
|
||||
# 저신뢰 detection (잠재 누락 신호): conf < LOW 인데 위치 정보가 있는 것 개수
|
||||
low_conf_count = sum(1 for _, c, _ in raw_items if c < CONF_LOW)
|
||||
|
||||
print(f" 감지(중복제거) {len(deduped)}개 채택 {len(accepted)}개 델타 {len(delta)}개 "
|
||||
f"저신뢰잔여 {low_conf_count}개")
|
||||
|
||||
summary_rows.append({
|
||||
"num": num,
|
||||
"fid": fid,
|
||||
"raw": len(raw_items),
|
||||
"dedup": len(deduped),
|
||||
"accepted": len(accepted),
|
||||
"delta": len(delta),
|
||||
"low_conf": low_conf_count,
|
||||
"delta_items": delta,
|
||||
"low_conf_items": [(t, c) for t, c, _ in raw_items if c < CONF_LOW],
|
||||
})
|
||||
|
||||
detail_lines.append(f"\n### {num}. frame `{fid}`")
|
||||
detail_lines.append(f"- OCR 감지(중복제거 후): {len(deduped)}개")
|
||||
detail_lines.append(f"- 기존 texts.md 토큰: {len(existing)}개")
|
||||
detail_lines.append(f"- 채택(교정 후): {len(accepted)}개")
|
||||
detail_lines.append(f"- **델타(신규 보강): {len(delta)}개**")
|
||||
if delta:
|
||||
detail_lines.append("")
|
||||
detail_lines.append("| 신규 문구 | conf |")
|
||||
detail_lines.append("|---|---|")
|
||||
for p, c in delta:
|
||||
detail_lines.append(f"| {p} | {c:.2f} |")
|
||||
low = summary_rows[-1]["low_conf_items"]
|
||||
if low:
|
||||
detail_lines.append("")
|
||||
detail_lines.append(f"<details><summary>저신뢰 잔여 {len(low)}개 (잠재 누락 단서)</summary>")
|
||||
detail_lines.append("")
|
||||
for t, c in sorted(low, key=lambda x: -x[1])[:20]:
|
||||
detail_lines.append(f"- `{t}` (conf={c:.3f})")
|
||||
if len(low) > 20:
|
||||
detail_lines.append(f"- ... 외 {len(low)-20}개")
|
||||
detail_lines.append("</details>")
|
||||
|
||||
if args.apply:
|
||||
new_text = append_delta(texts_md, delta)
|
||||
texts_md.parent.mkdir(parents=True, exist_ok=True)
|
||||
texts_md.write_text(new_text, encoding="utf-8")
|
||||
|
||||
# ─── summary ───
|
||||
frames_with_delta = [r for r in summary_rows if r["delta"] > 0]
|
||||
frames_no_delta = [r for r in summary_rows if r["delta"] == 0]
|
||||
|
||||
report = [
|
||||
"# OCR 보강 리포트 (EasyOCR + 전처리 + 교정)",
|
||||
"",
|
||||
f"- 드라이런: {'적용됨 (--apply)' if args.apply else '드라이런 (texts.md 미수정)'}",
|
||||
f"- 대상 프레임: {len(summary_rows)}개",
|
||||
f"- **텍스트 누락(델타 > 0) 프레임: {len(frames_with_delta)}개**",
|
||||
f"- 델타 없음(보강 불필요) 프레임: {len(frames_no_delta)}개",
|
||||
"",
|
||||
"## 프레임별 요약",
|
||||
"",
|
||||
"| # | frame_id | 감지 | 채택 | **델타** | 저신뢰 | 델타 미리보기 |",
|
||||
"|---|---|---|---|---|---|---|",
|
||||
]
|
||||
for r in summary_rows:
|
||||
preview = "; ".join(p for p, _ in r["delta_items"][:4])
|
||||
if len(r["delta_items"]) > 4:
|
||||
preview += "…"
|
||||
mark = "🔴" if r["delta"] > 0 else "·"
|
||||
report.append(
|
||||
f"| {r['num']} | `{r['fid']}` | {r['dedup']} | {r['accepted']} | "
|
||||
f"{mark} **{r['delta']}** | {r['low_conf']} | {preview} |"
|
||||
)
|
||||
|
||||
report.append("\n## 텍스트 누락 프레임 리스트 (델타 > 0)\n")
|
||||
if frames_with_delta:
|
||||
for r in frames_with_delta:
|
||||
items = ", ".join(p for p, _ in r["delta_items"])
|
||||
report.append(f"- **#{r['num']}** `{r['fid']}` — 델타 {r['delta']}개: {items}")
|
||||
else:
|
||||
report.append("_(누락 없음)_")
|
||||
|
||||
report.append("\n## 상세")
|
||||
report.extend(detail_lines)
|
||||
|
||||
out = out_dir / "report.md"
|
||||
out.write_text("\n".join(report), encoding="utf-8")
|
||||
print(f"\n[saved] {out}")
|
||||
if args.apply:
|
||||
print("[applied] texts.md 파일들 업데이트 완료")
|
||||
else:
|
||||
print("[dryrun] --apply 를 붙이면 texts.md 에 반영됩니다")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
416
scripts/previews/mdx04_f16_override.py
Normal file
416
scripts/previews/mdx04_f16_override.py
Normal file
@@ -0,0 +1,416 @@
|
||||
"""MDX04 F16 override slide-fit preview — slide_fit_preview, NOT a Phase Z final.
|
||||
|
||||
배경:
|
||||
- V4 top1 = F26. 사용자 semantic review 로 F16 채택
|
||||
- 사유: MDX04 04-2.* 는 4-issue diagnostic 구조 → F16 quadrant pattern 적합
|
||||
- F26 figma 1:1 변환 부재 (별도 작업 보류)
|
||||
- anchor 보정 / detect_mdx 수정 / v4_full32_result.yaml 변경 모두 없음
|
||||
|
||||
매핑 (사용자 결정 — B 수정판, 그대로 유지):
|
||||
04-2.1 (4) + 04-2.2 (4) = 8 항목을 4 원인군으로 그룹핑. 04-2.2 보존.
|
||||
|
||||
레이아웃 전환 (composition_preview → slide_fit_preview):
|
||||
이전: 1280×1230 비표준 (composition preview)
|
||||
현재: 1280×720 표준 슬라이드 (slide_fit_preview)
|
||||
├ title bar (1280×56)
|
||||
├ body (1200×590)
|
||||
│ ├ zone-left (340×590) = 04-1 compact 5-card stack
|
||||
│ └ zone-right (840×590) = F16 quadrant zone-fit (4분면 + center quote)
|
||||
└ footer pill (1280×48)
|
||||
F16 native dim (1280×1015) 폐기. zone (840×590) 에 맞게 좌표 재계산. 폰트 축소.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from html import escape
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MDX_PATH = ROOT / "samples" / "mdx_batch" / "04.mdx"
|
||||
V4_RESULT = ROOT / "tests" / "matching" / "v4_full32_result.yaml"
|
||||
RUN_DIR = ROOT / "data" / "runs" / "mdx04_f16_override"
|
||||
TEMPLATES_DIR = RUN_DIR / "templates"
|
||||
|
||||
|
||||
# ─── 그룹핑 정의 (사용자 예시 그대로) ──────────────────────────
|
||||
|
||||
GROUPING_RULE = {
|
||||
'description': '04-2.1 (4 정책 항목) + 04-2.2 (4 조직 항목) = 8 항목을 4 원인군으로 그룹핑. F16 4 분면 ribbon = 그룹명.',
|
||||
'reason': 'user wants 04-2.2 보존 + F16 4 분면 디자인 활용. 1:1 짝짓기 강제 회피.',
|
||||
'groups': [
|
||||
{
|
||||
'quadrant': 'q1',
|
||||
'name': '정책 집행 / 제도 운용 문제',
|
||||
'items': [
|
||||
{'source': '04-2.1', 'index': 0}, # 실질적 기술 경쟁을 저해하는 정책 집행
|
||||
{'source': '04-2.1', 'index': 1}, # 적용 효과가 있는 사례도 없이 방침부터 도입
|
||||
],
|
||||
},
|
||||
{
|
||||
'quadrant': 'q2',
|
||||
'name': '개념 이해 부족',
|
||||
'items': [
|
||||
{'source': '04-2.1', 'index': 2}, # 엔지니어링 S/W에 대한 개념 부재
|
||||
{'source': '04-2.2', 'index': 0}, # 공학적 개념 정립 부재
|
||||
{'source': '04-2.2', 'index': 2}, # DX/BIM의 근본 취지와 목표의 이해 부족
|
||||
],
|
||||
},
|
||||
{
|
||||
'quadrant': 'q3',
|
||||
'name': '기술 투자 / 본업 기술력 부족',
|
||||
'items': [
|
||||
{'source': '04-2.1', 'index': 3}, # 기술투자(R&D) 없는 성과 창출 기대
|
||||
{'source': '04-2.2', 'index': 1}, # '본업 기술력 확보' 우선의 개념 부재
|
||||
],
|
||||
},
|
||||
{
|
||||
'quadrant': 'q4',
|
||||
'name': '조직 / 수행 역량 문제',
|
||||
'items': [
|
||||
{'source': '04-2.2', 'index': 3}, # 과거의 타성에 머무르고 있는 기술자 집단
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ─── MDX 04 파싱 ────────────────────────────────────────────────
|
||||
|
||||
RE_SUBSECTION_HEAD = re.compile(r'^###\s+(\d+\.\d+)\s+(.+)$', re.MULTILINE)
|
||||
RE_TOP_BULLET = re.compile(r'^-\s+\*\*([^*]+)\*\*\s*$')
|
||||
|
||||
|
||||
def extract_subsection_items(text, num_label):
|
||||
lines = text.split('\n')
|
||||
start = None
|
||||
for i, ln in enumerate(lines):
|
||||
m = RE_SUBSECTION_HEAD.match(ln.strip())
|
||||
if m and m.group(1) == num_label:
|
||||
start = i
|
||||
break
|
||||
if start is None:
|
||||
return None, []
|
||||
end = len(lines)
|
||||
for j in range(start + 1, len(lines)):
|
||||
s = lines[j].strip()
|
||||
if RE_SUBSECTION_HEAD.match(s) or s == '---':
|
||||
end = j
|
||||
break
|
||||
section_title = lines[start].lstrip('# ').strip()
|
||||
body_lines = lines[start + 1:end]
|
||||
items = []
|
||||
cur = None
|
||||
for ln in body_lines:
|
||||
stripped = ln.strip()
|
||||
m = RE_TOP_BULLET.match(stripped)
|
||||
if m:
|
||||
if cur is not None:
|
||||
items.append(cur)
|
||||
cur = {'headline': m.group(1).strip(), 'subs': []}
|
||||
continue
|
||||
m2 = re.match(r'^-\s+(.+)$', stripped)
|
||||
if m2 and cur is not None and not stripped.startswith('- **'):
|
||||
cur['subs'].append(m2.group(1).strip())
|
||||
if cur is not None:
|
||||
items.append(cur)
|
||||
return section_title, items
|
||||
|
||||
|
||||
def extract_section_04_1_cards(text):
|
||||
m = re.search(r'## 1\. DX에 대한 인식(.*?)(?=^## 2\.)', text, re.DOTALL | re.MULTILINE)
|
||||
if not m:
|
||||
return None, []
|
||||
body = m.group(1)
|
||||
cards = []
|
||||
h3_iter = list(re.finditer(r'<h3[^>]*>([^<]+)</h3>', body))
|
||||
for idx, h3m in enumerate(h3_iter):
|
||||
label = h3m.group(1).strip()
|
||||
section_end = h3_iter[idx + 1].start() if idx + 1 < len(h3_iter) else len(body)
|
||||
section_text = body[h3m.end():section_end]
|
||||
# 인용 (첫 <p> 의 따옴표 텍스트)
|
||||
quote_m = re.search(r'<p[^>]*>(?:["“])(.+?)(?:["”])</p>', section_text, re.DOTALL)
|
||||
if not quote_m:
|
||||
quote_m = re.search(r'<p[^>]*>([^<]+)</p>', section_text, re.DOTALL)
|
||||
quote = quote_m.group(1).strip() if quote_m else ''
|
||||
bullets = [b.strip() for b in re.findall(r'<li[^>]*>([^<]+)</li>', section_text)]
|
||||
cards.append({'label': label, 'quote': quote, 'bullets': bullets})
|
||||
return '1. DX에 대한 인식', cards
|
||||
|
||||
|
||||
# ─── F16 grouped mapper ────────────────────────────────────────
|
||||
|
||||
def map_to_f16_grouped(items_2_1, items_2_2, slide_title):
|
||||
"""8 items (2.1 4 + 2.2 4) → 4 quadrant groups (사용자 그룹핑 룰 적용)."""
|
||||
source_map = {'04-2.1': items_2_1, '04-2.2': items_2_2}
|
||||
payload = {'center_quote': slide_title}
|
||||
for group in GROUPING_RULE['groups']:
|
||||
q = group['quadrant']
|
||||
items_for_q = []
|
||||
for ref in group['items']:
|
||||
src_items = source_map[ref['source']]
|
||||
idx = ref['index']
|
||||
if idx < len(src_items):
|
||||
src_item = src_items[idx]
|
||||
items_for_q.append({
|
||||
'source': '[' + ref['source'].replace('04-', '') + ']',
|
||||
'headline': src_item['headline'],
|
||||
'subs': src_item['subs'],
|
||||
})
|
||||
payload[f'{q}_label'] = group['name']
|
||||
payload[f'{q}_items'] = items_for_q
|
||||
return payload
|
||||
|
||||
|
||||
def map_to_5card_compact_slots(cards, section_title):
|
||||
return {'section_title': section_title, 'cards': cards}
|
||||
|
||||
|
||||
# ─── V4 metadata lookup ───────────────────────────────────────
|
||||
|
||||
def get_top1(v4, sid):
|
||||
sec = v4.get('mdx_sections', {}).get(sid)
|
||||
if not sec:
|
||||
return None
|
||||
j = sec.get('judgments_full32', [])
|
||||
return j[0] if j else None
|
||||
|
||||
|
||||
def get_frame_judgment(v4, sid, frame_number):
|
||||
sec = v4.get('mdx_sections', {}).get(sid)
|
||||
if not sec:
|
||||
return None
|
||||
for e in sec.get('judgments_full32', []):
|
||||
if e['frame_number'] == frame_number:
|
||||
return e
|
||||
return None
|
||||
|
||||
|
||||
# ─── 메인 ──────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
if not MDX_PATH.exists():
|
||||
print(f"ERROR: MDX 04 not found at {MDX_PATH}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not V4_RESULT.exists():
|
||||
print(f"ERROR: V4 result not found at {V4_RESULT}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
mdx_text = MDX_PATH.read_text(encoding='utf-8')
|
||||
v4 = yaml.safe_load(V4_RESULT.read_text(encoding='utf-8'))
|
||||
|
||||
title_2_1, items_2_1 = extract_subsection_items(mdx_text, '2.1')
|
||||
title_2_2, items_2_2 = extract_subsection_items(mdx_text, '2.2')
|
||||
title_1, cards_1 = extract_section_04_1_cards(mdx_text)
|
||||
|
||||
env = Environment(
|
||||
loader=FileSystemLoader(str(TEMPLATES_DIR)),
|
||||
autoescape=select_autoescape(['html', 'xml']),
|
||||
)
|
||||
f16_zonefit_tpl = env.get_template("bim_issues_quadrant_four_zonefit.html.j2")
|
||||
cards5_left_tpl = env.get_template("cards_5_left_zone.html.j2")
|
||||
slide_fit_tpl = env.get_template("slide_fit_base.html.j2")
|
||||
|
||||
# 04-2 통합 (그룹핑) → F16 zone-fit
|
||||
payload_f16 = map_to_f16_grouped(items_2_1, items_2_2, slide_title='DX 지연<br>요인')
|
||||
html_f16_zonefit = f16_zonefit_tpl.render(slot_payload=payload_f16)
|
||||
|
||||
# 04-1 → 5-card left zone
|
||||
payload_cards = map_to_5card_compact_slots(cards_1, section_title=title_1)
|
||||
html_cards_left = cards5_left_tpl.render(slot_payload=payload_cards)
|
||||
|
||||
# slide_fit base 조립 (1280×720)
|
||||
slide_fit_html = slide_fit_tpl.render(
|
||||
slide_title='4. DX 지연 요인',
|
||||
slide_meta='F16 user_semantic_override · slide_fit_preview',
|
||||
zone_left=html_cards_left,
|
||||
zone_right=html_f16_zonefit,
|
||||
slide_footer='검증 없는 정책의 일방적 추진과 조직의 회피, 이해 부족이 DX 지연을 반복시킨다',
|
||||
)
|
||||
|
||||
# 통합 1 슬라이드 페이지 (banner + slide_fit + metadata)
|
||||
timestamp = datetime.now().isoformat(timespec='seconds')
|
||||
page_html = f'''<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>MDX04 1280×720 slide_fit · F16 user_semantic_override</title>
|
||||
<style>
|
||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||
body {{ font-family: 'Noto Sans KR', 'Pretendard', sans-serif; background: #e8ecf0; padding: 24px; }}
|
||||
.preview-banner {{ max-width: 1280px; margin: 0 auto 16px; background: #fff7ed; border: 2px solid #f59e0b; border-radius: 8px; padding: 12px 16px; font-size: 12px; color: #92400e; line-height: 1.6; }}
|
||||
.preview-banner strong {{ color: #78350f; }}
|
||||
.preview-banner ul {{ margin-top: 6px; padding-left: 18px; font-family: monospace; font-size: 11px; }}
|
||||
.slide-wrap {{ display: flex; justify-content: center; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="preview-banner">
|
||||
<strong>MDX04 slide_fit_preview · 1280×720 표준 (NOT a Phase Z final)</strong><br>
|
||||
composition_preview (1280×1230) → <strong>slide_fit_preview (1280×720)</strong> 전환.
|
||||
같은 grouping rule 유지 (04-2.* 8 항목 → 4 원인군). 04-2.2 보존. F16 native dim 폐기, zone-fit 적용.
|
||||
<ul>
|
||||
<li>layout = title (56) + body (1200×590, left 340 + right 840) + footer pill (48)</li>
|
||||
<li>zone-left = 04-1 compact 5-card stack</li>
|
||||
<li>zone-right = F16 quadrant zone-fit (4분면 + center quote)</li>
|
||||
<li>q1 = 정책 집행 / 제도 운용 (2.1×2) · q2 = 개념 이해 부족 (2.1×1 + 2.2×2)</li>
|
||||
<li>q3 = 기술 투자 / 본업 기술력 부족 (2.1×1 + 2.2×1) · q4 = 조직 / 수행 역량 (2.2×1)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="slide-wrap">
|
||||
{slide_fit_html}
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
(RUN_DIR / "index.html").write_text(page_html, encoding='utf-8')
|
||||
|
||||
# 단독 slide_fit (banner 없이 슬라이드 자체만)
|
||||
standalone_slide = f'''<!DOCTYPE html>
|
||||
<html lang="ko"><head><meta charset="UTF-8"><title>MDX04 1280×720 slide_fit (standalone)</title>
|
||||
<style>* {{margin:0;padding:0;box-sizing:border-box}} body {{font-family:'Noto Sans KR',sans-serif;background:#e8ecf0;padding:20px;display:flex;justify-content:center}}</style></head><body>
|
||||
{slide_fit_html}
|
||||
</body></html>
|
||||
'''
|
||||
(RUN_DIR / "slide_1280x720.html").write_text(standalone_slide, encoding='utf-8')
|
||||
|
||||
# debug.json
|
||||
top1_2_1 = get_top1(v4, '04-2.1')
|
||||
top1_2_2 = get_top1(v4, '04-2.2')
|
||||
top1_1 = get_top1(v4, '04-1')
|
||||
f16_2_1 = get_frame_judgment(v4, '04-2.1', 16)
|
||||
f16_2_2 = get_frame_judgment(v4, '04-2.2', 16)
|
||||
|
||||
# grouping coverage 검증 (모든 8 항목 사용됐는지)
|
||||
used = set()
|
||||
for g in GROUPING_RULE['groups']:
|
||||
for ref in g['items']:
|
||||
used.add((ref['source'], ref['index']))
|
||||
expected = set([('04-2.1', i) for i in range(len(items_2_1))]
|
||||
+ [('04-2.2', i) for i in range(len(items_2_2))])
|
||||
missing = sorted(expected - used)
|
||||
extra = sorted(used - expected)
|
||||
|
||||
debug = {
|
||||
'kind': 'mdx04_f16_override_slide_fit',
|
||||
'preview_stage': 'slide_fit_preview',
|
||||
'transition_from': 'composition_preview (1280×1230 비표준)',
|
||||
'transition_to': 'slide_fit_preview (1280×720 표준)',
|
||||
'transition_note': '같은 grouping rule 유지. F16 native height (1015px) 폐기. zone-fit 좌표 재계산. 폰트 축소.',
|
||||
'is_phase_z_final': False,
|
||||
'is_diagnostic': True,
|
||||
'is_preview_or_result_candidate': True,
|
||||
'generated_at': timestamp,
|
||||
'v4_source': str(V4_RESULT.relative_to(ROOT)),
|
||||
'mdx_source': str(MDX_PATH.relative_to(ROOT)),
|
||||
'integrated_slide': True,
|
||||
'layout': {
|
||||
'slide_dimensions': '1280×720',
|
||||
'title_bar_height': 56,
|
||||
'body': {'width': 1200, 'height': 590, 'left_zone': 340, 'right_zone': 840, 'gap': 20},
|
||||
'footer_pill_height': 48,
|
||||
'zone_left': '04-1 compact 5-card stack (frame library gap)',
|
||||
'zone_right': '04-2 통합 F16 quadrant zone-fit (grouped)',
|
||||
'mdx_one_slide_principle': True,
|
||||
'standard_16_9': True,
|
||||
},
|
||||
'override_decision': {
|
||||
'selected_frame_source': 'user_semantic_override',
|
||||
'selected_frame': 'F16',
|
||||
'selected_template_id': 'bim_issues_quadrant_four',
|
||||
'reason': 'F16 quadrant pattern semantically/visually appropriate for MDX04 04-2.* '
|
||||
'(four-issue diagnostic structure). V4 top1 F26 figma 변환 부재 + semantic '
|
||||
'review 에서 F16 가 더 적합 판단.',
|
||||
},
|
||||
'grouping_rule': GROUPING_RULE,
|
||||
'grouping_coverage': {
|
||||
'total_items': len(items_2_1) + len(items_2_2),
|
||||
'mapped_items': len(used),
|
||||
'missing': [{'source': s, 'index': i} for s, i in missing],
|
||||
'extra': [{'source': s, 'index': i} for s, i in extra],
|
||||
'all_items_preserved': not missing,
|
||||
},
|
||||
'sections': {
|
||||
'04-2.1': {
|
||||
'mdx_title': title_2_1,
|
||||
'item_count': len(items_2_1),
|
||||
'v4_top1': {
|
||||
'frame_number': top1_2_1['frame_number'],
|
||||
'template_id': top1_2_1['template_id'],
|
||||
'label': top1_2_1['label'],
|
||||
'confidence': top1_2_1['confidence'],
|
||||
},
|
||||
'selected_frame': 16,
|
||||
'original_label': f16_2_1['label'] if f16_2_1 else None,
|
||||
'original_confidence': f16_2_1['confidence'] if f16_2_1 else None,
|
||||
},
|
||||
'04-2.2': {
|
||||
'mdx_title': title_2_2,
|
||||
'item_count': len(items_2_2),
|
||||
'v4_top1': {
|
||||
'frame_number': top1_2_2['frame_number'],
|
||||
'template_id': top1_2_2['template_id'],
|
||||
'label': top1_2_2['label'],
|
||||
'confidence': top1_2_2['confidence'],
|
||||
},
|
||||
'selected_frame': 16,
|
||||
'original_label': f16_2_2['label'] if f16_2_2 else None,
|
||||
'original_confidence': f16_2_2['confidence'] if f16_2_2 else None,
|
||||
'preserved_in_grouping': True,
|
||||
},
|
||||
'04-1': {
|
||||
'mdx_title': title_1,
|
||||
'card_count': len(cards_1),
|
||||
'v4_top1': {
|
||||
'frame_number': top1_1['frame_number'],
|
||||
'template_id': top1_1['template_id'],
|
||||
'label': top1_1['label'],
|
||||
'confidence': top1_1['confidence'],
|
||||
} if top1_1 else None,
|
||||
'selected_frame': None,
|
||||
'override_note': '5-card library gap (32 frame DB 에 cardinality.ideal=5 frame 부재). '
|
||||
'compact 5-column grid 로 통합 슬라이드 상단에 배치.',
|
||||
},
|
||||
},
|
||||
'caveats': [
|
||||
'정식 Phase Z final 아님 — V4 lookup 우회',
|
||||
'preview_stage = slide_fit_preview (1280×720 표준). 이전 composition_preview (1280×1230) 에서 전환',
|
||||
'F16 partial template = preview 전용 (data/runs/mdx04_f16_override/templates/) — design_agent/templates/phase_z2 미수정',
|
||||
'anchor 보정 / detect_mdx 수정 / v4_full32_result.yaml 변경 없음',
|
||||
'04-2.1 의 F16 original_label = reject (anchor=0). override 로 진행',
|
||||
'04-2.2 의 F16 original_label = restructure (사용 가능 라벨)',
|
||||
'04-2.2 보존 — 그룹핑으로 8 항목 모두 분면에 매핑',
|
||||
'04-1 = 5-card library gap. zone-left 에 compact stack 으로 배치',
|
||||
'그룹핑 룰은 사용자 semantic 결정 (yaml/dict 로 명시). 자동 생성 아님',
|
||||
'F16 native dim (1280×1015) 폐기 — zone (840×590) 에 맞춰 좌표 재계산. 폰트 14px(ribbon)/11.5px(headline)/9.5px(sub)',
|
||||
'slide-fit 으로 폰트 작아짐 → 가독성 trade-off. composition_preview 와 비교 필요',
|
||||
],
|
||||
}
|
||||
(RUN_DIR / "debug.json").write_text(
|
||||
json.dumps(debug, ensure_ascii=False, indent=2), encoding='utf-8',
|
||||
)
|
||||
|
||||
# 이전 composition_preview 산출물 정리 — slide_fit_preview 로 대체
|
||||
for old in ["slide_04-2.1.html", "slide_04-2.2.html", "slide_04-1.html",
|
||||
"slide_04-2_grouped.html", "slide_04-1_compact.html"]:
|
||||
p = RUN_DIR / old
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
|
||||
print(f"[mdx04_f16_override_slide_fit] generated:")
|
||||
print(f" index : {RUN_DIR / 'index.html'}")
|
||||
print(f" slide 1280×720 : {RUN_DIR / 'slide_1280x720.html'}")
|
||||
print(f" debug : {RUN_DIR / 'debug.json'}")
|
||||
print()
|
||||
print(f"Coverage: {len(used)}/{len(expected)} items mapped, missing={list(missing)}")
|
||||
print(f"Stage: composition_preview → slide_fit_preview")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
404
scripts/previews/mdx04_partial_preview.py
Normal file
404
scripts/previews/mdx04_partial_preview.py
Normal file
@@ -0,0 +1,404 @@
|
||||
"""MDX04 partial preview — diagnostic only, NOT a Phase Z final.
|
||||
|
||||
목적: F16 (`bim_issues_quadrant_four`) 가 04-2.1 / 04-2.2 의 4 항목 구조와
|
||||
시각적으로 정합하는지 사용자가 눈으로 확인.
|
||||
|
||||
방식:
|
||||
- V4 runtime 우회 (정식 Phase Z 아님)
|
||||
- F16 figma 원본 HTML 을 iframe 으로 임베드 (디자인 형태 그대로)
|
||||
- 04-2.1 / 04-2.2 의 MDX 4 항목을 옆에 시각화 (구조 비교)
|
||||
- 04-1 = frame library gap (5-card 구조, 매칭 frame 부재) placeholder
|
||||
- diagnostic banner + V4 metadata + debug.json
|
||||
|
||||
출력:
|
||||
data/runs/mdx04_partial_preview/index.html
|
||||
data/runs/mdx04_partial_preview/debug.json
|
||||
data/runs/mdx04_partial_preview/f16_original/ (figma 원본 + assets)
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from html import escape
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MDX_PATH = ROOT / "samples" / "mdx_batch" / "04.mdx"
|
||||
V4_RESULT = ROOT / "tests" / "matching" / "v4_full32_result.yaml"
|
||||
RUN_DIR = ROOT / "data" / "runs" / "mdx04_partial_preview"
|
||||
|
||||
|
||||
# ─── MDX 04 의 04-2.1 / 04-2.2 섹션 추출 (### bullet) ──────────────
|
||||
|
||||
RE_SUBSECTION_HEAD = re.compile(r'^###\s+(\d+\.\d+)\s+(.+)$', re.MULTILINE)
|
||||
RE_TOP_BULLET = re.compile(r'^-\s+\*\*([^*]+)\*\*\s*$')
|
||||
|
||||
|
||||
def extract_subsection(text, num_label):
|
||||
"""### {num_label} ... 부터 다음 ### 또는 --- 직전까지 추출."""
|
||||
lines = text.split('\n')
|
||||
start = None
|
||||
for i, ln in enumerate(lines):
|
||||
m = RE_SUBSECTION_HEAD.match(ln.strip())
|
||||
if m and m.group(1) == num_label:
|
||||
start = i
|
||||
break
|
||||
if start is None:
|
||||
return None, []
|
||||
end = len(lines)
|
||||
for j in range(start + 1, len(lines)):
|
||||
s = lines[j].strip()
|
||||
if RE_SUBSECTION_HEAD.match(s) or s == '---':
|
||||
end = j
|
||||
break
|
||||
section_title = lines[start].lstrip('# ').strip()
|
||||
body_lines = lines[start + 1:end]
|
||||
|
||||
# 4 항목 추출 (top bullet + nested bullets)
|
||||
items = []
|
||||
cur = None
|
||||
for ln in body_lines:
|
||||
stripped = ln.strip()
|
||||
m = RE_TOP_BULLET.match(stripped)
|
||||
if m:
|
||||
if cur is not None:
|
||||
items.append(cur)
|
||||
cur = {'headline': m.group(1).strip(), 'subs': []}
|
||||
continue
|
||||
m2 = re.match(r'^-\s+(.+)$', stripped)
|
||||
if m2 and cur is not None and not stripped.startswith('- **'):
|
||||
cur['subs'].append(m2.group(1).strip())
|
||||
if cur is not None:
|
||||
items.append(cur)
|
||||
return section_title, items
|
||||
|
||||
|
||||
def extract_section_04_1(text):
|
||||
"""04-1 = ## 1. DX에 대한 인식. <h3> 카드 5 개 + 각 카드 안 인용 + bullet 3 개."""
|
||||
lines = text.split('\n')
|
||||
start = None
|
||||
for i, ln in enumerate(lines):
|
||||
if ln.strip() == '## 1. DX에 대한 인식':
|
||||
start = i
|
||||
break
|
||||
if start is None:
|
||||
return None, []
|
||||
end = len(lines)
|
||||
for j in range(start + 1, len(lines)):
|
||||
s = lines[j].strip()
|
||||
if s.startswith('## ') and s != '## 1. DX에 대한 인식':
|
||||
end = j
|
||||
break
|
||||
|
||||
body = '\n'.join(lines[start:end])
|
||||
|
||||
# <h3> 라벨 + 다음 <p> 인용 + <ul><li> bullet 3 개
|
||||
cards = []
|
||||
for m in re.finditer(r'<h3[^>]*>([^<]+)</h3>', body):
|
||||
cards.append({'label': m.group(1).strip()})
|
||||
|
||||
return lines[start].lstrip('# ').strip(), cards
|
||||
|
||||
|
||||
# ─── V4 metadata lookup ──────────────────────────────────────────
|
||||
|
||||
def get_f16_judgment(v4, section_id):
|
||||
sec = v4['mdx_sections'].get(section_id)
|
||||
if not sec:
|
||||
return None
|
||||
for e in sec['judgments_full32']:
|
||||
if e['frame_number'] == 16:
|
||||
return e
|
||||
return None
|
||||
|
||||
|
||||
def get_top1(v4, section_id):
|
||||
sec = v4['mdx_sections'].get(section_id)
|
||||
if not sec:
|
||||
return None
|
||||
j = sec.get('judgments_full32', [])
|
||||
return j[0] if j else None
|
||||
|
||||
|
||||
# ─── HTML 렌더링 ─────────────────────────────────────────────────
|
||||
|
||||
def render_items_html(items):
|
||||
parts = ['<div class="items-list">']
|
||||
for i, it in enumerate(items, 1):
|
||||
parts.append('<div class="item">')
|
||||
parts.append(f'<div class="item-headline">{i}. {escape(it["headline"])}</div>')
|
||||
if it['subs']:
|
||||
parts.append('<ul class="item-subs">')
|
||||
for s in it['subs']:
|
||||
parts.append(f'<li>{escape(s)}</li>')
|
||||
parts.append('</ul>')
|
||||
parts.append('</div>')
|
||||
parts.append('</div>')
|
||||
return '\n'.join(parts)
|
||||
|
||||
|
||||
def render_cards_html(cards):
|
||||
parts = ['<div class="cards-list">']
|
||||
for i, c in enumerate(cards, 1):
|
||||
parts.append(f'<div class="card">{i}. {escape(c["label"])}</div>')
|
||||
parts.append('</div>')
|
||||
return '\n'.join(parts)
|
||||
|
||||
|
||||
def render_v4_metadata_html(j, label_note=''):
|
||||
if j is None:
|
||||
return '<div class="v4-meta v4-meta-missing">V4 entry not found</div>'
|
||||
axes = j.get('axes', {})
|
||||
return f'''<div class="v4-meta">
|
||||
<div class="v4-meta-row">
|
||||
<span class="v4-meta-key">V4 rank:</span><span class="v4-meta-val">{j["v4_full_rank"]}</span>
|
||||
<span class="v4-meta-key">conf:</span><span class="v4-meta-val">{j["confidence"]:.4f}</span>
|
||||
<span class="v4-meta-key">label:</span><span class="v4-meta-val v4-label-{j["label"]}">{j["label"]}</span>
|
||||
</div>
|
||||
<div class="v4-meta-row v4-axes">
|
||||
<span class="v4-meta-key">axes:</span>
|
||||
anchor={axes.get("anchor", 0):.2f} ·
|
||||
cardinality={axes.get("cardinality", 0):.2f} ·
|
||||
relation={axes.get("relation", 0):.2f} ·
|
||||
slot={axes.get("slot", 0):.2f} ·
|
||||
content={axes.get("content", 0):.4f}
|
||||
</div>
|
||||
{f'<div class="v4-meta-row v4-note">{label_note}</div>' if label_note else ''}
|
||||
</div>'''
|
||||
|
||||
|
||||
def render_section(section_id, mdx_title, items_html, j_f16, top1, note):
|
||||
"""좌: F16 figma 원본 iframe / 우: MDX 텍스트 4 항목 / 하: V4 metadata."""
|
||||
label_note = note
|
||||
return f'''<section class="preview-section" id="sec-{section_id}">
|
||||
<header class="section-head">
|
||||
<h2>{escape(section_id)} · {escape(mdx_title)}</h2>
|
||||
<div class="section-sub">
|
||||
F16 (bim_issues_quadrant_four) candidate · top1 = F{top1["frame_number"]} ({top1["label"]}, conf {top1["confidence"]:.4f})
|
||||
</div>
|
||||
</header>
|
||||
<div class="section-body">
|
||||
<div class="col col-figma">
|
||||
<div class="col-label">F16 figma 원본 (디자인 형태)</div>
|
||||
<div class="iframe-frame">
|
||||
<iframe src="f16_original/index.html" frameborder="0" scrolling="no"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col col-mdx">
|
||||
<div class="col-label">MDX 04 {escape(section_id)} 본문 (4 항목)</div>
|
||||
{items_html}
|
||||
</div>
|
||||
</div>
|
||||
{render_v4_metadata_html(j_f16, label_note)}
|
||||
</section>'''
|
||||
|
||||
|
||||
def render_04_1_placeholder(top1):
|
||||
return f'''<section class="preview-section preview-gap" id="sec-04-1">
|
||||
<header class="section-head">
|
||||
<h2>04-1 · DX에 대한 인식</h2>
|
||||
<div class="section-sub">
|
||||
Frame library gap — 5-card 구조, 32 frame DB 에 cardinality.ideal=5 frame 부재 (이번 preview 제외)
|
||||
</div>
|
||||
</header>
|
||||
<div class="gap-note">
|
||||
<strong>왜 제외</strong>: 04-1 은 5 개 카드 (기술/효과/인력/경제/실무) — h3_cards=5 인식까지는 정상. 다만 32 frame
|
||||
중 5-card 대응 frame 이 없어 V4 multi-constraint 통과 가능 frame 자체가 없음 (사용 가능 0/32, 모두 reject).
|
||||
이건 detect bug 가 아니라 <strong>frame library readiness 문제</strong>.
|
||||
<br><br>
|
||||
V4 top1 = F{top1["frame_number"]} (conf {top1["confidence"]:.4f}, {top1["label"]}) — F16 도 rank 15, conf 0.361, reject.
|
||||
</div>
|
||||
</section>'''
|
||||
|
||||
|
||||
# ─── 메인 ────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
if not V4_RESULT.exists():
|
||||
print(f"ERROR: V4 result not found at {V4_RESULT}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not MDX_PATH.exists():
|
||||
print(f"ERROR: MDX 04 not found at {MDX_PATH}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
mdx_text = MDX_PATH.read_text(encoding='utf-8')
|
||||
v4 = yaml.safe_load(V4_RESULT.read_text(encoding='utf-8'))
|
||||
|
||||
# 04-2.1
|
||||
title_2_1, items_2_1 = extract_subsection(mdx_text, '2.1')
|
||||
j16_2_1 = get_f16_judgment(v4, '04-2.1')
|
||||
top1_2_1 = get_top1(v4, '04-2.1')
|
||||
|
||||
# 04-2.2
|
||||
title_2_2, items_2_2 = extract_subsection(mdx_text, '2.2')
|
||||
j16_2_2 = get_f16_judgment(v4, '04-2.2')
|
||||
top1_2_2 = get_top1(v4, '04-2.2')
|
||||
|
||||
# 04-1
|
||||
title_1, cards_1 = extract_section_04_1(mdx_text)
|
||||
top1_1 = get_top1(v4, '04-1')
|
||||
|
||||
# HTML 조립
|
||||
section_2_1_html = render_section(
|
||||
'04-2.1', title_2_1,
|
||||
render_items_html(items_2_1),
|
||||
j16_2_1, top1_2_1,
|
||||
note='F16 candidate — V4 label=reject (anchor=0). 의미 매칭 회복했으나 anchor terms 부재로 multi-constraint 탈락. preview 목적 = F16 디자인 / 04-2.1 본문 정합성 시각 확인.'
|
||||
)
|
||||
section_2_2_html = render_section(
|
||||
'04-2.2', title_2_2,
|
||||
render_items_html(items_2_2),
|
||||
j16_2_2, top1_2_2,
|
||||
note='F16 restructure — V4 label=restructure 통과 (사용 가능 라벨). preview 목적 = F16 디자인이 04-2.2 본문에 시각적으로 fit 한지 확인.'
|
||||
)
|
||||
section_1_html = render_04_1_placeholder(top1_1)
|
||||
|
||||
timestamp = datetime.now().isoformat(timespec='seconds')
|
||||
page_html = f'''<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>MDX04 Partial Preview · diagnostic only</title>
|
||||
<style>
|
||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||
body {{
|
||||
font-family: -apple-system, "Pretendard", "Apple SD Gothic Neo", sans-serif;
|
||||
background: #f5f6f8; color: #111; line-height: 1.5;
|
||||
padding: 24px;
|
||||
}}
|
||||
.banner {{
|
||||
background: #fff7ed; border: 2px solid #f59e0b; border-radius: 8px;
|
||||
padding: 16px 20px; margin: 0 auto 24px; max-width: 1400px;
|
||||
}}
|
||||
.banner h1 {{ font-size: 18px; color: #92400e; margin-bottom: 6px; }}
|
||||
.banner p {{ font-size: 13px; color: #78350f; }}
|
||||
.banner .timestamp {{ font-size: 11px; color: #b45309; margin-top: 8px; font-family: monospace; }}
|
||||
|
||||
.preview-section {{
|
||||
max-width: 1400px; margin: 0 auto 32px;
|
||||
background: #fff; border: 1px solid #d1d5db; border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}}
|
||||
.section-head {{ padding: 16px 20px; border-bottom: 1px solid #e5e7eb; background: #f9fafb; }}
|
||||
.section-head h2 {{ font-size: 18px; color: #111827; margin-bottom: 4px; }}
|
||||
.section-sub {{ font-size: 13px; color: #6b7280; }}
|
||||
|
||||
.section-body {{
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 0;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}}
|
||||
.col {{ padding: 16px 20px; }}
|
||||
.col-figma {{ border-right: 1px solid #e5e7eb; background: #fafafa; }}
|
||||
.col-label {{
|
||||
font-size: 12px; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px;
|
||||
margin-bottom: 12px; font-weight: 600;
|
||||
}}
|
||||
|
||||
.iframe-frame {{
|
||||
width: 100%; height: 380px;
|
||||
background: #fff; border: 1px solid #d1d5db; border-radius: 4px;
|
||||
overflow: hidden; position: relative;
|
||||
}}
|
||||
.iframe-frame iframe {{
|
||||
width: 1280px; height: 720px;
|
||||
transform: scale(0.5); transform-origin: top left;
|
||||
}}
|
||||
|
||||
.items-list {{ display: flex; flex-direction: column; gap: 14px; }}
|
||||
.item {{ padding: 12px 14px; background: #f3f4f6; border-left: 3px solid #2563eb; border-radius: 4px; }}
|
||||
.item-headline {{ font-weight: 700; color: #111; font-size: 14px; margin-bottom: 6px; }}
|
||||
.item-subs {{ list-style: disc; padding-left: 20px; font-size: 13px; color: #374151; }}
|
||||
.item-subs li {{ margin-bottom: 3px; }}
|
||||
|
||||
.cards-list {{ display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }}
|
||||
.card {{ padding: 10px 12px; background: #f3f4f6; border-radius: 4px; font-size: 13px; }}
|
||||
|
||||
.v4-meta {{ padding: 12px 20px; background: #f9fafb; font-family: monospace; font-size: 12px; color: #374151; }}
|
||||
.v4-meta-row {{ margin-bottom: 4px; }}
|
||||
.v4-meta-key {{ color: #6b7280; margin-right: 4px; }}
|
||||
.v4-meta-val {{ color: #111; margin-right: 12px; font-weight: 600; }}
|
||||
.v4-axes {{ font-size: 11px; color: #6b7280; }}
|
||||
.v4-note {{ font-size: 12px; color: #6b7280; margin-top: 6px; line-height: 1.5; font-family: inherit; }}
|
||||
.v4-label-use_as_is {{ color: #059669; }}
|
||||
.v4-label-light_edit {{ color: #2563eb; }}
|
||||
.v4-label-restructure {{ color: #d97706; }}
|
||||
.v4-label-reject {{ color: #dc2626; }}
|
||||
|
||||
.preview-gap {{ background: #fef2f2; }}
|
||||
.preview-gap .section-head {{ background: #fee2e2; border-bottom-color: #fecaca; }}
|
||||
.gap-note {{ padding: 16px 20px; font-size: 13px; color: #7f1d1d; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="banner">
|
||||
<h1>MDX04 Partial Preview · diagnostic only</h1>
|
||||
<p>이 출력은 정식 Phase Z final 이 아닙니다. F16 (`bim_issues_quadrant_four`) 가 04-2.1 / 04-2.2 의 4 항목 구조와
|
||||
시각적으로 정합하는지 확인하기 위한 진단용 preview 입니다. V4 runtime / mapper / partial 우회.
|
||||
04-1 은 frame library gap 으로 이번 preview 제외.</p>
|
||||
<div class="timestamp">generated: {timestamp}</div>
|
||||
</div>
|
||||
|
||||
{section_2_1_html}
|
||||
|
||||
{section_2_2_html}
|
||||
|
||||
{section_1_html}
|
||||
|
||||
</body>
|
||||
</html>'''
|
||||
|
||||
out_html = RUN_DIR / "index.html"
|
||||
out_html.write_text(page_html, encoding='utf-8')
|
||||
|
||||
debug = {
|
||||
'kind': 'mdx04_partial_preview',
|
||||
'is_phase_z_final': False,
|
||||
'is_diagnostic': True,
|
||||
'purpose': 'F16 디자인 / 04-2.* 4 항목 구조 시각 정합성 확인',
|
||||
'generated_at': timestamp,
|
||||
'v4_source': str(V4_RESULT.relative_to(ROOT)),
|
||||
'mdx_source': str(MDX_PATH.relative_to(ROOT)),
|
||||
'sections': {
|
||||
'04-2.1': {
|
||||
'mdx_title': title_2_1,
|
||||
'item_count': len(items_2_1),
|
||||
'top1': top1_2_1,
|
||||
'f16_judgment': j16_2_1,
|
||||
'preview_label': 'F16 candidate (V4 label = reject, conf 0.648, anchor=0)',
|
||||
},
|
||||
'04-2.2': {
|
||||
'mdx_title': title_2_2,
|
||||
'item_count': len(items_2_2),
|
||||
'top1': top1_2_2,
|
||||
'f16_judgment': j16_2_2,
|
||||
'preview_label': 'F16 restructure (V4 label = restructure, 사용 가능 통과)',
|
||||
},
|
||||
'04-1': {
|
||||
'mdx_title': title_1,
|
||||
'card_count': len(cards_1),
|
||||
'top1': top1_1,
|
||||
'preview_label': 'EXCLUDED — frame library gap (5-card structure, no matching frame in 32 DB)',
|
||||
},
|
||||
},
|
||||
'caveats': [
|
||||
'정식 Phase Z final 아님 — V4 runtime / mapper / partial 모두 우회',
|
||||
'F16 figma 원본 HTML 을 그대로 임베드 — 디자인 형태만 시각화 (텍스트 슬롯 매핑 X)',
|
||||
'04-2.1 의 F16 V4 label = reject (anchor=0) — 의미 매칭 회복했으나 anchor terms 부재',
|
||||
'04-2.2 의 F16 V4 label = restructure — 사용 가능 라벨, 단 정식 partial 미작성',
|
||||
'04-1 = frame library readiness 문제 (detect bug 아님)',
|
||||
],
|
||||
}
|
||||
out_debug = RUN_DIR / "debug.json"
|
||||
out_debug.write_text(json.dumps(debug, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
|
||||
print(f"[mdx04_partial_preview] generated:")
|
||||
print(f" html : {out_html}")
|
||||
print(f" debug : {out_debug}")
|
||||
print(f" figma : {RUN_DIR / 'f16_original' / 'index.html'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
45
scripts/run_pipeline_v2.py
Normal file
45
scripts/run_pipeline_v2.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Pipeline v2 실행 스크립트.
|
||||
|
||||
사용법:
|
||||
python scripts/run_pipeline_v2.py
|
||||
python scripts/run_pipeline_v2.py samples/mdx/03.*.mdx
|
||||
"""
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from src.pipeline_v2 import generate_slide_v2
|
||||
|
||||
|
||||
def main():
|
||||
# 인자로 MDX 경로, 없으면 기본값
|
||||
if len(sys.argv) > 1:
|
||||
mdx_path = Path(sys.argv[1])
|
||||
else:
|
||||
mdx_path = Path("samples/mdx/03. DX 시행을 위한 필수 요건 및 혁신 방안.mdx")
|
||||
|
||||
if not mdx_path.exists():
|
||||
print(f"MDX 파일 없음: {mdx_path}")
|
||||
return
|
||||
|
||||
content = mdx_path.read_text(encoding="utf-8")
|
||||
print(f"MDX: {mdx_path.name}")
|
||||
print(f"길이: {len(content)}자")
|
||||
print()
|
||||
|
||||
start = time.time()
|
||||
result = generate_slide_v2(content, base_path=str(mdx_path.parent))
|
||||
elapsed = time.time() - start
|
||||
|
||||
print(f"\n완료! ({elapsed:.1f}초)")
|
||||
print(f"run_id: {result['run_id']}")
|
||||
print(f"결과: {result['run_dir']}/")
|
||||
print(f" final.html")
|
||||
print(f" final_context.json")
|
||||
print(f" steps/")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user