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:
2026-05-08 09:47:58 +09:00
parent ec83405770
commit 85c680f02a
26 changed files with 10507 additions and 46 deletions

View 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]:
"""![alt](path) + 그 근처의 이탤릭 [그림 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"![]({(rel / (n+'.png')).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())