wip: phase_z2 evidence 파이프라인 + matching 실험(phase2~26) + 프론트 trace 패널 진행분 스냅샷

- src: phase_z2 composition/mapper/pipeline/placement_planner/retry, ai_fallback(prompts/schema/validate), mdx_text_atoms 신규
- Front: PipelineTracePanel 신규, FramePanel/SlideCanvas/Home/designAgentApi 등 갱신 + 테스트 4종 추가
- templates/phase_z2: catalog(component_expansion_registry, node_slot_mapping 신규), frames, families, slide_base 갱신
- tests/matching: phase2~26 매칭 실험 스크립트·리포트·온톨로지 전체 (미커밋 진행분)
- tests: b4_v4 evidence, task5~28.5 시리즈, regression(imp95 baseline) 등 신규 테스트 대량 추가
- docs/reference: MDX 구조 인벤토리, MDX→Frame 구조 계약 문서
- scripts: mdx 계약/parity/coverage/viewport 체크, gitea comment, run sync 유틸
- .gitignore: tmp*.json, chromedriver, .orchestrator, *.pkl, Front_test* 등 임시/스냅샷 제외

미완성 작업의 보존용 스냅샷 커밋 (2026-07-02)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 17:03:42 +09:00
co-authored by Claude Opus 4.8
parent 97b7833a1b
commit b836e79ee1
527 changed files with 673036 additions and 717 deletions
+190
View File
@@ -0,0 +1,190 @@
"""MDX ↔ Figma frame matching — 공통 유틸리티"""
import os
import re
import yaml
from pathlib import Path
ROOT = Path(r"d:\ad-hoc\kei\design_agent")
BLOCKS_DIR = ROOT / "figma_to_html_agent" / "blocks"
MDX_DIR = ROOT / "samples" / "mdx_batch"
GT_PATH = ROOT / "tests" / "matching" / "ground_truth.yaml"
def load_ground_truth():
with open(GT_PATH, encoding="utf-8") as f:
return yaml.safe_load(f)["sections"]
def _strip_figma_meta(text):
"""texts.md의 메타 헤더/구조 라벨 제거 + 타이틀 3회 반복(가중치).
- '# Frame 1171281XXX — 텍스트 목록' 파일 최상위 제목 제거
- '> ...' blockquote 메타 제거
- '## 타이틀', '## 서브헤더', '## 열1', '### 세로 라벨' 구조 라벨 제거
- '## 타이틀' 아래 bullet 또는 plain 라인을 3회 반복
"""
lines = text.split("\n")
cleaned = []
title_lines = []
in_title_section = False
for ln in lines:
if re.match(r"^#\s+Frame\s+\d+", ln):
continue
if ln.startswith("> "):
continue
# ## 타이틀 시작
if re.match(r"^##\s+타이틀", ln):
in_title_section = True
continue
# 다른 ## 나 ### 헤더 → 타이틀 구역 종료 (헤더 자체는 제거)
if re.match(r"^#{2,}\s", ln):
in_title_section = False
continue
# 타이틀 구역 안의 비어있지 않은 라인은 수집 (bullet이든 plain이든)
if in_title_section:
s = ln.strip()
if s:
# 앞의 '- ' 제거해서 깔끔하게
clean_s = re.sub(r"^-\s*", "", s)
if clean_s:
title_lines.append(clean_s)
cleaned.append(ln)
continue
cleaned.append(ln)
out = "\n".join(cleaned).strip()
if title_lines:
boost = "\n".join(title_lines)
out = boost + "\n" + boost + "\n" + out
return out
def load_figma_texts(clean_meta=True):
"""32개 프레임의 texts.md 읽기. {frame_id: text}
clean_meta=True: 메타 헤더/라벨 제거 + 타이틀 가중치 적용 (권장)
"""
out = {}
for d in sorted(os.listdir(BLOCKS_DIR)):
p = BLOCKS_DIR / d / "texts.md"
if p.is_file():
with open(p, encoding="utf-8") as f:
text = f.read()
if clean_meta:
text = _strip_figma_meta(text)
out[d] = text
return out
def load_mdx_sections():
"""MDX 01~03을 ## 중목차 단위로 분리.
{section_id: section_text}. section_id는 GT의 id와 매칭."""
sections = {}
for fname in ["01.mdx", "02.mdx", "03.mdx"]:
mdx_num = fname.replace(".mdx", "")
with open(MDX_DIR / fname, encoding="utf-8") as f:
content = f.read()
# frontmatter 제거
content = re.sub(r"^---.*?---", "", content, flags=re.DOTALL).strip()
# import 문 제거
content = re.sub(r"^import .*$", "", content, flags=re.MULTILINE)
# ## 기준 분리
parts = re.split(r"(?=^## )", content, flags=re.MULTILINE)
intro_collected = False
sec_counter = 0
for part in parts:
part = part.strip()
if not part:
continue
if part.startswith("## "):
sec_counter += 1
key = f"MDX{mdx_num}-{sec_counter}"
else:
key = f"MDX{mdx_num}-intro"
intro_collected = True
sections[key] = part
return sections
def tokenize_simple(text):
"""단순 공백/특수문자 기준 토큰화."""
# HTML/MDX 태그 제거
text = re.sub(r"<[^>]+>", " ", text)
text = re.sub(r"\{[^}]+\}", " ", text)
text = re.sub(r"[|*#>\-\[\](){}:;,.!?/\\=\"'`~]", " ", text)
# 2글자 이상 토큰만
words = [w for w in text.split() if len(w) >= 2]
return words
def clean_text(text):
"""기본 정리 (HTML/MDX 제거)."""
text = re.sub(r"<[^>]+>", " ", text)
text = re.sub(r"\{[^}]+\}", " ", text)
text = re.sub(r"\s+", " ", text).strip()
return text
def char_ngrams(text, n):
"""겹치는 n글자 집합. 공백 제거 후."""
text = re.sub(r"\s+", "", clean_text(text))
return set(text[i:i + n] for i in range(max(0, len(text) - n + 1)))
def evaluate_ranking(gt_entry, ranked_frames):
"""한 섹션의 평가.
ranked_frames: [(frame_id, score), ...] top부터 내림차순.
returns: dict with hit@1, hit@3, mrr
"""
primary = gt_entry["primary"]
secondary = gt_entry.get("secondary", []) or []
if primary is None:
# null 정답: top-1 점수가 낮으면 OK (즉 "없음"을 잘 맞춤)
# 여기선 hit@1 = 1 if ranked_frames[0][1] < 0.2 else 0 같은 규칙
# 복잡하니 일단 null은 "모든 방법이 약한 점수 내야 성공"으로 단순 처리
# → hit 체크에서 제외, 별도 분석용
return {"hit@1": None, "hit@3": None, "mrr": None, "gt": None}
gt_set = {str(primary)} | {str(s) for s in secondary}
rank_ids = [str(fid) for fid, _ in ranked_frames]
hit1 = 1 if rank_ids and rank_ids[0] == str(primary) else 0
hit3 = 1 if any(r in gt_set for r in rank_ids[:3]) else 0
# MRR: primary의 역순위 (secondary는 MRR에서 제외 — 엄격)
mrr = 0.0
for i, r in enumerate(rank_ids, 1):
if r == str(primary):
mrr = 1.0 / i
break
return {"hit@1": hit1, "hit@3": hit3, "mrr": mrr, "gt": str(primary)}
def run_method(method_name, method_fn, mdx_sections, figma_texts, gt_list):
"""방법을 실행하고 결과 dict 반환.
method_fn(mdx_text, figma_texts_dict) -> [(frame_id, score), ...] top-k desc
"""
gt_by_id = {g["id"]: g for g in gt_list}
results = {}
for sec_id, sec_text in mdx_sections.items():
if sec_id not in gt_by_id:
continue
ranked = method_fn(sec_text, figma_texts)
results[sec_id] = {
"ranked": ranked[:5],
"eval": evaluate_ranking(gt_by_id[sec_id], ranked),
}
return {"method": method_name, "results": results}
def aggregate_metrics(method_result):
"""hit@1, hit@3, mrr 평균 (null GT 제외)"""
vals = {"hit@1": [], "hit@3": [], "mrr": []}
for sec_id, r in method_result["results"].items():
e = r["eval"]
if e["gt"] is None:
continue
for k in vals:
vals[k].append(e[k])
return {
k: (sum(v) / len(v) if v else 0.0) for k, v in vals.items()
}
+335
View File
@@ -0,0 +1,335 @@
"""MDX 분석 — template-fit-v1 매칭용 코드 기반 추출.
Spec: tests/matching/TEMPLATE_FIT_V1.md §3
출력:
{
title, summary, detected_terms,
item_count: {detected, source},
relation_type: {value, confidence},
content_shape: {has_table, has_subsections, has_bullets},
slot_candidates: [{label, body}, ...]
}
원칙: LLM 호출 없음. 모든 필드는 규칙 기반 코드로 추출.
"""
import re
from phase_common import normalize_with_synonyms, load_synonyms
from methods import _get_kiwi
# ─── 패턴 ────────────────────────────────────────────
RE_TABLE_ROW = re.compile(r'^\|.*\|')
RE_TABLE_SEP = re.compile(r'^\|[\s\-:|]+\|')
RE_SUBSECTION = re.compile(r'^###\s+(\d+\.\d+(?:\.\d+)?)\s+(.+)')
RE_TOP_BULLET = re.compile(r'^[-*]\s+\*\*([^*]+)\*\*')
RE_HTML_TAG = re.compile(r'<[^>]+>')
AXIS_LABEL = '구분' # 표 헤더 중 이 라벨이 있으면 해당 컬럼은 axis 로 제외
NOUN_TAGS = {'NNG', 'NNP', 'NR', 'SL', 'SN', 'SH'}
# relation_type 보정 키워드
COMPARE_HINTS = ['과정', '결과', 'Process', 'Product', 'AS-IS', 'TO-BE',
'Analogue', 'Digital', 'vs', 'VS', '대비']
SEQUENCE_HINTS = ['단계', 'Step', '순서', '차례']
# ═══ 표 파싱 ═══
def parse_first_table(text):
"""첫 markdown 테이블 반환 (header_cells, body_rows). 없으면 None."""
lines = text.split('\n')
tbl_start = None
for i in range(len(lines) - 1):
if RE_TABLE_ROW.match(lines[i].strip()) and RE_TABLE_SEP.match(lines[i+1].strip()):
tbl_start = i
break
if tbl_start is None:
return None
rows = []
for j in range(tbl_start, len(lines)):
s = lines[j].strip()
if not s.startswith('|'):
break
rows.append(s)
if len(rows) < 2:
return None
def cells(line):
return [c.strip().replace('**', '').strip() for c in line.strip('|').split('|')]
header = cells(rows[0])
body = [cells(r) for r in rows[2:]]
return header, body
def find_subsections(text):
"""### X.Y TITLE 라인 목록 → [(number, title), ...]"""
out = []
for ln in text.split('\n'):
m = RE_SUBSECTION.match(ln.strip())
if m:
out.append((m.group(1), m.group(2).strip()))
return out
def find_top_bullets(text):
"""최상위 '* **LABEL**' 블릿 목록 → [full_line, ...]"""
return [ln for ln in text.split('\n') if RE_TOP_BULLET.match(ln)]
# ═══ 슬롯 후보 추출 ═══
def slots_from_table(header, body):
"""축(구분) 제외 컬럼 → label. 첫 데이터 행의 해당 cell → body."""
axis_idx = next((i for i, h in enumerate(header) if AXIS_LABEL in h), None)
first_row = body[0] if body else []
result = []
for i, h in enumerate(header):
if i == axis_idx or not h:
continue
body_cell = first_row[i] if i < len(first_row) else ''
body_clean = RE_HTML_TAG.sub(' ', body_cell).strip()[:120]
result.append({'label': h, 'body': body_clean})
return result
def slots_from_subsections(subs):
"""### TITLE → label (괄호 앞부분), body (전체 타이틀)."""
out = []
for num, title in subs:
main = re.match(r'^([^\(]+)', title)
label = main.group(1).strip() if main else title
out.append({'label': label, 'body': title})
return out
def slots_from_bullets(bullets):
out = []
for b in bullets:
m = RE_TOP_BULLET.match(b)
if not m:
continue
full_label = m.group(1).strip()
main = re.match(r'^([^\(]+)', full_label)
label = main.group(1).strip() if main else full_label
out.append({'label': label, 'body': full_label})
return out
# ═══ detected_terms ═══
def clean_for_kiwi(text):
"""Kiwi 입력 전 HTML/JSX/MDX 노이즈 제거.
구조 파싱에는 원본 사용, Kiwi 명사 추출에만 이 함수 사용."""
# HTML 태그 (open/close/self-closing)
text = re.sub(r'<[^>]+>', ' ', text)
# JSX/MDX 속성 블록 {...} (style, eventHandler 등)
text = re.sub(r'\{[^}]*\}', ' ', text)
# HTML entity
text = re.sub(r'&[a-zA-Z]+;', ' ', text)
# 단위/수치 노이즈 (10px, 0.9rem 등)
text = re.sub(r'\b\d+(?:\.\d+)?(?:px|rem|em|%)\b', ' ', text)
# 공백 정규화
text = re.sub(r'\s+', ' ', text)
return text
def extract_detected_terms(normalized_text, anchor_vocab):
"""Kiwi 명사/외국어/숫자 + anchor_vocab substring.
- HTML/JSX 제거 후 Kiwi noun 추출
- anchor_vocab substring 은 원본(normalized) 에 대해 수행 (compound term 커버)
"""
cleaned = clean_for_kiwi(normalized_text)
kiwi = _get_kiwi()
tokens = kiwi.tokenize(cleaned)
noun_tokens = [t.form for t in tokens
if t.tag in NOUN_TAGS and len(t.form) >= 2]
# anchor_vocab substring (compound terms — Kiwi 가 분해하는 단어 커버)
for term in (anchor_vocab or []):
if len(term) >= 2 and term in normalized_text:
if term not in noun_tokens:
noun_tokens.append(term)
seen = set()
out = []
for t in noun_tokens:
if t not in seen:
seen.add(t)
out.append(t)
return out
# ═══ summary ═══
def build_summary(title, text, slot_candidates):
"""title + 첫 일반 문단(있으면) + 슬롯 라벨 조인."""
parts = [title]
for ln in text.split('\n'):
s = ln.strip()
if not s: continue
if s.startswith(('|', '#', '<', '*', '-', ':', '{', '```', '---')):
continue
if re.match(r'^\d+\.\s', s) or re.match(r'^<br', s):
continue
parts.append(s[:120])
break
if slot_candidates:
parts.append(' / '.join(sc.get('label', '') for sc in slot_candidates[:5]))
return '. '.join(p for p in parts if p)
# ═══ relation_type ═══
def infer_relation_type(item_count, item_source, text, subsections):
# subsection 에서 과정/결과 등 compare hint 있으면 compare
if subsections:
joined = ' '.join(t for _, t in subsections)
if any(kw in joined for kw in COMPARE_HINTS):
return ('compare', 'high')
if any(kw in joined for kw in SEQUENCE_HINTS):
return ('sequence', 'high')
if item_count == 2:
return ('compare', 'high')
if item_count >= 3:
return ('parallel', 'high')
return ('definition', 'low')
# ═══ structure_intent 추론 ═══
def infer_structure_intent(text, title, detected_terms, item_count, relation_type):
"""MDX 의 structure_intent 추론 (list, 여러 개 가능).
설계 원칙:
- Layer 1 (subject-defined): process_product, transformation, persona, industry 등
명확한 subject signal. 이들은 MDX 의 primary intent.
- Layer 2 (mutually exclusive): concept_comparison 은 Layer 1 의 특정 intent
(process_product/transformation) 와 배타적. 둘 다 설정되면 재구성 모호해짐.
- Layer 3 (secondary): multi_attribute / category — 보조 intent.
- Polarity (title 기반): requirement / problem 은 **title** 이나 구체 단어로만
감지 (body noise 방지).
"""
intents = []
terms_set = set(detected_terms)
# Layer 1: subject-defined primary intents
# process_product: terms_set 또는 text 패턴
has_pp_pattern = (
('과정' in text and ('혁신' in text or 'Process' in text)) and
('결과' in text and ('변화' in text or 'Product' in text))
)
if ('과정혁신' in terms_set and '결과혁신' in terms_set) or has_pp_pattern:
intents.append('process_product_split')
if ('AS-IS' in terms_set and 'TO-BE' in terms_set) or \
('Analogue' in terms_set and 'Digital' in terms_set):
if 'transformation_story' not in intents:
intents.append('transformation_story')
if {'발주자', '시공자', '설계자'} <= terms_set:
intents.append('persona_benefit')
if sum(1 for t in ['제조업', '건축', '토목'] if t in terms_set) >= 2:
intents.append('industry_comparison')
# Layer 2: concept_comparison vs category_comparison — exclusive
# (subject 가 개념 대조 vs 유형 분류 — 동시에 primary 될 수 없음)
concept_fired = False
if 'process_product_split' not in intents and 'transformation_story' not in intents:
concept_pairs = [
({'BIM', 'DX'}, ['BIM과 DX', 'DX와 BIM', 'BIM · DX', 'BIM vs DX', 'BIM/DX']),
]
for pair, markers in concept_pairs:
if pair <= terms_set and any(m in text or m in title for m in markers):
intents.append('concept_comparison')
concept_fired = True
break
# Layer 3: multi_attribute_comparison (secondary, co-occurring OK)
multi_markers = ['관점별', '행별', '기준별']
aspect_terms = {'범위', '성과품', '확장성', '수행개념', '수행주체', '프로세스', '활용'}
has_multi_marker = any(m in text for m in multi_markers)
aspect_hits = len(aspect_terms & terms_set)
if (has_multi_marker or aspect_hits >= 3) and relation_type == 'compare':
if 'multi_attribute_comparison' not in intents:
intents.append('multi_attribute_comparison')
# Layer 2 (secondary branch): category_comparison — only if concept didn't fire
# 명시 markers 필요 (title/text 에 분류 주제 언급)
if not concept_fired and 'process_product_split' not in intents and \
'transformation_story' not in intents:
cat_pair_markers = [
({'Package', 'Solution'}, ['Application S/W', 'S/W 구분', 'S/W의 구분', 'Package', 'Solution']),
({'상용', '전용'}, ['상용 Engn', '전용 S/W', '3rd Party', '3rdParty']),
]
for pair, markers in cat_pair_markers:
# pair 전체 + title/text 에 명시 marker 존재
if pair <= terms_set:
# title 에 marker 있으면 강한 신호
has_title_marker = any(m in title for m in markers)
if has_title_marker:
intents.append('category_comparison')
break
# Polarity: requirement_list (title 기반)
if any(s in title for s in ['필수', '요건', '3대', '필수조건']) and relation_type == 'parallel':
intents.append('requirement_list')
# Polarity: problem_diagnosis (title or specific problem-only terms)
problem_title_markers = ['문제', '문제점', '한계', '왜곡', '실정', '부재']
problem_specific = {'개념부재', '전문성부족', '비효율', '전제조건오류', '4대문제'}
has_problem_title = any(s in title for s in problem_title_markers)
has_problem_specific = bool(problem_specific & terms_set)
if has_problem_title or has_problem_specific:
intents.append('problem_diagnosis')
# Fallback: parallel N 이면 일반 requirement_or_pillar
if not intents and relation_type == 'parallel' and item_count in (3, 4, 5):
intents.append('requirement_or_pillar')
return intents
# ═══ 엔트리 포인트 ═══
def detect_mdx_analysis(text, title, anchor_vocab=None):
synonyms = load_synonyms()
normalized = normalize_with_synonyms(text, synonyms)
subs = find_subsections(text)
table = parse_first_table(text)
bullets = find_top_bullets(text)
# item_count 우선순위: subsections > table > bullets
if subs:
item_count = len(subs)
item_source = 'subsections'
slot_candidates = slots_from_subsections(subs)
elif table:
header, body = table
non_axis = [h for h in header if AXIS_LABEL not in h and h]
item_count = len(non_axis)
item_source = 'table_columns'
slot_candidates = slots_from_table(header, body)
elif bullets:
item_count = len(bullets)
item_source = 'bullets'
slot_candidates = slots_from_bullets(bullets)
else:
item_count = 1
item_source = 'none'
slot_candidates = []
rel_value, rel_conf = infer_relation_type(item_count, item_source, text, subs)
detected_terms = extract_detected_terms(normalized, anchor_vocab)
summary = build_summary(title, text, slot_candidates)
# structure_intent 추론 (Phase 24/25 의 구조 축 용)
intents = infer_structure_intent(text, title, detected_terms, item_count, rel_value)
return {
'title': title,
'summary': summary,
'detected_terms': detected_terms,
'item_count': {'detected': item_count, 'source': item_source},
'relation_type': {'value': rel_value, 'confidence': rel_conf},
'content_shape': {
'has_table': table is not None,
'has_subsections': bool(subs),
'has_bullets': bool(bullets),
},
'slot_candidates': slot_candidates,
'structure_intent': intents,
}
+37
View File
@@ -0,0 +1,37 @@
"""ko-sroberta embeddings for template-fit content similarity.
Model: jhgan/ko-sroberta-multitask (HuggingFace)
용도: MDX summary ↔ frame description 코사인 유사도 계산
32 frame 규모 — 벡터 DB 없이 numpy 만으로 충분
"""
import numpy as np
_model = None
def _get_model():
global _model
if _model is None:
from sentence_transformers import SentenceTransformer
_model = SentenceTransformer('jhgan/ko-sroberta-multitask')
return _model
def embed_texts(texts):
"""Batch embed. Returns numpy (N, 768)."""
model = _get_model()
return model.encode(list(texts), show_progress_bar=False, convert_to_numpy=True)
def cosine(a, b):
"""Single-vector cosine similarity."""
an = a / (np.linalg.norm(a) + 1e-12)
bn = b / (np.linalg.norm(b) + 1e-12)
return float(np.dot(an, bn))
def embed_map(id_to_text):
"""{id: text} → {id: vec(768,)}"""
ids = list(id_to_text.keys())
vecs = embed_texts([id_to_text[i] for i in ids])
return {ids[i]: vecs[i] for i in range(len(ids))}
+297
View File
@@ -0,0 +1,297 @@
"""공통 키워드 정규화 모듈.
Step 4, 5, 7 등 여러 파이프라인이 공유하는 정규화 로직을 한 파일로 집중.
이 모듈은 **AI 호출을 수행하지 않음** — 순수 결정론적 규칙만.
제공 기능:
- PRE_COLLAPSE regex (디지털 전환(DX) 계열 collapse)
- 괄호 확장 (A(B)C → AC + B)
- phrase_variants 치환
- Kiwi user_dict 토큰화
- 허용 태그 필터 (NNG/NNP/SL/SN + 1글자/순수숫자 제외)
사용 pipeline:
- pipeline_04_normalize.py (정규화 + 저장)
- pipeline_07_auto_anchor_candidates.py (source_text 기반 후보)
(미래) 기타 step
설계 제약:
- 상수/함수는 재진입 가능해야 함 (counter 를 None 허용)
- Kiwi 객체는 build_kiwi() 로 생성 (global state 금지)
- 모든 함수는 deterministic (random seed 없음)
"""
import re
from collections import Counter
from pathlib import Path
import yaml
from kiwipiepy import Kiwi
# ============================================================
# 상수
# ============================================================
# Kiwi user_dict — compound (SL)
USER_DICT_SL = ['S/W', 'H/W', '2D', '3D', 'DX', 'BIM', 'As-is', 'To-Be']
# Kiwi user_dict — phrase canonical (NNG)
USER_DICT_NNG = ['결과혁신', '과정혁신', '필수조건', '의사소통', '시행착오']
# Kiwi 허용 태그
ALLOWED_TAGS = {'NNG', 'NNP', 'SL', 'SN'}
# PRE_COLLAPSE: "디지털 전환(DX...)" / "DX(디지털전환)" / "DX(Digital Transformation)" / "DX(DX...)" → DX
# 순서 중요: 긴 → 짧은, 원본 → 치환 부산물.
PRE_COLLAPSE = [
(
re.compile(
r'디지털\s*전환\s*\(\s*DX(?:\s*[,/]\s*Digital\s+Transformation)?\s*\)',
re.IGNORECASE,
),
'DX',
'digital_transition_with_dx',
),
(
re.compile(r'DX\s*\(\s*디지털\s*전환\s*\)', re.IGNORECASE),
'DX',
'dx_with_digital_transition',
),
(
re.compile(r'DX\s*\(\s*Digital\s+Transformation\s*\)', re.IGNORECASE),
'DX',
'dx_with_english_fullname',
),
(
re.compile(
r'DX\s*\(\s*DX(?:\s*[,/]\s*Digital\s+Transformation)?\s*\)',
re.IGNORECASE,
),
'DX',
'dx_duplicate',
),
# 5. SW (단독 word) → S/W — 정규화 누락 보정 (word boundary 중요: SWOT 등 보호)
(
re.compile(r'(?<![A-Za-z])SW(?![A-Za-z])'),
'S/W',
'sw_to_sw_slash',
),
]
# 괄호 확장
MAX_PAREN_LEN = 40
PAREN_PATTERN = re.compile(r'\(([^()]{1,' + str(MAX_PAREN_LEN) + r'})\)')
# 숫자/기호-only 괄호 내용 배제 (예: (1), (2), (※))
PAREN_SKIP_PATTERN = re.compile(r'[\d\s\-.,:;※]+')
PAREN_SAMPLE_MAX = 20
# ============================================================
# 필터 규칙 (사용자 확정 — 제거 후보 3그룹 + 제품 whitelist)
# ============================================================
# 의문사 (대소문자 무관 제거)
INTERROGATIVES = {
'how', 'what', 'when', 'why', 'who', 'where', 'which',
}
# 완전 소문자 일반 기능어 제거
SOFT_STOPWORDS = {
'or', 'for', 'and', 'the', 'of', 'to', 'at', 'by', 'in', 'on', 'up',
'as', 'a', 'an', 'with', 'from', 'into',
'is', 'be', 'are', 'was', 'were', 'been', 'being',
'it', 'we', 'he', 'she', 'they', 'i', 'you',
'also', 'only', 'all', 'but', 'not', 'so', 'too', 'very',
'this', 'that', 'these', 'those', 'etc',
}
FILTER_NUMBER_COMMA_PATTERN = re.compile(r'^[\d,]+$')
FILTER_SHORT_LOWERCASE_PATTERN = re.compile(r'^[a-z]{1,3}$')
FILTER_UPPER_ABBREV_PATTERN = re.compile(r'^[A-Z][A-Z0-9]+$')
# 제품명/고유명사 수동 whitelist (사용자 승인, 자동 규칙 대신 수동)
PRODUCT_WHITELIST = {
# 상용 S/W 제품명
'Revit', 'AutoCAD', 'Autocad', 'AutoCad', 'Auto CAD',
'SketchUp', 'Sketchup',
'Navisworks', 'Naviswork',
'Infraworks',
'Rhino',
'ArchiCAD',
'ArcGIS', 'QGIS',
'Blender',
'WatchBIM',
'Domainer',
'BCMF',
# 회사/벤더명
'Bentley', 'Bently',
'Autodesk', 'AutoDesk',
'Nemetschek',
'ESRI',
# 국제기구 약어 (대문자 약어 규칙으로 자동 보호되지만 안전하게 명시)
'ADB', 'IBRD',
}
# 화이트리스트 (user_dict + 제품명) — 필터 규칙에 걸려도 절대 제거 안 함
FILTER_WHITELIST = set(USER_DICT_SL) | set(USER_DICT_NNG) | PRODUCT_WHITELIST
def should_filter_token(token):
"""필터 규칙 판정. (제거_여부, 이유) 반환.
우선순위:
0. 화이트리스트 → 절대 제거 안 함
1. 전부 대문자 약어 (IT/AI/ERP 등) → 보존
2. 의문사 (대소문자 무관) → 제거
3. 숫자+쉼표 → 제거
4. 완전 소문자 stopword → 제거
5. 소문자 1-3자 영어 → 제거
"""
# 0. 화이트리스트 우선
if token in FILTER_WHITELIST:
return False, None
# 1. 의문사 (대소문자 무관) — 대문자 약어 검사보다 먼저 (HOW/WHEN 등)
if token.lower() in INTERROGATIVES:
return True, 'interrogative'
# 2. 대문자 약어 보존 (IT, AI, ERP, CCTV 등)
if FILTER_UPPER_ABBREV_PATTERN.fullmatch(token):
return False, None
# 3. 숫자+쉼표 패턴
if FILTER_NUMBER_COMMA_PATTERN.fullmatch(token):
return True, 'number_pattern'
# 4. 완전 소문자 stopword
if token in SOFT_STOPWORDS:
return True, 'stopword'
# 5. 소문자 1-3자 영어
if FILTER_SHORT_LOWERCASE_PATTERN.fullmatch(token):
return True, 'short_lowercase'
return False, None
# ============================================================
# 함수
# ============================================================
def load_phrase_variants(synonyms_path):
"""synonyms.yaml 에서 phrase_variants 섹션 로드."""
data = yaml.safe_load(Path(synonyms_path).read_text(encoding='utf-8'))
pv = data.get('phrase_variants')
if not pv:
raise RuntimeError(f"{synonyms_path} 에 phrase_variants 섹션이 없음.")
return pv
def build_substitutions(phrase_variants):
"""(variant, canonical) 리스트. canonical 자기 자신 재치환 금지. 긴 variant 먼저."""
subs = []
seen = set()
for canonical, variants in phrase_variants.items():
for v in (variants or []):
if v == canonical:
continue
if (v, canonical) in seen:
continue
seen.add((v, canonical))
subs.append((v, canonical))
subs.sort(key=lambda x: (-len(x[0]), x[0]))
return subs
def expand_parentheses(text, paren_counter=None, paren_samples=None):
"""A(B)C → 'AC B' 로 펼침.
- 괄호 밖: 빈 문자열로 제거 (조사 결합 유지)
- 괄호 안: 뒤에 공백 연결
- 길이 > MAX_PAREN_LEN 은 펼치지 않음
- 숫자/기호-only 괄호 내용 제외 ((1), (※) 등)
paren_counter / paren_samples 는 None 허용 — 카운팅 안 할 때는 None.
"""
contents = PAREN_PATTERN.findall(text)
if not contents:
return text
meaningful = []
for c in contents:
stripped = c.strip()
if not stripped:
continue
if PAREN_SKIP_PATTERN.fullmatch(stripped):
continue
meaningful.append(stripped)
if not meaningful:
return text
before = text
text_out = PAREN_PATTERN.sub('', text)
text_out = re.sub(r'\s+', ' ', text_out).strip()
for c in meaningful:
text_out = text_out + ' ' + c
if paren_counter is not None:
paren_counter['expansions'] += 1
if paren_samples is not None and before != text_out and len(paren_samples) < PAREN_SAMPLE_MAX:
paren_samples.append({'before': before, 'after': text_out})
return text_out
def apply_substitutions(text, subs, replacement_counter, pre_collapse_counter,
paren_counter=None, paren_samples=None):
"""PRE_COLLAPSE → 괄호 확장 → phrase_variants 순서 적용."""
# 1) PRE_COLLAPSE (regex)
for pattern, replacement, rule_id in PRE_COLLAPSE:
new_text, n = pattern.subn(replacement, text)
if n > 0:
if pre_collapse_counter is not None:
pre_collapse_counter[rule_id] += n
text = new_text
# 1.5) 괄호 확장
text = expand_parentheses(text, paren_counter, paren_samples)
# 2) phrase_variants (literal)
for variant, canonical in subs:
new_text, n = re.subn(re.escape(variant), canonical, text)
if n > 0:
if replacement_counter is not None:
replacement_counter[canonical] += n
text = new_text
return text
def build_kiwi():
"""user_dict 포함 Kiwi 객체 생성."""
kiwi = Kiwi()
for w in USER_DICT_SL:
kiwi.add_user_word(w, 'SL', 9.0)
for w in USER_DICT_NNG:
kiwi.add_user_word(w, 'NNG', 9.0)
return kiwi
def extract_tokens(kiwi, text, removal_log=None):
"""Kiwi tokenize + 허용 태그/1글자/순수숫자 필터 + 사용자 확정 필터 규칙.
removal_log: dict[str, list[str]] 또는 None — 제거된 토큰 + 이유 기록 (추적용).
예: {'interrogative': ['How', 'WHEN'], 'stopword': ['the', 'to'], ...}
"""
out = []
for tok in kiwi.tokenize(text):
if tok.tag not in ALLOWED_TAGS:
continue
f = tok.form
if len(f) < 2:
continue
if re.fullmatch(r'\d+', f):
continue
# 사용자 확정 필터 (stopword / number / short_lowercase / interrogative)
remove, reason = should_filter_token(f)
if remove:
if removal_log is not None:
removal_log.setdefault(reason, []).append(f)
continue
out.append(f)
return out
def normalize_and_tokenize(text, subs, kiwi,
replacement_counter=None, pre_collapse_counter=None,
paren_counter=None, paren_samples=None):
"""text → (normalized_text, tokens). 복합 파이프라인 단일 진입점."""
normalized = apply_substitutions(
text, subs, replacement_counter, pre_collapse_counter,
paren_counter, paren_samples,
)
tokens = extract_tokens(kiwi, normalized)
return normalized, tokens
File diff suppressed because it is too large Load Diff
+326
View File
@@ -0,0 +1,326 @@
"""Phase 18~22 공통 모듈
- analysis.md 로드
- 동의어 사전 로드 + normalize
- 키워드 추출 / df 분류
- 점수 계산 (옵션별 축 포함/제외)
"""
import re
import math
import collections
import json
from pathlib import Path
import yaml
from methods import _get_kiwi, _extract_content_tokens, _detect_mdx_layout, _get_cross_encoder
ROOT = Path(r"d:\ad-hoc\kei\design_agent")
BLOCKS_DIR = ROOT / "figma_to_html_agent" / "blocks"
PREVIEW_DIR = ROOT / "data" / "figma_previews"
# ═══ 4개 테스트 유닛 ═══
TARGET_UNITS = [
("MDX01-2-details", "1. (MDX 1) 팝업 — DX와 BIM의 구분", "18", "01.mdx", "2. 용어간 상호관계", None),
("MDX02-2.2-table", "2. (MDX 2) 2.2 DX 시행 주체별 기대효과", "14", "02.mdx", "2. DX 기반 Process 혁신에 따른 주체별 기대효과", "2.2 DX 시행 주체별 기대효과"),
("MDX03-1", "3. (MDX 03) 1. DX 시행을 위한 필수요건", "13", "03.mdx", "1. DX 시행을 위한 필수 요건", None),
("MDX03-2", "4. (MDX 03) 2. Process 혁신과 Product 변화", "29", "03.mdx", "2. Process의 혁신과 Product의 변화", None),
]
def load_synonyms():
p = Path(__file__).parent / "synonyms.yaml"
with open(p, encoding="utf-8") as f:
return yaml.safe_load(f)["synonyms"]
def normalize_with_synonyms(text, synonyms):
"""variants를 canonical 표기로 치환. 긴 variant 먼저."""
replacements = []
for canonical, variants in synonyms.items():
for v in variants:
replacements.append((len(v), v, canonical))
replacements.sort(reverse=True) # 긴 것부터
for _, variant, canonical in replacements:
text = text.replace(variant, canonical)
return text
def load_keyword_base():
"""keyword_base.yaml 로드. normalize=false 는 MDX 치환 대상에서 제외.
반환: {canonical: [variants]} - synonyms.yaml 구조와 호환."""
p = Path(__file__).parent / "keyword_base.yaml"
if not p.exists():
return {}
with open(p, encoding="utf-8") as f:
data = yaml.safe_load(f)
out = {}
for canonical, entry in data.get('keywords', {}).items():
if entry.get('normalize', True) is False:
continue # anchor-only, MDX 치환 비활성
variants = entry.get('variants', [])
if variants:
out[canonical] = variants
# variants 없으면 치환할 게 없으니 dict 에 추가 안 함
return out
def normalize_with_keyword_base(text, kb):
"""keyword_base 로 MDX 정규화. synonyms 와 동일 로직."""
return normalize_with_synonyms(text, kb)
def parse_analysis_md(path):
"""analysis.md 파싱. Legacy ('구조', '내용') + Milestone 2 mirror ('구조 매칭 정보', '내용 설명') 둘 다 지원."""
text = path.read_text(encoding="utf-8")
result = {"keywords": [], "layout": "", "detail": "", "content": ""}
sections = re.split(r"\n## ", text)
for sec in sections[1:]:
lines = sec.split("\n", 1)
heading = lines[0].strip()
body = lines[1].strip() if len(lines) > 1 else ""
if heading in ("구조", "구조 매칭 정보"):
for ln in body.split("\n"):
m = re.match(r"- \*\*layout\*\*:\s*(.+)", ln)
if m: result["layout"] = m.group(1).strip()
m = re.match(r"- \*\*detail\*\*:\s*(.+)", ln)
if m: result["detail"] = m.group(1).strip()
elif heading in ("내용", "내용 설명"):
result["content"] = body.split("\n\n")[0].strip()
elif heading == "후보 키워드":
raw = body.split("\n\n")[0]
result["keywords"] = [k.strip() for k in raw.split(",") if k.strip()]
return result
def load_32_frames():
frames = {}
for d in sorted(BLOCKS_DIR.iterdir()):
if not d.is_dir(): continue
fid = d.name
if not fid.startswith("1171"): continue
a = d / "analysis.md"
if a.exists():
frames[fid] = parse_analysis_md(a)
return frames
def compute_df_idf_tier(frames):
df = collections.Counter()
N = len(frames)
for v in frames.values():
for kw in set(v["keywords"]):
df[kw] += 1
tier = {}
for kw, cnt in df.items():
ratio = cnt / N
if ratio <= 0.10: tier[kw] = "core"
elif ratio >= 0.30: tier[kw] = "general"
else: tier[kw] = "mid"
idf = {kw: math.log(N / cnt) if cnt > 0 else 0 for kw, cnt in df.items()}
return df, idf, tier, N
def extract_mdx_keywords(mdx_text, vocabulary, synonyms=None, keyword_base=None):
"""MDX 텍스트 → 정규화 → direct canonical hit + Kiwi token hit → union.
보강 이유: compound canonical (설계Data, 공사비절감, 3D모델 등) 은
Kiwi 가 분해하여 원형이 tokens 에 없음. vocabulary 에서 직접 substring 매칭
으로 별도 검사 → Kiwi hit 와 union.
Args:
mdx_text: 원본 MDX 텍스트
vocabulary: Figma frame 의 canonical keyword set (비교 대상)
synonyms: legacy synonyms.yaml 사전 (하위호환)
keyword_base: keyword_base.yaml 로드 결과 (우선 적용)
"""
if keyword_base is not None:
mdx_text = normalize_with_keyword_base(mdx_text, keyword_base)
elif synonyms is not None:
mdx_text = normalize_with_synonyms(mdx_text, synonyms)
# ① direct canonical hit (compound 보호)
direct = {t for t in vocabulary if len(t) >= 2 and t in mdx_text}
# ② Kiwi token hit
kiwi = _get_kiwi()
tokens = _extract_content_tokens(mdx_text, kiwi)
kiwi_hit = set(tokens) & vocabulary
return direct | kiwi_hit
def keyword_score(mdx_kws, fig_kws, idf, tier):
"""IDF × tier 가중 Jaccard"""
tier_weight = {"core": 1.0, "mid": 0.5, "general": 0.1}
inter = mdx_kws & fig_kws
union = mdx_kws | fig_kws
num = sum(idf.get(c, 0.5) * tier_weight.get(tier.get(c, "mid"), 0.5) for c in inter)
den = sum(idf.get(c, 0.5) * tier_weight.get(tier.get(c, "mid"), 0.5) for c in union)
return num / den if den > 0 else 0, inter
def content_scores_batch(mdx_content, frames):
"""Cross-encoder 일괄 채점 → {fid: [0,1]}"""
model = _get_cross_encoder()
fids = list(frames.keys())
pairs = [[mdx_content, frames[fid]["content"]] for fid in fids]
raw = model.predict(pairs, show_progress_bar=False)
return {fids[i]: 1 / (1 + math.exp(-float(raw[i]))) for i in range(len(fids))}
# ═══ 구조 매칭 v3 — 우선순위 수정 + 호환도 점수(graded) ═══
def detect_mdx_layout_v2(text):
"""MDX 본문 정밀 구조 감지.
우선순위: ### 서브섹션 > 표 > 블릿 (이전엔 표 먼저였음)"""
lines = text.split("\n")
# 1. ### 서브섹션 우선 (### X.Y 패턴)
subs = [ln for ln in lines if re.match(r"^###\s+\d+\.\d+", ln)]
if len(subs) == 2:
sub_text = " ".join(subs).lower()
if any(kw in sub_text for kw in ["과정", "결과", "process", "product", "as-is", "to-be"]):
return "compare-2banner"
return "compare-2col"
if len(subs) >= 3:
return "multi-section"
# 2. 표 감지 (서브섹션 없을 때)
table_header = None
for ln in lines:
stripped = ln.strip()
if re.match(r"^\|.*\|.*\|", stripped) and not re.match(r"^\|[\s\-:]+\|", stripped):
table_header = stripped
break
if table_header:
cols = [c.strip().replace("*", "").lower() for c in table_header.strip("|").split("|") if c.strip()]
col_text = " ".join(cols)
if any(kw in col_text for kw in ["발주자", "시공자", "설계자"]):
return "persona-3col"
if any(kw in col_text for kw in ["제조업", "건축", "토목"]) and "토목" in col_text:
return "table-3col"
if any(kw in col_text for kw in ["bim", "dx"]) and ("bim" in col_text and "dx" in col_text):
return "compare-rows"
n_cols = len(cols) - 1
if n_cols == 2: return "table-2col"
if n_cols >= 3: return "table-3col"
return "compare-rows"
# 3. 최상위 볼드 블릿
top_bullets = [ln for ln in lines if re.match(r"^[-*]\s+\*\*", ln)]
n = len(top_bullets)
if n == 3: return "3col-parallel"
if n == 2: return "compare-2col"
if n == 4: return "cards-4"
if n >= 5: return "multi-parallel"
return "single-column"
# ═══ 호환도 매트릭스 (compatibility) ═══
# MDX 구조 × Figma layout = 0.0~1.0
# "이 MDX 내용을 이 Figma 레이아웃으로 렌더링 시 얼마나 적합한가"
_COMPAT = {
# 2-column 계열 (서로 호환성 높음)
"compare-2col": {
"compare-2col": 1.0, "compare-2banner-top-2col-bottom": 0.9,
"table-2col": 0.9, "2col-paired": 0.85, "2-boxes": 0.75,
"central-split": 0.7, "paired-rows": 0.6,
"compare-rows": 0.55, "table-3col": 0.3, "persona-3col": 0.3,
"3col-parallel": 0.3, "single-column": 0.5,
},
"compare-2banner": {
"compare-2banner-top-2col-bottom": 1.0, "compare-2col": 0.9,
"table-2col": 0.85, "2col-paired": 0.8, "2-boxes": 0.7,
"paired-rows": 0.7, "central-split": 0.7,
"compare-rows": 0.5, "table-3col": 0.3, "persona-3col": 0.3,
"3col-parallel": 0.3, "single-column": 0.5,
},
"table-2col": {
"table-2col": 1.0, "compare-2col": 0.9,
"compare-2banner-top-2col-bottom": 0.85,
"compare-rows": 0.75, "2col-paired": 0.75,
"table-3col": 0.5, "persona-3col": 0.4, "3col-parallel": 0.3,
"single-column": 0.5,
},
# 3-column 계열
"persona-3col": {
"persona-3col": 1.0, "3col-parallel": 0.75,
"3col-cards": 0.75, "table-3col": 0.7, "3col-compare": 0.65,
"cards-4plus5": 0.4, "compare-rows": 0.35,
"compare-2col": 0.3, "table-2col": 0.4, "single-column": 0.5,
},
"3col-parallel": {
"3col-parallel": 1.0, "3col-cards": 0.9, "3col-compare": 0.9,
"persona-3col": 0.75, "table-3col": 0.6,
"3-emphasis": 0.6, "3-category": 0.65, "3-section": 0.65,
"cards-4": 0.5, "cards-4plus5": 0.4,
"compare-2col": 0.3, "single-column": 0.5,
},
"table-3col": {
"table-3col": 1.0, "3col-parallel": 0.65,
"3col-cards": 0.65, "persona-3col": 0.7,
"compare-rows": 0.7, "3col-compare": 0.75,
"table-2col": 0.5, "single-column": 0.45,
},
"compare-rows": {
"compare-rows": 1.0, "table-2col": 0.75,
"table-3col": 0.7, "paired-rows": 0.75,
"compare-2col": 0.55, "compare-2banner-top-2col-bottom": 0.5,
"2col-paired": 0.5, "single-column": 0.45,
},
# 4+ / 다중
"cards-4": {
"cards-4": 1.0, "cards-4plus5": 0.9,
"policy-4card-plus-list": 0.85, "quadrant-issues": 0.85,
"3col-parallel": 0.5, "table-3col": 0.4, "single-column": 0.4,
},
"multi-parallel": {
"cards-4": 0.85, "cards-4plus5": 0.9,
"policy-4card-plus-list": 0.8, "quadrant-issues": 0.75,
"3col-parallel": 0.55, "persona-3col": 0.45,
"table-3col": 0.4, "single-column": 0.4,
},
"multi-section": {
"3-section": 1.0, "3-category": 0.9, "3-emphasis": 0.85,
"3col-parallel": 0.6, "cards-4": 0.5, "single-column": 0.5,
},
# 단일
"single-column": {
"bullet-cards": 0.85, "list-numbered": 0.85, "list-stacked": 0.85,
"side-card": 0.75, "split-panel-diagram": 0.65,
"split-panel-numbered": 0.65, "central-split": 0.55,
"diagram-labels": 0.55, "diagram-5": 0.55, "central-5-goals": 0.5,
"circular-nodes": 0.5, "cycle-3way": 0.5,
"intro": 0.6, "definition-list": 0.7,
"compare-2col": 0.5, "compare-rows": 0.5, "3col-parallel": 0.5,
},
}
def structural_match_v2(mdx_layout, fig_layout):
"""구조 호환도 매칭 (0~1 graded). 정확일치=1.0, 유사=0.5~0.9, 무관=0.1~0.3."""
if not mdx_layout or not fig_layout: return 0.0
if mdx_layout == fig_layout: return 1.0
row = _COMPAT.get(mdx_layout, {})
return row.get(fig_layout, 0.15) # 명시 안 됐으면 매우 약한 기본 호환도
# ═══ 유닛 로드 ═══
def load_target_units():
"""4개 타겟 유닛의 MDX 본문 + 제목 hierarchy 반환"""
from extract_units import extract_units
from phase10 import extract_titles_only_mdx
units_full = extract_units()
units_title = {}
for uid, _, _, fname, mid, sub in TARGET_UNITS:
units_title[uid] = extract_titles_only_mdx(fname, mid, sub)
return units_full, units_title
def load_frame_index():
with open(PREVIEW_DIR / "index.json", encoding="utf-8") as f:
idx_data = json.load(f)
frame_to_short = {info["frame_id"]: sid for sid, info in idx_data.items()}
return idx_data, frame_to_short
@@ -0,0 +1,433 @@
"""Step 1: texts.md / MDX 에서 실제 text node 만 추출.
원칙:
- Figma texts.md: `- xxx` list item 만 포함 (heading/blockquote 제외)
- MDX: heading + 일반 문단 + list item + table cell 포함
(frontmatter / code block / :::note / standalone HTML tag 제외)
- HTML tag / markdown bold / markdown italic 제거, HTML entity unescape
- 단일 기호 / 1자 / 순수 숫자 제외 (30%, 2D, 3D 등은 보존)
- 각 프레임별 + 전체 corpus 카운트
- text_nodes 리스트는 중복 제거
산출: actual_text_nodes.yaml
"""
import html as html_lib
import re
import sys
from pathlib import Path
import yaml
sys.path.insert(0, str(Path(__file__).parent))
ROOT = Path(r"d:\ad-hoc\kei\design_agent")
BLOCKS_DIR = ROOT / "figma_to_html_agent" / "blocks"
MDX_DIR = ROOT / "samples" / "mdx_batch"
HERE = Path(__file__).parent
BEPS_ID = "1171281171"
FRAME_IDS = sorted([
d.name for d in BLOCKS_DIR.iterdir()
if d.is_dir() and d.name.startswith("1171") and d.name != BEPS_ID
])
def clean_text(raw):
"""HTML tag, markdown bold/italic, HTML entity 정리."""
clean = re.sub(r'<[^>]+>', ' ', raw)
clean = re.sub(r'\*\*([^*]+)\*\*', r'\1', clean)
clean = re.sub(r'(?<!\w)_([^_]+)_(?!\w)', r'\1', clean)
clean = html_lib.unescape(clean)
clean = re.sub(r'\s+', ' ', clean).strip()
return clean
def is_trivial(clean):
"""사소한 텍스트(빈 문자열/단일 기호/1자/순수 숫자) 여부."""
if not clean or len(clean) < 2:
return True
if clean in {'-', '', '', '(', ')', '/', '*', '**', '.', '|', '~'}:
return True
# 순수 숫자 배제 (30%, 40% 감소, 2D, 3D 는 보존됨)
if re.fullmatch(r'\d+', clean):
return True
return False
def extract_figma_nodes(path):
"""Figma texts.md 에서 text node 추출.
포함:
- list item (`- ...` / `* ...`)
- plain paragraph line (heading/blockquote 가 아닌 모든 비어있지 않은 줄)
제외:
- heading (#/##/###/####) — 구조 라벨 (예: ## 타이틀, ### 라벨1)
- blockquote (>) — meta 주석 (예: > 패턴, > 원본)
- 코드블록 (```...```)
- standalone HTML tag (<tag>, </tag>, <tag/>)
- markdown table separator (|---|---|)
- 빈 줄, 단일 기호, 1자, 순수 숫자 (is_trivial 필터)
"""
raw_nodes = []
clean_nodes = []
in_codeblock = False
for line in path.read_text(encoding='utf-8').split('\n'):
stripped = line.strip()
# 코드블록 토글
if stripped.startswith('```'):
in_codeblock = not in_codeblock
continue
if in_codeblock:
continue
if not stripped:
continue
# heading 제외 (구조 라벨)
if stripped.startswith('#'):
continue
# blockquote 제외 (meta 주석)
if stripped.startswith('>'):
continue
# standalone HTML tag 제외 (opening/closing/self-closing 모두)
if re.fullmatch(r'</?[^>]+>', stripped):
continue
# markdown table separator 제외 (|---|---|)
if re.fullmatch(r'\|[\s\-:|]+\|?', stripped):
continue
# list item → prefix 제거
m_list = re.match(r'^[-*]\s+(.+)$', stripped)
if m_list:
raw = m_list.group(1).strip()
else:
# plain line
raw = stripped
clean = clean_text(raw)
if is_trivial(clean):
continue
raw_nodes.append(raw)
clean_nodes.append(clean)
return raw_nodes, clean_nodes
def extract_mdx_content(path):
"""MDX 에서 시각 가능한 텍스트 추출.
포함:
- heading (#, ##, ### ...)
- 일반 문단 (paragraph)
- list item (-, *, 숫자. )
- table cell (| a | b | c |)
제외:
- frontmatter (--- ... ---)
- code block (``` ... ```)
- admonition 개행 (:::note, :::)
- standalone HTML tag 줄 (예: <Callout>)
"""
raw_nodes = []
clean_nodes = []
lines = path.read_text(encoding='utf-8').split('\n')
return _extract_mdx_from_lines(lines, handle_frontmatter=True)
def _extract_mdx_from_lines(lines, handle_frontmatter=False):
"""extract_mdx_content 의 내부 구현 (section 추출 재사용)."""
raw_nodes = []
clean_nodes = []
in_codeblock = False
in_admonition = False
start_idx = 0
if handle_frontmatter and lines and lines[0].strip() == '---':
for i in range(1, len(lines)):
if lines[i].strip() == '---':
start_idx = i + 1
break
for line in lines[start_idx:]:
stripped = line.strip()
# code block 토글
if stripped.startswith('```'):
in_codeblock = not in_codeblock
continue
if in_codeblock:
continue
# admonition 토글 (:::note ... :::)
if stripped.startswith(':::'):
in_admonition = not in_admonition if stripped == ':::' else True
if stripped == ':::':
in_admonition = False
else:
in_admonition = True
continue
if not stripped:
continue
# table separator 배제 (|---|---|)
if re.fullmatch(r'\|?[\s\-:|]+\|?', stripped) and '|' in stripped:
continue
# table row 처리
if stripped.startswith('|') and stripped.endswith('|'):
cells = [c.strip() for c in stripped.strip('|').split('|')]
for cell in cells:
if not cell:
continue
raw = cell
clean = clean_text(raw)
if is_trivial(clean):
continue
raw_nodes.append(raw)
clean_nodes.append(clean)
continue
# list item
m_list = re.match(r'^[-*]\s+(.+)$', stripped)
if m_list:
raw = m_list.group(1).strip()
clean = clean_text(raw)
if not is_trivial(clean):
raw_nodes.append(raw)
clean_nodes.append(clean)
continue
m_ol = re.match(r'^\d+\.\s+(.+)$', stripped)
if m_ol:
raw = m_ol.group(1).strip()
clean = clean_text(raw)
if not is_trivial(clean):
raw_nodes.append(raw)
clean_nodes.append(clean)
continue
# heading
m_h = re.match(r'^#+\s+(.+)$', stripped)
if m_h:
raw = m_h.group(1).strip()
clean = clean_text(raw)
if not is_trivial(clean):
raw_nodes.append(raw)
clean_nodes.append(clean)
continue
# blockquote 는 일반 문단으로 취급 (단, > 만 있는 줄은 제외)
if stripped.startswith('>'):
rest = stripped.lstrip('>').strip()
if not rest:
continue
raw = rest
clean = clean_text(raw)
if not is_trivial(clean):
raw_nodes.append(raw)
clean_nodes.append(clean)
continue
# standalone HTML tag 줄 배제 (<Callout> 같은)
if re.fullmatch(r'<[^>]+>', stripped):
continue
# HTML 닫는 태그만 있는 줄
if re.fullmatch(r'</[^>]+>', stripped):
continue
# 일반 문단
raw = stripped
clean = clean_text(raw)
if not is_trivial(clean):
raw_nodes.append(raw)
clean_nodes.append(clean)
return raw_nodes, clean_nodes
# MDX section 정의
# - TARGET (검증용): 01-2 / 02-2.2 / 03-1 / 03-2 (ANSWER_MAP 매핑 있음)
# - 홀드아웃 (블라인드 검증): 01-1 / 02-1 / 02-2.1 (기대 프레임 미지정)
MDX_SECTIONS = {
# --- TARGET (ANSWER_MAP 매핑 있음) ---
'01-2': {'file': '01.mdx', 'start': '## 2. 용어간 상호관계', 'end_prefix': None},
'02-2.2': {'file': '02.mdx', 'start': '### 2.2 DX 시행 주체별 기대효과', 'end_prefix': None},
'03-1': {'file': '03.mdx', 'start': '## 1. DX 시행을 위한 필수 요건', 'end_prefix': '## 2.'},
'03-2': {'file': '03.mdx', 'start': '## 2. Process의 혁신과 Product의 변화', 'end_prefix': None},
# --- 홀드아웃 (블라인드, 기대 프레임 없음) ---
'01-1': {'file': '01.mdx', 'start': '## 1. 용어 정의', 'end_prefix': '## 2.'},
'02-1': {'file': '02.mdx', 'start': '## 1. DX의 궁극적 목표', 'end_prefix': '## 2.'},
'02-2.1': {'file': '02.mdx', 'start': '### 2.1 업무 수행 과정(Process)의 변화', 'end_prefix': '### 2.2'},
}
def extract_mdx_section(path, start_heading, end_prefix=None):
"""MDX 파일에서 start_heading 줄부터 end_prefix 직전까지의 section 을 추출."""
lines = path.read_text(encoding='utf-8').split('\n')
start_idx = None
for i, line in enumerate(lines):
if line.strip() == start_heading.strip():
start_idx = i
break
if start_idx is None:
raise ValueError(f"start_heading 찾지 못함: {start_heading!r} in {path}")
end_idx = len(lines)
if end_prefix:
for i in range(start_idx + 1, len(lines)):
if lines[i].strip().startswith(end_prefix):
end_idx = i
break
section_lines = lines[start_idx:end_idx]
return _extract_mdx_from_lines(section_lines, handle_frontmatter=False)
def build_entry(raw, clean):
"""한 소스의 카운트 + dedup 결과 패키징."""
# dedup: 순서 유지
seen = set()
unique = []
for c in clean:
if c in seen:
continue
seen.add(c)
unique.append(c)
return {
'raw_nodes_count': len(raw),
'clean_nodes_count': len(clean),
'unique_clean_nodes_count': len(unique),
'duplicate_removed_count': len(clean) - len(unique),
'text_nodes': unique,
}
def main():
output = {
'meta': {
'pipeline_step': 1,
'description': 'texts.md / MDX 에서 실제 text node 추출, HTML/markdown 정리, 중복 제거',
'sources': {},
},
'frames': {},
'beps': {},
'mdx': {},
}
# BEPS
p = BLOCKS_DIR / BEPS_ID / "texts.md"
if p.exists():
raw, clean = extract_figma_nodes(p)
entry = build_entry(raw, clean)
entry['frame_id'] = BEPS_ID
# frame_id를 맨 앞으로
output['beps'] = {'frame_id': BEPS_ID, **{k: v for k, v in entry.items() if k != 'frame_id'}}
# 32 frames
for fid in FRAME_IDS:
p = BLOCKS_DIR / fid / "texts.md"
if not p.exists():
continue
raw, clean = extract_figma_nodes(p)
output['frames'][fid] = build_entry(raw, clean)
# MDX section 단위 (TARGET 4개)
for section_id, cfg in MDX_SECTIONS.items():
p = MDX_DIR / cfg['file']
if not p.exists():
continue
raw, clean = extract_mdx_section(p, cfg['start'], cfg.get('end_prefix'))
entry = build_entry(raw, clean)
entry['section_id'] = section_id
entry['source_file'] = cfg['file']
entry['section_heading'] = cfg['start']
output['mdx'][section_id] = entry
# 총계
def _sum(key, grp):
return sum(g[key] for g in grp.values())
beps = output['beps']
frames = output['frames']
mdx = output['mdx']
totals = {
'beps_raw': beps.get('raw_nodes_count', 0),
'beps_clean': beps.get('clean_nodes_count', 0),
'beps_unique': beps.get('unique_clean_nodes_count', 0),
'beps_duplicate_removed': beps.get('duplicate_removed_count', 0),
'frames_raw': _sum('raw_nodes_count', frames),
'frames_clean': _sum('clean_nodes_count', frames),
'frames_unique': _sum('unique_clean_nodes_count', frames),
'frames_duplicate_removed': _sum('duplicate_removed_count', frames),
'mdx_raw': _sum('raw_nodes_count', mdx),
'mdx_clean': _sum('clean_nodes_count', mdx),
'mdx_unique': _sum('unique_clean_nodes_count', mdx),
'mdx_duplicate_removed': _sum('duplicate_removed_count', mdx),
}
totals['total_raw'] = totals['beps_raw'] + totals['frames_raw'] + totals['mdx_raw']
totals['total_clean'] = totals['beps_clean'] + totals['frames_clean'] + totals['mdx_clean']
totals['total_unique'] = totals['beps_unique'] + totals['frames_unique'] + totals['mdx_unique']
totals['total_duplicate_removed'] = (
totals['beps_duplicate_removed']
+ totals['frames_duplicate_removed']
+ totals['mdx_duplicate_removed']
)
output['meta']['sources'] = {
'beps_count': 1 if beps else 0,
'frame_count': len(frames),
'mdx_count': len(mdx),
}
output['meta']['totals'] = totals
# 저장
out = HERE / "actual_text_nodes.yaml"
with open(out, 'w', encoding='utf-8') as f:
yaml.safe_dump(output, f, allow_unicode=True, sort_keys=False, width=200)
# 화면 요약
print(f"[Step 1] text node 추출 완료")
print(f" BEPS: raw={totals['beps_raw']}, clean={totals['beps_clean']}, "
f"unique={totals['beps_unique']}, dedup_removed={totals['beps_duplicate_removed']}")
print(f" Frames: raw={totals['frames_raw']}, clean={totals['frames_clean']}, "
f"unique={totals['frames_unique']}, dedup_removed={totals['frames_duplicate_removed']} "
f"({len(frames)}개 frame)")
print(f" MDX: raw={totals['mdx_raw']}, clean={totals['mdx_clean']}, "
f"unique={totals['mdx_unique']}, dedup_removed={totals['mdx_duplicate_removed']}")
print(f" TOTAL: raw={totals['total_raw']}, clean={totals['total_clean']}, "
f"unique={totals['total_unique']}, dedup_removed={totals['total_duplicate_removed']}")
print()
print(f"산출: {out}")
# Frame 18/29/14/13 샘플 미리보기
print()
print("" * 60)
print("샘플 프레임 (정답 TARGET 4개)")
print("" * 60)
for fnum in ['18', '29', '14', '13']:
# frame_id 는 BEPS 제외 frame 중에서 frame number 로 접근 어려움 → FRAME_IDS 순 찾기
# 사용자는 Frame 번호(순번)로 표현했으므로 FRAME_IDS[fnum-1] 로 추정
idx = int(fnum) - 1
if idx < 0 or idx >= len(FRAME_IDS):
continue
fid = FRAME_IDS[idx]
info = frames.get(fid)
if not info:
continue
print(f"\n[Frame {fnum}] id={fid}")
print(f" raw={info['raw_nodes_count']}, clean={info['clean_nodes_count']}, "
f"unique={info['unique_clean_nodes_count']}, dedup_removed={info['duplicate_removed_count']}")
print(f" sample text_nodes (최대 10개):")
for t in info['text_nodes'][:10]:
print(f" - {t}")
if __name__ == "__main__":
main()
@@ -0,0 +1,221 @@
"""Step 2.5: special form inventory.
목적:
- Step 4 synonym/canonical 룰 작성 전에 실제 표기형만 관찰 (아직 mapping 안 함).
- exact 타겟: S/W, H/W, 2D, 3D, DX, BIM
- variant 타겟: AS-IS, TO-BE (case-insensitive + 하이픈/공백/언더스코어 tolerant)
- general_candidates threshold 8 로 재계산 (Step 2 결과 재활용)
count 는 occurrences(매치 횟수) + nodes(매치된 text_node 수) 둘 다.
examples 는 source id(beps:<id> / frame:<id> / mdx:<n>) 포함.
산출: special_forms_inventory.yaml
"""
import re
from collections import Counter
from pathlib import Path
import yaml
HERE = Path(__file__).parent
INPUT_NODES = HERE / "actual_text_nodes.yaml"
INPUT_TOKENS = HERE / "actual_text_tokens.yaml"
OUTPUT = HERE / "special_forms_inventory.yaml"
EXACT_TARGETS = {
'S/W': re.compile(r'S/W'),
'H/W': re.compile(r'H/W'),
'2D': re.compile(r'(?<![A-Za-z0-9])2D(?![A-Za-z0-9])'),
'3D': re.compile(r'(?<![A-Za-z0-9])3D(?![A-Za-z0-9])'),
'DX': re.compile(r'(?<![A-Za-z0-9])DX(?![A-Za-z0-9])'),
'BIM': re.compile(r'(?<![A-Za-z0-9])BIM(?![A-Za-z0-9])'),
}
VARIANT_TARGETS = {
'AS-IS': re.compile(r'(?<![A-Za-z])as[-\s_]?is(?![A-Za-z])', re.IGNORECASE),
'TO-BE': re.compile(r'(?<![A-Za-z])to[-\s_]?be(?![A-Za-z])', re.IGNORECASE),
}
GROUPS = ['beps', 'frames', 'mdx']
def collect_sources(nodes_data):
"""[{'group', 'source', 'text'}] 평면 리스트."""
out = []
beps = nodes_data.get('beps') or {}
if beps.get('text_nodes'):
sid = f"beps:{beps.get('frame_id', '?')}"
for t in beps['text_nodes']:
out.append({'group': 'beps', 'source': sid, 'text': t})
for fid, info in nodes_data.get('frames', {}).items():
sid = f"frame:{fid}"
for t in info.get('text_nodes', []):
out.append({'group': 'frames', 'source': sid, 'text': t})
for n, info in nodes_data.get('mdx', {}).items():
sid = f"mdx:{n}"
for t in info.get('text_nodes', []):
out.append({'group': 'mdx', 'source': sid, 'text': t})
return out
def _empty_breakdown():
return {'total': 0, 'beps': 0, 'frames': 0, 'mdx': 0}
def _sum_total(d):
d['total'] = d['beps'] + d['frames'] + d['mdx']
return d
def _collect_examples(rows_with_match, limit=10):
"""group 별로 앞쪽 몇 개씩 섞어 최대 limit 개. text 기준 dedup."""
bucket = {g: [] for g in GROUPS}
per_group_cap = 5
for r in rows_with_match:
if len(bucket[r['group']]) < per_group_cap:
bucket[r['group']].append(r)
out = []
seen = set()
for g in GROUPS:
for r in bucket[g]:
if r['text'] in seen:
continue
seen.add(r['text'])
out.append({'source': r['source'], 'text': r['text']})
if len(out) >= limit:
return out
return out
def inventory_exact(pattern, sources):
occ = _empty_breakdown()
nodes = _empty_breakdown()
hits = []
for r in sources:
matches = pattern.findall(r['text'])
if not matches:
continue
occ[r['group']] += len(matches)
nodes[r['group']] += 1
hits.append(r)
return {
'count': {
'occurrences': _sum_total(occ),
'nodes': _sum_total(nodes),
},
'examples': _collect_examples(hits, limit=10),
}
def inventory_variant(pattern, sources):
occ = _empty_breakdown()
nodes = _empty_breakdown()
forms = Counter()
hits = []
for r in sources:
matches = pattern.findall(r['text'])
if not matches:
continue
for m in matches:
forms[m] += 1
occ[r['group']] += len(matches)
nodes[r['group']] += 1
hits.append(r)
observed = [{'form': f, 'count': c} for f, c in forms.most_common()]
return {
'count': {
'occurrences': _sum_total(occ),
'nodes': _sum_total(nodes),
},
'observed_forms': observed,
'examples': _collect_examples(hits, limit=10),
}
def rerun_general_candidates(tokens_data, threshold=8):
frames = tokens_data.get('frames', {})
mdx = tokens_data.get('mdx', {})
total_frames = len(frames)
frame_count = Counter()
mdx_count = Counter()
for fid, info in frames.items():
for tok in info.get('unique_tokens', []):
frame_count[tok] += 1
for n, info in mdx.items():
for tok in info.get('unique_tokens', []):
mdx_count[tok] += 1
candidates = sorted(
[
{'token': tok, 'frame_count': fc, 'mdx_count': mdx_count.get(tok, 0)}
for tok, fc in frame_count.items() if fc >= threshold
],
key=lambda x: (-x['frame_count'], -x['mdx_count'], x['token']),
)
return {
'threshold': threshold,
'total_frames': total_frames,
'count': len(candidates),
'note': '삭제 기준 아님. 검토 후보. BIM/건설/기술 등 도메인어 포함 가능.',
'candidates': candidates,
}
def main():
nodes_data = yaml.safe_load(INPUT_NODES.read_text(encoding='utf-8'))
tokens_data = yaml.safe_load(INPUT_TOKENS.read_text(encoding='utf-8'))
sources = collect_sources(nodes_data)
output = {
'meta': {
'pipeline_step': 2.5,
'description': (
'Special form inventory. synonym/canonical 적용 전 실제 표기형 관찰. '
'count 는 occurrences + nodes 둘 다. examples 는 source id 포함.'
),
},
'exact_targets': {t: inventory_exact(p, sources) for t, p in EXACT_TARGETS.items()},
'variant_targets': {t: inventory_variant(p, sources) for t, p in VARIANT_TARGETS.items()},
'general_candidates_threshold_8': rerun_general_candidates(tokens_data, threshold=8),
'general_candidates_threshold_4': rerun_general_candidates(tokens_data, threshold=4),
}
OUTPUT.write_text(
yaml.safe_dump(output, allow_unicode=True, sort_keys=False, width=200),
encoding='utf-8',
)
# 콘솔 요약
print("[Step 2.5] Special form inventory\n")
print("[exact_targets] (occ = occurrences, n = nodes)")
for t, info in output['exact_targets'].items():
o = info['count']['occurrences']
n = info['count']['nodes']
print(f" {t:6s} occ={o['total']:4d} (b{o['beps']:3d}/f{o['frames']:3d}/m{o['mdx']:3d}) "
f"nodes={n['total']:4d} (b{n['beps']:3d}/f{n['frames']:3d}/m{n['mdx']:3d})")
for ex in info['examples'][:3]:
print(f" └ [{ex['source']}] {ex['text']}")
print()
print("[variant_targets]")
for t, info in output['variant_targets'].items():
o = info['count']['occurrences']
n = info['count']['nodes']
print(f" {t:6s} occ={o['total']:3d} (b{o['beps']:3d}/f{o['frames']:3d}/m{o['mdx']:3d}) "
f"nodes={n['total']:3d} (b{n['beps']:3d}/f{n['frames']:3d}/m{n['mdx']:3d})")
print(f" observed_forms:")
for f in info['observed_forms']:
print(f" {f['form']!r}: {f['count']}")
for ex in info['examples'][:3]:
print(f" └ [{ex['source']}] {ex['text']}")
print()
for key in ['general_candidates_threshold_8', 'general_candidates_threshold_4']:
gc = output[key]
print(f"[{key}] threshold={gc['threshold']} count={gc['count']} (상위 30개)")
for c in gc['candidates'][:30]:
print(f" {c['token']:12s} frame_count={c['frame_count']:2d}/{gc['total_frames']} mdx_count={c['mdx_count']}")
print()
print(f"산출: {OUTPUT}")
if __name__ == "__main__":
main()
+258
View File
@@ -0,0 +1,258 @@
"""Step 2: actual_text_nodes.yaml → Kiwi 형태소 토큰 추출.
원칙:
- Kiwi 품사 NNG/NNP/SL/SN 만 (NNB 제외)
- 1글자 제거
- 순수 숫자 제거
- synonym/canonical 합침 없음 (Step 4 에서 처리)
- frame별 unique tokens
- corpus top_50_by_frequency + top_50_by_frame_df
- general_candidates 는 삭제 기준 아님, 검토 후보
- special_token_samples: 2D/3D/S/W/H/W/DX/BIM/AS-IS/TO-BE Kiwi 쪼개짐 관찰
산출: actual_text_tokens.yaml
"""
import re
from collections import Counter, defaultdict
from pathlib import Path
import yaml
from kiwipiepy import Kiwi
HERE = Path(__file__).parent
INPUT = HERE / "actual_text_nodes.yaml"
OUTPUT = HERE / "actual_text_tokens.yaml"
ALLOWED_TAGS = {'NNG', 'NNP', 'SL', 'SN'}
SPECIAL_TOKEN_TARGETS = ['2D', '3D', 'S/W', 'H/W', 'DX', 'BIM', 'AS-IS', 'TO-BE']
kiwi = Kiwi()
def extract_tokens(texts):
"""text 리스트에서 허용 태그 token 만 추출."""
raw = []
for t in texts:
for tok in kiwi.tokenize(t):
if tok.tag not in ALLOWED_TAGS:
continue
form = tok.form
if len(form) < 2:
continue
if re.fullmatch(r'\d+', form):
continue
raw.append(form)
return raw
def dedup_keep_order(seq):
seen = set()
out = []
for x in seq:
if x in seen:
continue
seen.add(x)
out.append(x)
return out
def tokenize_to_pairs(text):
"""tokenize 결과를 [[form, tag], ...] 로 변환."""
return [[tok.form, tok.tag] for tok in kiwi.tokenize(text)]
def process_source(source_key, text_nodes, token_freq, token_to_sources):
raw = extract_tokens(text_nodes)
unique = dedup_keep_order(raw)
for tok in raw:
token_freq[tok] += 1
for tok in set(raw):
token_to_sources[tok].add(source_key)
return {
'raw_token_count': len(raw),
'unique_token_count': len(unique),
'unique_tokens': unique,
}
def build_special_samples(data):
"""특수 토큰이 코퍼스에서 Kiwi 에 의해 어떻게 분해되는지 + source breakdown."""
# source 별 text_nodes 수집
beps_texts = (data.get('beps') or {}).get('text_nodes', [])
frame_texts = []
for info in data.get('frames', {}).values():
frame_texts.extend(info.get('text_nodes', []))
mdx_texts = []
for info in data.get('mdx', {}).values():
mdx_texts.extend(info.get('text_nodes', []))
all_texts = beps_texts + frame_texts + mdx_texts
samples = {}
for target in SPECIAL_TOKEN_TARGETS:
beps_hits = [t for t in beps_texts if target in t]
frame_hits = [t for t in frame_texts if target in t]
mdx_hits = [t for t in mdx_texts if target in t]
all_hits = [t for t in all_texts if target in t]
samples[target] = {
'tokenize_alone': tokenize_to_pairs(target),
'found_in': {
'total': len(all_hits),
'beps': len(beps_hits),
'frames': len(frame_hits),
'mdx': len(mdx_hits),
},
'in_context': [
{'text': t, 'tokens': tokenize_to_pairs(t)}
for t in all_hits[:5]
],
}
return samples
def main():
data = yaml.safe_load(INPUT.read_text(encoding='utf-8'))
output = {
'meta': {
'pipeline_step': 2,
'description': 'Kiwi 형태소 추출 (명사/외국어/숫자). 1글자/순수숫자 제외. synonym 미적용.',
'filters': {
'allowed_tags': sorted(ALLOWED_TAGS),
'exclude_1char': True,
'exclude_pure_number': True,
},
},
'beps': {},
'frames': {},
'mdx': {},
'corpus': {},
}
token_freq = Counter()
token_to_sources = defaultdict(set)
# BEPS
beps = data.get('beps') or {}
if beps.get('text_nodes'):
key = f"beps:{beps['frame_id']}"
entry = process_source(key, beps['text_nodes'], token_freq, token_to_sources)
output['beps'] = {'frame_id': beps['frame_id'], **entry}
# Frames
for fid, info in data.get('frames', {}).items():
key = f"frame:{fid}"
entry = process_source(key, info.get('text_nodes', []), token_freq, token_to_sources)
output['frames'][fid] = entry
# MDX
for n, info in data.get('mdx', {}).items():
key = f"mdx:{n}"
entry = process_source(key, info.get('text_nodes', []), token_freq, token_to_sources)
output['mdx'][n] = entry
# corpus 통계
total_frames = len(output['frames'])
frame_count_of = {
t: sum(1 for s in token_to_sources[t] if s.startswith('frame:'))
for t in token_freq
}
mdx_count_of = {
t: sum(1 for s in token_to_sources[t] if s.startswith('mdx:'))
for t in token_freq
}
top50_by_freq = [
{'token': t, 'count': c,
'frame_count': frame_count_of[t], 'mdx_count': mdx_count_of[t]}
for t, c in token_freq.most_common(50)
]
top50_by_frame_df = sorted(
[
{'token': t, 'frame_count': frame_count_of[t],
'count': token_freq[t], 'mdx_count': mdx_count_of[t]}
for t in token_freq
],
key=lambda x: (-x['frame_count'], -x['count'], x['token']),
)[:50]
threshold = max(3, total_frames // 2)
general = sorted(
[
{'token': t, 'frame_count': frame_count_of[t],
'total_count': token_freq[t], 'mdx_count': mdx_count_of[t]}
for t in token_freq if frame_count_of[t] >= threshold
],
key=lambda x: (-x['frame_count'], -x['total_count'], x['token']),
)
special_samples = build_special_samples(data)
output['corpus'] = {
'unique_token_count': len(token_freq),
'total_occurrences': sum(token_freq.values()),
'top_50_by_frequency': top50_by_freq,
'top_50_by_frame_df': top50_by_frame_df,
'general_candidates_note': (
'제거 기준 아님. 32개 frame 중 threshold 이상에 등장한 토큰 = 검토 후보. '
'BIM/건설/기술 등 도메인어가 포함될 수 있으므로 삭제하지 말 것.'
),
'general_candidates_threshold': threshold,
'general_candidates_count': len(general),
'general_candidates': general,
'special_token_samples_note': (
'Step 4 synonym/canonical 룰을 잡기 전에 Kiwi 가 '
'2D/3D/S/W/H/W/DX/BIM/AS-IS/TO-BE 를 어떻게 분해하는지 관찰용. '
'found_in 에 beps/frames/mdx breakdown 포함.'
),
'special_token_samples': special_samples,
}
output['meta']['totals'] = {
'beps_raw': output['beps'].get('raw_token_count', 0),
'beps_unique': output['beps'].get('unique_token_count', 0),
'frames_raw': sum(f['raw_token_count'] for f in output['frames'].values()),
'frames_unique_avg': (
sum(f['unique_token_count'] for f in output['frames'].values()) / total_frames
if total_frames else 0
),
'mdx_raw': sum(m['raw_token_count'] for m in output['mdx'].values()),
'mdx_unique_avg': (
sum(m['unique_token_count'] for m in output['mdx'].values()) / len(output['mdx'])
if output['mdx'] else 0
),
'corpus_unique': len(token_freq),
'corpus_total_occurrences': sum(token_freq.values()),
}
OUTPUT.write_text(
yaml.safe_dump(output, allow_unicode=True, sort_keys=False, width=200),
encoding='utf-8',
)
# 화면 요약
t = output['meta']['totals']
print(f"[Step 2] Kiwi 토큰화 완료")
print(f" BEPS: raw={t['beps_raw']}, unique={t['beps_unique']}")
print(f" Frames: raw={t['frames_raw']}, unique_avg={t['frames_unique_avg']:.1f} "
f"({total_frames}개 frame)")
print(f" MDX: raw={t['mdx_raw']}, unique_avg={t['mdx_unique_avg']:.1f}")
print(f" Corpus: unique={t['corpus_unique']}, occurrences={t['corpus_total_occurrences']}")
print()
print(f" 일반 token 후보 (frame_count >= {threshold}, 검토용): {len(general)}")
print(f" 상위 15개:")
for g in general[:15]:
print(f" {g['token']:12s} frames={g['frame_count']:2d}/{total_frames} "
f"count={g['total_count']:3d} mdx={g['mdx_count']}")
print()
print(f" special_token_samples (Kiwi 쪼개짐 + source breakdown):")
for target, info in output['corpus']['special_token_samples'].items():
tokens_alone = ' '.join(f"{f}({tag})" for f, tag in info['tokenize_alone'])
fi = info['found_in']
print(f" {target:7s} alone=[{tokens_alone:35s}] "
f"total={fi['total']:3d} beps={fi['beps']:3d} "
f"frames={fi['frames']:3d} mdx={fi['mdx']:3d}")
print()
print(f"산출: {OUTPUT}")
if __name__ == "__main__":
main()
@@ -0,0 +1,266 @@
"""Step 4 (4.1 반영): pre-Kiwi substitution + Kiwi user_dict 기반 canonical 토큰 정규화.
파이프라인:
text_node
→ 4a-pre. PRE_COLLAPSE (regex): 디지털 전환(DX) / DX(DX) 계열 collapse
→ 4a. phrase_variants 치환 (긴 variant 먼저, canonical 재치환 금지)
→ 4b. Kiwi tokenize (user_dict: SL 8 + NNG 5)
→ 4c. NNG/NNP/SL/SN + 1글자/순수숫자 필터
→ canonical token list
설계 제약:
- 긴 variant 먼저 치환
- canonical 자기 자신 재치환 금지
- DX canonical 하나로 통일 (디지털전환 canonical 없음)
- 3D모델/2D도면 은 Step 4 에서 제외 (3D/2D 일반 차원 토큰 보존)
- applied_replacements / pre_collapse_applied 카운트 분리 출력
- replacement samples: per-group quota (beps 8 / frames 6 / mdx 6 = 20)
- corpus 에 full token_frequency / token_frame_df / token_mdx_df 저장
산출:
- normalized_text_tokens.yaml
- replacement_report.yaml
"""
import re
from collections import Counter
from pathlib import Path
import yaml
# 정규화 로직은 keyword_normalizer 모듈에서 import (공유)
from keyword_normalizer import (
USER_DICT_SL, USER_DICT_NNG, ALLOWED_TAGS,
PRE_COLLAPSE, MAX_PAREN_LEN, PAREN_PATTERN, PAREN_SKIP_PATTERN, PAREN_SAMPLE_MAX,
load_phrase_variants as _load_phrase_variants,
build_substitutions, expand_parentheses, apply_substitutions,
build_kiwi, extract_tokens,
)
HERE = Path(__file__).parent
INPUT_NODES = HERE / "actual_text_nodes.yaml"
SYNONYMS = HERE / "synonyms.yaml"
OUTPUT_TOKENS = HERE / "normalized_text_tokens.yaml"
OUTPUT_REPORT = HERE / "replacement_report.yaml"
# 로컬 wrapper (기존 호출 형식 유지: load_phrase_variants())
def load_phrase_variants():
return _load_phrase_variants(SYNONYMS)
# replacement samples per-group quota (합 20)
SAMPLE_QUOTA = {'beps': 8, 'frames': 6, 'mdx': 6}
def dedup_keep_order(seq):
seen = set()
out = []
for x in seq:
if x in seen:
continue
seen.add(x)
out.append(x)
return out
def process_source(kiwi, subs, source_group, source_key, text_nodes,
replacement_counter, pre_collapse_counter,
replacement_samples, sample_count_by_group,
corpus_freq, paren_counter, paren_samples):
raw = []
for t in text_nodes:
before = t
after = apply_substitutions(t, subs, replacement_counter, pre_collapse_counter,
paren_counter, paren_samples)
if before != after and sample_count_by_group[source_group] < SAMPLE_QUOTA[source_group]:
replacement_samples.append({
'group': source_group,
'source': source_key,
'before': before,
'after': after,
})
sample_count_by_group[source_group] += 1
tokens = extract_tokens(kiwi, after)
for tk in tokens:
corpus_freq[tk] += 1
raw.extend(tokens)
unique = dedup_keep_order(raw)
return {
'raw_token_count': len(raw),
'unique_token_count': len(unique),
'unique_tokens': unique,
}
def main():
data = yaml.safe_load(INPUT_NODES.read_text(encoding='utf-8'))
phrase_variants = load_phrase_variants()
subs = build_substitutions(phrase_variants)
kiwi = build_kiwi()
replacement_counter = Counter()
pre_collapse_counter = Counter()
replacement_samples = []
sample_count_by_group = {'beps': 0, 'frames': 0, 'mdx': 0}
corpus_freq = Counter()
paren_counter = Counter()
paren_samples = []
output = {
'meta': {
'pipeline_step': 4.2,
'description': 'PRE_COLLAPSE + pre-Kiwi substitution + user_dict. phrase_variants 기준.',
'user_dict': {'SL': USER_DICT_SL, 'NNG': USER_DICT_NNG},
'phrase_variants_count': len(phrase_variants),
'substitution_rule_count': len(subs),
'pre_collapse_rule_count': len(PRE_COLLAPSE),
'sample_quota': SAMPLE_QUOTA,
'notes': [
'DX canonical 유지 (디지털전환은 DX variant 로 통합)',
'3D모델/2D도면 canonical 은 Step 4 에서 제외 (3D/2D 일반 차원 토큰 보존)',
'PRE_COLLAPSE 는 regex 로 "디지털 전환(DX)" / "DX(DX)" 계열 통합',
],
},
'beps': {}, 'frames': {}, 'mdx': {}, 'corpus': {},
}
beps = data.get('beps') or {}
if beps.get('text_nodes'):
key = f"beps:{beps['frame_id']}"
entry = process_source(kiwi, subs, 'beps', key, beps['text_nodes'],
replacement_counter, pre_collapse_counter,
replacement_samples, sample_count_by_group, corpus_freq,
paren_counter, paren_samples)
output['beps'] = {'frame_id': beps['frame_id'], **entry}
for fid, info in data.get('frames', {}).items():
key = f"frame:{fid}"
entry = process_source(kiwi, subs, 'frames', key, info.get('text_nodes', []),
replacement_counter, pre_collapse_counter,
replacement_samples, sample_count_by_group, corpus_freq,
paren_counter, paren_samples)
output['frames'][fid] = entry
for n, info in data.get('mdx', {}).items():
key = f"mdx:{n}"
entry = process_source(kiwi, subs, 'mdx', key, info.get('text_nodes', []),
replacement_counter, pre_collapse_counter,
replacement_samples, sample_count_by_group, corpus_freq,
paren_counter, paren_samples)
output['mdx'][n] = entry
# corpus 통계
total_frames = len(output['frames'])
frame_df = Counter()
mdx_df = Counter()
for fid, info in output['frames'].items():
for tk in info['unique_tokens']:
frame_df[tk] += 1
for n, info in output['mdx'].items():
for tk in info['unique_tokens']:
mdx_df[tk] += 1
top50_by_freq = [
{'token': t, 'count': c,
'frame_count': frame_df.get(t, 0), 'mdx_count': mdx_df.get(t, 0)}
for t, c in corpus_freq.most_common(50)
]
top50_by_frame_df = sorted(
[{'token': t, 'frame_count': frame_df[t],
'count': corpus_freq[t], 'mdx_count': mdx_df.get(t, 0)}
for t in frame_df],
key=lambda x: (-x['frame_count'], -x['count'], x['token']),
)[:50]
output['corpus'] = {
'unique_token_count': len(corpus_freq),
'total_occurrences': sum(corpus_freq.values()),
'top_50_by_frequency': top50_by_freq,
'top_50_by_frame_df': top50_by_frame_df,
'token_frequency': dict(sorted(corpus_freq.items(), key=lambda x: (-x[1], x[0]))),
'token_frame_df': dict(sorted(frame_df.items(), key=lambda x: (-x[1], x[0]))),
'token_mdx_df': dict(sorted(mdx_df.items(), key=lambda x: (-x[1], x[0]))),
}
output['meta']['totals'] = {
'beps_raw': output['beps'].get('raw_token_count', 0),
'beps_unique': output['beps'].get('unique_token_count', 0),
'frames_raw': sum(f['raw_token_count'] for f in output['frames'].values()),
'frames_unique_avg': (
sum(f['unique_token_count'] for f in output['frames'].values()) / total_frames
if total_frames else 0
),
'mdx_raw': sum(m['raw_token_count'] for m in output['mdx'].values()),
'mdx_unique_avg': (
sum(m['unique_token_count'] for m in output['mdx'].values()) / len(output['mdx'])
if output['mdx'] else 0
),
'corpus_unique': len(corpus_freq),
}
OUTPUT_TOKENS.write_text(
yaml.safe_dump(output, allow_unicode=True, sort_keys=False, width=200),
encoding='utf-8',
)
pre_collapse_total = sum(pre_collapse_counter.values())
phrase_total = sum(replacement_counter.values())
total_text_changes = pre_collapse_total + phrase_total
report = {
'meta': {
'pipeline_step': 4.2,
'description': 'PRE_COLLAPSE + 괄호 확장 + phrase_variants 치환 카운트 + per-group 쿼터 샘플',
'pre_collapse_rule_count': len(PRE_COLLAPSE),
'substitution_rule_count': len(subs),
'sample_quota': SAMPLE_QUOTA,
'paren_expansion_max_len': MAX_PAREN_LEN,
},
'pre_collapse_applied': dict(
sorted(pre_collapse_counter.items(), key=lambda x: -x[1])
),
'pre_collapse_total': pre_collapse_total,
'paren_expanded': {
'total': paren_counter.get('expansions', 0),
'samples_count': len(paren_samples),
'samples': paren_samples,
},
'applied_replacements': dict(
sorted(replacement_counter.items(), key=lambda x: -x[1])
),
'total_replacements': phrase_total,
'total_text_changes': total_text_changes,
'sample_count_by_group': sample_count_by_group,
'samples': replacement_samples,
}
OUTPUT_REPORT.write_text(
yaml.safe_dump(report, allow_unicode=True, sort_keys=False, width=200),
encoding='utf-8',
)
# 콘솔 요약
t = output['meta']['totals']
print(f"[Step 4.2] PRE_COLLAPSE (4 rules) + pre-Kiwi substitution + user_dict")
print(f" PRE_COLLAPSE: {len(PRE_COLLAPSE)} regex rules")
print(f" phrase_variants: {len(phrase_variants)}개 canonical, {len(subs)}개 rule")
print(f" user_dict: SL {len(USER_DICT_SL)}, NNG {len(USER_DICT_NNG)}")
print()
print(f" BEPS: raw={t['beps_raw']}, unique={t['beps_unique']}")
print(f" Frames: raw={t['frames_raw']}, unique_avg={t['frames_unique_avg']:.1f} ({total_frames}개 frame)")
print(f" MDX: raw={t['mdx_raw']}, unique_avg={t['mdx_unique_avg']:.1f}")
print(f" Corpus: unique={t['corpus_unique']}, occurrences={sum(corpus_freq.values())}")
print()
print(f" pre_collapse_total: {pre_collapse_total}")
for rid, cnt in report['pre_collapse_applied'].items():
print(f" [{rid}] {cnt}")
print(f" applied_replacements total: {phrase_total}")
for canonical, cnt in report['applied_replacements'].items():
print(f" {canonical:10s} {cnt}")
print(f" total_text_changes: {total_text_changes}")
print()
print(f" samples per group: {sample_count_by_group} (quota={SAMPLE_QUOTA})")
print()
print(f"산출: {OUTPUT_TOKENS}")
print(f" {OUTPUT_REPORT}")
if __name__ == "__main__":
main()
@@ -0,0 +1,342 @@
"""Step 5.1: anchor 후보 랭킹 — 3그룹 + Full candidate.
변경점 (5.0 → 5.1):
- top 15 → top 20 (frequent 그룹만)
- 후보를 3 그룹 + full 로 분리:
1. frequent_candidates — frame_local_count 내림차순 top 20
2. special_candidates — USER_DICT_SL/NNG 중 해당 frame 에 등장한 것 (local desc)
3. unique_to_frame_candidates — frame_df=1 후보 (local desc)
4. full_candidates — 전체 frame 토큰 (local desc)
- score 계산 없음. raw 지표만.
원칙:
- AI 자동 추천 없음. 검수용 후보 목록.
- 3그룹은 서로 겹칠 수 있음 (예: BIM 은 frequent + special 둘 다).
산출:
- anchor_candidates.yaml (4개 리스트 구조)
- anchor_candidates_report.md
- anchor_candidates_report.html (details/summary, TARGET frames open)
"""
import math
import sys
from collections import Counter
from pathlib import Path
import yaml
import markdown
HERE = Path(__file__).parent
sys.path.insert(0, str(HERE))
from pipeline_04_normalize import (
apply_substitutions, build_substitutions, build_kiwi,
extract_tokens, USER_DICT_SL, USER_DICT_NNG,
load_phrase_variants,
)
INPUT_NODES = HERE / "actual_text_nodes.yaml"
INPUT_NORMALIZED = HERE / "normalized_text_tokens.yaml"
OUTPUT_YAML = HERE / "anchor_candidates.yaml"
OUTPUT_MD = HERE / "anchor_candidates_report.md"
OUTPUT_HTML = HERE / "anchor_candidates_report.html"
SPECIAL_TOKENS = set(USER_DICT_SL) | set(USER_DICT_NNG)
FREQUENT_TOP_N = 20
EXAMPLE_COUNT = 3
EXAMPLE_TRUNCATE = 80
TARGET_FRAMES = {13, 14, 18, 29}
TOTAL_FRAMES = 32
def truncate(s, n):
return s if len(s) <= n else s[:n - 1] + ''
def build_frame_token_counter(text_nodes, subs, kiwi):
dummy_rep = Counter()
dummy_pre = Counter()
counter = Counter()
normalized_texts = []
for t in text_nodes:
after = apply_substitutions(t, subs, dummy_rep, dummy_pre)
normalized_texts.append((t, after))
tokens = extract_tokens(kiwi, after)
for tk in tokens:
counter[tk] += 1
return counter, normalized_texts
def find_examples(token, normalized_texts, limit=EXAMPLE_COUNT):
seen = set()
exs = []
for original, after in normalized_texts:
if token in after and original not in seen:
seen.add(original)
exs.append(original)
if len(exs) >= limit:
break
return exs
def build_row(token, local, corpus_fd, corpus_md_df, normalized_texts):
frame_df = corpus_fd.get(token, 0)
mdx_df = corpus_md_df.get(token, 0)
idf = round(math.log(TOTAL_FRAMES / frame_df), 3) if frame_df > 0 else 0.0
flags = []
if frame_df == 1:
flags.append('unique_to_frame')
return {
'token': token,
'frame_local_count': local,
'frame_df': frame_df,
'mdx_df': mdx_df,
'mdx_hit': mdx_df > 0,
'is_special': token in SPECIAL_TOKENS,
'idf': idf,
'flags': flags,
'examples': find_examples(token, normalized_texts),
}
def rank_groups_for_frame(text_nodes, subs, kiwi, corpus_fd, corpus_md_df):
frame_counter, normalized_texts = build_frame_token_counter(text_nodes, subs, kiwi)
all_rows = [
build_row(tok, local, corpus_fd, corpus_md_df, normalized_texts)
for tok, local in frame_counter.items()
]
# 정렬: local desc, idf desc, token asc
all_rows.sort(key=lambda x: (-x['frame_local_count'], -x['idf'], x['token']))
frequent = all_rows[:FREQUENT_TOP_N]
special = [r for r in all_rows if r['is_special']]
unique_to_frame = [r for r in all_rows if 'unique_to_frame' in r['flags']]
full = all_rows
return {
'total_unique_tokens': len(all_rows),
'frequent_candidates': frequent,
'special_candidates': special,
'unique_to_frame_candidates': unique_to_frame,
'full_candidates': full,
}
# ---------- md / html 렌더 ----------
TABLE_HEADERS = ['token', 'local', 'df/32', 'mdx_df/3', 'mdx_hit',
'special', 'idf', 'flags', 'examples']
def format_examples_md(examples):
if not examples:
return ''
return '<br>'.join(truncate(e, EXAMPLE_TRUNCATE) for e in examples)
def rows_to_md_table(rows):
if not rows:
return '_(없음)_'
lines = ['| ' + ' | '.join(TABLE_HEADERS) + ' |',
'|' + '|'.join(['---'] * len(TABLE_HEADERS)) + '|']
for c in rows:
lines.append('| ' + ' | '.join([
c['token'],
str(c['frame_local_count']),
str(c['frame_df']),
str(c['mdx_df']),
'Y' if c['mdx_hit'] else '',
'Y' if c['is_special'] else '',
str(c['idf']),
', '.join(c['flags']),
format_examples_md(c['examples']),
]) + ' |')
return '\n'.join(lines)
def frame_section_md(frame_number, frame_id, groups):
head = f"### Frame {frame_number} / {frame_id} (total_unique {groups['total_unique_tokens']})"
sub1 = f"#### 1. Frequent candidates — top {FREQUENT_TOP_N}\n\n" + rows_to_md_table(groups['frequent_candidates'])
sub2 = f"#### 2. Special / canonical — {len(groups['special_candidates'])}\n\n" + rows_to_md_table(groups['special_candidates'])
sub3 = f"#### 3. Unique-to-frame (df=1) — {len(groups['unique_to_frame_candidates'])}\n\n" + rows_to_md_table(groups['unique_to_frame_candidates'])
sub4 = f"#### 4. Full candidates — 전체 {len(groups['full_candidates'])}\n\n" + rows_to_md_table(groups['full_candidates'])
return '\n\n'.join([head, sub1, sub2, sub3, sub4])
def frame_section_html(frame_number, frame_id, groups, open_by_default):
head_text = f"Frame {frame_number} / {frame_id} (total_unique {groups['total_unique_tokens']})"
def sub(label, rows):
body_md = rows_to_md_table(rows)
body_html = markdown.markdown(body_md, extensions=['tables'])
return f"<h4>{label}</h4>\n{body_html}"
sub1 = sub(f"1. Frequent candidates — top {FREQUENT_TOP_N}", groups['frequent_candidates'])
sub2 = sub(f"2. Special / canonical — {len(groups['special_candidates'])}",
groups['special_candidates'])
sub3 = sub(f"3. Unique-to-frame (df=1) — {len(groups['unique_to_frame_candidates'])}",
groups['unique_to_frame_candidates'])
full_html = markdown.markdown(rows_to_md_table(groups['full_candidates']),
extensions=['tables'])
sub4 = (f"<details><summary><b>4. Full candidates — 전체 "
f"{len(groups['full_candidates'])}개 (접기/펼치기)</b></summary>\n"
f"{full_html}\n</details>")
opened = 'open' if open_by_default else ''
return f"""<details {opened}>
<summary><b>{head_text}</b></summary>
{sub1}
{sub2}
{sub3}
{sub4}
</details>"""
def main():
nodes = yaml.safe_load(INPUT_NODES.read_text(encoding='utf-8'))
normalized = yaml.safe_load(INPUT_NORMALIZED.read_text(encoding='utf-8'))
phrase_variants = load_phrase_variants()
subs = build_substitutions(phrase_variants)
kiwi = build_kiwi()
corpus_fd = normalized['corpus']['token_frame_df']
corpus_md_df = normalized['corpus']['token_mdx_df']
frame_ids = sorted(nodes['frames'].keys())
output_yaml = {
'meta': {
'pipeline_step': 5.1,
'frequent_top_n': FREQUENT_TOP_N,
'total_frames': len(frame_ids),
'special_tokens': sorted(SPECIAL_TOKENS),
'sort_order': ['frame_local_count desc', 'idf desc', 'token asc'],
'groups': [
'1. frequent_candidates (top 20)',
'2. special_candidates (USER_DICT 중 해당 frame 등장)',
'3. unique_to_frame_candidates (frame_df=1)',
'4. full_candidates (전체 frame token)',
],
'note': (
'score 없음. raw 지표 + flags + examples. '
'3 그룹은 서로 겹칠 수 있음 (예: BIM 은 frequent + special 둘 다). '
'AI 자동 추천 없음. 검수용 후보 목록.'
),
},
'frames': {},
}
md_sections = [
"# Anchor Candidates Report — Step 5.1",
"",
f"- total frames: **{len(frame_ids)}**",
f"- frequent top N: **{FREQUENT_TOP_N}**",
f"- sort: frame_local_count desc → idf desc → token asc",
f"- special tokens ({len(SPECIAL_TOKENS)}): {', '.join(sorted(SPECIAL_TOKENS))}",
"",
"## 그룹 설명",
"1. **Frequent candidates** — frame 내 등장 횟수 top 20",
"2. **Special / canonical** — USER_DICT_SL/NNG 중 해당 frame 에 등장한 것",
"3. **Unique-to-frame** — frame_df=1 (그 frame 에만 등장)",
"4. **Full candidates** — 전체 frame 토큰 (참조용)",
"",
"**score 없음. raw 지표만. 검수용 후보 목록.**",
"",
]
html_sections = []
for i, fid in enumerate(frame_ids, 1):
info = nodes['frames'][fid]
text_nodes = info.get('text_nodes', [])
if not text_nodes:
continue
groups = rank_groups_for_frame(text_nodes, subs, kiwi, corpus_fd, corpus_md_df)
output_yaml['frames'][fid] = {
'frame_number': i,
'total_unique_tokens': groups['total_unique_tokens'],
'frequent_candidates': groups['frequent_candidates'],
'special_candidates': groups['special_candidates'],
'unique_to_frame_candidates': groups['unique_to_frame_candidates'],
'full_candidates': groups['full_candidates'],
}
md_sections.append(frame_section_md(i, fid, groups))
md_sections.append("")
html_sections.append(
frame_section_html(i, fid, groups, open_by_default=(i in TARGET_FRAMES))
)
OUTPUT_YAML.write_text(
yaml.safe_dump(output_yaml, allow_unicode=True, sort_keys=False, width=300),
encoding='utf-8',
)
OUTPUT_MD.write_text('\n'.join(md_sections), encoding='utf-8')
html_header = f"""<h1>Anchor Candidates Report — Step 5.1</h1>
<ul>
<li>total frames: <b>{len(frame_ids)}</b></li>
<li>frequent top N: <b>{FREQUENT_TOP_N}</b></li>
<li>sort: frame_local_count desc → idf desc → token asc</li>
<li>special tokens ({len(SPECIAL_TOKENS)}): {', '.join(sorted(SPECIAL_TOKENS))}</li>
</ul>
<h2>그룹 설명</h2>
<ol>
<li><b>Frequent candidates</b> — frame 내 등장 횟수 top {FREQUENT_TOP_N}</li>
<li><b>Special / canonical</b> — USER_DICT 중 해당 frame 에 등장한 것</li>
<li><b>Unique-to-frame</b> — frame_df=1 (그 frame 에만 등장)</li>
<li><b>Full candidates</b> — 전체 frame 토큰 (참조용, 접기)</li>
</ol>
<p><b>score 없음. raw 지표만. AI 자동 추천 없음. 검수용 후보 목록.</b></p>
<p>TARGET frames 13 / 14 / 18 / 29 는 펼쳐진 상태, 나머지는 접힘.</p>
<hr>
"""
html = f"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>Anchor Candidates Report — Step 5.1</title>
<style>
body {{ font-family: -apple-system, "Segoe UI", Pretendard, sans-serif; max-width: 1300px; margin: 2em auto; padding: 0 1em; line-height: 1.5; color: #222; }}
h1 {{ border-bottom: 2px solid #333; padding-bottom: 0.2em; }}
h2 {{ margin-top: 1.5em; }}
h3 {{ margin: 0; color: #333; }}
h4 {{ margin-top: 1.2em; margin-bottom: 0.4em; color: #555; border-bottom: 1px dashed #ccc; padding-bottom: 0.2em; }}
details {{ margin: 0.6em 0; border: 1px solid #ddd; border-radius: 4px; padding: 0.5em 1em; }}
details[open] > summary {{ border-bottom: 1px solid #eee; margin-bottom: 0.5em; padding-bottom: 0.3em; }}
details details {{ border: 1px dashed #ccc; background: #fcfcfc; }}
details[open] {{ background: #fafbfc; }}
summary {{ cursor: pointer; font-size: 1.02em; padding: 0.3em 0; color: #0a6; }}
summary b {{ color: #222; }}
table {{ border-collapse: collapse; margin: 0.3em 0 0.8em 0; font-size: 0.85em; width: 100%; }}
th, td {{ border: 1px solid #ddd; padding: 4px 8px; text-align: left; vertical-align: top; }}
th {{ background: #f4f4f4; }}
code {{ background: #f4f4f4; padding: 2px 4px; border-radius: 3px; }}
em {{ color: #888; }}
</style>
</head>
<body>
{html_header}
{''.join(html_sections)}
</body>
</html>"""
OUTPUT_HTML.write_text(html, encoding='utf-8')
print(f"[Step 5.1] 3그룹 + Full 구조 생성 완료")
print(f" frames: {len(output_yaml['frames'])}")
print(f" frequent_top_n: {FREQUENT_TOP_N}")
print(f" special tokens: {len(SPECIAL_TOKENS)}")
# 그룹별 크기 통계
special_sizes = [len(f['special_candidates']) for f in output_yaml['frames'].values()]
uniq_sizes = [len(f['unique_to_frame_candidates']) for f in output_yaml['frames'].values()]
full_sizes = [len(f['full_candidates']) for f in output_yaml['frames'].values()]
print(f" special_candidates: avg={sum(special_sizes)/len(special_sizes):.1f} max={max(special_sizes)}")
print(f" unique_to_frame: avg={sum(uniq_sizes)/len(uniq_sizes):.1f} max={max(uniq_sizes)}")
print(f" full_candidates: avg={sum(full_sizes)/len(full_sizes):.1f} max={max(full_sizes)}")
print()
print(f" 산출:")
print(f" yaml: {OUTPUT_YAML}")
print(f" md: {OUTPUT_MD}")
print(f" html: {OUTPUT_HTML}")
if __name__ == "__main__":
main()
@@ -0,0 +1,624 @@
"""Generate MDX_MATCHING_REPORT.
The report compares MDX section keywords against frame keywords and shows the
top matching frames with Korean labels that are readable outside the codebase.
"""
from __future__ import annotations
import hashlib
from collections import defaultdict
from datetime import datetime
from pathlib import Path
import markdown
import yaml
HERE = Path(__file__).parent
INPUT_AUTO = HERE / "auto_anchor_candidates.yaml"
INPUT_NORMALIZED = HERE / "normalized_text_tokens.yaml"
OUT_YAML = HERE / "mdx_matching_result.yaml"
OUT_MD = HERE / "MDX_MATCHING_REPORT.md"
OUT_HTML = HERE / "MDX_MATCHING_REPORT.html"
# Current review weights. These are intentionally shown in the report.
WEIGHT_STANDALONE = 0.414 # Logistic Regression 학습 (TARGET 4, LOOCV 4/4)
WEIGHT_GROUP = 0.320
WEIGHT_RELATED = 0.265
# Review targets with known expected frames (ANSWER_MAP 잠금: 수정 금지).
ANSWER_MAP = {
"01-2": 18,
"02-2.2": 14,
"03-1": 13,
"03-2": 29,
}
# 홀드아웃 섹션 (블라인드 검증, 기대 프레임 미지정)
HOLDOUT_SECTIONS = ['01-1', '02-1', '02-2.1']
# 기준 잠금 대상 파일 (홀드아웃 실행 시 sha256 기록)
LOCK_SNAPSHOT_FILES = [
'keyword_normalizer.py',
'synonyms.yaml',
'pipeline_01_extract_nodes.py',
'pipeline_04_normalize.py',
'pipeline_06_2_mdx_matching.py',
'pipeline_07_auto_anchor_candidates.py',
]
def sha256_file(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def build_lock_snapshot() -> dict:
"""홀드아웃 실행 시점의 기준 잠금 스냅샷."""
return {
'timestamp': datetime.now().isoformat(timespec='seconds'),
'files': {
name: sha256_file(HERE / name) if (HERE / name).exists() else None
for name in LOCK_SNAPSHOT_FILES
},
'config': {
'weights': {
'W1_standalone': WEIGHT_STANDALONE,
'W2_group': WEIGHT_GROUP,
'W3_related': WEIGHT_RELATED,
},
'answer_map': dict(ANSWER_MAP),
'holdout_sections': list(HOLDOUT_SECTIONS),
},
'principle': [
'검증 대상 고정 — ANSWER_MAP 4개 유지',
'기준 잠금 — 전처리/anchor/가중치 수정 금지',
'블라인드 실행 — 홀드아웃 기대 프레임 미지정',
'사후 평가 — 실행 후 사람이 결과 해석',
'수정 규칙 제한 — 섹션 맞추기 보정 금지, 공통 규칙만',
],
}
def is_standalone_keyword(frame_appearances: int, is_important: bool) -> bool:
return frame_appearances == 1 or (is_important and frame_appearances <= 2)
def write_html(md_text: str) -> None:
html_body = markdown.markdown(md_text, extensions=["tables"])
html = f"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>키워드 기반 MDX 매칭</title>
<style>
body {{ font-family: -apple-system, "Segoe UI", Pretendard, sans-serif; max-width: 1400px; margin: 2em auto; padding: 0 1.5em 4em; line-height: 1.65; color: #222; background: #f8fafc; }}
h1 {{ border-bottom: 3px solid #2563eb; padding-bottom: 0.25em; }}
h2 {{ margin-top: 2.6em; background: #e0e7ff; padding: 0.6em 0.9em; border-left: 4px solid #0a6; border-radius: 4px; }}
h3 {{ margin-top: 1.5em; color: #1a365d; font-size: 1.05em; }}
table {{ border-collapse: collapse; margin: 0.35em 0 1em 0; font-size: 0.88em; background: #fff; box-shadow: 0 2px 6px rgba(0,0,0,0.05); }}
th, td {{ border: 1px solid #e2e8f0; padding: 8px; text-align: left; vertical-align: top; }}
th {{ background: #1e293b; color: #fff; font-weight: 700; text-align: center; }}
code {{ background: #f4f4f4; padding: 1px 6px; border-radius: 3px; font-size: 0.9em; color: #111; }}
details {{ margin: 0.7em 0; background: #fafafa; padding: 0.7em 0.9em; border-radius: 4px; border: 1px solid #ddd; }}
summary {{ cursor: pointer; color: #555; font-size: 0.94em; font-weight: 600; }}
hr {{ border: 0; border-top: 2px dashed #ccc; margin: 2em 0; }}
strong {{ color: #0a6; }}
/* 매트릭스 셀 이미지 */
.matrix-cell img {{ max-width: 240px; height: auto; display: block; margin: 0 auto 6px; border: 1px solid #cbd5e1; border-radius: 4px; }}
.matrix-cell {{ min-width: 260px; vertical-align: top; }}
.answer-badge {{ background: #fff3cd; border: 3px solid #dc2626; padding: 8px; border-radius: 6px; }}
.holdout-top {{ background: #eef2ff; border: 2px solid #4338ca; padding: 8px; border-radius: 6px; }}
.frame-label {{ display: block; margin-top: 4px; font-size: 0.9em; }}
.frame-label strong {{ color: #2563eb; font-size: 1.05em; }}
.frame-desc {{ display: block; color: #64748b; font-size: 0.82em; margin-top: 4px; }}
.score {{ color: #059669; font-weight: 600; }}
</style>
</head>
<body>
{html_body}
</body>
</html>"""
OUT_HTML.write_text(html, encoding="utf-8")
def build_frame_layers(auto: dict) -> tuple[dict, dict]:
frame_groups = defaultdict(list)
frame_keyword_info = defaultdict(dict)
for set_id, info in auto["source_text_sets"].items():
frame_id = info["frame_id"]
frame_groups[frame_id].append(
{
"source_text_raw": info["source_text_raw"],
"keywords": info["term_values"],
}
)
for term in info["terms"]:
keyword = term["token"]
if keyword not in frame_keyword_info[frame_id]:
frame_keyword_info[frame_id][keyword] = {
"frame_appearances": term["frame_df"],
"mdx_appearances": term["mdx_df"],
"is_important": term["is_special"],
}
frame_layers = {}
for frame_id, keyword_map in frame_keyword_info.items():
standalone = sorted(
keyword
for keyword, info in keyword_map.items()
if is_standalone_keyword(info["frame_appearances"], info["is_important"])
)
related = sorted(
keyword
for keyword, info in keyword_map.items()
if not is_standalone_keyword(info["frame_appearances"], info["is_important"])
)
frame_layers[frame_id] = {
"standalone_keywords": standalone,
"related_keywords": related,
"keyword_groups": frame_groups[frame_id],
}
frame_numbers = {
frame_id: auto["frame_stats"][frame_id]["frame_number"]
for frame_id in auto["frame_stats"]
}
return frame_layers, frame_numbers
def score_frame(mdx_keywords: set[str], layers: dict) -> dict:
standalone_total = layers["standalone_keywords"]
standalone_hit = [kw for kw in standalone_total if kw in mdx_keywords]
standalone_score = len(standalone_hit) / len(standalone_total) if standalone_total else 0.0
group_details = []
for group in layers["keyword_groups"]:
hit = [kw for kw in group["keywords"] if kw in mdx_keywords]
coverage = len(hit) / len(group["keywords"]) if group["keywords"] else 0.0
group_details.append(
{
"source_text_raw": group["source_text_raw"],
"keywords": group["keywords"],
"hit_keywords": hit,
"coverage": round(coverage, 3),
}
)
group_score_avg = (
sum(group["coverage"] for group in group_details) / len(group_details)
if group_details
else 0.0
)
group_score_max = max((group["coverage"] for group in group_details), default=0.0)
related_total = layers["related_keywords"]
related_hit = [kw for kw in related_total if kw in mdx_keywords]
related_score = len(related_hit) / len(related_total) if related_total else 0.0
total_score = (
WEIGHT_STANDALONE * standalone_score
+ WEIGHT_GROUP * group_score_avg
+ WEIGHT_RELATED * related_score
)
return {
"standalone": {
"total": standalone_total,
"total_count": len(standalone_total),
"hit": standalone_hit,
"hit_count": len(standalone_hit),
"score": round(standalone_score, 3),
},
"keyword_group": {
"total_count": len(group_details),
"score_avg": round(group_score_avg, 3),
"score_max": round(group_score_max, 3),
"groups": group_details,
},
"related": {
"total_count": len(related_total),
"hit": related_hit,
"hit_count": len(related_hit),
"score": round(related_score, 3),
},
"matching_score": round(total_score, 3),
}
def build_frame_descriptions(auto: dict) -> dict:
"""Frame 번호 → 첫 source_text (설명용)."""
out = {}
for fid, info in auto["frame_stats"].items():
fnum = info["frame_number"]
# 해당 frame 의 source_text_sets 중 index=1 인 것
for set_id, sinfo in auto["source_text_sets"].items():
if sinfo["frame_id"] == fid and sinfo["source_text_index"] == 1:
raw = sinfo["source_text_raw"]
if len(raw) > 35:
raw = raw[:34] + ""
out[fnum] = raw
break
if fnum not in out:
out[fnum] = f"Frame {fnum}"
return out
def cell_html(rank_row: dict, descriptions: dict, is_answer: bool = False,
is_holdout_top: bool = False) -> str:
"""Top-N 매트릭스 한 칸의 HTML."""
fn = rank_row["frame_number"]
score = rank_row["matching_score"]
desc = descriptions.get(fn, "")
img_src = f"../../data/figma_previews/{fn:02d}.png"
inner = (
f'<img alt="{fn:02d}" src="{img_src}" />'
f'<span class="frame-label"><strong>Frame {fn}</strong> '
f'<span class="score">{score:.3f}</span></span>'
f'<span class="frame-desc">{desc}</span>'
)
if is_answer:
return f'<td class="matrix-cell"><div class="answer-badge">🎯 <b>정답</b><br>{inner}</div></td>'
if is_holdout_top:
return f'<td class="matrix-cell"><div class="holdout-top">{inner}</div></td>'
return f'<td class="matrix-cell">{inner}</td>'
def main() -> None:
auto = yaml.safe_load(INPUT_AUTO.read_text(encoding="utf-8"))
normalized = yaml.safe_load(INPUT_NORMALIZED.read_text(encoding="utf-8"))
frame_layers, frame_numbers = build_frame_layers(auto)
frame_descriptions = build_frame_descriptions(auto)
results = {}
# TARGET + 홀드아웃 모두 처리. ANSWER_MAP 없는 섹션은 answer 없이 Top 만.
all_mdx_sections = list(ANSWER_MAP.keys()) + [
s for s in HOLDOUT_SECTIONS if s not in ANSWER_MAP
]
for mdx_id in all_mdx_sections:
if mdx_id not in normalized["mdx"]:
continue
answer_frame_number = ANSWER_MAP.get(mdx_id) # 홀드아웃은 None
mdx_keywords = set(normalized["mdx"][mdx_id]["unique_tokens"])
per_frame = {}
for frame_id, layers in frame_layers.items():
scored = score_frame(mdx_keywords, layers)
scored["frame_number"] = frame_numbers[frame_id]
per_frame[frame_id] = scored
def rank_by(key_fn):
return sorted(per_frame.items(), key=lambda x: (-key_fn(x[1]), x[0]))
rank_total = rank_by(lambda d: d["matching_score"])
rank_group = rank_by(lambda d: d["keyword_group"]["score_avg"])
rank_standalone = rank_by(lambda d: d["standalone"]["score"])
rank_related = rank_by(lambda d: d["related"]["score"])
def find_rank(ranked_list, frame_number: int) -> int | None:
for idx, (_frame_id, info) in enumerate(ranked_list, start=1):
if info["frame_number"] == frame_number:
return idx
return None
is_holdout = mdx_id in HOLDOUT_SECTIONS
# 홀드아웃 섹션은 answer_frame_number 가 None 이라 answer_ranks 계산 불가 → None 기록
answer_ranks_val = None
if answer_frame_number is not None:
answer_ranks_val = {
"by_matching_score": find_rank(rank_total, answer_frame_number),
"by_keyword_group_score": find_rank(rank_group, answer_frame_number),
"by_standalone_keyword_score": find_rank(rank_standalone, answer_frame_number),
"by_related_keyword_score": find_rank(rank_related, answer_frame_number),
}
results[mdx_id] = {
"mdx_keywords_count": len(mdx_keywords),
"section_type": "holdout" if is_holdout else "target",
"answer_frame_number": answer_frame_number,
"answer_ranks": answer_ranks_val,
"rank_by_matching_score": [
{
"frame_id": frame_id,
"frame_number": info["frame_number"],
"matching_score": info["matching_score"],
"standalone": info["standalone"]["score"],
"keyword_group": info["keyword_group"]["score_avg"],
"related": info["related"]["score"],
}
for frame_id, info in rank_total
],
"rank_by_keyword_group_score": [
{
"frame_id": frame_id,
"frame_number": info["frame_number"],
"score_avg": info["keyword_group"]["score_avg"],
"score_max": info["keyword_group"]["score_max"],
}
for frame_id, info in rank_group
],
"rank_by_standalone_keyword_score": [
{
"frame_id": frame_id,
"frame_number": info["frame_number"],
"score": info["standalone"]["score"],
"hit": info["standalone"]["hit"],
}
for frame_id, info in rank_standalone
],
"rank_by_related_keyword_score": [
{
"frame_id": frame_id,
"frame_number": info["frame_number"],
"score": info["related"]["score"],
"hit_count": info["related"]["hit_count"],
}
for frame_id, info in rank_related
],
"per_frame_detail": per_frame,
}
output = {
"meta": {
"pipeline_step": "6.2",
"description": "MDX 키워드와 프레임별 키워드 매칭 결과 (TARGET + 홀드아웃)",
"score_parts": ["단독 대표 키워드", "대표 키워드 묶음", "연관 키워드"],
"weights": {
"standalone_keyword": WEIGHT_STANDALONE,
"keyword_group": WEIGHT_GROUP,
"related_keyword": WEIGHT_RELATED,
},
"formula": "matching_score = 0.30*standalone + 0.50*keyword_group + 0.20*related",
"answer_map": ANSWER_MAP,
"holdout_sections": HOLDOUT_SECTIONS,
"source_mdx_keywords": "normalized_text_tokens.yaml",
"source_frame_keywords": "auto_anchor_candidates.yaml",
"lock_snapshot": build_lock_snapshot(),
"note": "규칙 기반 점수입니다. 홀드아웃 섹션(01-1/02-1/02-2.1)은 기대 프레임 미지정 — 블라인드 검증용.",
},
"mdx_sections": results,
}
OUT_YAML.write_text(
yaml.safe_dump(output, allow_unicode=True, sort_keys=False, width=300),
encoding="utf-8",
)
md: list[str] = [
"# 키워드 기반 MDX 매칭",
"",
"이 문서는 **키워드 수준** 의 매칭 결과입니다. MDX 구간에서 추출한 키워드와 각 프레임에 적용된 키워드를 비교해, 어떤 프레임이 가장 잘 맞는지 1~3순위로 보여줍니다.",
"",
"_향후 단계에서 의미(semantic) / 구조(structure) / 적용가능성(template fit) 매칭이 추가될 예정입니다._",
"",
"점수는 세 가지 단서를 합산합니다.",
"",
"| 단서 | 의미 | 가중치 |",
"|---|---|---:|",
f"| 단독 대표 키워드 | 특정 프레임을 혼자서도 강하게 가리키는 키워드가 MDX에 얼마나 맞는지 | {WEIGHT_STANDALONE} |",
f"| 대표 키워드 묶음 | 프레임 원문에서 함께 등장한 키워드 조합이 MDX에 얼마나 같이 맞는지 | {WEIGHT_GROUP} |",
f"| 연관 키워드 | 그 외 참고 키워드가 MDX와 얼마나 겹치는지 | {WEIGHT_RELATED} |",
"",
"```text",
f"종합 매칭 점수 = {WEIGHT_STANDALONE} x 단독 대표 키워드 점수",
f" + {WEIGHT_GROUP} x 대표 키워드 묶음 점수",
f" + {WEIGHT_RELATED} x 연관 키워드 점수",
"```",
"",
"### 검증용 정답 프레임",
"",
"| MDX 구간 | 기대 프레임 |",
"|---|---:|",
]
for mdx_id, frame_number in ANSWER_MAP.items():
md.append(f"| {mdx_id} | Frame {frame_number} |")
md += ["", "---", ""]
# ============================================================
# 🖼 Top-3 매트릭스 (이미지 포함, TARGET + 홀드아웃)
# ============================================================
md.append("## 🖼 Top-3 매칭 매트릭스")
md.append("")
md.append("**TARGET 섹션** (ANSWER_MAP 4개): 정답 프레임은 🎯 노란 박스. ")
md.append("**홀드아웃 섹션** (기대 프레임 미지정): 1위는 파란 박스, 해석은 사용자 판단.")
md.append("")
def _matrix_table(section_order, label_fn):
rows = [
'<table><thead><tr><th style="min-width:200px">콘텐츠</th>'
'<th>1순위</th><th>2순위</th><th>3순위</th></tr></thead><tbody>'
]
for mdx_id in section_order:
if mdx_id not in results:
continue
r = results[mdx_id]
top3 = r["rank_by_matching_score"][:3]
label = label_fn(mdx_id, r)
tds = [f'<td style="background:#f1f5f9;text-align:center;font-weight:600"><br>{label}</td>']
for idx, row in enumerate(top3):
is_answer = (r["answer_frame_number"] is not None
and row["frame_number"] == r["answer_frame_number"])
is_hold_top = (r["answer_frame_number"] is None and idx == 0)
tds.append(cell_html(row, frame_descriptions,
is_answer=is_answer,
is_holdout_top=is_hold_top))
rows.append('<tr>' + ''.join(tds) + '</tr>')
rows.append('</tbody></table>')
return '\n'.join(rows)
target_order = ["01-2", "02-2.2", "03-1", "03-2"]
holdout_order = HOLDOUT_SECTIONS
md.append("### TARGET 4 섹션")
md.append("")
md.append(_matrix_table(
target_order,
lambda mid, r: f'<strong>MDX {mid}</strong><br>정답 Frame <strong>{r["answer_frame_number"]}</strong>',
))
md.append("")
md.append("### 🔒 홀드아웃 3 섹션 (블라인드)")
md.append("")
md.append(_matrix_table(
holdout_order,
lambda mid, r: f'<strong>MDX {mid}</strong><br><em>기대 프레임 미지정</em>',
))
md.append("")
md.append("---")
md.append("")
for mdx_id in ["01-2", "02-2.2", "03-1", "03-2"]:
result = results[mdx_id]
ranks = result["answer_ranks"]
answer_frame = result["answer_frame_number"]
md.append(f"## MDX {mdx_id} (기대 프레임: Frame {answer_frame})")
md.append("")
md.append("**기대 프레임의 순위**")
md.append("")
md.append(f"- 종합 매칭 점수 기준: **{ranks['by_matching_score']}위**")
md.append(f"- 대표 키워드 묶음 기준: {ranks['by_keyword_group_score']}")
md.append(f"- 단독 대표 키워드 기준: {ranks['by_standalone_keyword_score']}")
md.append(f"- 연관 키워드 기준: {ranks['by_related_keyword_score']}")
md.append("")
md.append("**종합 매칭 점수 Top 3**")
md.append("")
md.append("| 순위 | 프레임 | 종합 점수 | 단독 대표 키워드 | 대표 키워드 묶음 | 연관 키워드 |")
md.append("|---:|---|---:|---:|---:|---:|")
for idx, row in enumerate(result["rank_by_matching_score"][:3], start=1):
mark = "" if row["frame_number"] == answer_frame else ""
md.append(
f"| **{idx}**{mark} | Frame {row['frame_number']} / {row['frame_id']} | "
f"**{row['matching_score']}** | {row['standalone']} | {row['keyword_group']} | {row['related']} |"
)
md.append("")
md.append("<details><summary>Top 10 전체 보기</summary>")
md.append("")
md.append("| 순위 | 프레임 | 종합 점수 | 단독 대표 키워드 | 대표 키워드 묶음 | 연관 키워드 |")
md.append("|---:|---|---:|---:|---:|---:|")
for idx, row in enumerate(result["rank_by_matching_score"][:10], start=1):
mark = "" if row["frame_number"] == answer_frame else ""
md.append(
f"| {idx}{mark} | Frame {row['frame_number']} / {row['frame_id']} | "
f"{row['matching_score']} | {row['standalone']} | {row['keyword_group']} | {row['related']} |"
)
md.append("")
md.append("</details>")
md.append("")
answer_frame_id = [
frame_id
for frame_id, info in result["per_frame_detail"].items()
if info["frame_number"] == answer_frame
][0]
detail = result["per_frame_detail"][answer_frame_id]
md.append(f"<details><summary>기대 프레임 Frame {answer_frame} / {answer_frame_id} 상세 보기</summary>")
md.append("")
md.append(
f"- 단독 대표 키워드: {detail['standalone']['hit_count']} / {detail['standalone']['total_count']} = {detail['standalone']['score']}"
)
md.append(f" - 맞은 키워드: {', '.join(detail['standalone']['hit']) or '없음'}")
md.append(
f"- 대표 키워드 묶음: 평균 {detail['keyword_group']['score_avg']} / 최고 {detail['keyword_group']['score_max']}"
)
for group in detail["keyword_group"]["groups"]:
terms = ", ".join(group["keywords"])
hits = ", ".join(group["hit_keywords"]) or "없음"
md.append(f" - `{group['source_text_raw']}` → [{terms}] / 맞은 키워드 [{hits}] / 일치율 **{group['coverage']}**")
md.append(
f"- 연관 키워드: {detail['related']['hit_count']} / {detail['related']['total_count']} = {detail['related']['score']}"
)
md.append(f" - 맞은 키워드: {', '.join(detail['related']['hit']) or '없음'}")
md.append(f"- 종합 매칭 점수: **{detail['matching_score']}**")
md.append("")
md.append("</details>")
md.append("")
md.append("---")
md.append("")
# ============================================================
# 홀드아웃 섹션 (블라인드, 기대 프레임 없음)
# ============================================================
md.append("")
md.append("# 🔒 홀드아웃 검증 섹션 (블라인드)")
md.append("")
md.append("아래 섹션들은 **기대 프레임을 사전에 지정하지 않은** 블라인드 검증 대상입니다.")
md.append("현재 파이프라인으로 그대로 돌려 Top 1~3 결과와 점수를 raw 로 기록합니다.")
md.append("결과 해석은 사람이 사후에 수행합니다.")
md.append("")
md.append("### 기준 잠금 스냅샷")
md.append("")
snap = output['meta']['lock_snapshot']
md.append(f"- **실행 시각**: {snap['timestamp']}")
md.append(f"- **가중치**: W1={snap['config']['weights']['W1_standalone']} / "
f"W2={snap['config']['weights']['W2_group']} / "
f"W3={snap['config']['weights']['W3_related']}")
md.append("")
md.append("**파일 지문 (sha256, 앞 16자)**:")
md.append("")
md.append("| 파일 | sha256 |")
md.append("|---|---|")
for name, h in snap['files'].items():
h_short = h[:16] + '...' if h else '(없음)'
md.append(f"| `{name}` | `{h_short}` |")
md.append("")
md.append("**원칙**:")
md.append("")
for p in snap['principle']:
md.append(f"- {p}")
md.append("")
md.append("---")
md.append("")
for mdx_id in HOLDOUT_SECTIONS:
if mdx_id not in results:
continue
result = results[mdx_id]
md.append(f"## 🔒 MDX {mdx_id} (홀드아웃, 기대 프레임 미지정)")
md.append("")
md.append(f"- MDX 키워드 수: {result['mdx_keywords_count']}")
md.append("")
md.append("**Top 3 (기대 프레임 없음, 해석은 사후)**")
md.append("")
md.append("| 순위 | 프레임 | 종합 점수 | 단독 대표 키워드 | 대표 키워드 묶음 | 연관 키워드 |")
md.append("|---:|---|---:|---:|---:|---:|")
for idx, row in enumerate(result["rank_by_matching_score"][:3], start=1):
md.append(
f"| **{idx}** | Frame {row['frame_number']} / {row['frame_id']} | "
f"**{row['matching_score']}** | {row['standalone']} | {row['keyword_group']} | {row['related']} |"
)
md.append("")
md.append("<details><summary>Top 10 전체 보기</summary>")
md.append("")
md.append("| 순위 | 프레임 | 종합 점수 | 단독 대표 키워드 | 대표 키워드 묶음 | 연관 키워드 |")
md.append("|---:|---|---:|---:|---:|---:|")
for idx, row in enumerate(result["rank_by_matching_score"][:10], start=1):
md.append(
f"| {idx} | Frame {row['frame_number']} / {row['frame_id']} | "
f"{row['matching_score']} | {row['standalone']} | {row['keyword_group']} | {row['related']} |"
)
md.append("")
md.append("</details>")
md.append("")
md.append("---")
md.append("")
md_text = "\n".join(md)
OUT_MD.write_text(md_text, encoding="utf-8")
write_html(md_text)
print("산출 완료:")
print(f" yaml: {OUT_YAML}")
print(f" md: {OUT_MD}")
print(f" html: {OUT_HTML}")
print()
print("기준 잠금 스냅샷:")
print(f" 실행시각: {output['meta']['lock_snapshot']['timestamp']}")
for name, h in output['meta']['lock_snapshot']['files'].items():
if h:
print(f" {name:40s} {h[:16]}...")
if __name__ == "__main__":
main()
@@ -0,0 +1,344 @@
"""Step 6: anchor_sets 검증 + 근거 첨부.
원칙:
- AI 는 의미 묶음 판단 안 함. anchor_sets_input.yaml 이 사람이 편집한 source.
- pipeline_06 은 term 존재 검증 + 근거(local/df/source) 첨부 + 보고서 생성만.
- 없는 term 은 not_in_frame 플래그 (에러 아님, 사용자 재검토용).
입력:
- anchor_sets_input.yaml (사용자 편집)
- anchor_candidates.yaml (Step 5.1)
- normalized_text_tokens.yaml (corpus 통계)
산출:
- anchor_sets_draft.yaml
- anchor_sets_report.md
- anchor_sets_report.html
"""
from pathlib import Path
import yaml
import markdown
HERE = Path(__file__).parent
INPUT = HERE / "anchor_sets_input.yaml"
CANDIDATES = HERE / "anchor_candidates.yaml"
NORMALIZED = HERE / "normalized_text_tokens.yaml"
OUT_YAML = HERE / "anchor_sets_draft.yaml"
OUT_MD = HERE / "anchor_sets_report.md"
OUT_HTML = HERE / "anchor_sets_report.html"
EXAMPLE_TRUNCATE = 80
def truncate(s, n):
return s if len(s) <= n else s[:n - 1] + ''
def build_candidate_index(frame_info):
"""frame 의 full_candidates 를 token → candidate dict 로 인덱싱."""
idx = {c['token']: c for c in frame_info['full_candidates']}
frequent_set = {c['token'] for c in frame_info['frequent_candidates']}
special_set = {c['token'] for c in frame_info['special_candidates']}
unique_set = {c['token'] for c in frame_info['unique_to_frame_candidates']}
return idx, frequent_set, special_set, unique_set
def verify_term(token, cand_idx, freq_set, spec_set, uniq_set, corpus_fd, corpus_md_df):
"""term 검증 + 근거 수집."""
if token in cand_idx:
c = cand_idx[token]
sources = []
if token in freq_set: sources.append('Frequent')
if token in spec_set: sources.append('Special')
if token in uniq_set: sources.append('Unique')
if not sources: sources.append('Full')
example = c['examples'][0] if c.get('examples') else None
return {
'token': token,
'in_frame': True,
'local_count': c['frame_local_count'],
'frame_df': c['frame_df'],
'mdx_df': c['mdx_df'],
'mdx_hit': c['mdx_hit'],
'is_special': c['is_special'],
'source_groups': sources,
'example': example,
}
else:
# corpus 에 있는지 확인 (다른 frame 에만 있는 경우)
in_corpus = token in corpus_fd
return {
'token': token,
'in_frame': False,
'in_corpus': in_corpus,
'corpus_frame_df': corpus_fd.get(token, 0) if in_corpus else 0,
'corpus_mdx_df': corpus_md_df.get(token, 0) if in_corpus else 0,
'note': (
'token 이 해당 frame 의 실제 후보에 없음. '
'코퍼스에 있지만 다른 frame/source 에만 등장.'
if in_corpus else
'token 이 전체 코퍼스에 없음. 철자/정규화 확인 필요.'
),
}
def verify_flagged_terms(flagged_list, cand_idx, corpus_fd, corpus_md_df):
"""flagged_terms 검증 + corpus 존재 여부 첨부."""
out = []
for ft in flagged_list or []:
tok = ft.get('token')
out.append({
'token': tok,
'reason': ft.get('reason', ''),
'in_frame': tok in cand_idx,
'in_corpus': tok in corpus_fd,
'corpus_frame_df': corpus_fd.get(tok, 0),
'corpus_mdx_df': corpus_md_df.get(tok, 0),
})
return out
def verify_review_needed(review_list, cand_idx, corpus_fd, corpus_md_df):
"""review_needed 검증 (frame 에는 있으나 의미상 확인 필요)."""
out = []
for rv in review_list or []:
tok = rv.get('token')
cand = cand_idx.get(tok)
out.append({
'token': tok,
'reason': rv.get('reason', ''),
'in_frame': tok in cand_idx,
'local_count': cand['frame_local_count'] if cand else 0,
'frame_df': cand['frame_df'] if cand else corpus_fd.get(tok, 0),
'mdx_df': cand['mdx_df'] if cand else corpus_md_df.get(tok, 0),
})
return out
def process_frame(fid, frame_input, candidates_data, corpus_fd, corpus_md_df):
frame_info = candidates_data['frames'][fid]
cand_idx, freq_set, spec_set, uniq_set = build_candidate_index(frame_info)
anchor_sets_out = []
for aset in frame_input.get('anchor_sets', []):
verified_terms = []
for tok in aset.get('terms', []):
verified_terms.append(
verify_term(tok, cand_idx, freq_set, spec_set, uniq_set,
corpus_fd, corpus_md_df)
)
in_frame_count = sum(1 for t in verified_terms if t['in_frame'])
not_in_frame_count = len(verified_terms) - in_frame_count
anchor_sets_out.append({
'id': aset['id'],
'term_count': len(verified_terms),
'in_frame_count': in_frame_count,
'not_in_frame_count': not_in_frame_count,
'terms': verified_terms,
})
result = {
'frame_number': frame_info['frame_number'],
'total_unique_tokens': frame_info['total_unique_tokens'],
'anchor_sets': anchor_sets_out,
}
if 'notes' in frame_input:
result['notes'] = frame_input['notes']
if 'review_needed' in frame_input:
result['review_needed'] = verify_review_needed(
frame_input['review_needed'], cand_idx, corpus_fd, corpus_md_df
)
if 'flagged_terms' in frame_input:
result['flagged_terms'] = verify_flagged_terms(
frame_input['flagged_terms'], cand_idx, corpus_fd, corpus_md_df
)
return result
# ---------- 렌더 ----------
def term_row_md(t):
if t['in_frame']:
flags = []
if t['is_special']: flags.append('S')
if t['mdx_hit']: flags.append('M')
flag_str = ''.join(flags) if flags else ''
ex = truncate(t['example'], EXAMPLE_TRUNCATE) if t.get('example') else ''
return (f"| ✓ {t['token']} | {t['local_count']} | {t['frame_df']} | "
f"{t['mdx_df']} | {flag_str} | {'+'.join(t['source_groups'])} | {ex} |")
else:
note = t.get('note', '')
return (f"| ⚠ {t['token']} | — | — | — | — | **not_in_frame** | {note} |")
def anchor_set_md(aset):
lines = [
f"##### anchor_set: `{aset['id']}` "
f"({aset['in_frame_count']}/{aset['term_count']} in_frame"
+ (f", ⚠ {aset['not_in_frame_count']} not_in_frame" if aset['not_in_frame_count'] else "")
+ ")",
"",
"| token | local | df/32 | mdx/3 | flags | source | example |",
"|---|---|---|---|---|---|---|",
]
for t in aset['terms']:
lines.append(term_row_md(t))
return '\n'.join(lines)
def frame_section_md(fid, frame_out):
head = (f"### Frame {frame_out['frame_number']} / {fid} "
f"(total_unique {frame_out['total_unique_tokens']})")
sets = [anchor_set_md(a) for a in frame_out['anchor_sets']]
return head + "\n\n" + "\n\n".join(sets)
def main():
input_data = yaml.safe_load(INPUT.read_text(encoding='utf-8'))
candidates_data = yaml.safe_load(CANDIDATES.read_text(encoding='utf-8'))
normalized = yaml.safe_load(NORMALIZED.read_text(encoding='utf-8'))
corpus_fd = normalized['corpus']['token_frame_df']
corpus_md_df = normalized['corpus']['token_mdx_df']
output = {
'meta': {
'pipeline_step': 6,
'scope': 'TARGET_FRAMES (13, 14, 18, 29)',
'note': (
'AI 자동 묶음 없음. anchor_sets_input.yaml 이 사람 편집 source. '
'이 파이프라인은 존재 검증 + 근거 첨부만 수행. '
'not_in_frame 은 에러가 아니라 사용자 재검토 플래그.'
),
},
'frames': {},
}
total_sets = 0
total_terms = 0
total_in_frame = 0
total_not_in_frame = 0
not_in_frame_details = [] # (frame_id, set_id, token, note)
for fid, frame_input in input_data.get('frames', {}).items():
fid_str = str(fid)
if fid_str not in candidates_data['frames']:
print(f"WARNING: frame {fid_str} 이 anchor_candidates.yaml 에 없음")
continue
frame_out = process_frame(fid_str, frame_input, candidates_data,
corpus_fd, corpus_md_df)
output['frames'][fid_str] = frame_out
for aset in frame_out['anchor_sets']:
total_sets += 1
total_terms += aset['term_count']
total_in_frame += aset['in_frame_count']
total_not_in_frame += aset['not_in_frame_count']
for t in aset['terms']:
if not t['in_frame']:
not_in_frame_details.append({
'frame_id': fid_str,
'frame_number': frame_out['frame_number'],
'set_id': aset['id'],
'token': t['token'],
'in_corpus': t.get('in_corpus', False),
'note': t.get('note', ''),
})
output['meta']['totals'] = {
'frames': len(output['frames']),
'anchor_sets': total_sets,
'terms': total_terms,
'in_frame': total_in_frame,
'not_in_frame': total_not_in_frame,
}
output['meta']['not_in_frame_details'] = not_in_frame_details
OUT_YAML.write_text(
yaml.safe_dump(output, allow_unicode=True, sort_keys=False, width=300),
encoding='utf-8',
)
# md 생성
md_sections = [
"# Anchor Sets Report — Step 6",
"",
f"- scope: TARGET_FRAMES 4개 (13, 14, 18, 29)",
f"- frames: {len(output['frames'])}",
f"- anchor_sets: {total_sets}",
f"- terms: {total_terms} ({total_in_frame} in_frame, {total_not_in_frame} not_in_frame)",
f"- source: `anchor_sets_input.yaml` (사람이 직접 편집)",
f"- 이 보고서: `pipeline_06_anchor_sets.py` 가 검증 + 근거 첨부 후 생성",
"",
]
if not_in_frame_details:
md_sections.append("## ⚠ not_in_frame terms (사용자 재검토)")
md_sections.append("")
md_sections.append("| Frame | set_id | token | in_corpus | note |")
md_sections.append("|---|---|---|---|---|")
for d in not_in_frame_details:
md_sections.append(
f"| Frame {d['frame_number']} / {d['frame_id']} | {d['set_id']} | "
f"{d['token']} | {'Y' if d['in_corpus'] else 'N'} | {d['note']} |"
)
md_sections.append("")
md_sections.append("## Frame 별 anchor_sets 상세")
md_sections.append("")
md_sections.append("**flags**: S = special token, M = mdx_hit")
md_sections.append("")
for fid, frame_out in output['frames'].items():
md_sections.append(frame_section_md(fid, frame_out))
md_sections.append("")
md_text = '\n'.join(md_sections)
OUT_MD.write_text(md_text, encoding='utf-8')
# html
html_body = markdown.markdown(md_text, extensions=['tables'])
html = f"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>Anchor Sets Report — Step 6</title>
<style>
body {{ font-family: -apple-system, "Segoe UI", Pretendard, sans-serif; max-width: 1200px; margin: 2em auto; padding: 0 1em; line-height: 1.5; color: #222; }}
h1 {{ border-bottom: 2px solid #333; padding-bottom: 0.2em; }}
h2 {{ margin-top: 2em; border-bottom: 1px solid #ccc; padding-bottom: 0.2em; }}
h3 {{ margin-top: 1.8em; color: #333; }}
h5 {{ margin-top: 1em; margin-bottom: 0.3em; color: #0a6; font-size: 0.95em; }}
table {{ border-collapse: collapse; margin: 0.3em 0 1.2em 0; font-size: 0.9em; width: 100%; }}
th, td {{ border: 1px solid #ddd; padding: 5px 9px; text-align: left; vertical-align: top; }}
th {{ background: #f4f4f4; }}
code {{ background: #f4f4f4; padding: 2px 4px; border-radius: 3px; }}
strong {{ color: #d33; }}
</style>
</head>
<body>
{html_body}
</body>
</html>"""
OUT_HTML.write_text(html, encoding='utf-8')
print(f"[Step 6] anchor_sets 검증 + 근거 첨부 완료")
print(f" frames: {len(output['frames'])}")
print(f" anchor_sets: {total_sets}")
print(f" terms: {total_terms}")
print(f" in_frame: {total_in_frame}")
print(f" not_in_frame: {total_not_in_frame}")
if not_in_frame_details:
print()
print(f" [⚠ not_in_frame 상세]")
for d in not_in_frame_details:
print(f" Frame {d['frame_number']} [{d['set_id']}] {d['token']!r} "
f"corpus={'Y' if d['in_corpus'] else 'N'} {d['note']}")
print()
print(f"산출:")
print(f" yaml: {OUT_YAML}")
print(f" md: {OUT_MD}")
print(f" html: {OUT_HTML}")
if __name__ == "__main__":
main()
@@ -0,0 +1,498 @@
"""Step 7: 통계 기반 자동 anchor 후보 증거집 생성.
Step 7 산출물은 anchor_set 확정본이 아니다.
Step 7 산출물은 source_text 기반 anchor 후보 증거집이다.
이 보고서는 원문 텍스트 항목에서 정규화/형태소 분석으로 나온 후보만 보여준다.
의미 이름 붙이기, 병합 확정, 없는 단어 추가는 수행하지 않는다.
AI 개입 없음:
- LLM 호출 없음 (anthropic/openai/transformers 등 미사용)
- 의미 이름 붙임 없음 (ID 는 자동 생성: auto_set_{frame_id}_{idx:04d})
- 병합 자동 확정 없음 (status: candidate_only)
- 단어 추가/수정 없음
Generation rules:
- SOURCE_TEXT_TOKEN_SET_V1 — text_node 원문을 정규화 + Kiwi tokenize
- COOCCURRENCE_PAIR_V1 — 같은 text_node 에 공존한 token pair
- SHARED_TOKEN_MERGE_CANDIDATE_V1 — 공통 token 기반 병합 후보 (확정 아님)
"""
import hashlib
import sys
from collections import Counter, defaultdict
from pathlib import Path
import yaml
import markdown
HERE = Path(__file__).parent
sys.path.insert(0, str(HERE))
from keyword_normalizer import (
USER_DICT_SL, USER_DICT_NNG,
load_phrase_variants, build_substitutions,
apply_substitutions, build_kiwi, extract_tokens,
)
INPUT_NODES = HERE / "actual_text_nodes.yaml"
INPUT_NORMALIZED = HERE / "normalized_text_tokens.yaml"
SYNONYMS = HERE / "synonyms.yaml"
NORMALIZER_MODULE = HERE / "keyword_normalizer.py"
OUT_YAML = HERE / "auto_anchor_candidates.yaml"
OUT_MD = HERE / "auto_anchor_candidates_report.md"
OUT_HTML = HERE / "auto_anchor_candidates_report.html"
BROAD_FRAME_DF_THRESHOLD = 10 # 32 frame 중 10 이상 = broad
SPECIAL_TOKENS = set(USER_DICT_SL) | set(USER_DICT_NNG)
def sha256_file(p):
return hashlib.sha256(Path(p).read_bytes()).hexdigest()
def main():
nodes = yaml.safe_load(INPUT_NODES.read_text(encoding='utf-8'))
normalized = yaml.safe_load(INPUT_NORMALIZED.read_text(encoding='utf-8'))
phrase_variants = load_phrase_variants(SYNONYMS)
subs = build_substitutions(phrase_variants)
kiwi = build_kiwi()
corpus_fd = normalized['corpus']['token_frame_df']
corpus_md_df = normalized['corpus']['token_mdx_df']
frame_ids = sorted(nodes['frames'].keys())
source_text_sets = {}
cooccur_by_frame = {}
merge_candidates = {}
frame_stats = {}
for fnum, fid in enumerate(frame_ids, 1):
finfo = nodes['frames'][fid]
text_nodes = finfo.get('text_nodes', [])
per_text = [] # [(idx(1-base), raw, normalized_text, tokens)]
for i, t in enumerate(text_nodes, 1):
normalized_text = apply_substitutions(t, subs, None, None)
tokens = extract_tokens(kiwi, normalized_text)
per_text.append((i, t, normalized_text, tokens))
# frame 내 local count (같은 token 이 여러 text_node 에 나온 경우 포함)
frame_local = Counter()
for _, _, _, toks in per_text:
for tk in toks:
frame_local[tk] += 1
# source_text_sets (SOURCE_TEXT_TOKEN_SET_V1)
frame_sets_ids = []
for idx, raw, nt, tokens in per_text:
if not tokens:
continue
set_id = f"auto_set_{fid}_{idx:04d}"
frame_sets_ids.append(set_id)
terms = []
for tok in tokens:
terms.append({
'token': tok,
'from_source_text_index': idx,
'local_count_in_frame': frame_local[tok],
'frame_df': corpus_fd.get(tok, 0),
'mdx_df': corpus_md_df.get(tok, 0),
'is_special': tok in SPECIAL_TOKENS,
})
avg_fd = round(sum(t['frame_df'] for t in terms) / len(terms), 2)
# weak_reason 계산 (자동 삭제 아님, 플래그만)
weak_reason = []
if len(tokens) < 2:
weak_reason.append('single_token')
if all(t['frame_df'] >= BROAD_FRAME_DF_THRESHOLD for t in terms):
weak_reason.append('all_terms_broad')
if all(t['mdx_df'] == 0 for t in terms):
weak_reason.append('all_terms_mdx_zero')
source_text_sets[set_id] = {
'generation_rule': 'SOURCE_TEXT_TOKEN_SET_V1',
'source_type': 'figma_text_layer', # enum: figma_text_layer | image_text_verified | mdx_section
'source_file': f"figma_to_html_agent/blocks/{fid}/texts.md",
'frame_id': fid,
'frame_number': fnum,
'source_text_index': idx,
'source_text_index_base': 1,
'source_text_raw': raw,
'source_text_normalized': nt,
'term_values': tokens,
'terms': terms,
'stats': {
'term_count': len(tokens),
'contains_special': any(t['is_special'] for t in terms),
'contains_unique_to_frame': any(t['frame_df'] == 1 for t in terms),
'avg_frame_df': avg_fd,
},
'candidate_strength': {
'weak_reason': weak_reason,
'is_weak': bool(weak_reason),
},
}
# co-occurrence (COOCCURRENCE_PAIR_V1)
pairs = Counter()
for _, _, _, tokens in per_text:
uniq_sorted = sorted(set(tokens))
for i_a, a in enumerate(uniq_sorted):
for b in uniq_sorted[i_a + 1:]:
pairs[(a, b)] += 1
if pairs:
cooccur_by_frame[fid] = {
'generation_rule': 'COOCCURRENCE_PAIR_V1',
'frame_id': fid,
'frame_number': fnum,
'pair_count': len(pairs),
'pairs': [
{'tokens': list(p), 'count': c}
for p, c in sorted(pairs.items(), key=lambda x: (-x[1], x[0]))
],
}
# merge_candidates (SHARED_TOKEN_MERGE_CANDIDATE_V1)
shared_map = defaultdict(list)
for sid in frame_sets_ids:
for tok in source_text_sets[sid]['term_values']:
shared_map[tok].append(sid)
merge_idx = 0
for shared_tok, sids in sorted(shared_map.items()):
if len(sids) < 2:
continue
merge_idx += 1
safe_tok = shared_tok.replace('/', '-').replace(' ', '_')
merge_id = f"merge_{fid}_{safe_tok}_{merge_idx:04d}"
merged = []
seen = set()
for sid in sids:
for tok in source_text_sets[sid]['term_values']:
if tok not in seen:
seen.add(tok)
merged.append(tok)
shared_fd = corpus_fd.get(shared_tok, 0)
merge_candidates[merge_id] = {
'generation_rule': 'SHARED_TOKEN_MERGE_CANDIDATE_V1',
'status': 'candidate_only',
'frame_id': fid,
'shared_token': shared_tok,
'shared_token_frame_df': shared_fd,
'shared_token_is_broad': shared_fd >= BROAD_FRAME_DF_THRESHOLD,
'source_set_ids': sids,
'source_set_count': len(sids),
'merged_terms': merged,
'merged_term_count': len(merged),
}
# frame_stats
unique_to_frame = sorted(
t for t in frame_local if corpus_fd.get(t, 0) == 1
)
top_local = sorted(frame_local.items(), key=lambda x: (-x[1], x[0]))[:15]
frame_stats[fid] = {
'frame_number': fnum,
'total_text_nodes': len(text_nodes),
'total_source_sets': len(frame_sets_ids),
'total_tokens_in_frame': sum(frame_local.values()),
'unique_tokens_in_frame': len(frame_local),
'unique_to_frame_tokens': unique_to_frame,
'top_local_count': [{'token': t, 'local': c} for t, c in top_local],
}
# manifest
meta = {
'pipeline_step': 7,
'disclaimer': (
'Step 7 산출물은 anchor_set 확정본이 아니다. '
'Step 7 산출물은 source_text 기반 anchor 후보 증거집이다. '
'이 보고서는 원문 텍스트 항목에서 정규화/형태소 분석으로 나온 후보만 보여준다. '
'의미 이름 붙이기, 병합 확정, 없는 단어 추가는 수행하지 않는다.'
),
'generation_rules': [
'SOURCE_TEXT_TOKEN_SET_V1',
'COOCCURRENCE_PAIR_V1',
'SHARED_TOKEN_MERGE_CANDIDATE_V1',
],
'ai_generation_used': False,
'manual_grouping_used': False,
'deterministic': True,
'input_files': {
'actual_text_nodes.yaml': {'sha256': sha256_file(INPUT_NODES)},
'synonyms.yaml': {'sha256': sha256_file(SYNONYMS)},
'normalized_text_tokens.yaml': {'sha256': sha256_file(INPUT_NORMALIZED)},
'keyword_normalizer.py': {'sha256': sha256_file(NORMALIZER_MODULE)},
},
'totals': {
'frames': len(frame_ids),
'source_text_sets': len(source_text_sets),
'cooccur_frames': len(cooccur_by_frame),
'merge_candidates': len(merge_candidates),
},
'broad_frame_df_threshold': BROAD_FRAME_DF_THRESHOLD,
'special_tokens': sorted(SPECIAL_TOKENS),
'source_type_policy': {
'current': 'figma_text_layer_default',
'possible_values': [
'figma_text_layer',
'image_text_verified',
'mdx_section',
],
'note': 'OCR 보강 텍스트가 늘어나면 heading 또는 별도 source map 으로 구분한다. 현재 모든 source_text 는 figma_text_layer 로 분류.',
},
}
output = {
'meta': meta,
'source_text_sets': source_text_sets,
'cooccurrence_by_frame': cooccur_by_frame,
'merge_candidates': merge_candidates,
'frame_stats': frame_stats,
}
OUT_YAML.write_text(
yaml.safe_dump(output, allow_unicode=True, sort_keys=False, width=300),
encoding='utf-8',
)
# 약한 이유 한국어 매핑
WEAK_REASON_KR = {
'single_token': '키워드 1개',
'all_terms_broad': '모든 키워드가 흔함',
'all_terms_mdx_zero': 'MDX 매칭 없음',
}
# md report — 상단 주의사항 + frame 별 키워드 세트 (한국어)
md = [
"# 자동 키워드 후보 증거집 — Step 7",
"",
"## ⚠ 주의 (AI 개입 없음)",
"",
"**이 보고서는 anchor 세트 확정본이 아닙니다.**",
"**원문 텍스트 기반으로 자동 추출된 anchor 후보 증거집입니다.**",
"",
"원문 항목을 정규화/형태소 분석한 결과만 보여주며, 다음은 수행하지 않습니다:",
"- 의미 이름 붙이기",
"- 병합 확정",
"- 원문에 없는 단어 추가",
"",
"### 생성 방식",
"",
"| 규칙 ID | 설명 |",
"|---|---|",
"| `SOURCE_TEXT_TOKEN_SET_V1` | 원문 한 줄을 형태소 분석한 결과 |",
"| `COOCCURRENCE_PAIR_V1` | 같은 원문 항목에 같이 등장한 단어쌍 |",
"| `SHARED_TOKEN_MERGE_CANDIDATE_V1` | 공통 단어로 묶을 수 있는 병합 후보 (확정 아님) |",
"",
"### AI 개입 없음 증거",
"",
f"- **LLM 호출 없음** (`ai_generation_used: false`)",
f"- **의미 이름 붙이기 없음** (세트 ID 는 `auto_set_프레임ID_번호` 형식, 숫자만)",
f"- **병합 자동 확정 없음** (모든 병합 후보는 `status: candidate_only`)",
f"- **원문에 없는 단어 추가 없음** (키워드는 원문 형태소 분석 결과만)",
"",
"### 입력 파일 지문 (재현성)",
"",
"| 파일 | sha256 |",
"|---|---|",
]
for k, v in meta['input_files'].items():
md.append(f"| `{k}` | `{v['sha256']}` |")
md += [
"",
"### 통계",
"",
f"- **프레임 수**: {meta['totals']['frames']}",
f"- **키워드 세트 수**: {meta['totals']['source_text_sets']} (원문 항목 수와 동일)",
f"- **병합 후보 수**: {meta['totals']['merge_candidates']} (모두 확정 아님)",
f"- **흔한 단어 기준**: 32 프레임 중 **{meta['broad_frame_df_threshold']}개** 이상 등장하면 '흔함' 으로 표시",
"",
"### 용어",
"",
"| 용어 | 의미 |",
"|---|---|",
"| 원문 번호 | texts.md 의 몇 번째 줄인지 (1부터 시작) |",
"| 키워드 목록 | 그 원문 한 줄을 형태소 분석해서 나온 단어들 |",
"| 약한 이유 | 이 세트가 anchor 로 쓰기 약한 이유 (키워드 1개 / 모든 키워드 흔함 / MDX 매칭 없음) |",
"| 특수 | 특수 토큰 포함 (S/W, H/W, 2D, 3D, DX, BIM, As-is, To-Be, 결과혁신, 과정혁신, 필수조건, 의사소통, 시행착오) |",
"| 전용 | 이 프레임에만 등장하는 키워드 포함 |",
"| 평균 분포 | 세트의 키워드들이 평균 몇 개 프레임에 등장하는가 (낮을수록 이 프레임 특화) |",
"| 공통 단어 | 여러 세트를 묶을 수 있는 공통 단어 (병합 후보의 근거) |",
"| 공통어 흔함 (⚠) | 공통 단어가 10개 이상 프레임에 등장 — 묶으면 너무 넓어질 수 있음 |",
"",
"---",
"",
"## 프레임별 키워드 후보",
"",
]
for fnum, fid in enumerate(frame_ids, 1):
fstat = frame_stats[fid]
# 해당 frame 의 source_text_sets
frame_sets = [
(sid, info) for sid, info in source_text_sets.items()
if info['frame_id'] == fid
]
frame_sets.sort(key=lambda x: x[1]['source_text_index'])
# strong / weak 분류
strong_sets = [(s, i) for s, i in frame_sets if not i['candidate_strength']['is_weak']]
weak_sets = [(s, i) for s, i in frame_sets if i['candidate_strength']['is_weak']]
# merge_candidates 중 해당 frame
frame_merges = [
(mid, minfo) for mid, minfo in merge_candidates.items()
if minfo['frame_id'] == fid
]
non_broad_merges = [(m, i) for m, i in frame_merges if not i['shared_token_is_broad']]
broad_merges = [(m, i) for m, i in frame_merges if i['shared_token_is_broad']]
# special_in_frame 계산
special_in_frame = sorted({
t for _, info in frame_sets for t in info['term_values']
if t in SPECIAL_TOKENS
})
md.append(f"### 프레임 {fnum} / {fid}")
md.append("")
md.append("**요약**:")
md.append("")
md.append(f"- 🎯 **프레임 전용 키워드** ({len(fstat['unique_to_frame_tokens'])}개): "
f"{', '.join('`'+t+'`' for t in fstat['unique_to_frame_tokens']) if fstat['unique_to_frame_tokens'] else '_(없음)_'}")
md.append(f"- ⭐ **특수 토큰** ({len(special_in_frame)}개): "
f"{', '.join('`'+t+'`' for t in special_in_frame) if special_in_frame else '_(없음)_'}")
md.append(f"- 📄 **키워드 세트**: 쓸 만함 **{len(strong_sets)}** / 약함 **{len(weak_sets)}** / 전체 **{len(frame_sets)}**")
md.append(f"- 🔗 **병합 후보**: 공통어 흔하지 않음 **{len(non_broad_merges)}** / 공통어 흔함 ⚠ **{len(broad_merges)}** / 전체 **{len(frame_merges)}**")
md.append("")
# 1) 키워드 세트 상세
md.append(f"<details><summary>📄 키워드 세트 상세 ({len(frame_sets)}개)</summary>")
md.append("")
md.append("| 번호 | 원문 | 키워드 목록 | 약한 이유 | 특수 | 전용 | 평균 분포 |")
md.append("|---|---|---|---|---|---|---|")
for sid, info in frame_sets:
raw_short = info['source_text_raw']
if len(raw_short) > 60:
raw_short = raw_short[:59] + ''
terms_str = ', '.join(info['term_values'])
weak_reasons_kr = [WEAK_REASON_KR.get(r, r) for r in info['candidate_strength']['weak_reason']]
weak = ', '.join(weak_reasons_kr) or ''
sp = '' if info['stats']['contains_special'] else ''
uq = '' if info['stats']['contains_unique_to_frame'] else ''
idx_num = info['source_text_index']
md.append(f"| {idx_num} | {raw_short} | {terms_str} | {weak} | {sp} | {uq} | {info['stats']['avg_frame_df']} |")
md.append("")
md.append("</details>")
md.append("")
# 2) 병합 후보 non-broad
md.append(f"<details><summary>🔗 병합 후보 · 공통어 흔하지 않음 ({len(non_broad_merges)}개)</summary>")
md.append("")
if non_broad_merges:
md.append("| 공통 단어 | 공통어 프레임 분포 | 병합 대상 번호 | 합친 키워드 |")
md.append("|---|---|---|---|")
for mid, minfo in sorted(non_broad_merges, key=lambda x: (-x[1]['source_set_count'], x[0])):
src_ids_short = ', '.join(str(int(sid.split('_')[-1])) for sid in minfo['source_set_ids'])
merged_short = ', '.join(minfo['merged_terms'])
if len(merged_short) > 80:
merged_short = merged_short[:79] + ''
md.append(f"| `{minfo['shared_token']}` | {minfo['shared_token_frame_df']} / 32 | {src_ids_short} | {merged_short} |")
else:
md.append("_(없음)_")
md.append("")
md.append("</details>")
md.append("")
# 3) 병합 후보 broad
md.append(f"<details><summary>🔗 병합 후보 · 공통어 흔함 ⚠ 주의 ({len(broad_merges)}개)</summary>")
md.append("")
if broad_merges:
md.append("| 공통 단어 | 공통어 프레임 분포 | 병합 대상 번호 | 합친 키워드 |")
md.append("|---|---|---|---|")
for mid, minfo in sorted(broad_merges, key=lambda x: (-x[1]['source_set_count'], x[0])):
src_ids_short = ', '.join(str(int(sid.split('_')[-1])) for sid in minfo['source_set_ids'])
merged_short = ', '.join(minfo['merged_terms'])
if len(merged_short) > 80:
merged_short = merged_short[:79] + ''
md.append(f"| `{minfo['shared_token']}` | ⚠ {minfo['shared_token_frame_df']} / 32 | {src_ids_short} | {merged_short} |")
else:
md.append("_(없음)_")
md.append("")
md.append("</details>")
md.append("")
# 4) cooccurrence
pairs = cooccur_by_frame.get(fid, {}).get('pairs', [])
md.append(f"<details><summary>📐 같이 나온 단어쌍 ({len(pairs)}개)</summary>")
md.append("")
if pairs:
md.append("| 단어쌍 | 빈도 |")
md.append("|---|---|")
for p in pairs:
md.append(f"| {', '.join(p['tokens'])} | {p['count']} |")
else:
md.append("_(없음)_")
md.append("")
md.append("</details>")
md.append("")
md_text = '\n'.join(md)
OUT_MD.write_text(md_text, encoding='utf-8')
html_body = markdown.markdown(md_text, extensions=['tables'])
html = f"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>Auto Anchor Candidates Report — Step 7</title>
<style>
body {{ font-family: -apple-system, "Segoe UI", Pretendard, sans-serif; max-width: 1300px; margin: 2em auto; padding: 0 1em 4em; line-height: 1.5; color: #222; }}
h1 {{ border-bottom: 2px solid #333; padding-bottom: 0.2em; }}
h2 {{ margin-top: 2em; border-bottom: 1px solid #ccc; padding-bottom: 0.3em; }}
h3 {{ margin-top: 1.8em; color: #333; }}
table {{ border-collapse: collapse; margin: 0.3em 0 1em 0; font-size: 0.82em; width: 100%; }}
th, td {{ border: 1px solid #ddd; padding: 4px 8px; text-align: left; vertical-align: top; word-break: break-word; }}
th {{ background: #f4f4f4; }}
code {{ background: #f4f4f4; padding: 1px 5px; border-radius: 3px; font-size: 0.9em; }}
strong {{ color: #0a6; }}
h2:first-of-type + p + p, h2:first-of-type ~ p {{ background: #fffbdc; padding: 0.5em 0.8em; border-left: 4px solid #d33; }}
</style>
</head>
<body>
{html_body}
</body>
</html>"""
OUT_HTML.write_text(html, encoding='utf-8')
output_sha = sha256_file(OUT_YAML)
print("[Step 7] auto_anchor_candidates 생성 완료")
print()
print(" AI 개입 없음 증거:")
print(f" ai_generation_used: {meta['ai_generation_used']}")
print(f" manual_grouping_used: {meta['manual_grouping_used']}")
print(f" deterministic: {meta['deterministic']}")
print()
print(" Input sha256:")
for k, v in meta['input_files'].items():
print(f" {k}: {v['sha256'][:16]}...")
print()
print(" Output sha256:")
print(f" auto_anchor_candidates.yaml: {output_sha[:16]}...")
print()
print(f" Totals:")
for k, v in meta['totals'].items():
print(f" {k}: {v}")
print()
print(f"산출:")
print(f" yaml: {OUT_YAML}")
print(f" md: {OUT_MD}")
print(f" html: {OUT_HTML}")
if __name__ == "__main__":
main()
@@ -0,0 +1,215 @@
"""Pipeline Step 8 — V2: V1 Top-K semantic rerank
파이프라인 위치:
V1 (pipeline_06_2_mdx_matching.py) → **V2 (이 스크립트)** → V3 → V4
설계 원칙:
- V1 결과(mdx_matching_result.yaml) 는 **고정 baseline**. 건드리지 않음.
- V1 rank_by_matching_score Top-K 만 rerank 대상 — K=5 (기준 잠금).
- 가중합 아님. **순수 rerank** — V1 점수는 저장만, 재정렬 키는 의미 유사도뿐.
- AI 판단 없음. ko-sroberta 임베딩은 결정론적 (seed 고정 가정).
입력:
- mdx_matching_result.yaml (V1)
- MDX_SECTIONS 원문 (pipeline_01 의 MDX_DIR + section config)
- Figma frame content (phase_common.load_32_frames() → analysis.md '내용')
처리:
1. 각 MDX 섹션(TARGET 4 + Holdout 3)별로:
- V1 Top-5 frame_id 추출
- MDX summary = detect_mdx.build_summary (title + 첫 문단 + slot labels)
- Top-5 frame.content ↔ summary 의 ko-sroberta cosine 계산
- cosine 내림차순으로 재정렬
2. lock_snapshot 기록 (V1 yaml + 이 스크립트 + 의존 모듈 sha256)
출력:
- v2_semantic_rerank_result.yaml
"""
import hashlib
import sys
import datetime
from pathlib import Path
import yaml
HERE = Path(__file__).parent
sys.path.insert(0, str(HERE))
from phase_common import load_32_frames, load_frame_index
from detect_mdx import detect_mdx_analysis
from embeddings import embed_texts, cosine
from pipeline_01_extract_nodes import MDX_SECTIONS, MDX_DIR
TOP_K = 5
MODEL_ID = 'jhgan/ko-sroberta-multitask'
# ============================================================
# 상수 — lock_snapshot 대상
# ============================================================
LOCK_SNAPSHOT_FILES = [
'pipeline_08_v2_semantic_rerank.py',
'embeddings.py',
'detect_mdx.py',
'phase_common.py',
'pipeline_01_extract_nodes.py',
'synonyms.yaml',
'mdx_matching_result.yaml',
]
def sha256_file(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def extract_mdx_raw_section(section_id: str) -> tuple[str, str]:
"""(title, body_text) 반환. title = 첫 줄 heading 마커 제거, body_text = 원문 그대로."""
cfg = MDX_SECTIONS[section_id]
p = MDX_DIR / cfg['file']
lines = p.read_text(encoding='utf-8').split('\n')
start_idx = None
for i, ln in enumerate(lines):
if ln.strip() == cfg['start'].strip():
start_idx = i
break
if start_idx is None:
raise ValueError(f"start_heading 찾지 못함: {cfg['start']!r} in {p}")
end_idx = len(lines)
if cfg.get('end_prefix'):
for i in range(start_idx + 1, len(lines)):
if lines[i].strip().startswith(cfg['end_prefix']):
end_idx = i
break
section_lines = lines[start_idx:end_idx]
raw = '\n'.join(section_lines)
# title = heading 마커(#, ##, ###) 제거한 첫 줄
title_line = section_lines[0].lstrip('#').strip()
return title_line, raw
def main():
# 1. V1 결과 로드
v1_path = HERE / 'mdx_matching_result.yaml'
v1 = yaml.safe_load(v1_path.read_text(encoding='utf-8'))
# 2. Figma frame 로드 + content 임베딩 (한 번만)
frames = load_32_frames()
idx_data, frame_to_short = load_frame_index()
fids = list(frames.keys())
frame_contents = [frames[fid].get('content', '') for fid in fids]
fid_to_idx = {fid: i for i, fid in enumerate(fids)}
print(f"[V2] ko-sroberta 모델 로드 + 32 frame content 임베딩...")
frame_vecs = embed_texts(frame_contents)
# 3. 섹션별 처리
out_sections = {}
for sid, sec in v1['mdx_sections'].items():
top = sec['rank_by_matching_score'][:TOP_K]
top_fids = [r['frame_id'] for r in top]
# MDX summary 생성
title, raw_text = extract_mdx_raw_section(sid)
analysis = detect_mdx_analysis(raw_text, title, anchor_vocab=None)
summary = analysis['summary']
# summary 임베딩
mdx_vec = embed_texts([summary])[0]
# Top-K 에 대해서만 cosine 계산
rerank = []
for v1_rank_idx, r in enumerate(top, start=1):
fid = r['frame_id']
frame_idx = fid_to_idx[fid]
sem = cosine(mdx_vec, frame_vecs[frame_idx])
rerank.append({
'frame_id': fid,
'frame_number': r['frame_number'],
'v1_rank': v1_rank_idx,
'v1_score': r['matching_score'],
'semantic_score': round(float(sem), 4),
})
# semantic_score 내림차순 재정렬
rerank.sort(key=lambda x: -x['semantic_score'])
for new_rank, item in enumerate(rerank, start=1):
item['v2_rank'] = new_rank
out_sections[sid] = {
'section_type': sec.get('section_type'),
'answer_frame_number': sec.get('answer_frame_number'),
'mdx_title': title,
'mdx_summary': summary,
'top_k': TOP_K,
'v1_top_k': [
{
'rank': i + 1,
'frame_id': r['frame_id'],
'frame_number': r['frame_number'],
'v1_score': r['matching_score'],
}
for i, r in enumerate(top)
],
'v2_rerank': rerank,
}
# 4. lock_snapshot
lock = {
'timestamp': datetime.datetime.now().isoformat(timespec='seconds'),
'model': MODEL_ID,
'top_k': TOP_K,
'files': {
name: sha256_file(HERE / name) for name in LOCK_SNAPSHOT_FILES
},
'principle': [
'V1 baseline 고정 — 재정렬 대상만 Top-K 로 한정',
'가중합 금지 — 순수 semantic rerank',
'Holdout 성적을 설계 근거로 사용하지 않음',
],
}
out = {
'meta': {
'pipeline_step': '8.v2',
'description': 'V1 Top-K 후보를 ko-sroberta cosine 으로 재정렬 (캐스케이드 rerank 1단)',
'model': MODEL_ID,
'similarity': 'cosine',
'top_k': TOP_K,
'mdx_summary_spec': 'title + 첫 일반 문단(≤120자) + slot label Top-5 join (detect_mdx.build_summary)',
'frame_source': 'analysis.md "내용" 섹션 (phase_common.load_32_frames)',
'v1_source': 'mdx_matching_result.yaml rank_by_matching_score',
'holdout_sections': ['01-1', '02-1', '02-2.1'],
'answer_map': v1['meta']['answer_map'],
'lock_snapshot': lock,
},
'mdx_sections': out_sections,
}
out_path = HERE / 'v2_semantic_rerank_result.yaml'
out_path.write_text(
yaml.safe_dump(out, allow_unicode=True, sort_keys=False, width=1000),
encoding='utf-8',
)
# 콘솔 요약
print()
print("=" * 70)
print(f"V2 재정렬 완료: {out_path}")
print("=" * 70)
answer_map = v1['meta']['answer_map']
for sid, s in out_sections.items():
answer_num = answer_map.get(sid)
v1_top1 = s['v1_top_k'][0]['frame_number']
v2_top1 = s['v2_rerank'][0]['frame_number']
v2_top1_v1rank = s['v2_rerank'][0]['v1_rank']
mark = ''
if answer_num is not None:
v1_ok = '' if v1_top1 == answer_num else ''
v2_ok = '' if v2_top1 == answer_num else ''
mark = f" 정답={answer_num} V1{v1_ok} V2{v2_ok}"
else:
mark = f" (holdout)"
print(f" [{sid:8}] V1 top1=Frame {v1_top1:>2} → V2 top1=Frame {v2_top1:>2} (V1 rank {v2_top1_v1rank}){mark}")
print()
if __name__ == '__main__':
main()
@@ -0,0 +1,368 @@
"""Pipeline Step 8 — V3 r2: v2 스키마 기반 구조 rerank.
기존 V3 (pipeline_08_v3_structure_rerank.py) 의 `_COMPAT` 수작업 테이블 대신
`structure_ontology_v2_final.yaml` 의 content_affinity + structure_intent +
alternative_patterns 로 자동 파생 매칭.
매칭 공식 (스키마 초안 §4 반영):
v3_score = 0.40 × layout_compat_v2
+ 0.35 × affinity_match(mdx, frame)
+ 0.25 × intent_match(mdx, frame)
layout_compat_v2 = 0.25 family + 0.20 relation_type + 0.15 cardinality
+ 0.20 alternative_patterns + 0.20 exact layout match
affinity_match / intent_match:
primary == primary → 1.0
primary ∈ other secondary → 0.6
secondary 교집합 ≥ 1 → 0.3
else → 0.0
입력:
- v2_semantic_rerank_result.yaml (V2 Top-K 유지)
- structure_ontology_v2_final.yaml (프레임 v2 라벨)
- MDX 섹션 원문 (pipeline_01)
출력:
- v3_structure_rerank_r2_result.yaml
"""
import datetime
import hashlib
import sys
from pathlib import Path
import yaml
HERE = Path(__file__).parent
sys.path.insert(0, str(HERE))
from detect_mdx import detect_mdx_analysis
from phase_common import detect_mdx_layout_v2
from pipeline_01_extract_nodes import MDX_SECTIONS, MDX_DIR
# r3 에서 튜닝된 AFFINITY_KEYWORDS + INTENT_KEYWORDS 재사용
import pipeline_12_r3_generate_templates_v2 as r3mod
OUT_PATH = HERE / 'v3_structure_rerank_r2_result.yaml'
FINAL_ONTOLOGY = HERE / 'structure_ontology_v2_final.yaml'
V2_RESULT = HERE / 'v2_semantic_rerank_result.yaml'
LOCK_SNAPSHOT_FILES = [
'pipeline_08_v3_r2_structure_rerank.py',
'structure_ontology_v2_final.yaml',
'v2_semantic_rerank_result.yaml',
'pipeline_12_r3_generate_templates_v2.py',
'pipeline_12_r2_generate_templates_v2.py',
'pipeline_12_generate_templates_v2.py',
]
# ============================================================
# MDX layout → family / relation_type 추론 (frame 쪽과 맞추기 위해)
# ============================================================
MDX_LAYOUT_STRUCTURE = {
'3col-parallel': {'family': 'card', 'relation_type': 'parallel', 'cardinality_ideal': 3},
'compare-rows': {'family': 'table', 'relation_type': 'compare', 'cardinality_ideal': 2},
'compare-2banner': {'family': 'diagram', 'relation_type': 'compare', 'cardinality_ideal': 2},
'compare-2col': {'family': 'card', 'relation_type': 'compare', 'cardinality_ideal': 2},
'persona-3col': {'family': 'card', 'relation_type': 'parallel', 'cardinality_ideal': 3},
'table-2col': {'family': 'table', 'relation_type': 'compare', 'cardinality_ideal': 2},
'table-3col': {'family': 'table', 'relation_type': 'parallel', 'cardinality_ideal': 3},
'cards-4': {'family': 'card', 'relation_type': 'parallel', 'cardinality_ideal': 4},
'multi-parallel': {'family': 'card', 'relation_type': 'parallel', 'cardinality_ideal': 5},
'multi-section': {'family': 'list', 'relation_type': 'parallel', 'cardinality_ideal': 3},
'single-column': {'family': 'list', 'relation_type': 'parallel', 'cardinality_ideal': 1},
}
def sha256_file(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def extract_mdx_raw(sid: str) -> tuple[str, str]:
cfg = MDX_SECTIONS[sid]
p = MDX_DIR / cfg['file']
lines = p.read_text(encoding='utf-8').split('\n')
start = None
for i, ln in enumerate(lines):
if ln.strip() == cfg['start'].strip():
start = i
break
end = len(lines)
if cfg.get('end_prefix'):
for i in range(start + 1, len(lines)):
if lines[i].strip().startswith(cfg['end_prefix']):
end = i
break
section = lines[start:end]
title = section[0].lstrip('#').strip()
return title, '\n'.join(section)
# ============================================================
# MDX v2 profile 추출
# ============================================================
def find_keyword_hits(text: str, keyword_map: dict):
hits = []
for label, kws in keyword_map.items():
found = [kw for kw in kws if kw in text]
if found:
hits.append((label, found))
return hits
def detect_mdx_v2_profile(sid: str) -> dict:
"""MDX 섹션 → v2 프로필 추출."""
title, raw_text = extract_mdx_raw(sid)
layout = detect_mdx_layout_v2(raw_text)
struct = MDX_LAYOUT_STRUCTURE.get(layout, {})
# content_affinity: content(title + raw) 키워드 매칭
all_text = title + ' ' + raw_text
aff_hits = find_keyword_hits(all_text, r3mod.AFFINITY_KEYWORDS_R3)
aff_hits.sort(key=lambda x: -len(x[1]))
if aff_hits:
aff_primary = aff_hits[0][0]
aff_secondary = [l for l, _ in aff_hits[1:3]]
else:
aff_primary = 'concept_definition' # safe default
aff_secondary = []
# structure_intent: 키워드 + 구조 신호
import pipeline_12_generate_templates_v2 as r1mod
int_hits = list(find_keyword_hits(raw_text, r1mod.INTENT_KEYWORDS))
rel = struct.get('relation_type')
ideal = struct.get('cardinality_ideal')
if rel == 'compare' and ideal == 2:
if any(k in raw_text for k in ('AS-IS', 'TO-BE', '혁신', '전환', '이중')):
int_hits.append(('state_transition', ['AS-IS/TO-BE/혁신']))
else:
int_hits.append(('binary_compare', ['compare+2']))
elif rel == 'parallel' and ideal and ideal >= 3:
int_hits.append(('multi_parallel', [f'parallel+{ideal}']))
int_hits.sort(key=lambda x: -len(x[1]))
if int_hits:
int_primary = int_hits[0][0]
int_secondary = [l for l, _ in int_hits[1:3] if l != int_primary]
else:
int_primary = 'multi_parallel'
int_secondary = []
return {
'section_id': sid,
'title': title,
'layout': layout,
'family': struct.get('family'),
'relation_type': rel,
'cardinality': {'ideal': ideal} if ideal else {},
'content_affinity': {'primary': aff_primary, 'secondary': aff_secondary},
'structure_intent': {'primary': int_primary, 'secondary': int_secondary},
}
# ============================================================
# 매칭 함수
# ============================================================
def profile_match(mdx_axis: dict, frame_axis: dict) -> tuple[float, str]:
"""primary == primary → 1.0, 교차 → 0.6, secondary 교집합 → 0.3"""
a_p = mdx_axis.get('primary')
b_p = frame_axis.get('primary')
a_sec = set(mdx_axis.get('secondary') or [])
b_sec = set(frame_axis.get('secondary') or [])
if a_p and a_p == b_p:
return 1.0, 'primary_match'
if (a_p and a_p in b_sec) or (b_p and b_p in a_sec):
return 0.6, 'cross_primary_secondary'
if a_sec & b_sec:
return 0.3, 'secondary_overlap'
return 0.0, 'no_match'
def layout_compat_v2(mdx_profile: dict, frame_tpl: dict) -> tuple[float, dict]:
vp = frame_tpl['visual_pattern']
score = 0.0
bd = {}
# family (0.25)
if mdx_profile.get('family') and mdx_profile['family'] == vp.get('family'):
score += 0.25
bd['family'] = 1.0
else:
bd['family'] = 0.0
# relation_type (0.20)
if mdx_profile.get('relation_type') and mdx_profile['relation_type'] == vp.get('relation_type'):
score += 0.20
bd['relation_type'] = 1.0
else:
bd['relation_type'] = 0.0
# cardinality overlap (0.15)
mdx_card = mdx_profile.get('cardinality', {}).get('ideal')
f_card = vp.get('cardinality', {})
if mdx_card and f_card:
mn = f_card.get('min', 0)
mx = f_card.get('max', 999)
if mn <= mdx_card <= mx:
score += 0.15
bd['cardinality'] = 1.0
else:
bd['cardinality'] = 0.0
else:
bd['cardinality'] = 0.0
# layout 일치 또는 alternative_patterns (0.40)
mdx_layout = mdx_profile.get('layout')
frame_orig = frame_tpl['source'].get('original_layout')
alts = {a['pattern']: a['confidence'] for a in frame_tpl.get('alternative_patterns', [])}
if mdx_layout and mdx_layout == frame_orig:
score += 0.40
bd['layout_match'] = 1.0
bd['layout_match_source'] = 'exact'
elif mdx_layout and mdx_layout in alts:
c = alts[mdx_layout]
score += 0.40 * c
bd['layout_match'] = c
bd['layout_match_source'] = 'alternative'
else:
bd['layout_match'] = 0.0
bd['layout_match_source'] = 'none'
return round(score, 4), bd
def v3_r2_score(mdx_profile: dict, frame_tpl: dict) -> dict:
layout_s, layout_bd = layout_compat_v2(mdx_profile, frame_tpl)
aff_s, aff_src = profile_match(
mdx_profile['content_affinity'],
frame_tpl['content_affinity'],
)
int_s, int_src = profile_match(
mdx_profile['structure_intent'],
frame_tpl['structure_intent_v2'],
)
total = 0.40 * layout_s + 0.35 * aff_s + 0.25 * int_s
return {
'total': round(total, 4),
'layout_compat': layout_s,
'layout_breakdown': layout_bd,
'content_affinity': round(aff_s, 4),
'content_affinity_source': aff_src,
'structure_intent': round(int_s, 4),
'structure_intent_source': int_src,
}
# ============================================================
# 메인
# ============================================================
def main():
v2 = yaml.safe_load(V2_RESULT.read_text(encoding='utf-8'))
ontology = yaml.safe_load(FINAL_ONTOLOGY.read_text(encoding='utf-8'))
templates = ontology['templates_v2']
out_sections = {}
for sid, v2_sec in v2['mdx_sections'].items():
mdx_profile = detect_mdx_v2_profile(sid)
rerank = []
for cand in v2_sec['v2_rerank']:
fid = cand['frame_id']
if fid not in templates:
continue
tpl = templates[fid]
s = v3_r2_score(mdx_profile, tpl)
rerank.append({
'frame_id': fid,
'frame_number': cand['frame_number'],
'v1_rank': cand['v1_rank'],
'v2_rank': cand['v2_rank'],
'v1_score': cand['v1_score'],
'semantic_score': cand['semantic_score'],
'v3_r2_total': s['total'],
'v3_r2_breakdown': {
'layout_compat': s['layout_compat'],
'layout_detail': s['layout_breakdown'],
'content_affinity': s['content_affinity'],
'content_affinity_source': s['content_affinity_source'],
'structure_intent': s['structure_intent'],
'structure_intent_source': s['structure_intent_source'],
},
'fig_layout': tpl['source'].get('original_layout'),
'fig_content_affinity': tpl['content_affinity']['primary'],
'fig_structure_intent': tpl['structure_intent_v2']['primary'],
})
rerank.sort(key=lambda x: (-x['v3_r2_total'], x['v2_rank']))
for new_rank, item in enumerate(rerank, start=1):
item['v3_r2_rank'] = new_rank
out_sections[sid] = {
'section_type': v2_sec.get('section_type'),
'answer_frame_number': v2_sec.get('answer_frame_number'),
'mdx_title': v2_sec['mdx_title'],
'mdx_profile': mdx_profile,
'v3_r2_rerank': rerank,
}
lock = {
'timestamp': datetime.datetime.now().isoformat(timespec='seconds'),
'top_k': v2['meta']['top_k'],
'figma_profile_source': 'structure_ontology_v2_final.yaml',
'matching_formula': '0.40 × layout_compat_v2 + 0.35 × affinity_match + 0.25 × intent_match',
'files': {name: sha256_file(HERE / name) for name in LOCK_SNAPSHOT_FILES},
'principle': [
'v2 스키마 기반 — content_affinity + structure_intent + alternative_patterns',
'_COMPAT 수작업 테이블 대신 자동 파생',
'V1→V2→V3 캐스케이드 유지, rerank 범위는 V2 Top-K 내부',
],
}
out = {
'meta': {
'pipeline_step': '8.v3.r2',
'description': 'v2 스키마 기반 V3 구조 rerank — _COMPAT 대신 content_affinity + intent + alt',
'top_k': v2['meta']['top_k'],
'holdout_sections': v2['meta']['holdout_sections'],
'answer_map': v2['meta']['answer_map'],
'lock_snapshot': lock,
},
'mdx_sections': out_sections,
}
OUT_PATH.write_text(
yaml.safe_dump(out, allow_unicode=True, sort_keys=False, width=1000),
encoding='utf-8',
)
print('=' * 70)
print('V3 r2 (v2 스키마 기반) 재정렬 완료')
print('=' * 70)
print(f' {OUT_PATH}')
print()
answer_map = v2['meta']['answer_map']
for sid, s in out_sections.items():
top = s['v3_r2_rerank'][0]
mp = s['mdx_profile']
ans = answer_map.get(sid)
mark = ''
if ans is not None:
ok = '' if top['frame_number'] == ans else ''
mark = f" 정답={ans} {ok}"
else:
mark = " (holdout)"
print(f" [{sid:8}] mdx[layout={mp['layout']:14} aff={mp['content_affinity']['primary']:24} intent={mp['structure_intent']['primary']}]")
print(f" → V3 r2 top1 Frame {top['frame_number']:>2} "
f"(total={top['v3_r2_total']:.3f}, layout={top['v3_r2_breakdown']['layout_compat']:.2f}, "
f"aff={top['v3_r2_breakdown']['content_affinity']:.2f}, "
f"intent={top['v3_r2_breakdown']['structure_intent']:.2f}){mark}")
if __name__ == '__main__':
main()
@@ -0,0 +1,293 @@
"""Pipeline Step 8 — V3 r3: MDX 입력 하네스 안정화 (B).
r2 에서 드러난 퇴행 (TARGET 4/4 → 2/4) 의 원인은 **MDX 쪽 affinity/intent 추출 과민**.
"전환", "혁신", "변화" 같은 광범위 단어 단독이 거의 모든 MDX 에 before_after_change /
state_transition 을 뿌렸음. DX = "디지털 전환" 이므로 "전환" 은 평범한 배경 단어.
r3 변경 (입력 하네스):
1. STRONG_AFFINITY_PATTERNS / STRONG_INTENT_PATTERNS 정의 — 구절 단위 강한 신호
2. Validator 도입: before_after_change / state_transition 은 STRONG 매치 1개 이상 필요
(WEAK 키워드 단독이면 해당 label 은 primary/secondary 후보에서 제외)
3. r2 의 layout_compat_v2 + profile_match 공식은 그대로 유지 (점수식은 안 건드림)
V4 는 C.4 완결 전까지 재실행 보류.
출력:
- v3_structure_rerank_r3_result.yaml
"""
import datetime
import hashlib
import sys
from pathlib import Path
import yaml
HERE = Path(__file__).parent
sys.path.insert(0, str(HERE))
from detect_mdx import detect_mdx_analysis
from phase_common import detect_mdx_layout_v2
from pipeline_01_extract_nodes import MDX_SECTIONS, MDX_DIR
# r3 키워드 테이블 + r2 점수식 재사용
import pipeline_12_r3_generate_templates_v2 as r3mod
import pipeline_12_generate_templates_v2 as r1mod
from pipeline_08_v3_r2_structure_rerank import (
MDX_LAYOUT_STRUCTURE,
find_keyword_hits,
profile_match,
layout_compat_v2,
sha256_file,
extract_mdx_raw,
)
OUT_PATH = HERE / 'v3_structure_rerank_r3_result.yaml'
FINAL_ONTOLOGY = HERE / 'structure_ontology_v2_final.yaml'
V2_RESULT = HERE / 'v2_semantic_rerank_result.yaml'
LOCK_SNAPSHOT_FILES = [
'pipeline_08_v3_r3_structure_rerank.py',
'pipeline_08_v3_r2_structure_rerank.py',
'structure_ontology_v2_final.yaml',
'v2_semantic_rerank_result.yaml',
]
# ============================================================
# STRONG 신호 패턴 (r3 신규)
# ============================================================
# 이 label 을 활성화하려면 STRONG 패턴 1개 이상 필요.
# WEAK (기존 AFFINITY_KEYWORDS_R3) 만으로는 후보에서 제외.
STRONG_AFFINITY_PATTERNS = {
'before_after_change': [
'AS-IS', 'TO-BE', 'as-is', 'to-be',
'이중 변환', '이중 Transformation',
'과정 혁신', '과정의 혁신',
'Process 혁신', 'Process의 혁신',
'Product 변화', 'Product/Process',
'Analogue', '2D→3D', '2D -> 3D',
'전후 비교', '전후 대비', 'before/after', 'Before/After',
],
}
STRONG_INTENT_PATTERNS = {
'state_transition': [
'AS-IS', 'TO-BE', 'as-is', 'to-be',
'이중 변환', '이중 Transformation',
'Process 혁신', 'Process의 혁신',
'과정 혁신', '과정의 혁신',
'AS-IS ↔ TO-BE', 'AS-IS vs TO-BE',
'전후 비교', '전후 대비', 'before/after',
],
}
# 이 label 들은 STRONG validator 통과 필수
STRONG_REQUIRED_AFFINITY = set(STRONG_AFFINITY_PATTERNS.keys())
STRONG_REQUIRED_INTENT = set(STRONG_INTENT_PATTERNS.keys())
def has_strong_pattern(text: str, patterns: list[str]) -> list[str]:
return [p for p in patterns if p in text]
def detect_mdx_v2_profile_r3(sid: str) -> dict:
"""MDX 섹션 → v2 profile. r3: STRONG validator 적용."""
title, raw_text = extract_mdx_raw(sid)
layout = detect_mdx_layout_v2(raw_text)
struct = MDX_LAYOUT_STRUCTURE.get(layout, {})
all_text = title + ' ' + raw_text
# ─── content_affinity ───
weak_hits = find_keyword_hits(all_text, r3mod.AFFINITY_KEYWORDS_R3)
# STRONG required label 들은 validator 통과 필요
validated_aff = []
strong_matches_aff = {}
for lab, kws in weak_hits:
if lab in STRONG_REQUIRED_AFFINITY:
strongs = has_strong_pattern(all_text, STRONG_AFFINITY_PATTERNS[lab])
if not strongs:
# WEAK only → 제외
continue
strong_matches_aff[lab] = strongs
validated_aff.append((lab, kws))
# primary 결정: validated 중 score 가장 높은 (len 기준)
validated_aff.sort(key=lambda x: -len(x[1]))
if validated_aff:
aff_primary = validated_aff[0][0]
aff_secondary = [l for l, _ in validated_aff[1:3] if l != aff_primary]
else:
# 모두 필터됨 → layout-default
aff_primary = 'concept_definition'
aff_secondary = []
# ─── structure_intent ───
int_weak_hits = find_keyword_hits(raw_text, r1mod.INTENT_KEYWORDS)
# relation_type/cardinality 기반 구조 신호 (STRONG-aware)
rel = struct.get('relation_type')
ideal = struct.get('cardinality_ideal')
extra_int_hits = []
if rel == 'compare' and ideal == 2:
st_strongs = has_strong_pattern(raw_text, STRONG_INTENT_PATTERNS['state_transition'])
if st_strongs:
extra_int_hits.append(('state_transition', st_strongs))
else:
extra_int_hits.append(('binary_compare', ['compare+cardinality=2']))
elif rel == 'parallel' and ideal and ideal >= 3:
extra_int_hits.append(('multi_parallel', [f'parallel+cardinality={ideal}']))
elif rel == 'sequence':
extra_int_hits.append(('sequence', ['relation_type=sequence']))
int_all = list(int_weak_hits) + extra_int_hits
# STRONG required intent 들 validator
validated_int = []
strong_matches_int = {}
for lab, kws in int_all:
if lab in STRONG_REQUIRED_INTENT:
strongs = has_strong_pattern(all_text, STRONG_INTENT_PATTERNS[lab])
if not strongs:
continue
strong_matches_int[lab] = strongs
validated_int.append((lab, kws))
validated_int.sort(key=lambda x: -len(x[1]))
if validated_int:
int_primary = validated_int[0][0]
int_secondary = [l for l, _ in validated_int[1:3] if l != int_primary]
else:
int_primary = 'multi_parallel'
int_secondary = []
return {
'section_id': sid,
'title': title,
'layout': layout,
'family': struct.get('family'),
'relation_type': rel,
'cardinality': {'ideal': ideal} if ideal else {},
'content_affinity': {'primary': aff_primary, 'secondary': aff_secondary},
'structure_intent': {'primary': int_primary, 'secondary': int_secondary},
'validator_log': {
'strong_aff_matches': strong_matches_aff,
'strong_int_matches': strong_matches_int,
},
}
def v3_r3_score(mdx_profile: dict, frame_tpl: dict) -> dict:
layout_s, layout_bd = layout_compat_v2(mdx_profile, frame_tpl)
aff_s, aff_src = profile_match(
mdx_profile['content_affinity'],
frame_tpl['content_affinity'],
)
int_s, int_src = profile_match(
mdx_profile['structure_intent'],
frame_tpl['structure_intent_v2'],
)
total = 0.40 * layout_s + 0.35 * aff_s + 0.25 * int_s
return {
'total': round(total, 4),
'layout_compat': layout_s,
'layout_breakdown': layout_bd,
'content_affinity': round(aff_s, 4),
'content_affinity_source': aff_src,
'structure_intent': round(int_s, 4),
'structure_intent_source': int_src,
}
def main():
v2 = yaml.safe_load(V2_RESULT.read_text(encoding='utf-8'))
ontology = yaml.safe_load(FINAL_ONTOLOGY.read_text(encoding='utf-8'))
templates = ontology['templates_v2']
out_sections = {}
for sid, v2_sec in v2['mdx_sections'].items():
mdx_profile = detect_mdx_v2_profile_r3(sid)
rerank = []
for cand in v2_sec['v2_rerank']:
fid = cand['frame_id']
if fid not in templates:
continue
tpl = templates[fid]
s = v3_r3_score(mdx_profile, tpl)
rerank.append({
'frame_id': fid,
'frame_number': cand['frame_number'],
'v1_rank': cand['v1_rank'],
'v2_rank': cand['v2_rank'],
'v3_r3_total': s['total'],
'v3_r3_breakdown': s,
'fig_layout': tpl['source'].get('original_layout'),
'fig_content_affinity': tpl['content_affinity']['primary'],
'fig_structure_intent': tpl['structure_intent_v2']['primary'],
})
rerank.sort(key=lambda x: (-x['v3_r3_total'], x['v2_rank']))
for new_rank, item in enumerate(rerank, start=1):
item['v3_r3_rank'] = new_rank
out_sections[sid] = {
'answer_frame_number': v2_sec.get('answer_frame_number'),
'mdx_title': v2_sec['mdx_title'],
'mdx_profile': mdx_profile,
'v3_r3_rerank': rerank,
}
lock = {
'timestamp': datetime.datetime.now().isoformat(timespec='seconds'),
'top_k': v2['meta']['top_k'],
'files': {name: sha256_file(HERE / name) for name in LOCK_SNAPSHOT_FILES},
'r3_changes': [
'STRONG_AFFINITY_PATTERNS / STRONG_INTENT_PATTERNS 도입',
'before_after_change / state_transition 은 STRONG 매치 필수',
'"전환" 같은 일반 단어 단독은 후보에서 제외',
],
}
out = {
'meta': {
'pipeline_step': '8.v3.r3',
'description': 'MDX 입력 하네스 안정화 — STRONG 패턴 validator 도입',
'top_k': v2['meta']['top_k'],
'holdout_sections': v2['meta']['holdout_sections'],
'answer_map': v2['meta']['answer_map'],
'lock_snapshot': lock,
},
'mdx_sections': out_sections,
}
OUT_PATH.write_text(
yaml.safe_dump(out, allow_unicode=True, sort_keys=False, width=1000),
encoding='utf-8',
)
print('=' * 70)
print('V3 r3 (STRONG validator) 재정렬 완료')
print('=' * 70)
print(f' {OUT_PATH}')
print()
answer_map = v2['meta']['answer_map']
for sid, s in out_sections.items():
top = s['v3_r3_rerank'][0]
mp = s['mdx_profile']
ans = answer_map.get(sid)
mark = ''
if ans is not None:
ok = '' if top['frame_number'] == ans else ''
mark = f" 정답={ans} {ok}"
else:
mark = " (holdout)"
validator = mp.get('validator_log', {})
strong = (list(validator.get('strong_aff_matches', {}).keys())
+ list(validator.get('strong_int_matches', {}).keys()))
strong_str = f" [STRONG: {strong}]" if strong else ""
print(f" [{sid:8}] mdx[aff={mp['content_affinity']['primary']:24} "
f"int={mp['structure_intent']['primary']:20}]{strong_str}")
print(f" → top1 Frame {top['frame_number']:>2} "
f"(total={top['v3_r3_total']:.3f}){mark}")
if __name__ == '__main__':
main()
@@ -0,0 +1,287 @@
"""Pipeline Step 8 — V3 r4: MDX 입력 하네스 추가 안정화 (B-1 + B-2).
r3 에서도 남은 문제:
- 01-2 "용어간 상호관계" 에서 aff=concept_definition, intent=hierarchy 오판
- 원인: 본문 "개념" 빈도 / "상위" 단독 매칭
r4 변경 (B-1 + B-2 만. B-3 title weighting 은 미적용 — 사용자 지시):
B-1. AFFINITY_KEYWORDS['concept_definition'] 에서 "개념" **제거**
(concept_definition = ['정의', '이란', '분류', '구분'] 만)
B-2. INTENT_KEYWORDS['hierarchy'] 단일 키워드 **제거** + STRONG pair 요구
(hierarchy 활성화는 "상위 하위"/"상하위"/"계층"/"포함 관계" 같은 구절 패턴 필요)
나머지 매칭 공식은 r3 그대로 유지.
출력:
- v3_structure_rerank_r4_result.yaml
"""
import datetime
import sys
from pathlib import Path
import yaml
HERE = Path(__file__).parent
sys.path.insert(0, str(HERE))
from phase_common import detect_mdx_layout_v2
import pipeline_12_r3_generate_templates_v2 as r3mod
import pipeline_12_generate_templates_v2 as r1mod
from pipeline_08_v3_r2_structure_rerank import (
MDX_LAYOUT_STRUCTURE, find_keyword_hits, profile_match, layout_compat_v2,
sha256_file, extract_mdx_raw,
)
from pipeline_08_v3_r3_structure_rerank import (
STRONG_AFFINITY_PATTERNS,
has_strong_pattern,
)
OUT_PATH = HERE / 'v3_structure_rerank_r4_result.yaml'
FINAL_ONTOLOGY = HERE / 'structure_ontology_v2_final.yaml'
V2_RESULT = HERE / 'v2_semantic_rerank_result.yaml'
LOCK_SNAPSHOT_FILES = [
'pipeline_08_v3_r4_structure_rerank.py',
'pipeline_08_v3_r3_structure_rerank.py',
'pipeline_08_v3_r2_structure_rerank.py',
'structure_ontology_v2_final.yaml',
'v2_semantic_rerank_result.yaml',
]
# ============================================================
# r4 키워드 튜닝
# ============================================================
# B-1: concept_definition 에서 '개념' 제거
AFFINITY_KEYWORDS_R4 = dict(r3mod.AFFINITY_KEYWORDS_R3)
AFFINITY_KEYWORDS_R4['concept_definition'] = ['정의', '이란', '분류', '구분']
# B-2: hierarchy 단일 키워드 제거 → 구절 패턴만
INTENT_KEYWORDS_R4 = dict(r1mod.INTENT_KEYWORDS)
INTENT_KEYWORDS_R4['hierarchy'] = [] # 단일 키워드 매칭 금지
# STRONG patterns (r3 것 확장)
STRONG_AFFINITY_PATTERNS_R4 = dict(STRONG_AFFINITY_PATTERNS)
STRONG_INTENT_PATTERNS_R4 = {
'state_transition': [
'AS-IS', 'TO-BE', 'as-is', 'to-be',
'이중 변환', '이중 Transformation',
'Process 혁신', 'Process의 혁신',
'과정 혁신', '과정의 혁신',
'전후 비교', '전후 대비', 'before/after',
],
# B-2: hierarchy 는 구절 패턴 필수
'hierarchy': [
'상위 하위', '상하위', '상위-하위', '상위, 하위',
'상위 개념 하위', '상위개념 하위개념',
'계층', '포함 관계', '트리 구조', 'hierarchy',
],
}
STRONG_REQUIRED_AFFINITY_R4 = set(STRONG_AFFINITY_PATTERNS_R4.keys())
STRONG_REQUIRED_INTENT_R4 = set(STRONG_INTENT_PATTERNS_R4.keys()) # state_transition + hierarchy
def detect_mdx_v2_profile_r4(sid: str) -> dict:
title, raw_text = extract_mdx_raw(sid)
layout = detect_mdx_layout_v2(raw_text)
struct = MDX_LAYOUT_STRUCTURE.get(layout, {})
all_text = title + ' ' + raw_text
# ─── affinity ───
weak_hits = find_keyword_hits(all_text, AFFINITY_KEYWORDS_R4)
validated_aff = []
strong_matches_aff = {}
for lab, kws in weak_hits:
if lab in STRONG_REQUIRED_AFFINITY_R4:
strongs = has_strong_pattern(all_text, STRONG_AFFINITY_PATTERNS_R4[lab])
if not strongs:
continue
strong_matches_aff[lab] = strongs
validated_aff.append((lab, kws))
validated_aff.sort(key=lambda x: -len(x[1]))
if validated_aff:
aff_primary = validated_aff[0][0]
aff_secondary = [l for l, _ in validated_aff[1:3] if l != aff_primary]
else:
aff_primary = 'concept_definition'
aff_secondary = []
# ─── intent ───
int_weak_hits = find_keyword_hits(raw_text, INTENT_KEYWORDS_R4)
rel = struct.get('relation_type')
ideal = struct.get('cardinality_ideal')
extra_int_hits = []
if rel == 'compare' and ideal == 2:
st_strongs = has_strong_pattern(all_text, STRONG_INTENT_PATTERNS_R4['state_transition'])
if st_strongs:
extra_int_hits.append(('state_transition', st_strongs))
else:
extra_int_hits.append(('binary_compare', ['compare+cardinality=2']))
elif rel == 'parallel' and ideal and ideal >= 3:
extra_int_hits.append(('multi_parallel', [f'parallel+cardinality={ideal}']))
elif rel == 'sequence':
extra_int_hits.append(('sequence', ['relation_type=sequence']))
# STRONG hierarchy pattern 있으면 추가
h_strongs = has_strong_pattern(all_text, STRONG_INTENT_PATTERNS_R4['hierarchy'])
if h_strongs:
extra_int_hits.append(('hierarchy', h_strongs))
int_all = list(int_weak_hits) + extra_int_hits
validated_int = []
strong_matches_int = {}
for lab, kws in int_all:
if lab in STRONG_REQUIRED_INTENT_R4:
strongs = has_strong_pattern(all_text, STRONG_INTENT_PATTERNS_R4[lab])
if not strongs:
continue
strong_matches_int[lab] = strongs
validated_int.append((lab, kws))
validated_int.sort(key=lambda x: -len(x[1]))
if validated_int:
int_primary = validated_int[0][0]
int_secondary = [l for l, _ in validated_int[1:3] if l != int_primary]
else:
int_primary = 'multi_parallel'
int_secondary = []
return {
'section_id': sid,
'title': title,
'layout': layout,
'family': struct.get('family'),
'relation_type': rel,
'cardinality': {'ideal': ideal} if ideal else {},
'content_affinity': {'primary': aff_primary, 'secondary': aff_secondary},
'structure_intent': {'primary': int_primary, 'secondary': int_secondary},
'validator_log': {
'strong_aff_matches': strong_matches_aff,
'strong_int_matches': strong_matches_int,
},
}
def v3_r4_score(mdx_profile: dict, frame_tpl: dict) -> dict:
layout_s, layout_bd = layout_compat_v2(mdx_profile, frame_tpl)
aff_s, aff_src = profile_match(
mdx_profile['content_affinity'],
frame_tpl['content_affinity'],
)
int_s, int_src = profile_match(
mdx_profile['structure_intent'],
frame_tpl['structure_intent_v2'],
)
total = 0.40 * layout_s + 0.35 * aff_s + 0.25 * int_s
return {
'total': round(total, 4),
'layout_compat': layout_s,
'layout_breakdown': layout_bd,
'content_affinity': round(aff_s, 4),
'content_affinity_source': aff_src,
'structure_intent': round(int_s, 4),
'structure_intent_source': int_src,
}
def main():
v2 = yaml.safe_load(V2_RESULT.read_text(encoding='utf-8'))
ontology = yaml.safe_load(FINAL_ONTOLOGY.read_text(encoding='utf-8'))
templates = ontology['templates_v2']
out_sections = {}
for sid, v2_sec in v2['mdx_sections'].items():
mdx_profile = detect_mdx_v2_profile_r4(sid)
rerank = []
for cand in v2_sec['v2_rerank']:
fid = cand['frame_id']
if fid not in templates:
continue
tpl = templates[fid]
s = v3_r4_score(mdx_profile, tpl)
rerank.append({
'frame_id': fid,
'frame_number': cand['frame_number'],
'v1_rank': cand['v1_rank'],
'v2_rank': cand['v2_rank'],
'v3_r4_total': s['total'],
'v3_r4_breakdown': s,
'fig_layout': tpl['source'].get('original_layout'),
'fig_content_affinity': tpl['content_affinity']['primary'],
'fig_structure_intent': tpl['structure_intent_v2']['primary'],
})
rerank.sort(key=lambda x: (-x['v3_r4_total'], x['v2_rank']))
for new_rank, item in enumerate(rerank, start=1):
item['v3_r4_rank'] = new_rank
out_sections[sid] = {
'answer_frame_number': v2_sec.get('answer_frame_number'),
'mdx_title': v2_sec['mdx_title'],
'mdx_profile': mdx_profile,
'v3_r4_rerank': rerank,
}
lock = {
'timestamp': datetime.datetime.now().isoformat(timespec='seconds'),
'top_k': v2['meta']['top_k'],
'files': {name: sha256_file(HERE / name) for name in LOCK_SNAPSHOT_FILES},
'r4_changes': [
'B-1: concept_definition 에서 "개념" 제거',
'B-2: hierarchy 단일 키워드 제거 — STRONG 구절 패턴 필수',
'B-3 (title weighting) 은 미적용 (사용자 지시)',
],
}
out = {
'meta': {
'pipeline_step': '8.v3.r4',
'description': 'MDX 입력 하네스 B-1 + B-2 적용',
'top_k': v2['meta']['top_k'],
'answer_map': v2['meta']['answer_map'],
'holdout_sections': v2['meta']['holdout_sections'],
'lock_snapshot': lock,
},
'mdx_sections': out_sections,
}
OUT_PATH.write_text(
yaml.safe_dump(out, allow_unicode=True, sort_keys=False, width=1000),
encoding='utf-8',
)
print('=' * 70)
print('V3 r4 (B-1 + B-2) 재정렬 완료')
print('=' * 70)
print(f' {OUT_PATH}')
print()
answer_map = v2['meta']['answer_map']
hits_r4 = 0
for sid, s in out_sections.items():
top = s['v3_r4_rerank'][0]
mp = s['mdx_profile']
ans = answer_map.get(sid)
mark = ''
if ans is not None:
ok = '' if top['frame_number'] == ans else ''
mark = f" 정답={ans} {ok}"
if top['frame_number'] == ans:
hits_r4 += 1
else:
mark = " (holdout)"
validator = mp.get('validator_log', {})
strong = (list(validator.get('strong_aff_matches', {}).keys())
+ list(validator.get('strong_int_matches', {}).keys()))
strong_str = f" [STRONG: {strong}]" if strong else ""
print(f" [{sid:8}] mdx[aff={mp['content_affinity']['primary']:24} "
f"int={mp['structure_intent']['primary']:20}]{strong_str}")
print(f" → top1 Frame {top['frame_number']:>2} "
f"(total={top['v3_r4_total']:.3f}){mark}")
print()
print(f'TARGET 정답률: {hits_r4}/4')
if __name__ == '__main__':
main()
@@ -0,0 +1,133 @@
"""Pipeline Step 8 — V3 r5: r4 MDX 하네스 + final_r2 ontology.
변경:
- ontology 입력을 structure_ontology_v2_final.yaml → structure_ontology_v2_final_r2.yaml
(Frame 13 review_queue 해제 반영)
- MDX 하네스는 r4 (B-1 + B-2) 그대로
목표:
03-1 정답률 교정 확인 — Frame 20 → Frame 13 으로 바뀌는지.
TARGET 4/4 달성 시 C.5 (V4 재실행) 진입 가능.
출력: v3_structure_rerank_r5_result.yaml
"""
import datetime
import sys
from pathlib import Path
import yaml
HERE = Path(__file__).parent
sys.path.insert(0, str(HERE))
from pipeline_08_v3_r2_structure_rerank import (
sha256_file,
)
from pipeline_08_v3_r4_structure_rerank import (
detect_mdx_v2_profile_r4, v3_r4_score,
)
OUT_PATH = HERE / 'v3_structure_rerank_r5_result.yaml'
FINAL_R2 = HERE / 'structure_ontology_v2_final_r2.yaml'
V2_RESULT = HERE / 'v2_semantic_rerank_result.yaml'
LOCK_SNAPSHOT_FILES = [
'pipeline_08_v3_r5_structure_rerank.py',
'pipeline_08_v3_r4_structure_rerank.py',
'structure_ontology_v2_final_r2.yaml',
'v2_semantic_rerank_result.yaml',
]
def main():
v2 = yaml.safe_load(V2_RESULT.read_text(encoding='utf-8'))
ontology = yaml.safe_load(FINAL_R2.read_text(encoding='utf-8'))
templates = ontology['templates_v2']
out_sections = {}
for sid, v2_sec in v2['mdx_sections'].items():
mdx_profile = detect_mdx_v2_profile_r4(sid)
rerank = []
for cand in v2_sec['v2_rerank']:
fid = cand['frame_id']
if fid not in templates:
continue
tpl = templates[fid]
s = v3_r4_score(mdx_profile, tpl)
rerank.append({
'frame_id': fid,
'frame_number': cand['frame_number'],
'v1_rank': cand['v1_rank'],
'v2_rank': cand['v2_rank'],
'v3_r5_total': s['total'],
'v3_r5_breakdown': s,
'fig_layout': tpl['source'].get('original_layout'),
'fig_content_affinity': tpl['content_affinity']['primary'],
'fig_structure_intent': tpl['structure_intent_v2']['primary'],
})
rerank.sort(key=lambda x: (-x['v3_r5_total'], x['v2_rank']))
for new_rank, item in enumerate(rerank, start=1):
item['v3_r5_rank'] = new_rank
out_sections[sid] = {
'answer_frame_number': v2_sec.get('answer_frame_number'),
'mdx_title': v2_sec['mdx_title'],
'mdx_profile': mdx_profile,
'v3_r5_rerank': rerank,
}
lock = {
'timestamp': datetime.datetime.now().isoformat(timespec='seconds'),
'top_k': v2['meta']['top_k'],
'files': {name: sha256_file(HERE / name) for name in LOCK_SNAPSHOT_FILES},
'r5_changes': [
'ontology 입력: final.yaml → final_r2.yaml (Frame 13 review_queue 해제 반영)',
'MDX 하네스: r4 (B-1 + B-2) 그대로',
],
}
out = {
'meta': {
'pipeline_step': '8.v3.r5',
'description': 'r4 MDX 하네스 + Frame 13 SSOT 확정 ontology',
'top_k': v2['meta']['top_k'],
'answer_map': v2['meta']['answer_map'],
'holdout_sections': v2['meta']['holdout_sections'],
'lock_snapshot': lock,
},
'mdx_sections': out_sections,
}
OUT_PATH.write_text(
yaml.safe_dump(out, allow_unicode=True, sort_keys=False, width=1000),
encoding='utf-8',
)
print('=' * 70)
print('V3 r5 재정렬 완료 (final_r2 ontology + B-1/B-2 하네스)')
print('=' * 70)
print(f' {OUT_PATH}')
print()
answer_map = v2['meta']['answer_map']
hits = 0
for sid, s in out_sections.items():
top = s['v3_r5_rerank'][0]
mp = s['mdx_profile']
ans = answer_map.get(sid)
mark = ''
if ans is not None:
ok = '' if top['frame_number'] == ans else ''
mark = f" 정답={ans} {ok}"
if top['frame_number'] == ans:
hits += 1
else:
mark = " (holdout)"
print(f" [{sid:8}] mdx[aff={mp['content_affinity']['primary']:24} "
f"int={mp['structure_intent']['primary']:20}]")
print(f" → top1 Frame {top['frame_number']:>2} "
f"(total={top['v3_r5_total']:.3f}){mark}")
print()
print(f'TARGET 정답률: {hits}/4')
if __name__ == '__main__':
main()
@@ -0,0 +1,123 @@
"""Pipeline Step 12-final — r3 에 최소 수동 오버라이드 적용하고 V3 용 최종 v2 고정.
사용자 승인 사항:
- Frame 04 → interrelation (관계도 기반)
- Frame 05 → concept_definition (보상현황은 정책 현황이 아님)
- Frame 31 → comparative_matrix (산업별 3열 비교)
- Frame 13, 19, 22, 28 은 **review_queue** 에 남김 (수동 하드코딩 최소화)
출력:
- structure_ontology_v2_final.yaml ← C.4 (V3 매칭 로직 v2) 에서 사용
"""
import datetime
import sys
from pathlib import Path
import yaml
HERE = Path(__file__).parent
sys.path.insert(0, str(HERE))
R3_PATH = HERE / 'structure_ontology_v2_r3.yaml'
FINAL_PATH = HERE / 'structure_ontology_v2_final.yaml'
# ============================================================
# 수동 오버라이드 (사용자 승인, 3건)
# ============================================================
MANUAL_OVERRIDES = {
'04': {
'content_affinity_primary': 'interrelation',
'reason': '"관계도" 가 타이틀/내용에 있어 의미상 interrelation 이 더 적합',
},
'05': {
'content_affinity_primary': 'concept_definition',
'reason': '"보상현황""현황" 은 정책 현황 아님 — 민원 관리 방식 정의',
},
'31': {
'content_affinity_primary': 'comparative_matrix',
'reason': '산업별 3열 table 구조는 다축 비교 매트릭스',
},
}
# 재검토 대기 목록 (수동 오버라이드 안 함)
REVIEW_QUEUE = [
{'frame': '13', 'reason': '"필수조건" 이 comparative_matrix 로 잡힘 — capability_requirements 여야 할 가능성'},
{'frame': '19', 'reason': '"설계방식 왜곡" 이 capability_requirements 로 잡힘 — stakeholder_roles 또는 problem_diagnosis 여부 검토'},
{'frame': '22', 'reason': '"Model 특화 S/W" 가 process_steps 로 잡힘 — concept_definition 또는 capability_requirements 여부 검토'},
{'frame': '28', 'reason': '"현존 S/W 의 현실" 이 goal_axes 로 잡힘 — singleton_emphasis 또는 problem_diagnosis 여부 검토'},
]
def main():
r3 = yaml.safe_load(R3_PATH.read_text(encoding='utf-8'))
templates = r3['templates_v2']
# short_id → fid 매핑
short_to_fid = {v['short_id']: k for k, v in templates.items()}
# 오버라이드 적용
override_log = []
for short_id, override in MANUAL_OVERRIDES.items():
fid = short_to_fid.get(short_id)
if not fid:
continue
entry = templates[fid]
old_primary = entry['content_affinity']['primary']
new_primary = override['content_affinity_primary']
# primary 교체 (secondary 는 그대로 유지)
entry['content_affinity']['primary'] = new_primary
# evidence 에 오버라이드 기록
entry['content_affinity']['evidence']['primary'] = {
'source': 'manual_override',
'prior_primary': old_primary,
'reason': override['reason'],
'confidence': 1.0,
}
entry.setdefault('v2_meta', {})['manually_overridden'] = True
override_log.append({
'frame': short_id,
'frame_id': fid,
'field': 'content_affinity.primary',
'from': old_primary,
'to': new_primary,
'reason': override['reason'],
})
# 메타 업데이트
meta = dict(r3['meta'])
meta['schema_version'] = 'template-fit-v2-final'
meta['generated_at'] = datetime.datetime.now().isoformat(timespec='seconds')
meta['generator'] = 'pipeline_12_finalize_v2.py'
meta['predecessor_drafts'] = meta.get('predecessor_drafts', []) + ['structure_ontology_v2_r3.yaml (r3)']
meta['manual_overrides'] = override_log
meta['review_queue'] = REVIEW_QUEUE
meta['status'] = 'locked_for_v3_matching'
meta['r4_note'] = (
'3건 수동 오버라이드 (Frame 04/05/31) 적용. '
'재검토 대기 4건 (13/19/22/28) 은 review_queue 로 남김 — '
'하드코딩 최소화, 추후 샘플 확장 시 재평가.'
)
out = {'meta': meta, 'templates_v2': templates}
FINAL_PATH.write_text(
yaml.safe_dump(out, allow_unicode=True, sort_keys=False, width=1000),
encoding='utf-8',
)
print('=' * 70)
print('v2 final 고정 완료 (C.4 입력)')
print('=' * 70)
print(f' {FINAL_PATH}')
print()
print('수동 오버라이드:')
for log in override_log:
print(f" Frame {log['frame']}: {log['from']}{log['to']}")
print()
print(f"review_queue ({len(REVIEW_QUEUE)} 건):")
for r in REVIEW_QUEUE:
print(f" Frame {r['frame']}: {r['reason']}")
if __name__ == '__main__':
main()
@@ -0,0 +1,695 @@
"""Pipeline Step 12 — templates_v2 draft 생성 (C.2-b AI 일괄 라벨링).
사용자 지침:
1. 원본 structure_ontology.yaml 는 건드리지 않음. structure_ontology_v2.yaml 별도 파일.
2. 각 프레임에 content_affinity / structure_intent / alternative_patterns + evidence/reason.
3. 32개 전부 자동 확정하지 말고 우선 검토 5개 (12, 20, 29, 3, 11) 별도 리포트.
4. 결과 3개 산출물:
(a) structure_ontology_v2.yaml
(b) PRIORITY_5_REVIEW.md/html (우선 5개 상세)
(c) TEMPLATES_V2_DIFF.md (v1 대비 무엇이 달라졌나)
라벨링 전략:
- Layout-default heuristic (fig_layout → 기본 affinity/intent)
- 내용 설명 키워드 refinement (override primary)
- suits/not_suits 에서 교차 검증 신호 추출
- alternative_patterns: _COMPAT 에서 파생 + layout family 유사도
- 각 라벨에 evidence: {field, quote, confidence} 구조
AI 라벨링이지만 결정론적 (재현 가능) — LLM 호출 없이 규칙+키워드로 생성.
"""
import datetime
import re
import sys
from collections import Counter, defaultdict
from pathlib import Path
import markdown
import yaml
HERE = Path(__file__).parent
ROOT = HERE.parent.parent
BLOCKS_DIR = ROOT / 'figma_to_html_agent' / 'blocks'
sys.path.insert(0, str(HERE))
from phase_common import _COMPAT
ONTOLOGY_V1_PATH = HERE / 'structure_ontology.yaml'
OUT_V2_PATH = HERE / 'structure_ontology_v2.yaml'
PRIORITY_MD = HERE / 'PRIORITY_5_REVIEW.md'
PRIORITY_HTML = HERE / 'PRIORITY_5_REVIEW.html'
DIFF_MD = HERE / 'TEMPLATES_V2_DIFF.md'
PRIORITY_FRAME_NUMBERS = {3, 11, 12, 20, 29}
# ============================================================
# Vocabularies (schema v2)
# ============================================================
CONTENT_AFFINITY_ENUM = [
'concept_definition', 'concept_comparison', 'goal_axes',
'persona_benefit', 'process_steps', 'before_after_change',
'capability_requirements', 'comparative_matrix',
'stakeholder_roles', 'policy_requirements',
'tool_ecosystem', 'interrelation',
]
STRUCTURE_INTENT_ENUM = [
'binary_compare', 'multi_parallel', 'hierarchy',
'sequence', 'state_transition', 'cycle_interrelation',
'matrix_coverage', 'persona_mapping', 'singleton_emphasis',
]
# ============================================================
# Layout → default content_affinity (primary 먼저)
# ============================================================
# Key 는 original_layout (visual_pattern.layout 또는 source.original_layout)
LAYOUT_DEFAULT_AFFINITY = {
# Compare family
'compare-rows': ['concept_comparison', 'comparative_matrix'],
'compare-2banner-top-2col-bottom': ['before_after_change', 'concept_comparison'],
'compare-2col': ['concept_comparison'],
'compare-2banner': ['before_after_change', 'concept_comparison'],
'banner-top-2col-bottom': ['before_after_change', 'concept_comparison'],
# Table
'table-2col': ['concept_comparison', 'comparative_matrix'],
'table-3col': ['comparative_matrix'],
# Persona / 3-column
'persona-3col': ['persona_benefit', 'stakeholder_roles'],
'3col-parallel': ['goal_axes'],
'3col-cards': ['capability_requirements'],
'3col-compare': ['concept_comparison', 'comparative_matrix'],
'3-column': ['goal_axes'],
'3-category': ['concept_definition'],
'cards-3-category': ['concept_definition'],
'cards-3-compare': ['concept_comparison'],
'cards-3-header': ['capability_requirements'],
# Cards 4+
'cards-4': ['capability_requirements'],
'cards-4-grid': ['capability_requirements'],
'policy-4card-plus-list': ['policy_requirements'],
# Cycle / interrelation
'cycle-3way': ['interrelation', 'goal_axes'],
'cycle-3way-intersection': ['interrelation', 'goal_axes'],
'circular-nodes': ['interrelation'],
'circular-nodes-6': ['interrelation'],
'quadrilateral-relations': ['interrelation'],
# Lists
'list-numbered': ['policy_requirements', 'capability_requirements'],
'list-numbered-4': ['policy_requirements', 'capability_requirements'],
'list-stacked': ['policy_requirements'],
'list-stacked-vertical': ['policy_requirements'],
'bullet-cards': ['capability_requirements'],
'bullet-cards-4-plus-center': ['capability_requirements', 'goal_axes'],
# Paired / Quadrant
'paired-rows': ['comparative_matrix'],
'paired-rows-2x2': ['comparative_matrix'],
'2col-paired': ['persona_benefit'],
'2col-paired-list': ['persona_benefit', 'stakeholder_roles'],
'2-boxes': ['concept_comparison'],
'quadrant-issues': ['policy_requirements', 'singleton_emphasis'],
'quadrant-4': ['comparative_matrix'],
# Diagram / radial
'diagram-5': ['goal_axes'],
'radial-diagram-5': ['goal_axes'],
'diagram-labels': ['concept_definition'],
'central-5-goals': ['goal_axes'],
'central-split': ['concept_comparison'],
'central-split-synthesis': ['concept_comparison'],
# Split panel
'split-panel-diagram': ['concept_comparison'],
'split-panel-numbered': ['process_steps'],
# Side / Sections
'side-card': ['concept_definition'],
'side-card-with-list': ['concept_definition', 'capability_requirements'],
'3-section': ['goal_axes'],
'3-section-framework': ['goal_axes', 'policy_requirements'],
'3-emphasis': ['goal_axes', 'singleton_emphasis'],
'title-plus-3-emphasis': ['goal_axes', 'singleton_emphasis'],
# Full page
'full-page-map': ['policy_requirements', 'stakeholder_roles'],
'full-page-map-banner': ['policy_requirements'],
}
LAYOUT_DEFAULT_INTENT = {
'compare-rows': ['matrix_coverage', 'binary_compare'],
'compare-2banner-top-2col-bottom': ['state_transition', 'binary_compare'],
'compare-2col': ['binary_compare'],
'compare-2banner': ['state_transition', 'binary_compare'],
'banner-top-2col-bottom': ['state_transition', 'binary_compare'],
'table-2col': ['matrix_coverage'],
'table-3col': ['matrix_coverage'],
'persona-3col': ['persona_mapping', 'multi_parallel'],
'3col-parallel': ['multi_parallel'],
'3col-cards': ['multi_parallel'],
'3col-compare': ['binary_compare', 'matrix_coverage'],
'3-column': ['multi_parallel'],
'3-category': ['multi_parallel'],
'cards-3-category': ['multi_parallel'],
'cards-3-compare': ['binary_compare'],
'cards-3-header': ['multi_parallel'],
'cards-4': ['multi_parallel'],
'cards-4-grid': ['multi_parallel'],
'policy-4card-plus-list': ['multi_parallel'],
'cycle-3way': ['cycle_interrelation', 'multi_parallel'],
'cycle-3way-intersection': ['cycle_interrelation', 'multi_parallel'],
'circular-nodes': ['cycle_interrelation'],
'circular-nodes-6': ['cycle_interrelation'],
'quadrilateral-relations': ['cycle_interrelation'],
'list-numbered': ['sequence'],
'list-numbered-4': ['sequence', 'multi_parallel'],
'list-stacked': ['multi_parallel'],
'list-stacked-vertical': ['multi_parallel'],
'bullet-cards': ['multi_parallel'],
'bullet-cards-4-plus-center': ['multi_parallel', 'hierarchy'],
'paired-rows': ['matrix_coverage'],
'paired-rows-2x2': ['matrix_coverage'],
'2col-paired': ['persona_mapping', 'binary_compare'],
'2col-paired-list': ['persona_mapping'],
'2-boxes': ['binary_compare'],
'quadrant-issues': ['matrix_coverage', 'multi_parallel'],
'quadrant-4': ['matrix_coverage'],
'diagram-5': ['hierarchy'],
'radial-diagram-5': ['hierarchy', 'multi_parallel'],
'diagram-labels': ['singleton_emphasis'],
'central-5-goals': ['hierarchy', 'multi_parallel'],
'central-split': ['binary_compare'],
'central-split-synthesis': ['binary_compare'],
'split-panel-diagram': ['binary_compare'],
'split-panel-numbered': ['sequence'],
'side-card': ['singleton_emphasis'],
'side-card-with-list': ['singleton_emphasis', 'multi_parallel'],
'3-section': ['multi_parallel'],
'3-section-framework': ['multi_parallel', 'hierarchy'],
'3-emphasis': ['multi_parallel', 'singleton_emphasis'],
'title-plus-3-emphasis': ['multi_parallel', 'singleton_emphasis'],
'full-page-map': ['singleton_emphasis', 'matrix_coverage'],
'full-page-map-banner': ['singleton_emphasis'],
}
# ============================================================
# 키워드 → affinity 보강 (내용 설명에서 발견 시 primary override)
# ============================================================
AFFINITY_KEYWORDS = {
'concept_definition': ['정의', '이란', '개념', '분류', '구분'],
'concept_comparison': ['비교', '대조', '차이', 'vs', 'VS', '상호관계'],
'goal_axes': ['목표', '궁극적', '비전', '목적', '지향'],
'persona_benefit': ['발주자', '설계자', '시공자', '기대효과', '혜택', '이익'],
'process_steps': ['단계', '순서', 'Step', '1단계', '2단계', '흐름'],
'before_after_change': ['AS-IS', 'TO-BE', '전환', '혁신', '변화', '이중 변환', '과정'],
'capability_requirements': ['필수', '요건', '필요', '역량', 'S/W', '도구'],
'comparative_matrix': ['다면', '다축', '관점별', '여러 관점', ''],
'stakeholder_roles': ['역할', '책임', '주도', '수행'],
'policy_requirements': ['정책', '제도', '도입', '거버넌스', '전면 도입', '국외'],
'tool_ecosystem': ['Revit', 'Navisworks', 'SketchUp', '소프트웨어', 'S/W 생태'],
'interrelation': ['상호관계', '순환', '조화', '교차', '수렴', '3원'],
}
# ============================================================
# 키워드 → intent 보강
# ============================================================
INTENT_KEYWORDS = {
'binary_compare': ['2개 비교', '2개 개념', '대조', '양분'],
'state_transition': ['AS-IS', 'TO-BE', '전환', '혁신', '이중 Transformation', '과정'],
'cycle_interrelation': ['상호관계', '순환', '조화', '교차', '3원'],
'multi_parallel': ['3개 병렬', '4개 병렬', '카드 3열', '3관점'],
'hierarchy': ['중앙', '상위', '하위', '포함'],
'sequence': ['단계', '순서', '흐름', 'step'],
'matrix_coverage': ['다면', '관점별', '여러 관점', '축별'],
'persona_mapping': ['주체별', '발주자/설계자/시공자', '역할별'],
'singleton_emphasis': ['강조', '문제', '진단', '약점'],
}
# ============================================================
# 핵심 함수
# ============================================================
def parse_analysis_md(path: Path) -> dict:
"""analysis.md 의 주요 섹션 추출."""
text = path.read_text(encoding='utf-8')
# 내용 설명
m = re.search(r'##\s*내용 설명\s*\n+([\s\S]+?)(?=\n##\s|\Z)', text)
content = m.group(1).strip() if m else ''
# suits
m_s = re.search(r'###\s*suits\s*\n([\s\S]+?)(?=\n###|\n##\s|\Z)', text)
suits = []
if m_s:
for ln in m_s.group(1).strip().split('\n'):
ln = ln.strip()
if ln.startswith('-'):
suits.append(ln.lstrip('-').strip())
# not_suits
m_ns = re.search(r'###\s*not_suits\s*\n([\s\S]+?)(?=\n###|\n##\s|\Z)', text)
not_suits = []
if m_ns:
for ln in m_ns.group(1).strip().split('\n'):
ln = ln.strip()
if ln.startswith('-'):
not_suits.append(ln.lstrip('-').strip())
return {'content': content, 'suits': suits, 'not_suits': not_suits}
def find_keyword_hits(text: str, keyword_map: dict) -> list[tuple[str, list[str]]]:
"""text 안에서 각 label 의 키워드 찾기."""
hits = []
for label, kws in keyword_map.items():
found = [kw for kw in kws if kw in text]
if found:
hits.append((label, found))
return hits
def label_content_affinity(layout: str, original_layout: str, content: str,
suits: list, not_suits: list) -> dict:
"""content_affinity primary + secondary + evidence."""
# 1. default
defaults = (LAYOUT_DEFAULT_AFFINITY.get(original_layout)
or LAYOUT_DEFAULT_AFFINITY.get(layout)
or ['concept_definition'])
default_primary = defaults[0]
default_secondary = defaults[1:]
# 2. 키워드 hit
all_text = content + ' ' + ' '.join(suits)
kw_hits = find_keyword_hits(all_text, AFFINITY_KEYWORDS)
# 3. primary 결정: 키워드 가장 강한 것 (개수 기준), 없으면 default
if kw_hits:
kw_hits.sort(key=lambda x: -len(x[1]))
primary = kw_hits[0][0]
primary_kws = kw_hits[0][1]
primary_source = 'keyword_match'
else:
primary = default_primary
primary_kws = []
primary_source = 'layout_default'
# 4. secondary: primary 제외 keyword hit + default secondary
sec_candidates = [l for l, kws in kw_hits[1:] if l != primary]
for d in default_secondary:
if d not in sec_candidates and d != primary:
sec_candidates.append(d)
secondary = sec_candidates[:2]
# 5. evidence 생성
evidence = {}
if primary_source == 'keyword_match':
evidence['primary'] = {
'source': 'keyword_match',
'field': '내용 설명 / suits',
'keywords': primary_kws,
'rule': f"'{primary_kws[0]}' 등 키워드가 {primary} 를 가리킴",
'confidence': min(0.95, 0.6 + 0.1 * len(primary_kws)),
}
else:
evidence['primary'] = {
'source': 'layout_default',
'field': f'original_layout = {original_layout or layout}',
'rule': f'layout → 기본 affinity 매핑',
'confidence': 0.6,
}
for i, s in enumerate(secondary, start=1):
hits_for_s = next((kws for l, kws in kw_hits if l == s), None)
if hits_for_s:
evidence[f'secondary_{i}'] = {
'source': 'keyword_match',
'keywords': hits_for_s,
'confidence': 0.65,
}
else:
evidence[f'secondary_{i}'] = {
'source': 'layout_default',
'confidence': 0.5,
}
return {
'primary': primary,
'secondary': secondary,
'evidence': evidence,
}
def label_structure_intent(layout: str, original_layout: str, content: str,
relation_type: str, cardinality: dict) -> dict:
"""structure_intent primary + secondary + evidence."""
defaults = (LAYOUT_DEFAULT_INTENT.get(original_layout)
or LAYOUT_DEFAULT_INTENT.get(layout)
or ['multi_parallel'])
default_primary = defaults[0]
default_secondary = defaults[1:]
kw_hits = find_keyword_hits(content, INTENT_KEYWORDS)
# relation_type + cardinality 보강
ideal = cardinality.get('ideal') if cardinality else None
if relation_type == 'compare' and ideal == 2:
# state_transition 우선 (내용에 전환/AS-IS 가 있으면)
if 'AS-IS' in content or 'TO-BE' in content or '혁신' in content or '전환' in content:
kw_hits.append(('state_transition', ['AS-IS/TO-BE/혁신/전환']))
else:
kw_hits.append(('binary_compare', ['compare + cardinality.ideal=2']))
elif relation_type == 'parallel' and ideal and ideal >= 3:
kw_hits.append(('multi_parallel', [f'parallel + cardinality.ideal={ideal}']))
elif relation_type == 'sequence':
kw_hits.append(('sequence', ['relation_type=sequence']))
if kw_hits:
kw_hits.sort(key=lambda x: -len(x[1]))
primary = kw_hits[0][0]
primary_kws = kw_hits[0][1]
primary_source = 'keyword_or_structure_match'
else:
primary = default_primary
primary_kws = []
primary_source = 'layout_default'
sec_candidates = []
for l, kws in kw_hits[1:]:
if l != primary and l not in sec_candidates:
sec_candidates.append(l)
for d in default_secondary:
if d not in sec_candidates and d != primary:
sec_candidates.append(d)
secondary = sec_candidates[:2]
evidence = {
'primary': {
'source': primary_source,
'rule': primary_kws[0] if primary_kws else f'layout default → {primary}',
'confidence': 0.7 if primary_source == 'keyword_or_structure_match' else 0.6,
}
}
for i, s in enumerate(secondary, start=1):
evidence[f'secondary_{i}'] = {
'source': 'layout_default' if s in default_secondary else 'keyword_match',
'confidence': 0.55,
}
return {
'primary': primary,
'secondary': secondary,
'evidence': evidence,
}
def derive_alternative_patterns(fig_layout: str) -> list[dict]:
"""_COMPAT 에서 파생 — 이 fig_layout 과 의미적으로 호환 가능한 다른 layout."""
alternatives = {}
# 이 fig_layout 을 높게 평가하는 mdx_layout 들
for mdx_l, fig_dict in _COMPAT.items():
my_compat = fig_dict.get(fig_layout, 0)
if my_compat >= 0.6:
# 같은 mdx_l 에서 compat >= 0.7 인 다른 fig_layout 들 → 대안
for other_fig, c in fig_dict.items():
if other_fig == fig_layout:
continue
if c >= 0.7:
# 가중 합산: 공통으로 호환되는 mdx_l 이 많을수록 강한 대안
alternatives[other_fig] = alternatives.get(other_fig, 0) + my_compat * c
# 정규화 (0~1)
if not alternatives:
return []
max_v = max(alternatives.values())
alts_list = []
for fig_l, v in sorted(alternatives.items(), key=lambda x: -x[1])[:5]:
conf = round(v / max_v, 2)
alts_list.append({
'pattern': fig_l,
'reason': f'_COMPAT 공통 호환 mdx_layout 집합 기반 파생 (정규화 {conf})',
'confidence': conf,
})
return alts_list
def generate_v2_entry(fid: str, tpl_v1: dict) -> dict:
"""v1 엔트리에 v2 필드 추가."""
layout = tpl_v1['visual_pattern']['layout']
original_layout = tpl_v1['source'].get('original_layout', layout)
relation_type = tpl_v1['visual_pattern'].get('relation_type')
cardinality = tpl_v1['visual_pattern'].get('cardinality', {})
# analysis.md 읽기
analysis = parse_analysis_md(BLOCKS_DIR / fid / 'analysis.md')
# v2 필드 생성
affinity = label_content_affinity(
layout, original_layout,
analysis['content'], analysis['suits'], analysis['not_suits'],
)
intent = label_structure_intent(
layout, original_layout, analysis['content'],
relation_type, cardinality,
)
alternatives = derive_alternative_patterns(original_layout)
# v1 엔트리 그대로 복제 + v2 필드 추가
entry = dict(tpl_v1)
entry['content_affinity'] = affinity
entry['structure_intent_v2'] = intent # v1 의 structure_intent 와 구분 위해 이름 변경
entry['alternative_patterns'] = alternatives
entry['v2_meta'] = {
'generated_at': datetime.datetime.now().isoformat(timespec='seconds'),
'source_analysis': f'{fid}/analysis.md',
'needs_review': tpl_v1.get('short_id') in {'03', '11', '12', '20', '29'},
}
return entry
# ============================================================
# 메인
# ============================================================
def main():
v1 = yaml.safe_load(ONTOLOGY_V1_PATH.read_text(encoding='utf-8'))
templates_v1 = v1['templates_v1']
templates_v2 = {}
for fid, tpl in templates_v1.items():
templates_v2[fid] = generate_v2_entry(fid, tpl)
# 산출물 1: structure_ontology_v2.yaml
out = {
'meta': {
'schema_version': 'template-fit-v2-draft',
'generated_from': 'structure_ontology.yaml (templates_v1)',
'generated_at': datetime.datetime.now().isoformat(timespec='seconds'),
'generator': 'pipeline_12_generate_templates_v2.py',
'status': 'draft_pending_user_review',
'priority_review_frames': sorted(PRIORITY_FRAME_NUMBERS),
'vocabularies': {
'content_affinity': CONTENT_AFFINITY_ENUM,
'structure_intent': STRUCTURE_INTENT_ENUM,
},
'matching_weights_initial': {
'layout_compat': 0.40,
'content_affinity': 0.35,
'structure_intent': 0.25,
},
'note': (
'AI 초안. 원본 structure_ontology.yaml 는 유지. '
'사용자 검토 우선순위: Frame 12, 20, 29, 3, 11 → 2차 (3col/cycle/table/process 계열) → 3차 (나머지).'
),
},
'templates_v2': templates_v2,
}
OUT_V2_PATH.write_text(
yaml.safe_dump(out, allow_unicode=True, sort_keys=False, width=1000),
encoding='utf-8',
)
# ============================================================
# 산출물 2: PRIORITY_5_REVIEW
# ============================================================
pr_md = []
pr_md.append('# Priority 5 프레임 검토 리포트 (C.2-b AI 초안)')
pr_md.append('')
pr_md.append(
'사용자 검토 우선순위 1순위 — Holdout 평가에서 V3 문제가 드러난 프레임.'
)
pr_md.append('')
pr_md.append(
'각 프레임마다: (a) v1 대비 추가된 v2 필드, (b) 라벨 근거(evidence), '
'(c) 검토 포인트.'
)
pr_md.append('')
short_to_fid = {v['short_id']: k for k, v in templates_v1.items()}
for fn in ['03', '11', '12', '20', '29']:
fid = short_to_fid.get(fn)
if not fid:
continue
v2_entry = templates_v2[fid]
v1_entry = templates_v1[fid]
title = v1_entry['source']['title']
original_layout = v1_entry['source'].get('original_layout')
pr_md.append(f"## Frame {fn}{title}")
pr_md.append('')
pr_md.append(f"- **frame_id**: `{fid}`")
pr_md.append(f"- **layout**: `{v1_entry['visual_pattern']['layout']}` (original: `{original_layout}`)")
pr_md.append(f"- **family / relation_type / cardinality**: "
f"`{v1_entry['visual_pattern']['family']}` / "
f"`{v1_entry['visual_pattern'].get('relation_type')}` / "
f"ideal={v1_entry['visual_pattern'].get('cardinality', {}).get('ideal')}")
pr_md.append('')
pr_md.append('### content_affinity')
aff = v2_entry['content_affinity']
pr_md.append(f"- **primary**: `{aff['primary']}`")
if aff['secondary']:
pr_md.append(f"- **secondary**: {', '.join(f'`{s}`' for s in aff['secondary'])}")
pr_md.append('- **evidence**:')
for k, v in aff['evidence'].items():
extras = []
if 'keywords' in v and v['keywords']:
extras.append(f"keywords={v['keywords']}")
if 'rule' in v:
extras.append(f"rule={v['rule']}")
extras.append(f"conf={v['confidence']}")
pr_md.append(f" - `{k}` (source: {v['source']}) — {' / '.join(extras)}")
pr_md.append('')
pr_md.append('### structure_intent (v2)')
si = v2_entry['structure_intent_v2']
pr_md.append(f"- **primary**: `{si['primary']}`")
if si['secondary']:
pr_md.append(f"- **secondary**: {', '.join(f'`{s}`' for s in si['secondary'])}")
pr_md.append('- **evidence**:')
for k, v in si['evidence'].items():
pr_md.append(f" - `{k}` (source: {v['source']}) — rule={v.get('rule', '')} / conf={v['confidence']}")
pr_md.append('')
pr_md.append('### alternative_patterns (파생)')
if v2_entry['alternative_patterns']:
pr_md.append('| 대안 layout | confidence | 근거 |')
pr_md.append('|---|---:|---|')
for a in v2_entry['alternative_patterns']:
pr_md.append(f"| `{a['pattern']}` | {a['confidence']} | {a['reason']} |")
else:
pr_md.append('(파생된 대안 없음 — _COMPAT 기반 공통 호환 부재)')
pr_md.append('')
pr_md.append('### 검토 포인트')
pr_md.append(f"- primary content_affinity 가 Frame 의 실제 의도에 맞나?")
pr_md.append(f"- structure_intent primary/secondary 가 layout 의 시각적 메시지를 정확히 기술하나?")
pr_md.append(f"- alternative_patterns 에 **빠진** 의미적 대안이 있나? (예: Frame 12 라면 `3col-parallel` 이 포함되어 있나)")
pr_md.append('')
pr_md.append('---')
pr_md.append('')
pr_text = '\n'.join(pr_md)
PRIORITY_MD.write_text(pr_text, encoding='utf-8')
style = """
body { font-family: -apple-system, "Segoe UI", Pretendard, sans-serif; max-width: 1100px; margin: 2em auto; padding: 0 1.5em 4em; line-height: 1.65; color: #222; background: #f8fafc; }
h1 { border-bottom: 3px solid #2563eb; padding-bottom: 0.25em; }
h2 { margin-top: 2.5em; background: #e0e7ff; padding: 0.6em 0.9em; border-left: 4px solid #0a6; border-radius: 4px; }
h3 { margin-top: 1.3em; color: #1a365d; }
table { border-collapse: collapse; background: #fff; margin: 0.5em 0 1em; }
th, td { border: 1px solid #e2e8f0; padding: 8px 10px; text-align: left; vertical-align: top; }
th { background: #1e293b; color: #fff; }
code { background: #f4f4f4; padding: 1px 6px; border-radius: 3px; font-size: 0.9em; color: #111; }
strong { color: #0a6; }
"""
html_body = markdown.markdown(pr_text, extensions=['tables'])
html = f"""<!DOCTYPE html>
<html lang="ko"><head><meta charset="utf-8"><title>Priority 5 검토</title><style>{style}</style></head>
<body>{html_body}</body></html>"""
PRIORITY_HTML.write_text(html, encoding='utf-8')
# ============================================================
# 산출물 3: TEMPLATES_V2_DIFF.md (v1 대비 변경 요약)
# ============================================================
d_md = []
d_md.append('# templates_v1 → templates_v2 변경 요약')
d_md.append('')
d_md.append('_v2 는 v1 엔트리를 **복제 + 필드 추가** 방식으로 생성. 기존 필드는 변경 없음._')
d_md.append('')
d_md.append('## 추가된 필드')
d_md.append('')
d_md.append('| 필드 | 타입 | 역할 |')
d_md.append('|---|---|---|')
d_md.append('| `content_affinity` | `{primary, secondary[], evidence{}}` | 프레임이 선호하는 콘텐츠 성격 |')
d_md.append('| `structure_intent_v2` | `{primary, secondary[], evidence{}}` | 레이아웃의 시각 의도 (v1 의 structure_intent 와 별도 축) |')
d_md.append('| `alternative_patterns` | `[{pattern, reason, confidence}]` | 의미적 대안 layout 목록 |')
d_md.append('| `v2_meta` | `{generated_at, source_analysis, needs_review}` | 생성 메타 |')
d_md.append('')
d_md.append('## 유지된 필드 (변경 없음)')
d_md.append('')
d_md.append('- `short_id` / `template_id` / `schema_version` / `source`')
d_md.append('- `description` / `visual_pattern` (전체)')
d_md.append('- `slots` / `suits` / `not_suits` / `adaptation_allowed`')
d_md.append('')
d_md.append('## 통계')
d_md.append('')
# affinity 분포
aff_counter = Counter()
intent_counter = Counter()
alt_counts = []
for fid, e in templates_v2.items():
aff_counter[e['content_affinity']['primary']] += 1
intent_counter[e['structure_intent_v2']['primary']] += 1
alt_counts.append(len(e['alternative_patterns']))
d_md.append('### content_affinity primary 분포')
d_md.append('')
d_md.append('| primary | 프레임 수 |')
d_md.append('|---|---:|')
for lab, c in aff_counter.most_common():
d_md.append(f"| `{lab}` | {c} |")
d_md.append('')
d_md.append('### structure_intent (v2) primary 분포')
d_md.append('')
d_md.append('| primary | 프레임 수 |')
d_md.append('|---|---:|')
for lab, c in intent_counter.most_common():
d_md.append(f"| `{lab}` | {c} |")
d_md.append('')
d_md.append('### alternative_patterns 수')
d_md.append('')
d_md.append(f"- 평균 대안 수: {round(sum(alt_counts)/len(alt_counts), 1)}")
d_md.append(f"- 대안 0개 프레임: {sum(1 for c in alt_counts if c == 0)}")
d_md.append(f"- 대안 5개 프레임: {sum(1 for c in alt_counts if c == 5)}")
d_md.append('')
d_md.append('## v1 과의 관계')
d_md.append('')
d_md.append(
'- `structure_ontology.yaml` 원본 **유지** — v2 는 `structure_ontology_v2.yaml` 별도 파일.'
)
d_md.append(
'- v2 매칭 로직 (pipeline_08_v3_structure_rerank.py 재구현) 에서 `_COMPAT` 대신 '
'`content_affinity + structure_intent_v2 + alternative_patterns` 조합 사용 예정.'
)
d_md.append(
'- v2 최종 승인 전까지 V3 재구현 보류 — 사용자 검토 (priority 5) 후 확정.'
)
d_md.append('')
DIFF_MD.write_text('\n'.join(d_md), encoding='utf-8')
print('=' * 70)
print('templates_v2 draft 생성 완료')
print('=' * 70)
print(f' 1. structure_ontology_v2.yaml: {OUT_V2_PATH}')
print(f' 2. PRIORITY_5_REVIEW.md/html: {PRIORITY_MD}')
print(f' 3. TEMPLATES_V2_DIFF.md: {DIFF_MD}')
print()
print(f' affinity primary 분포: {dict(aff_counter.most_common(5))}')
print(f' intent primary 분포: {dict(intent_counter.most_common(5))}')
print(f' 평균 대안 수: {round(sum(alt_counts)/len(alt_counts), 1)}')
if __name__ == '__main__':
main()
@@ -0,0 +1,567 @@
"""Pipeline Step 12-r2 — templates_v2 재라벨링 (규칙 버그 수정 버전).
r1 (`pipeline_12_generate_templates_v2.py`) 의 버그:
- 내용 설명 + suits + not_suits 를 **모두** 긍정 신호로 스캔
- 결과: not_suits 의 "시간 순서 단계", "BIM vs DX 비교" 등에서 긍정 키워드 매칭 → 오판 4건
r2 수정:
1. **내용 설명** = 주 신호 (positive)
2. **suits** = 보너스 긍정 신호 (존재 시 confidence +)
3. **not_suits** = **반대 신호** — 해당 affinity/intent 가 primary 로 오르지 못하게 **차단**
4. Frame 12 (cycle-3way) 의 `3col-parallel` 대안 — 자동 파생 불가 항목 **수동 시드** 등록
산출물 (기존과 별도):
- structure_ontology_v2_r2.yaml
- PRIORITY_5_REVIEW_r2.md/html
- TEMPLATES_V2_DIFF_r2.md
원본 r1 결과는 **유지** — 재현성 / 디버그용.
"""
import datetime
import re
import sys
from collections import Counter, defaultdict
from pathlib import Path
import markdown
import yaml
HERE = Path(__file__).parent
ROOT = HERE.parent.parent
BLOCKS_DIR = ROOT / 'figma_to_html_agent' / 'blocks'
sys.path.insert(0, str(HERE))
from phase_common import _COMPAT
# r1 의 상수 재사용 (heuristic 테이블은 그대로)
from pipeline_12_generate_templates_v2 import (
CONTENT_AFFINITY_ENUM,
STRUCTURE_INTENT_ENUM,
LAYOUT_DEFAULT_AFFINITY,
LAYOUT_DEFAULT_INTENT,
AFFINITY_KEYWORDS,
INTENT_KEYWORDS,
parse_analysis_md,
find_keyword_hits,
derive_alternative_patterns as r1_derive_alternatives,
)
ONTOLOGY_V1_PATH = HERE / 'structure_ontology.yaml'
OUT_V2_PATH = HERE / 'structure_ontology_v2_r2.yaml'
PRIORITY_MD = HERE / 'PRIORITY_5_REVIEW_r2.md'
PRIORITY_HTML = HERE / 'PRIORITY_5_REVIEW_r2.html'
DIFF_MD = HERE / 'TEMPLATES_V2_DIFF_r2.md'
PRIORITY_FRAME_NUMBERS = {3, 11, 12, 20, 29}
# ============================================================
# 수동 시드 (자동 파생 한계 보완 — 사용자 승인 항목)
# ============================================================
MANUAL_SEED_ALTERNATIVES = {
# Frame 12 (cycle-3way): _COMPAT 에 MDX 쪽 매핑 없어 자동 파생 불가
'1171281189': [
{
'pattern': '3col-parallel',
'reason': '3개 요소를 순환 대신 평면 병렬 나열로 표현하는 대안',
'confidence': 0.7,
'source': 'manual_seed',
},
{
'pattern': 'circular-nodes',
'reason': 'N개 요소를 radial 로 배치하는 대안',
'confidence': 0.6,
'source': 'manual_seed',
},
],
}
# ============================================================
# 수정된 라벨 함수 — not_suits 반대 신호 처리
# ============================================================
def label_content_affinity_r2(layout, original_layout, content, suits, not_suits):
"""r2: 긍정/반대 신호 분리.
1. content(내용 설명) 에서 키워드 매칭 → 주 긍정 신호
2. suits 에서 키워드 매칭 → 보너스 긍정 (confidence +)
3. not_suits 에서 키워드 매칭 → **반대 신호** (해당 affinity 는 primary 후보에서 제거)
"""
defaults = (LAYOUT_DEFAULT_AFFINITY.get(original_layout)
or LAYOUT_DEFAULT_AFFINITY.get(layout)
or ['concept_definition'])
default_primary = defaults[0]
default_secondary = defaults[1:]
content_hits = find_keyword_hits(content, AFFINITY_KEYWORDS)
suits_text = ' '.join(suits)
suits_hits = find_keyword_hits(suits_text, AFFINITY_KEYWORDS)
not_suits_text = ' '.join(not_suits)
not_suits_hits_dict = {lab: kws for lab, kws in find_keyword_hits(not_suits_text, AFFINITY_KEYWORDS)}
# 점수 계산: content 가중치 1.0, suits 보너스 0.5, not_suits 매칭 시 차단 (강한 penalty)
score = defaultdict(float)
kw_source = defaultdict(dict)
for lab, kws in content_hits:
score[lab] += 1.0 * len(kws)
kw_source[lab]['content'] = kws
for lab, kws in suits_hits:
score[lab] += 0.5 * len(kws)
kw_source[lab]['suits'] = kws
# not_suits 차단 — score 0 으로 강제
blocked_labels = set()
for lab in list(score.keys()):
if lab in not_suits_hits_dict:
blocked_labels.add(lab)
# 완전 제거 (primary 후보에서 빠지도록)
del score[lab]
kw_source[lab]['blocked_by_not_suits'] = not_suits_hits_dict[lab]
# primary 결정
if score:
primary = max(score.keys(), key=lambda k: score[k])
primary_source = 'keyword_match'
primary_kws = []
for field in ('content', 'suits'):
primary_kws.extend(kw_source[primary].get(field, []))
confidence = min(0.95, 0.55 + 0.10 * score[primary])
else:
primary = default_primary
primary_source = 'layout_default'
primary_kws = []
confidence = 0.6
# secondary: 나머지 score 상위 + default
sec_candidates = [k for k in sorted(score.keys(), key=lambda k: -score[k]) if k != primary]
for d in default_secondary:
if d not in sec_candidates and d != primary and d not in blocked_labels:
sec_candidates.append(d)
secondary = sec_candidates[:2]
# evidence
evidence = {}
if primary_source == 'keyword_match':
evidence['primary'] = {
'source': 'keyword_match',
'field': '내용 설명 / suits',
'keywords': primary_kws,
'rule': f"'{primary_kws[0] if primary_kws else '?'}' 등 키워드가 {primary} 를 가리킴",
'confidence': round(confidence, 2),
}
else:
evidence['primary'] = {
'source': 'layout_default',
'field': f'original_layout = {original_layout or layout}',
'rule': 'layout → 기본 affinity',
'confidence': 0.6,
}
for i, s in enumerate(secondary, start=1):
hits_for_s = list(kw_source[s].get('content', [])) + list(kw_source[s].get('suits', []))
if hits_for_s:
evidence[f'secondary_{i}'] = {
'source': 'keyword_match',
'keywords': hits_for_s,
'confidence': 0.65,
}
else:
evidence[f'secondary_{i}'] = {
'source': 'layout_default',
'confidence': 0.5,
}
if blocked_labels:
evidence['blocked_by_not_suits'] = {
lab: kws for lab, kws in
((lab, not_suits_hits_dict[lab]) for lab in blocked_labels)
}
return {
'primary': primary,
'secondary': secondary,
'evidence': evidence,
}
def label_structure_intent_r2(layout, original_layout, content, suits, not_suits,
relation_type, cardinality):
"""r2: intent 도 not_suits 차단 적용."""
defaults = (LAYOUT_DEFAULT_INTENT.get(original_layout)
or LAYOUT_DEFAULT_INTENT.get(layout)
or ['multi_parallel'])
default_primary = defaults[0]
default_secondary = defaults[1:]
content_hits = find_keyword_hits(content, INTENT_KEYWORDS)
suits_hits = find_keyword_hits(' '.join(suits), INTENT_KEYWORDS)
not_suits_hits_dict = {lab: kws for lab, kws in
find_keyword_hits(' '.join(not_suits), INTENT_KEYWORDS)}
score = defaultdict(float)
kw_source = defaultdict(list)
for lab, kws in content_hits:
score[lab] += 1.0 * len(kws)
kw_source[lab].extend(kws)
for lab, kws in suits_hits:
score[lab] += 0.5 * len(kws)
kw_source[lab].extend(kws)
# relation_type + cardinality 구조 추가 신호 (content-based, 안전)
ideal = cardinality.get('ideal') if cardinality else None
if relation_type == 'compare' and ideal == 2:
if any(s in content for s in ('AS-IS', 'TO-BE', '혁신', '전환', '이중')):
score['state_transition'] += 1.0
kw_source['state_transition'].append('AS-IS/TO-BE/혁신(content)')
else:
score['binary_compare'] += 0.8
kw_source['binary_compare'].append('compare+cardinality=2')
elif relation_type == 'parallel' and ideal and ideal >= 3:
score['multi_parallel'] += 1.0
kw_source['multi_parallel'].append(f'parallel+cardinality={ideal}')
elif relation_type == 'sequence':
score['sequence'] += 1.0
kw_source['sequence'].append('relation_type=sequence')
# not_suits 차단
blocked = set()
for lab in list(score.keys()):
if lab in not_suits_hits_dict:
blocked.add(lab)
del score[lab]
if score:
primary = max(score.keys(), key=lambda k: score[k])
primary_source = 'content_or_structure'
primary_rule = (kw_source[primary][0] if kw_source[primary] else primary)
confidence = min(0.95, 0.55 + 0.10 * score[primary])
else:
primary = default_primary
primary_source = 'layout_default'
primary_rule = f'default {primary}'
confidence = 0.6
sec_candidates = [k for k in sorted(score.keys(), key=lambda k: -score[k]) if k != primary]
for d in default_secondary:
if d not in sec_candidates and d != primary and d not in blocked:
sec_candidates.append(d)
secondary = sec_candidates[:2]
evidence = {
'primary': {
'source': primary_source,
'rule': primary_rule,
'confidence': round(confidence, 2),
}
}
for i, s in enumerate(secondary, start=1):
evidence[f'secondary_{i}'] = {
'source': 'layout_default' if s in default_secondary else 'keyword_match',
'confidence': 0.55,
}
if blocked:
evidence['blocked_by_not_suits'] = {lab: not_suits_hits_dict[lab] for lab in blocked}
return {
'primary': primary,
'secondary': secondary,
'evidence': evidence,
}
def derive_alternatives_with_seed(fid, original_layout):
"""r1 자동 파생 + 수동 시드 병합."""
auto = r1_derive_alternatives(original_layout)
seed = MANUAL_SEED_ALTERNATIVES.get(fid, [])
# auto 결과에 source 필드 없으면 추가
for a in auto:
a.setdefault('source', 'derived_from_COMPAT')
# 시드 병합 (중복 pattern 은 시드 우선)
seen = {a['pattern'] for a in seed}
for a in auto:
if a['pattern'] not in seen:
seed.append(a)
seen.add(a['pattern'])
return seed
def generate_v2_entry_r2(fid, tpl_v1):
layout = tpl_v1['visual_pattern']['layout']
original_layout = tpl_v1['source'].get('original_layout', layout)
relation_type = tpl_v1['visual_pattern'].get('relation_type')
cardinality = tpl_v1['visual_pattern'].get('cardinality', {})
analysis = parse_analysis_md(BLOCKS_DIR / fid / 'analysis.md')
affinity = label_content_affinity_r2(
layout, original_layout,
analysis['content'], analysis['suits'], analysis['not_suits'],
)
intent = label_structure_intent_r2(
layout, original_layout,
analysis['content'], analysis['suits'], analysis['not_suits'],
relation_type, cardinality,
)
alternatives = derive_alternatives_with_seed(fid, original_layout)
entry = dict(tpl_v1)
entry['content_affinity'] = affinity
entry['structure_intent_v2'] = intent
entry['alternative_patterns'] = alternatives
entry['v2_meta'] = {
'generated_at': datetime.datetime.now().isoformat(timespec='seconds'),
'source_analysis': f'{fid}/analysis.md',
'generator_version': 'r2 (not_suits 차단 + 수동 시드)',
'needs_review': tpl_v1.get('short_id') in {'03', '11', '12', '20', '29'},
}
return entry
def main():
v1 = yaml.safe_load(ONTOLOGY_V1_PATH.read_text(encoding='utf-8'))
templates_v1 = v1['templates_v1']
templates_v2 = {}
for fid, tpl in templates_v1.items():
templates_v2[fid] = generate_v2_entry_r2(fid, tpl)
# structure_ontology_v2_r2.yaml
out = {
'meta': {
'schema_version': 'template-fit-v2-r2-draft',
'generated_from': 'structure_ontology.yaml (templates_v1)',
'generated_at': datetime.datetime.now().isoformat(timespec='seconds'),
'generator': 'pipeline_12_r2_generate_templates_v2.py',
'predecessor_draft': 'structure_ontology_v2.yaml (r1, 유지)',
'r2_changes': [
'r1 버그 수정: not_suits 를 긍정 신호로 스캔하던 문제 해결',
'suits = 보너스 긍정, not_suits = 반대 신호(primary 후보 차단)',
'Frame 12 (cycle-3way) alternative_patterns 에 3col-parallel 수동 시드 추가',
],
'status': 'draft_pending_user_review',
'priority_review_frames': sorted(PRIORITY_FRAME_NUMBERS),
'vocabularies': {
'content_affinity': CONTENT_AFFINITY_ENUM,
'structure_intent': STRUCTURE_INTENT_ENUM,
},
'matching_weights_initial': {
'layout_compat': 0.40,
'content_affinity': 0.35,
'structure_intent': 0.25,
},
},
'templates_v2': templates_v2,
}
OUT_V2_PATH.write_text(
yaml.safe_dump(out, allow_unicode=True, sort_keys=False, width=1000),
encoding='utf-8',
)
# PRIORITY_5_REVIEW_r2 — r1 포맷 재사용 + r1 vs r2 비교 추가
r1 = yaml.safe_load((HERE / 'structure_ontology_v2.yaml').read_text(encoding='utf-8'))
templates_v2_r1 = r1['templates_v2']
short_to_fid = {v['short_id']: k for k, v in templates_v1.items()}
pr_md = []
pr_md.append('# Priority 5 프레임 검토 리포트 (C.2-b r2)')
pr_md.append('')
pr_md.append('r1 버그 수정 후 재라벨링. 각 프레임마다 **r1 → r2 변화** 를 함께 표시.')
pr_md.append('')
for fn in ['03', '11', '12', '20', '29']:
fid = short_to_fid.get(fn)
if not fid:
continue
v2_r1 = templates_v2_r1[fid]
v2_r2 = templates_v2[fid]
v1_entry = templates_v1[fid]
title = v1_entry['source']['title']
original_layout = v1_entry['source'].get('original_layout')
pr_md.append(f"## Frame {fn}{title}")
pr_md.append('')
pr_md.append(f"- layout: `{v1_entry['visual_pattern']['layout']}` "
f"(original: `{original_layout}`)")
pr_md.append(f"- relation_type / cardinality.ideal: "
f"`{v1_entry['visual_pattern'].get('relation_type')}` / "
f"{v1_entry['visual_pattern'].get('cardinality', {}).get('ideal')}")
pr_md.append('')
# r1 → r2 변화
r1_aff = v2_r1['content_affinity']
r2_aff = v2_r2['content_affinity']
r1_int = v2_r1['structure_intent_v2']
r2_int = v2_r2['structure_intent_v2']
changed_aff = r1_aff['primary'] != r2_aff['primary']
changed_int = r1_int['primary'] != r2_int['primary']
pr_md.append('### r1 → r2 변화 요약')
pr_md.append('')
pr_md.append('| 축 | r1 primary | r2 primary | 변경 |')
pr_md.append('|---|---|---|---|')
pr_md.append(
f"| content_affinity | `{r1_aff['primary']}` | "
f"**`{r2_aff['primary']}`** | {'' if changed_aff else ''} |"
)
pr_md.append(
f"| structure_intent | `{r1_int['primary']}` | "
f"**`{r2_int['primary']}`** | {'' if changed_int else ''} |"
)
pr_md.append('')
# r2 content_affinity
pr_md.append('### r2 content_affinity')
pr_md.append(f"- primary: **`{r2_aff['primary']}`**")
if r2_aff['secondary']:
pr_md.append(f"- secondary: {', '.join(f'`{s}`' for s in r2_aff['secondary'])}")
pr_md.append('- evidence:')
for k, v in r2_aff['evidence'].items():
if k == 'blocked_by_not_suits':
blocked_str = ', '.join(f'{lab}({kws})' for lab, kws in v.items())
pr_md.append(f" - `{k}` — {blocked_str}")
else:
extras = []
if 'keywords' in v and v['keywords']:
extras.append(f"kw={v['keywords']}")
if 'rule' in v:
extras.append(f"rule={v['rule']}")
extras.append(f"conf={v['confidence']}")
pr_md.append(f" - `{k}` ({v['source']}) — {' / '.join(extras)}")
pr_md.append('')
# r2 structure_intent
pr_md.append('### r2 structure_intent')
pr_md.append(f"- primary: **`{r2_int['primary']}`**")
if r2_int['secondary']:
pr_md.append(f"- secondary: {', '.join(f'`{s}`' for s in r2_int['secondary'])}")
pr_md.append('- evidence:')
for k, v in r2_int['evidence'].items():
if k == 'blocked_by_not_suits':
blocked_str = ', '.join(f'{lab}({kws})' for lab, kws in v.items())
pr_md.append(f" - `{k}` — {blocked_str}")
else:
pr_md.append(f" - `{k}` ({v['source']}) — rule={v.get('rule', '')} / conf={v['confidence']}")
pr_md.append('')
# alternative_patterns
pr_md.append('### alternative_patterns')
if v2_r2['alternative_patterns']:
pr_md.append('| 대안 | confidence | source | reason |')
pr_md.append('|---|---:|---|---|')
for a in v2_r2['alternative_patterns']:
pr_md.append(f"| `{a['pattern']}` | {a['confidence']} | "
f"{a.get('source', 'derived')} | {a['reason']} |")
else:
pr_md.append('(없음)')
pr_md.append('')
pr_md.append('---')
pr_md.append('')
pr_text = '\n'.join(pr_md)
PRIORITY_MD.write_text(pr_text, encoding='utf-8')
style = """
body { font-family: -apple-system, "Segoe UI", Pretendard, sans-serif; max-width: 1100px; margin: 2em auto; padding: 0 1.5em 4em; line-height: 1.65; color: #222; background: #f8fafc; }
h1 { border-bottom: 3px solid #2563eb; padding-bottom: 0.25em; }
h2 { margin-top: 2.5em; background: #e0e7ff; padding: 0.6em 0.9em; border-left: 4px solid #0a6; border-radius: 4px; }
h3 { margin-top: 1.3em; color: #1a365d; }
table { border-collapse: collapse; background: #fff; margin: 0.5em 0 1em; }
th, td { border: 1px solid #e2e8f0; padding: 8px 10px; text-align: left; vertical-align: top; }
th { background: #1e293b; color: #fff; }
code { background: #f4f4f4; padding: 1px 6px; border-radius: 3px; font-size: 0.9em; color: #111; }
strong { color: #0a6; }
"""
html_body = markdown.markdown(pr_text, extensions=['tables'])
html = f"""<!DOCTYPE html>
<html lang="ko"><head><meta charset="utf-8"><title>Priority 5 r2</title><style>{style}</style></head>
<body>{html_body}</body></html>"""
PRIORITY_HTML.write_text(html, encoding='utf-8')
# TEMPLATES_V2_DIFF_r2.md
d_md = []
d_md.append('# templates_v2 r1 → r2 변경 요약')
d_md.append('')
d_md.append('## 규칙 수정')
d_md.append('- `suits` = 보너스 긍정 신호 (0.5 가중치)')
d_md.append('- `not_suits` = **반대 신호** — 해당 affinity/intent 가 primary 후보에서 차단')
d_md.append('- Frame 12 (cycle-3way) 에 `3col-parallel` / `circular-nodes` 수동 시드 추가')
d_md.append('')
# primary 변경된 프레임 집계
changed_affinity = []
changed_intent = []
for fid, r2_entry in templates_v2.items():
r1_entry = templates_v2_r1[fid]
short_id = templates_v1[fid]['short_id']
if r1_entry['content_affinity']['primary'] != r2_entry['content_affinity']['primary']:
changed_affinity.append((short_id, fid,
r1_entry['content_affinity']['primary'],
r2_entry['content_affinity']['primary']))
if r1_entry['structure_intent_v2']['primary'] != r2_entry['structure_intent_v2']['primary']:
changed_intent.append((short_id, fid,
r1_entry['structure_intent_v2']['primary'],
r2_entry['structure_intent_v2']['primary']))
d_md.append(f'## r1 → r2 primary 변경된 프레임')
d_md.append('')
d_md.append(f"- content_affinity primary 변경: **{len(changed_affinity)}/32 프레임**")
d_md.append(f"- structure_intent primary 변경: **{len(changed_intent)}/32 프레임**")
d_md.append('')
if changed_affinity:
d_md.append('### content_affinity primary 변경')
d_md.append('')
d_md.append('| Frame | r1 | → | r2 |')
d_md.append('|---|---|---|---|')
for short_id, fid, r1_p, r2_p in sorted(changed_affinity):
d_md.append(f"| {short_id} | `{r1_p}` | → | **`{r2_p}`** |")
d_md.append('')
if changed_intent:
d_md.append('### structure_intent primary 변경')
d_md.append('')
d_md.append('| Frame | r1 | → | r2 |')
d_md.append('|---|---|---|---|')
for short_id, fid, r1_p, r2_p in sorted(changed_intent):
d_md.append(f"| {short_id} | `{r1_p}` | → | **`{r2_p}`** |")
d_md.append('')
# r2 분포
aff_counter = Counter()
intent_counter = Counter()
for e in templates_v2.values():
aff_counter[e['content_affinity']['primary']] += 1
intent_counter[e['structure_intent_v2']['primary']] += 1
d_md.append('## r2 primary 분포')
d_md.append('')
d_md.append('### content_affinity')
d_md.append('| primary | 프레임 수 |')
d_md.append('|---|---:|')
for lab, c in aff_counter.most_common():
d_md.append(f"| `{lab}` | {c} |")
d_md.append('')
d_md.append('### structure_intent')
d_md.append('| primary | 프레임 수 |')
d_md.append('|---|---:|')
for lab, c in intent_counter.most_common():
d_md.append(f"| `{lab}` | {c} |")
d_md.append('')
DIFF_MD.write_text('\n'.join(d_md), encoding='utf-8')
print('=' * 70)
print('r2 재라벨링 완료')
print('=' * 70)
print(f' 1. {OUT_V2_PATH}')
print(f' 2. {PRIORITY_MD} / .html')
print(f' 3. {DIFF_MD}')
print()
print(f' primary 변경: affinity {len(changed_affinity)}/32, intent {len(changed_intent)}/32')
print(f' r2 affinity 분포 Top 5: {dict(aff_counter.most_common(5))}')
print(f' r2 intent 분포 Top 5: {dict(intent_counter.most_common(5))}')
if __name__ == '__main__':
main()
@@ -0,0 +1,313 @@
"""Pipeline Step 12-r3 — 키워드 튜닝 후 재라벨링.
r2 에서 확인된 남은 문제:
- Frame 03/04 stakeholder_roles: "수행" 이 너무 약한 신호로 잡힘
- Frame 01 comparative_matrix: "순환도" 는 interrelation 에 가까움
- 일부 "국외/현황/사례" 가 policy_requirements 로 안 잡힘
r3 변경 (키워드 튜닝만):
- `stakeholder_roles` 에서 "수행" 제거 — 너무 약한 단독 신호
- `policy_requirements` 에 "국외", "선진", "현황", "사례" 추가
- `interrelation` 에 "순환도", "관계도" 추가
r2 의 모든 로직(not_suits 차단, 수동 시드) 그대로 유지.
산출물:
- structure_ontology_v2_r3.yaml
- PRIORITY_5_REVIEW_r3.md (+ 추가 샘플 7개)
- TEMPLATES_V2_DIFF_r3.md (r2 대비)
"""
import datetime
import sys
from collections import Counter, defaultdict
from pathlib import Path
import markdown
import yaml
HERE = Path(__file__).parent
ROOT = HERE.parent.parent
BLOCKS_DIR = ROOT / 'figma_to_html_agent' / 'blocks'
sys.path.insert(0, str(HERE))
from phase_common import _COMPAT
# r1/r2 의 상수 재사용. AFFINITY_KEYWORDS 는 **r3 에서 덮어쓸** 것.
import pipeline_12_generate_templates_v2 as r1mod
import pipeline_12_r2_generate_templates_v2 as r2mod
from pipeline_12_r2_generate_templates_v2 import (
MANUAL_SEED_ALTERNATIVES,
label_content_affinity_r2,
label_structure_intent_r2,
derive_alternatives_with_seed,
)
# ============================================================
# 키워드 튜닝 (r3)
# ============================================================
AFFINITY_KEYWORDS_R3 = dict(r1mod.AFFINITY_KEYWORDS)
AFFINITY_KEYWORDS_R3['stakeholder_roles'] = ['역할', '책임', '주도'] # '수행' 제거
AFFINITY_KEYWORDS_R3['policy_requirements'] = [
'정책', '제도', '도입', '거버넌스', '전면 도입',
'국외', '국내', '선진', '현황', '사례', # 추가
]
AFFINITY_KEYWORDS_R3['interrelation'] = [
'상호관계', '순환', '조화', '교차', '수렴', '3원',
'순환도', '관계도', # 추가
]
# r1/r2 모듈 네임스페이스 모두에 tuned keywords 주입
# (r2 가 `from r1 import AFFINITY_KEYWORDS` 했기 때문에 r2 네임스페이스에도 새 바인딩 필요)
r1mod.AFFINITY_KEYWORDS = AFFINITY_KEYWORDS_R3
r2mod.AFFINITY_KEYWORDS = AFFINITY_KEYWORDS_R3
ONTOLOGY_V1_PATH = HERE / 'structure_ontology.yaml'
OUT_V2_PATH = HERE / 'structure_ontology_v2_r3.yaml'
PRIORITY_MD = HERE / 'PRIORITY_5_REVIEW_r3.md'
PRIORITY_HTML = HERE / 'PRIORITY_5_REVIEW_r3.html'
DIFF_MD = HERE / 'TEMPLATES_V2_DIFF_r3.md'
PRIORITY_FRAMES = ['03', '11', '12', '20', '29']
ADDITIONAL_SAMPLES = ['01', '04', '13', '14', '19', '22', '28'] # 다양한 카테고리 샘플
R2_PATH = HERE / 'structure_ontology_v2_r2.yaml'
def generate_v2_entry_r3(fid, tpl_v1):
layout = tpl_v1['visual_pattern']['layout']
original_layout = tpl_v1['source'].get('original_layout', layout)
relation_type = tpl_v1['visual_pattern'].get('relation_type')
cardinality = tpl_v1['visual_pattern'].get('cardinality', {})
analysis = r1mod.parse_analysis_md(BLOCKS_DIR / fid / 'analysis.md')
affinity = label_content_affinity_r2(
layout, original_layout,
analysis['content'], analysis['suits'], analysis['not_suits'],
)
intent = label_structure_intent_r2(
layout, original_layout,
analysis['content'], analysis['suits'], analysis['not_suits'],
relation_type, cardinality,
)
alternatives = derive_alternatives_with_seed(fid, original_layout)
entry = dict(tpl_v1)
entry['content_affinity'] = affinity
entry['structure_intent_v2'] = intent
entry['alternative_patterns'] = alternatives
entry['v2_meta'] = {
'generated_at': datetime.datetime.now().isoformat(timespec='seconds'),
'source_analysis': f'{fid}/analysis.md',
'generator_version': 'r3 (r2 + 키워드 튜닝: 수행 제거, 국외/사례 추가, 순환도 추가)',
'needs_review': tpl_v1.get('short_id') in set(PRIORITY_FRAMES),
}
return entry
def main():
v1 = yaml.safe_load(ONTOLOGY_V1_PATH.read_text(encoding='utf-8'))
templates_v1 = v1['templates_v1']
templates_v2_r3 = {}
for fid, tpl in templates_v1.items():
templates_v2_r3[fid] = generate_v2_entry_r3(fid, tpl)
out = {
'meta': {
'schema_version': 'template-fit-v2-r3-draft',
'generated_at': datetime.datetime.now().isoformat(timespec='seconds'),
'generator': 'pipeline_12_r3_generate_templates_v2.py',
'predecessor_drafts': ['structure_ontology_v2.yaml (r1)', 'structure_ontology_v2_r2.yaml (r2)'],
'r3_changes': [
'stakeholder_roles 키워드에서 "수행" 제거 — 너무 약한 단독 신호',
'policy_requirements 에 국외/국내/선진/현황/사례 추가',
'interrelation 에 순환도/관계도 추가',
'r2 의 not_suits 차단 + 수동 시드 로직 그대로 유지',
],
'status': 'draft_pending_user_review',
'priority_review_frames': [int(x) for x in PRIORITY_FRAMES],
'additional_sample_frames': [int(x) for x in ADDITIONAL_SAMPLES],
'vocabularies': {
'content_affinity': r1mod.CONTENT_AFFINITY_ENUM,
'structure_intent': r1mod.STRUCTURE_INTENT_ENUM,
},
},
'templates_v2': templates_v2_r3,
}
OUT_V2_PATH.write_text(
yaml.safe_dump(out, allow_unicode=True, sort_keys=False, width=1000),
encoding='utf-8',
)
# 비교 리포트
r2 = yaml.safe_load(R2_PATH.read_text(encoding='utf-8'))
templates_r2 = r2['templates_v2']
short_to_fid = {v['short_id']: k for k, v in templates_v1.items()}
# r2 → r3 변경 집계
changed_aff = []
changed_int = []
for fid, r3_e in templates_v2_r3.items():
r2_e = templates_r2[fid]
s = templates_v1[fid]['short_id']
if r2_e['content_affinity']['primary'] != r3_e['content_affinity']['primary']:
changed_aff.append((s, r2_e['content_affinity']['primary'], r3_e['content_affinity']['primary']))
if r2_e['structure_intent_v2']['primary'] != r3_e['structure_intent_v2']['primary']:
changed_int.append((s, r2_e['structure_intent_v2']['primary'], r3_e['structure_intent_v2']['primary']))
# ============================================================
# PRIORITY + 샘플 리포트
# ============================================================
pr_md = []
pr_md.append('# Priority 5 + 추가 샘플 7개 검토 (r3)')
pr_md.append('')
pr_md.append(
'키워드 튜닝 후 재라벨링. 각 프레임에 **r2 → r3 변화** 표시. '
'추가 샘플은 r2 에서 의심스러웠던 카테고리 (stakeholder_roles, comparative_matrix, '
'goal_axes, process_steps, persona_benefit) 에서 선별.'
)
pr_md.append('')
pr_md.append(f'## r2 → r3 primary 변경 요약')
pr_md.append('')
pr_md.append(f"- content_affinity primary 변경: **{len(changed_aff)}/32 프레임**")
pr_md.append(f"- structure_intent primary 변경: **{len(changed_int)}/32 프레임**")
pr_md.append('')
if changed_aff:
pr_md.append('### content_affinity primary 변경 (전체)')
pr_md.append('| Frame | r2 | → | r3 |')
pr_md.append('|---|---|---|---|')
for s, o, n in sorted(changed_aff):
pr_md.append(f"| {s} | `{o}` | → | **`{n}`** |")
pr_md.append('')
# 프레임별 상세
def render_frame(fn, category):
fid = short_to_fid.get(fn)
if not fid:
return ''
r3_e = templates_v2_r3[fid]
r2_e = templates_r2[fid]
v1_e = templates_v1[fid]
lines = []
lines.append(f"## [{category}] Frame {fn}{v1_e['source']['title']}")
lines.append('')
lines.append(f"- layout: `{v1_e['visual_pattern']['layout']}` "
f"(original: `{v1_e['source'].get('original_layout')}`)")
lines.append(f"- relation_type / cardinality.ideal: "
f"`{v1_e['visual_pattern'].get('relation_type')}` / "
f"{v1_e['visual_pattern'].get('cardinality', {}).get('ideal')}")
lines.append('')
# r2 vs r3 변화
r2a = r2_e['content_affinity']['primary']
r3a = r3_e['content_affinity']['primary']
r2i = r2_e['structure_intent_v2']['primary']
r3i = r3_e['structure_intent_v2']['primary']
lines.append('### r2 → r3')
lines.append('| 축 | r2 | r3 | 변경 |')
lines.append('|---|---|---|---|')
lines.append(f"| content_affinity | `{r2a}` | **`{r3a}`** | {'' if r2a != r3a else ''} |")
lines.append(f"| structure_intent | `{r2i}` | **`{r3i}`** | {'' if r2i != r3i else ''} |")
lines.append('')
# r3 상세
aff = r3_e['content_affinity']
intent = r3_e['structure_intent_v2']
lines.append(f"### r3 content_affinity: **`{aff['primary']}`**")
if aff['secondary']:
lines.append(f"- secondary: {', '.join(f'`{s}`' for s in aff['secondary'])}")
ev = aff['evidence'].get('primary', {})
lines.append(f"- evidence: source={ev.get('source')}, "
f"rule={ev.get('rule', '')}, conf={ev.get('confidence')}")
if 'blocked_by_not_suits' in aff['evidence']:
blocked = list(aff['evidence']['blocked_by_not_suits'].keys())
lines.append(f"- blocked: {blocked}")
lines.append('')
lines.append(f"### r3 structure_intent: **`{intent['primary']}`**")
if intent['secondary']:
lines.append(f"- secondary: {', '.join(f'`{s}`' for s in intent['secondary'])}")
ev = intent['evidence'].get('primary', {})
lines.append(f"- evidence: source={ev.get('source')}, "
f"rule={ev.get('rule', '')}, conf={ev.get('confidence')}")
lines.append('')
lines.append(f"### alternative_patterns ({len(r3_e['alternative_patterns'])})")
for a in r3_e['alternative_patterns']:
lines.append(f"- `{a['pattern']}` ({a['confidence']}, {a.get('source', 'derived')}): {a['reason']}")
lines.append('')
lines.append('---')
lines.append('')
return '\n'.join(lines)
pr_md.append('## Priority 5')
pr_md.append('')
for fn in PRIORITY_FRAMES:
pr_md.append(render_frame(fn, 'Priority'))
pr_md.append('## 추가 샘플 (r2 의심 프레임)')
pr_md.append('')
for fn in ADDITIONAL_SAMPLES:
pr_md.append(render_frame(fn, 'Sample'))
PRIORITY_MD.write_text('\n'.join(pr_md), encoding='utf-8')
style = """body { font-family: -apple-system, "Segoe UI", Pretendard, sans-serif; max-width: 1100px; margin: 2em auto; padding: 0 1.5em 4em; line-height: 1.65; color: #222; background: #f8fafc; }
h1 { border-bottom: 3px solid #2563eb; padding-bottom: 0.25em; }
h2 { margin-top: 2em; background: #e0e7ff; padding: 0.5em 0.8em; border-left: 4px solid #0a6; border-radius: 4px; }
h3 { margin-top: 1em; color: #1a365d; font-size: 1em; }
table { border-collapse: collapse; background: #fff; margin: 0.5em 0 1em; }
th, td { border: 1px solid #e2e8f0; padding: 6px 10px; vertical-align: top; }
th { background: #1e293b; color: #fff; font-size: 0.9em; }
code { background: #f4f4f4; padding: 1px 6px; border-radius: 3px; font-size: 0.9em; color: #111; }
strong { color: #0a6; }"""
html_body = markdown.markdown(PRIORITY_MD.read_text(encoding='utf-8'), extensions=['tables'])
html = f"""<!DOCTYPE html><html lang="ko"><head><meta charset="utf-8"><title>Priority+Sample r3</title><style>{style}</style></head><body>{html_body}</body></html>"""
PRIORITY_HTML.write_text(html, encoding='utf-8')
# DIFF_r3
d = []
d.append('# r2 → r3 변경 요약')
d.append('')
d.append('## 키워드 튜닝')
d.append('- `stakeholder_roles`: "수행" **제거** (너무 약한 단독 신호)')
d.append('- `policy_requirements`: "국외/국내/선진/현황/사례" 추가')
d.append('- `interrelation`: "순환도/관계도" 추가')
d.append('')
d.append(f'## primary 변경 집계')
d.append(f'- content_affinity: **{len(changed_aff)}/32 변경**')
d.append(f'- structure_intent: **{len(changed_int)}/32 변경**')
d.append('')
if changed_aff:
d.append('### 변경 상세')
d.append('| Frame | r2 | → | r3 |')
d.append('|---|---|---|---|')
for s, o, n in sorted(changed_aff):
d.append(f'| {s} | `{o}` | → | **`{n}`** |')
d.append('')
# r3 분포
aff_counter = Counter(e['content_affinity']['primary'] for e in templates_v2_r3.values())
d.append('## r3 affinity 분포')
d.append('| primary | 수 |')
d.append('|---|---:|')
for lab, c in aff_counter.most_common():
d.append(f'| `{lab}` | {c} |')
DIFF_MD.write_text('\n'.join(d), encoding='utf-8')
print('=' * 70)
print('r3 재라벨링 완료')
print('=' * 70)
print(f' 1. {OUT_V2_PATH}')
print(f' 2. {PRIORITY_MD} / .html')
print(f' 3. {DIFF_MD}')
print()
print(f' primary 변경 (r2→r3): affinity {len(changed_aff)}/32, intent {len(changed_int)}/32')
print(f' r3 affinity 분포: {dict(aff_counter.most_common())}')
if __name__ == '__main__':
main()
@@ -0,0 +1,151 @@
"""Pipeline Step 15 (BM25 + 세트) — 개별 토큰 + 복합 토큰(세트) 모두 넣은 BM25.
이전 pipeline_15_bm25_comparison 은 개별 토큰만 썼음 → 세트 정보 누락.
이번엔 세트를 "복합 토큰(phrase token)" 으로 취급해 BM25 에 포함.
복합 토큰 정의:
· 프레임의 각 source_text_line 에서 추출된 키워드 묶음 (예: [BIM, DX, 이해])
· tf(compound, frame) = 1 (한 source line = 1 occurrence)
· df(compound) = "그 모든 키워드를 다 가진 프레임 수"
· IDF(compound) = log((N - df + 0.5) / (df + 0.5) + 1)
복합 토큰 매칭 (MDX 쪽):
· MDX 에 compound 의 키워드가 얼마나 있나 → coverage (0~1)
· 기여도 = IDF(compound) × tf_norm × coverage (부분 매칭 허용)
최종 BM25 점수 = Σ 개별 토큰 기여 + Σ 복합 토큰 기여
"""
from collections import defaultdict
from math import log
from pathlib import Path
import yaml
HERE = Path(__file__).parent
N_FRAMES = 32
K1 = 1.5
B = 0.75
def main():
auto = yaml.safe_load((HERE / 'auto_anchor_candidates.yaml').read_text(encoding='utf-8'))
v1 = yaml.safe_load((HERE / 'mdx_matching_result.yaml').read_text(encoding='utf-8'))
normalized = yaml.safe_load((HERE / 'normalized_text_tokens.yaml').read_text(encoding='utf-8'))
# ─── 개별 토큰 tf (프레임별) ───
frame_tokens = defaultdict(lambda: defaultdict(int))
for set_id, s in auto['source_text_sets'].items():
fid = s['frame_id']
for t in s.get('terms', []):
frame_tokens[fid][t['token']] += t.get('local_count_in_frame', 1)
# ─── 복합 토큰 (세트) 프레임별 ───
frame_compounds = defaultdict(list)
for set_id, s in auto['source_text_sets'].items():
fid = s['frame_id']
comp = tuple(sorted(set(s['term_values'])))
if len(comp) >= 1:
frame_compounds[fid].append(comp)
# ─── 문서 길이 |D| ───
# 개별 토큰 수 + 복합 토큰 수 (각 compound 는 1 unit)
frame_len = {}
for fid in frame_tokens:
ind_len = sum(frame_tokens[fid].values())
comp_len = len(frame_compounds.get(fid, []))
frame_len[fid] = ind_len + comp_len
avg_dl = sum(frame_len.values()) / max(len(frame_len), 1)
# ─── 개별 토큰 df ───
ind_df = defaultdict(int)
for fid, counts in frame_tokens.items():
for tok in counts:
ind_df[tok] += 1
# ─── 복합 토큰 df ───
all_compounds = set()
for comps in frame_compounds.values():
all_compounds.update(comps)
comp_df = {}
for c in all_compounds:
c_set = set(c)
df = sum(1 for fid, tokens in frame_tokens.items() if c_set.issubset(tokens))
comp_df[c] = max(df, 1)
def idf(df):
return log((N_FRAMES - df + 0.5) / (df + 0.5) + 1)
# ─── BM25 점수 (개별 + 복합) ───
def score_frame(fid, mdx_tokens):
dl = frame_len[fid]
if dl == 0:
return 0.0
score = 0.0
# 개별 토큰
for t, tf in frame_tokens[fid].items():
if t in mdx_tokens:
idf_val = idf(ind_df[t])
tf_norm = tf * (K1 + 1) / (tf + K1 * (1 - B + B * dl / avg_dl))
score += idf_val * tf_norm
# 복합 토큰
for c in frame_compounds.get(fid, []):
c_set = set(c)
if not c_set:
continue
hits = c_set & mdx_tokens
coverage = len(hits) / len(c_set)
if coverage > 0:
idf_val = idf(comp_df[c])
tf = 1
tf_norm = tf * (K1 + 1) / (tf + K1 * (1 - B + B * dl / avg_dl))
score += idf_val * tf_norm * coverage
return score
frame_num_map = {fid: info['frame_number'] for fid, info in auto['frame_stats'].items()}
print('=' * 95)
print(f'{"섹션":<8} | {"순위":<3} | {"현재 (0.30/0.50/0.20)":<28} | {"BM25 + 세트 (복합 토큰)":<30}')
print('=' * 95)
top1_agree = 0
top3_agree = 0
target_hits_bm25 = 0
target_sids = ['01-2', '02-2.2', '03-1', '03-2']
total = 0
answer_map = v1['meta']['answer_map']
for sid, sec in v1['mdx_sections'].items():
mdx_tokens = set(normalized['mdx'][sid].get('unique_tokens', []))
scores = {fid: (score_frame(fid, mdx_tokens), frame_num_map.get(fid)) for fid in frame_tokens}
ranking = sorted(scores.items(), key=lambda x: -x[1][0])
current = sec['rank_by_matching_score']
total += 1
if current[0]['frame_id'] == ranking[0][0]:
top1_agree += 1
if {r['frame_id'] for r in current[:3]} == {x[0] for x in ranking[:3]}:
top3_agree += 1
# TARGET 정답률
if sid in target_sids:
ans_num = answer_map[sid]
if ranking[0][1][1] == ans_num:
target_hits_bm25 += 1
for i in range(5):
cur = current[i]
bm = ranking[i]
cur_s = f'Frame {cur["frame_number"]:>2} {cur["matching_score"]:.3f}'
bm_s = f'Frame {bm[1][1]:>2} {bm[1][0]:>7.2f}'
prefix = sid if i == 0 else ''
print(f'{prefix:<8} | {i+1:<3} | {cur_s:<28} | {bm_s:<30}')
print('-' * 95)
print()
print('=' * 95)
print(f'Top-1 일치: {top1_agree}/{total}')
print(f'Top-3 집합 일치: {top3_agree}/{total}')
print(f'TARGET 정답률: BM25+세트 = {target_hits_bm25}/4 (vs 현재 4/4)')
print('=' * 95)
if __name__ == '__main__':
main()
@@ -0,0 +1,134 @@
"""Pipeline Step 15 (Logistic Regression) — TARGET 4 로 가중치 학습.
방법:
1. TARGET 4 섹션 × 32 프레임 = 128 샘플
2. Feature: (standalone_score, group_score, related_score) — 각 [0, 1]
3. Label: 1 if 정답 프레임 else 0 (정답 4, 오답 124)
4. Logistic Regression + Linear Regression 두 가지 fit
5. 학습 가중치를 sum=1 로 정규화 → 현재 0.30/0.50/0.20 와 비교
6. 학습 가중치로 예측 시 TARGET 정답률 검증
"""
from pathlib import Path
import numpy as np
import yaml
from sklearn.linear_model import LogisticRegression, LinearRegression
HERE = Path(__file__).parent
TARGET_SIDS = ['01-2', '02-2.2', '03-1', '03-2']
def main():
v1 = yaml.safe_load((HERE / 'mdx_matching_result.yaml').read_text(encoding='utf-8'))
auto = yaml.safe_load((HERE / 'auto_anchor_candidates.yaml').read_text(encoding='utf-8'))
answer_map = v1['meta']['answer_map']
frame_num = {fid: info['frame_number'] for fid, info in auto['frame_stats'].items()}
# ─── 데이터 구성 ───
rows = [] # (sid, frame_id, frame_num, s, g, r, label)
for sid in TARGET_SIDS:
sec = v1['mdx_sections'][sid]
answer = answer_map[sid]
for frame_id, detail in sec['per_frame_detail'].items():
s = detail['standalone']['score']
g = detail['keyword_group'].get('score_avg', 0)
r = detail['related']['score']
label = 1 if frame_num[frame_id] == answer else 0
rows.append((sid, frame_id, frame_num[frame_id], s, g, r, label))
X = np.array([[r[3], r[4], r[5]] for r in rows])
y = np.array([r[6] for r in rows])
print('=' * 75)
print(f'데이터: {len(y)} 샘플 (정답 {y.sum()}, 오답 {len(y)-y.sum()}) — TARGET 4 × 32 frames')
print('=' * 75)
print()
# ─── Logistic Regression ───
clf = LogisticRegression(penalty='l2', C=1.0, fit_intercept=True, max_iter=2000)
clf.fit(X, y)
w_lr = clf.coef_[0]
bias_lr = clf.intercept_[0]
w_lr_norm = w_lr / w_lr.sum()
print(f'[Logistic Regression (L2, C=1.0)]')
print(f' raw weights: standalone={w_lr[0]:+.3f} group={w_lr[1]:+.3f} related={w_lr[2]:+.3f}')
print(f' bias: {bias_lr:+.3f}')
print(f' 정규화 (sum=1): standalone={w_lr_norm[0]:.3f} group={w_lr_norm[1]:.3f} related={w_lr_norm[2]:.3f}')
print()
# ─── Linear Regression (OLS, no intercept) ───
ols = LinearRegression(fit_intercept=False)
ols.fit(X, y)
w_ols = ols.coef_
w_ols_norm = w_ols / w_ols.sum()
print(f'[Linear Regression (OLS, no intercept)]')
print(f' raw weights: standalone={w_ols[0]:+.3f} group={w_ols[1]:+.3f} related={w_ols[2]:+.3f}')
print(f' 정규화 (sum=1): standalone={w_ols_norm[0]:.3f} group={w_ols_norm[1]:.3f} related={w_ols_norm[2]:.3f}')
print()
# ─── 현재 vs 학습 가중치 비교 ───
print('[비교]')
print(f' standalone group related')
print(f' 현재 (수동): 0.300 0.500 0.200')
print(f' Logistic: {w_lr_norm[0]:.3f} {w_lr_norm[1]:.3f} {w_lr_norm[2]:.3f}')
print(f' OLS: {w_ols_norm[0]:.3f} {w_ols_norm[1]:.3f} {w_ols_norm[2]:.3f}')
print()
# ─── 학습 가중치로 TARGET 정답률 검증 ───
def evaluate(w_normalized, name):
hits = 0
for sid in TARGET_SIDS:
sec = v1['mdx_sections'][sid]
answer = answer_map[sid]
scores = {}
for fid, detail in sec['per_frame_detail'].items():
s = detail['standalone']['score']
g = detail['keyword_group'].get('score_avg', 0)
r = detail['related']['score']
scores[fid] = w_normalized[0]*s + w_normalized[1]*g + w_normalized[2]*r
top_fid = max(scores, key=scores.get)
if frame_num[top_fid] == answer:
hits += 1
return hits
hits_current = evaluate([0.30, 0.50, 0.20], '현재')
hits_lr = evaluate(w_lr_norm, 'Logistic')
hits_ols = evaluate(w_ols_norm, 'OLS')
print('[TARGET 4 정답률 (학습 가중치로 재예측)]')
print(f' 현재 (0.30/0.50/0.20): {hits_current}/4')
print(f' Logistic Regression: {hits_lr}/4')
print(f' OLS Linear Regression: {hits_ols}/4')
print()
# ─── LOOCV (Leave-One-Out Cross-Validation) — 과적합 체크 ───
print('[LOOCV — 과적합 확인]')
print(' 각 TARGET 을 hold-out, 나머지 3개로 학습 후 테스트')
loocv_hits = 0
for hold_out_idx, hold_out_sid in enumerate(TARGET_SIDS):
train_X = np.array([[r[3], r[4], r[5]] for r in rows if r[0] != hold_out_sid])
train_y = np.array([r[6] for r in rows if r[0] != hold_out_sid])
clf_cv = LogisticRegression(penalty='l2', C=1.0, fit_intercept=True, max_iter=2000)
clf_cv.fit(train_X, train_y)
w_cv = clf_cv.coef_[0] / clf_cv.coef_[0].sum()
sec = v1['mdx_sections'][hold_out_sid]
answer = answer_map[hold_out_sid]
scores = {}
for fid, detail in sec['per_frame_detail'].items():
s = detail['standalone']['score']
g = detail['keyword_group'].get('score_avg', 0)
r = detail['related']['score']
scores[fid] = w_cv[0]*s + w_cv[1]*g + w_cv[2]*r
top_fid = max(scores, key=scores.get)
correct = frame_num[top_fid] == answer
if correct:
loocv_hits += 1
print(f' hold-out {hold_out_sid}: weights={w_cv.round(3)} 정답? {"" if correct else ""}')
print(f' LOOCV 정답률: {loocv_hits}/4')
if __name__ == '__main__':
main()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,145 @@
"""Pipeline Step 17 — V4 (template-fit) 를 V3 Top-K 가 아니라 32 프레임 전체에 적용.
기존 V4 r2 는 V3 Top-5 만 평가 → 5 개 중 선택.
이 스크립트는 32 프레임 전체에 template-fit 적용 → confidence 기준 전체 랭킹.
V1/V2/V3 처럼 "32 중 Top-3" 형태 산출.
출력: v4_full32_result.yaml
"""
import datetime
import sys
from pathlib import Path
import yaml
HERE = Path(__file__).parent
sys.path.insert(0, str(HERE))
from phase_common import load_32_frames, load_frame_index
from detect_mdx import detect_mdx_analysis
from template_fit import (
load_templates_v1, collect_anchor_vocab, compute_template_fit, route,
)
from embeddings import embed_texts, cosine
from pipeline_01_extract_nodes import MDX_SECTIONS, MDX_DIR
OUT_PATH = HERE / 'v4_full32_result.yaml'
def extract_mdx_raw(sid):
cfg = MDX_SECTIONS[sid]
p = MDX_DIR / cfg['file']
lines = p.read_text(encoding='utf-8').split('\n')
start = None
for i, ln in enumerate(lines):
if ln.strip() == cfg['start'].strip():
start = i
break
end = len(lines)
if cfg.get('end_prefix'):
for i in range(start + 1, len(lines)):
if lines[i].strip().startswith(cfg['end_prefix']):
end = i
break
section = lines[start:end]
return section[0].lstrip('#').strip(), '\n'.join(section)
def main():
v1 = yaml.safe_load((HERE / 'mdx_matching_result.yaml').read_text(encoding='utf-8'))
answer_map = v1['meta']['answer_map']
holdout = v1['meta']['holdout_sections']
templates = load_templates_v1()
anchor_vocab = collect_anchor_vocab(templates)
frames = load_32_frames()
idx_data, frame_to_short = load_frame_index()
fids = list(frames.keys())
frame_num_map = {fid: int(frame_to_short[fid]) for fid in fids}
frame_contents = [frames[fid].get('content', '') for fid in fids]
print('[V4 full-32] 32 frame content 임베딩 중...')
frame_vecs = embed_texts(frame_contents)
out_sections = {}
for sid in v1['mdx_sections']:
title, raw_text = extract_mdx_raw(sid)
mdx_analysis = detect_mdx_analysis(raw_text, title, anchor_vocab=anchor_vocab)
mdx_summary = mdx_analysis['summary']
mdx_vec = embed_texts([mdx_summary])[0]
judgments = []
for i, fid in enumerate(fids):
if fid not in templates:
continue
template = templates[fid]
content_emb = max(0.0, min(1.0, float(cosine(mdx_vec, frame_vecs[i]))))
fit = compute_template_fit(mdx_analysis, template, content_emb)
label = route(fit['confidence'], fit['axes'], fit['adaptation'], fit['not_suits'])
judgments.append({
'frame_id': fid,
'frame_number': frame_num_map[fid],
'template_id': template.get('template_id'),
'confidence': round(float(fit['confidence']), 4),
'base': round(float(fit['base']), 4),
'penalty': round(float(fit['total_penalty']), 4),
'label': label,
'content_embedding': round(content_emb, 4),
'axes': {
'anchor': round(float(fit['axes']['anchor']['score']), 4),
'cardinality': round(float(fit['axes']['cardinality']), 4),
'relation': round(float(fit['axes']['relation']), 4),
'slot': round(float(fit['axes']['slot']), 4),
'content': round(float(fit['axes']['content']), 4),
},
})
# confidence 내림차순
judgments.sort(key=lambda x: -x['confidence'])
for new_rank, item in enumerate(judgments, start=1):
item['v4_full_rank'] = new_rank
ans = answer_map.get(sid)
out_sections[sid] = {
'mdx_title': title,
'answer_frame_number': ans,
'is_holdout': sid in holdout,
'judgments_full32': judgments,
'usable_count': sum(1 for j in judgments if j['label'] != 'reject'),
'reject_count': sum(1 for j in judgments if j['label'] == 'reject'),
}
out = {
'meta': {
'pipeline_step': '8.v4.full32',
'description': 'V4 template-fit 을 V3 Top-K 가 아니라 32 프레임 전체에 적용',
'note': '기존 v4_template_fit_r2_result.yaml 는 V3 Top-5 만 평가. 이 파일은 32 전체.',
'generated_at': datetime.datetime.now().isoformat(timespec='seconds'),
'answer_map': answer_map,
'holdout_sections': holdout,
},
'mdx_sections': out_sections,
}
OUT_PATH.write_text(
yaml.safe_dump(out, allow_unicode=True, sort_keys=False, width=1000),
encoding='utf-8',
)
print('=' * 70)
print(f'V4 full-32 평가 완료: {OUT_PATH}')
print('=' * 70)
print(f'\n각 섹션별 사용 가능 프레임 (label != reject) 개수 + Top-3:')
for sid, s in out_sections.items():
ans = s.get('answer_frame_number')
ans_str = f'(정답 {ans})' if ans else '(holdout)'
print(f'\n[{sid}] {ans_str} 사용가능 {s["usable_count"]}/{s["usable_count"]+s["reject_count"]}')
usable = [j for j in s['judgments_full32'] if j['label'] != 'reject']
for j in usable[:3]:
mark = ' 🎯' if ans and j['frame_number'] == ans else ''
print(f' Frame {j["frame_number"]:>2}{mark} conf {j["confidence"]:.3f} {j["label"]}')
if not usable:
print(' (사용 가능 후보 없음 — 모두 reject)')
if __name__ == '__main__':
main()
@@ -0,0 +1,195 @@
"""Pipeline Step 18 — V4 slot 축 ablation (실증).
질문: V4 의 5축 중 slot 축 (W_SLOT=0.15) 이 frame 매칭에 실제로 기여하는가?
방법:
1. 기존 v4_full32_result (W_SLOT=0.15 유지) 를 baseline 으로
2. W_SLOT=0 으로 두고 나머지 4축 (anchor/cardinality/relation/content) 가중치
재정규화 — sum=1.0 유지 → ablated 결과 산출
3. 32 frame × 7 MDX 섹션 confidence 재계산
4. 비교:
- TARGET 4개의 정답 frame 라벨/순위 변화
- Top-3 매칭 변화
- 라벨 분포 변화 (use_as_is/light_edit/restructure/reject 카운트)
5. 결론: slot 축이 의미 있나?
출력: V4_SLOT_ABLATION.md
"""
import sys
from pathlib import Path
import yaml
HERE = Path(__file__).parent
sys.path.insert(0, str(HERE))
from template_fit import route
OUT_PATH = HERE / 'V4_SLOT_ABLATION.md'
# Baseline (현재): 0.25 + 0.20 + 0.20 + 0.15 + 0.20 = 1.00
W_BASELINE = {'anchor': 0.25, 'cardinality': 0.20, 'relation': 0.20, 'slot': 0.15, 'content': 0.20}
# Ablated: slot=0, 나머지 4축 비례 재정규화 → sum=1.00
# 4축 합 = 0.85, 각 축 / 0.85
W_ABLATED = {
'anchor': 0.25 / 0.85, # 0.2941
'cardinality': 0.20 / 0.85, # 0.2353
'relation': 0.20 / 0.85, # 0.2353
'slot': 0.0,
'content': 0.20 / 0.85, # 0.2353
}
def recompute_confidence(axes, weights, penalty):
base = sum(weights[k] * axes.get(k, 0) for k in weights)
return max(0.0, base - penalty)
def main():
v4 = yaml.safe_load((HERE / 'v4_full32_result.yaml').read_text(encoding='utf-8'))
answer_map = v4['meta']['answer_map']
holdout = set(v4['meta']['holdout_sections'])
md = []
md.append('# V4 slot 축 ablation — 실증\n')
md.append('## 질문\n')
md.append('V4 confidence 계산식의 slot 축 (W_SLOT=0.15) 이 frame 매칭에 실제로 기여하는가?\n')
md.append('## 방법\n')
md.append('- **Baseline**: 5축 가중치 그대로 — 0.25 anchor + 0.20 card + 0.20 rel + 0.15 slot + 0.20 content\n')
md.append('- **Ablated**: W_SLOT=0, 나머지 4축 비례 재정규화 (sum=1.0)\n')
md.append(' - anchor: 0.294, cardinality: 0.235, relation: 0.235, content: 0.235\n')
md.append('- 32 frame × 7 MDX 섹션 confidence 재계산 → label 재할당 → 비교\n\n')
section_results = {}
for sid, sec in v4['mdx_sections'].items():
is_target = sid not in holdout
ans_fn = answer_map.get(sid)
# Baseline 결과 (이미 v4_full32 에 있음)
baseline = sorted(sec['judgments_full32'], key=lambda x: -x['confidence'])
# Ablated 결과 재계산
ablated = []
for j in sec['judgments_full32']:
new_conf = recompute_confidence(j['axes'], W_ABLATED, j['penalty'])
new_label = route(new_conf)
ablated.append({
'frame_number': j['frame_number'],
'frame_id': j['frame_id'],
'baseline_conf': j['confidence'],
'baseline_label': j['label'],
'ablated_conf': round(new_conf, 4),
'ablated_label': new_label,
})
ablated.sort(key=lambda x: -x['ablated_conf'])
# 정답 frame 변화
ans_baseline = next((x for x in baseline if x['frame_number'] == ans_fn), None) if ans_fn else None
ans_ablated = next((x for x in ablated if x['frame_number'] == ans_fn), None) if ans_fn else None
# 순위 비교
baseline_top3 = [(x['frame_number'], x['confidence'], x['label']) for x in baseline[:3]]
ablated_top3 = [(x['frame_number'], x['ablated_conf'], x['ablated_label']) for x in ablated[:3]]
# 라벨 분포 비교
from collections import Counter
base_lbl = Counter(x['label'] for x in baseline)
abl_lbl = Counter(x['ablated_label'] for x in ablated)
section_results[sid] = {
'is_target': is_target,
'ans_fn': ans_fn,
'baseline_top3': baseline_top3,
'ablated_top3': ablated_top3,
'ans_baseline': ans_baseline,
'ans_ablated': ans_ablated,
'base_lbl': base_lbl,
'abl_lbl': abl_lbl,
}
# ─── TARGET 정답 매칭 변화 ───
md.append('## 결과 1 — TARGET 4 섹션의 정답 frame 라벨/순위 변화\n')
md.append('| MDX | 정답 Frame | Baseline conf / 라벨 / 순위 | Ablated conf / 라벨 / 순위 | 변화 |\n')
md.append('|---|---|---|---|---|\n')
target_sids = [sid for sid in section_results if section_results[sid]['is_target']]
correct_baseline = 0
correct_ablated = 0
for sid in target_sids:
r = section_results[sid]
ans_fn = r['ans_fn']
ab = r['ans_baseline']
aa = r['ans_ablated']
base_rank = next((i+1 for i, x in enumerate(sorted(v4['mdx_sections'][sid]['judgments_full32'], key=lambda x: -x['confidence'])) if x['frame_number'] == ans_fn), '?')
# ablated rank
ablated_sorted = sorted(
[{'fn': j['frame_number'], 'c': recompute_confidence(j['axes'], W_ABLATED, j['penalty'])}
for j in v4['mdx_sections'][sid]['judgments_full32']],
key=lambda x: -x['c']
)
abl_rank = next((i+1 for i, x in enumerate(ablated_sorted) if x['fn'] == ans_fn), '?')
# 정답률 — baseline / ablated 의 Top-1 이 정답인가
if v4['mdx_sections'][sid]['judgments_full32']:
base_top1_fn = sorted(v4['mdx_sections'][sid]['judgments_full32'], key=lambda x: -x['confidence'])[0]['frame_number']
if base_top1_fn == ans_fn:
correct_baseline += 1
abl_top1_fn = ablated_sorted[0]['fn']
if abl_top1_fn == ans_fn:
correct_ablated += 1
change = '동일' if (ab['label'] == aa['ablated_label'] and base_rank == abl_rank) else '⚠️ 변화'
md.append(f'| {sid} | Frame {ans_fn} | '
f'{ab["confidence"]:.3f} / {ab["label"]} / 순위 {base_rank} | '
f'{aa["ablated_conf"]:.3f} / {aa["ablated_label"]} / 순위 {abl_rank} | {change} |\n')
md.append(f'\n**TARGET Top-1 정답률**: Baseline {correct_baseline}/4, Ablated {correct_ablated}/4\n\n')
# ─── Top-3 변화 ───
md.append('## 결과 2 — 각 섹션 Top-3 매칭 변화\n')
for sid, r in section_results.items():
tag = 'TARGET' if r['is_target'] else 'Holdout'
ans = f'(정답 Frame {r["ans_fn"]})' if r['ans_fn'] else '(holdout)'
md.append(f'\n### [{sid}] {tag} {ans}\n')
md.append('| 순위 | Baseline | Ablated |\n|---|---|---|\n')
for i in range(3):
b = r['baseline_top3'][i] if i < len(r['baseline_top3']) else (None, 0, '')
a = r['ablated_top3'][i] if i < len(r['ablated_top3']) else (None, 0, '')
b_mark = ' 🎯' if r['ans_fn'] and b[0] == r['ans_fn'] else ''
a_mark = ' 🎯' if r['ans_fn'] and a[0] == r['ans_fn'] else ''
md.append(f'| {i+1} | Frame {b[0]}{b_mark} ({b[1]:.3f}, {b[2]}) | Frame {a[0]}{a_mark} ({a[1]:.3f}, {a[2]}) |\n')
# ─── 라벨 분포 변화 ───
md.append('\n## 결과 3 — 라벨 분포 변화 (32 frame 기준)\n')
md.append('| MDX | use_as_is | light_edit | restructure | reject |\n|---|---|---|---|---|\n')
for sid, r in section_results.items():
b = r['base_lbl']; a = r['abl_lbl']
def fmt(lbl):
bv = b.get(lbl, 0); av = a.get(lbl, 0)
if bv == av: return f'{bv}'
return f'{bv}{av}'
md.append(f'| {sid} | {fmt("use_as_is")} | {fmt("light_edit")} | {fmt("restructure")} | {fmt("reject")} |\n')
# ─── 결론 ───
md.append('\n## 결론\n')
same_top1 = sum(1 for sid, r in section_results.items()
if r['baseline_top3'] and r['ablated_top3']
and r['baseline_top3'][0][0] == r['ablated_top3'][0][0])
same_top3 = sum(1 for sid, r in section_results.items()
if r['baseline_top3'] and r['ablated_top3']
and set(x[0] for x in r['baseline_top3']) == set(x[0] for x in r['ablated_top3']))
md.append(f'- **Top-1 일치 섹션 수**: {same_top1}/{len(section_results)}\n')
md.append(f'- **Top-3 멤버 일치 섹션 수**: {same_top3}/{len(section_results)}\n')
md.append(f'- **TARGET Top-1 정답률**: Baseline {correct_baseline}/4 → Ablated {correct_ablated}/4\n')
OUT_PATH.write_text(''.join(md), encoding='utf-8')
print('=' * 70)
print(f'V4 slot 축 ablation 완료: {OUT_PATH}')
print('=' * 70)
print(f'TARGET Top-1 정답률: Baseline {correct_baseline}/4, Ablated {correct_ablated}/4')
print(f'Top-1 동일 섹션: {same_top1}/{len(section_results)}')
print(f'Top-3 멤버 동일 섹션: {same_top3}/{len(section_results)}')
if __name__ == '__main__':
main()
+195
View File
@@ -0,0 +1,195 @@
"""Phase 21b용 구조 ontology 기반 매칭
- detect_mdx_structure_v3(): MDX 본문에서 같은 schema 속성 emit
- structural_match_v3(): 속성 교집합 기반 score (0~1)
- 가중치는 tie-breaker 수준 (0.05~0.10)
- confidence high 일 때만 점수 부여
"""
import re
import yaml
from pathlib import Path
HERE = Path(__file__).parent
def load_ontology():
p = HERE / "structure_ontology.yaml"
with open(p, encoding="utf-8") as f:
return yaml.safe_load(f)["frames"]
# ═══ MDX 구조 감지 v3 ═══
def detect_mdx_structure_v3(text, mdx_title=""):
"""schema emit: {family, surface, columns, items, has_table, has_cards, semantic_role, confidence}"""
lines = text.split("\n")
result = {
"family": None, "surface": None, "semantic_role": None,
"columns": None, "items": None,
"has_table": False, "has_cards": False, "has_diagram": False,
"confidence": "low",
}
# 1. ### X.Y 서브섹션 감지
subs = [ln for ln in lines if re.match(r"^###\s+\d+\.\d+", ln)]
subsection_texts = [ln for ln in subs]
n_subs = len(subs)
# 2. 표 감지
table_header = None
for ln in lines:
stripped = ln.strip()
if re.match(r"^\|.*\|.*\|", stripped) and not re.match(r"^\|[\s\-:]+\|", stripped):
table_header = stripped
break
# 3. 최상위 볼드 블릿 수
top_bullets = [ln for ln in lines if re.match(r"^[-*]\s+\*\*", ln)]
n_bullets = len(top_bullets)
full_text = (text + " " + mdx_title).lower()
# ═══ semantic_role 감지 (figma_audit과 동일 — best-match + priority) ═══
from figma_audit import _SEMANTIC_HINT_PATTERNS, _ROLE_PRIORITY
candidates = []
for role_name, req_kws, min_req in _SEMANTIC_HINT_PATTERNS:
hits = sum(1 for k in req_kws if k in full_text)
if hits >= min_req:
ratio = hits / len(req_kws)
candidates.append((hits, ratio, role_name))
if candidates:
# 1) hits 많음 2) ratio 높음 3) priority 높음
candidates.sort(key=lambda x: (-x[0], -x[1], -_ROLE_PRIORITY.get(x[2], 0)))
result["semantic_role"] = candidates[0][2]
else:
result["semantic_role"] = None
# ═══ family / surface / columns 분기 ═══
# 우선순위: ### 서브섹션 > 표 > 블릿
if n_subs >= 2:
# 과정/결과 비교 → compare family
sub_lower = " ".join(subsection_texts).lower()
if any(kw in sub_lower for kw in ["과정", "결과", "process", "product"]):
result["family"] = "compare"
result["surface"] = "banner-plus-2col"
result["columns"] = 2
result["items"] = 2
result["confidence"] = "high"
elif n_subs == 2:
result["family"] = "compare"
result["surface"] = "2col-paired"
result["columns"] = 2
result["items"] = 2
result["confidence"] = "high"
else:
result["family"] = "list"
result["surface"] = "cards-3-horizontal"
result["columns"] = n_subs
result["items"] = n_subs
result["confidence"] = "medium"
elif table_header:
result["has_table"] = True
cols = [c.strip().replace("*", "").lower() for c in table_header.strip("|").split("|") if c.strip()]
n_cols = len(cols) - 1 # 첫 열 = label
col_text = " ".join(cols)
if any(kw in col_text for kw in ["발주자", "시공자", "설계자"]):
result["family"] = "cards"
result["surface"] = "table-persona-3col"
result["semantic_role"] = result.get("semantic_role") or "persona-benefits"
result["columns"] = 3
result["items"] = 3
result["has_cards"] = True
result["confidence"] = "high"
elif any(kw in col_text for kw in ["제조업", "건축", "토목"]) and "토목" in col_text:
result["family"] = "table"
result["surface"] = "table-3col"
result["columns"] = 3
result["items"] = 3
result["confidence"] = "high"
elif ("bim" in col_text) and ("dx" in col_text):
result["family"] = "compare"
result["surface"] = "table-multi-row"
result["columns"] = 2
result["confidence"] = "high"
else:
result["family"] = "table"
result["surface"] = f"table-{n_cols}col"
result["columns"] = n_cols
result["confidence"] = "medium"
elif n_bullets >= 1:
result["has_cards"] = True
if n_bullets == 3:
result["family"] = "list"
result["surface"] = "bullets-3"
result["columns"] = 3
result["items"] = 3
result["confidence"] = "high"
elif n_bullets == 2:
result["family"] = "compare"
result["surface"] = "bullets-2col"
result["columns"] = 2
result["items"] = 2
result["confidence"] = "medium"
elif n_bullets == 4:
result["family"] = "cards"
result["surface"] = "cards-4-grid"
result["columns"] = 4
result["items"] = 4
result["confidence"] = "medium"
else:
result["family"] = "list"
result["surface"] = "mixed-bullets"
result["items"] = n_bullets
result["confidence"] = "low"
else:
result["family"] = None
result["surface"] = "single-column"
result["confidence"] = "low"
return result
# ═══ 구조 매칭 v3 ═══
def structural_match_v3(mdx_schema, fig_schema):
"""속성 교집합 기반 score (0~1). confidence high + 주요 속성 일치시에만 점수 부여.
Tie-breaker 용도라 가중치는 호출 측에서 낮게 적용 (0.05~0.10 권장)."""
if not mdx_schema or not fig_schema:
return 0.0, []
# confidence 체크: low면 신뢰 안 함
if mdx_schema.get("confidence") == "low":
return 0.0, ["mdx confidence low → 0"]
# 속성 5개 비교
aligns = []
scores = []
# family (가장 중요)
m_fam = mdx_schema.get("family")
f_fam = fig_schema.get("family")
if m_fam and f_fam and m_fam == f_fam:
scores.append(0.40)
aligns.append(f"family:{m_fam}")
elif m_fam and f_fam:
aligns.append(f"family:{m_fam}{f_fam}")
# columns / items
m_cols = mdx_schema.get("columns") or mdx_schema.get("items")
f_cols = fig_schema.get("columns") or fig_schema.get("items")
if m_cols and f_cols and m_cols == f_cols:
scores.append(0.25)
aligns.append(f"columns:{m_cols}")
elif m_cols and f_cols:
aligns.append(f"columns:{m_cols}{f_cols}")
# has_table
if mdx_schema.get("has_table") is not None and fig_schema.get("has_table") is not None:
if mdx_schema["has_table"] == fig_schema["has_table"]:
scores.append(0.10)
aligns.append(f"has_table:{mdx_schema['has_table']}")
# has_cards
if mdx_schema.get("has_cards") is not None and fig_schema.get("has_cards") is not None:
if mdx_schema["has_cards"] == fig_schema["has_cards"]:
scores.append(0.10)
aligns.append(f"has_cards:{mdx_schema['has_cards']}")
# semantic_role (bonus)
m_role = mdx_schema.get("semantic_role")
f_role = fig_schema.get("semantic_role")
if m_role and f_role and m_role == f_role and m_role != "(검수 필요)":
scores.append(0.15)
aligns.append(f"role:{m_role}")
total = sum(scores)
return total, aligns
+674
View File
@@ -0,0 +1,674 @@
"""Template-fit-v1 scoring engine.
Spec: tests/matching/TEMPLATE_FIT_V1.md
공식:
base = 0.25*anchor + 0.20*card + 0.20*relation + 0.15*slot + 0.20*content
total_penalty = min(0.50, min(0.30, adaptation_cost) + min(0.30, not_suits_hits*0.20))
confidence = max(0, base - total_penalty)
v1 특성:
- 매칭 단계 LLM 호출 없음 (코드 only)
- content_embedding은 mock dict (ko-sroberta 연결은 검증 통과 후)
- MDX analysis는 4 TARGET 수동 fixture (detect_mdx_analysis() 구현은 나중)
"""
import yaml
from pathlib import Path
from detect_mdx import detect_mdx_analysis
from phase_common import TARGET_UNITS, load_target_units
from embeddings import embed_texts, cosine
HERE = Path(__file__).parent
# ─── 가중치 ────────────────────────────────────────────
W_ANCHOR = 0.25
W_CARDINALITY = 0.20
W_RELATION = 0.20
W_SLOT = 0.15
W_CONTENT = 0.20
# ─── 캡 ──────────────────────────────────────────────
CAP_ADAPTATION = 0.30
CAP_NOT_SUITS = 0.30
CAP_TOTAL = 0.50
NOT_SUITS_HIT_PENALTY = 0.20
# ─── adaptation 조작별 비용 ──────────────────────────
ADAPT_COST = {
'split': 0.10,
'merge': 0.15,
'infer_missing_slot': 0.25,
'rewrite_label': 0.05,
'rewrite_body': 0.05,
}
# ─── structure_intent 호환 매트릭스 ───────────────────
# 0~1 범위. 높을수록 "이 콘텐츠를 이 구조로 옮기기 쉬움".
# Symmetric 기본. 1.0 = 완전 일치, 0.2 이하 = 거의 불가능.
INTENT_COMPAT = {
# exact match
('concept_comparison', 'concept_comparison'): 1.0,
('multi_attribute_comparison', 'multi_attribute_comparison'): 1.0,
('transformation_story', 'transformation_story'): 1.0,
('process_product_split', 'process_product_split'): 1.0,
('category_comparison', 'category_comparison'): 1.0,
('industry_comparison', 'industry_comparison'): 1.0,
('persona_benefit', 'persona_benefit'): 1.0,
('requirement_list', 'requirement_list'): 1.0,
('problem_diagnosis', 'problem_diagnosis'): 1.0,
('requirement_or_pillar', 'requirement_or_pillar'): 1.0,
# strong compat (same family)
('concept_comparison', 'multi_attribute_comparison'): 0.8,
('multi_attribute_comparison', 'category_comparison'): 0.8,
('multi_attribute_comparison', 'industry_comparison'): 0.8,
('transformation_story', 'process_product_split'): 0.9,
('requirement_list', 'requirement_or_pillar'): 0.7,
# moderate (partial overlap)
('concept_comparison', 'category_comparison'): 0.5,
('multi_attribute_comparison', 'persona_benefit'): 0.4,
('industry_comparison', 'concept_comparison'): 0.4,
('requirement_or_pillar', 'persona_benefit'): 0.4,
# weak (different family)
('concept_comparison', 'transformation_story'): 0.2,
('concept_comparison', 'process_product_split'): 0.2,
('multi_attribute_comparison', 'transformation_story'): 0.2,
('multi_attribute_comparison', 'process_product_split'): 0.2,
('category_comparison', 'transformation_story'): 0.2,
('industry_comparison', 'transformation_story'): 0.2,
# polarity opposite
('requirement_list', 'problem_diagnosis'): 0.15,
('requirement_or_pillar', 'problem_diagnosis'): 0.25,
('persona_benefit', 'problem_diagnosis'): 0.2,
# structurally different (compare vs parallel world)
('persona_benefit', 'concept_comparison'): 0.25,
('requirement_list', 'concept_comparison'): 0.2,
('requirement_list', 'multi_attribute_comparison'): 0.3,
('requirement_list', 'persona_benefit'): 0.3,
('requirement_list', 'category_comparison'): 0.2,
('requirement_list', 'industry_comparison'): 0.25,
('requirement_list', 'process_product_split'): 0.2,
('requirement_list', 'transformation_story'): 0.2,
('problem_diagnosis', 'concept_comparison'): 0.2,
('problem_diagnosis', 'multi_attribute_comparison'): 0.25,
('problem_diagnosis', 'category_comparison'): 0.2,
('problem_diagnosis', 'industry_comparison'): 0.2,
('problem_diagnosis', 'process_product_split'): 0.15,
('problem_diagnosis', 'transformation_story'): 0.15,
}
def _sym_lookup(a, b, default=0.3):
"""대칭 조회. (a,b) 또는 (b,a) 있으면 반환, 아니면 default."""
if (a, b) in INTENT_COMPAT:
return INTENT_COMPAT[(a, b)]
if (b, a) in INTENT_COMPAT:
return INTENT_COMPAT[(b, a)]
return default
def intent_compat(mdx_intents, frame_intents):
"""MDX vs Frame 의 structure_intent list 호환도 계산.
- 둘 다 non-empty: max over all pairs
- 한쪽이 empty: neutral 0.5 (부분 적용 상태 — intent 미태깅 frame 배려)
"""
if not mdx_intents or not frame_intents:
return 0.5
return max(_sym_lookup(mi, fi) for mi in mdx_intents for fi in frame_intents)
def intent_compat_with_source(mdx_intents, frame_intents):
"""호환도 + source ('tagged' | 'neutral').
Phase 25 gate 에서 tagged mismatch 만 강하게 막기 위해 사용."""
if not mdx_intents or not frame_intents:
return 0.5, 'neutral'
return (max(_sym_lookup(mi, fi) for mi in mdx_intents for fi in frame_intents),
'tagged')
# ─── relation 호환 매트릭스 ──────────────────────────
RELATION_MATRIX = {
('parallel', 'parallel'): 1.0,
('parallel', 'sequence'): 0.2,
('parallel', 'compare'): 0.4,
('parallel', 'hierarchy'): 0.3,
('sequence', 'parallel'): 0.2,
('sequence', 'sequence'): 1.0,
('sequence', 'compare'): 0.3,
('sequence', 'hierarchy'): 0.4,
('compare', 'parallel'): 0.4,
('compare', 'sequence'): 0.3,
('compare', 'compare'): 1.0,
('compare', 'hierarchy'): 0.3,
('hierarchy', 'parallel'): 0.3,
('hierarchy', 'sequence'): 0.4,
('hierarchy', 'compare'): 0.3,
('hierarchy', 'hierarchy'): 1.0,
}
# ─── not_suits 규칙 엔진 (pattern 키워드 → MDX 시그널 판정) ──
# v1: structured signal 기반. 나중에 fit_notes.not_suits를 structured로 확장 예정.
NOT_SUITS_RULES = {
'시간 순서': lambda m: m['relation_type']['value'] == 'sequence',
'시간 순서 단계': lambda m: m['relation_type']['value'] == 'sequence',
'원인-결과': lambda m: m['relation_type']['value'] == 'sequence',
'2개 비교': lambda m: m['relation_type']['value'] == 'compare' and m['item_count']['detected'] == 2,
'2주체만': lambda m: m['item_count']['detected'] == 2,
'3개 이상 비교': lambda m: m['relation_type']['value'] == 'compare' and m['item_count']['detected'] >= 3,
'3개 이상 병렬': lambda m: m['relation_type']['value'] == 'parallel' and m['item_count']['detected'] >= 3,
'4개 이상': lambda m: m['item_count']['detected'] >= 4,
'4주체 이상': lambda m: m['item_count']['detected'] >= 4,
'1개 개념': lambda m: m['item_count']['detected'] == 1,
'단일 주체': lambda m: m['item_count']['detected'] == 1,
'단일 주제': lambda m: m['item_count']['detected'] == 1,
'단일 산업': lambda m: m['item_count']['detected'] == 1,
'단일 관점': lambda m: m['item_count']['detected'] == 1,
'병렬 나열': lambda m: m['relation_type']['value'] == 'parallel',
'주체별 나열': lambda m: any(p in m.get('detected_terms', [])
for p in ['발주자', '시공자', '설계자', '주체별']),
'산업 비교': lambda m: any(p in m.get('detected_terms', [])
for p in ['제조업', '건축', '토목', '산업별']),
'필수요건': lambda m: any(p in m.get('detected_terms', [])
for p in ['필수조건', '필수요건', '필수']),
'요건 나열': lambda m: any(p in m.get('detected_terms', [])
for p in ['필수조건', '필수요건']),
'BIM vs DX 직접 대조': lambda m: ('BIM' in m.get('detected_terms', []) and
'DX' in m.get('detected_terms', []) and
m['relation_type']['value'] == 'compare' and
m['item_count']['detected'] == 2),
}
def load_templates_v1():
path = HERE / 'structure_ontology.yaml'
with open(path, encoding='utf-8') as f:
data = yaml.safe_load(f)
assert data['meta']['schema_version'] == 'template-fit-v1', \
f"schema_version mismatch: {data['meta']['schema_version']}"
return data['templates_v1']
# ═══ 각 축별 점수 계산 ═══
def anchor_match(detected_terms, anchor_sets):
"""max across anchor_sets with per-set conditional cap.
per-set options (anchor_sets[i]):
min_hits → 이 수치 미만이면 해당 set 무시 (기본 1)
confidence_cap → ratio 상한 (기본 1.0 = 미적용)
cap_exempt_if_corroborated_by → 같은 템플릿의 다른 set 최고 ratio가
이 값 이상이면 cap 면제 (기본 None)
returns: (effective_ratio, set_id, matched_terms, cap_note)
"""
if not anchor_sets:
return 0.0, None, set(), ''
detected_set = set(detected_terms)
# 1. min_hits 통과하는 set 만 수집
raw = []
for s in anchor_sets:
terms = s['terms']
min_hits = s.get('min_hits', 1)
matched = detected_set & set(terms)
if len(matched) < min_hits:
continue
raw.append({
'id': s['id'],
'raw_ratio': len(matched) / len(terms) if terms else 0.0,
'matched': matched,
'cap': s.get('confidence_cap', 1.0),
'exempt': s.get('cap_exempt_if_corroborated_by', None),
})
if not raw:
return 0.0, None, set(), ''
# 2. 각 set 별 effective ratio 계산 (cap 조건부 적용)
for r in raw:
others_max = max(
(x['raw_ratio'] for x in raw if x['id'] != r['id']),
default=0.0,
)
if r['cap'] < 1.0 and r['raw_ratio'] > r['cap']:
if r['exempt'] is not None and others_max >= r['exempt']:
r['effective'] = r['raw_ratio']
r['note'] = f"cap{r['cap']:.2f} 면제 (방증 {others_max:.2f}{r['exempt']})"
else:
r['effective'] = r['cap']
r['note'] = f"cap{r['cap']:.2f} 적용 (방증 {others_max:.2f}<{r['exempt']})"
else:
r['effective'] = r['raw_ratio']
r['note'] = ''
# 3. effective ratio 최대 set 선택
best = max(raw, key=lambda x: x['effective'])
return best['effective'], best['id'], best['matched'], best['note']
def cardinality_match(mdx_count, card, adaptation_allowed):
ideal, mn, mx = card['ideal'], card['min'], card['max']
if mdx_count == ideal:
return 1.0
if mn <= mdx_count <= mx:
return 0.8
if adaptation_allowed.get('split') or adaptation_allowed.get('merge'):
return 0.5
return 0.0
def relation_match(mdx_rel, frame_rel):
if mdx_rel == frame_rel:
return 1.0
return RELATION_MATRIX.get((mdx_rel, frame_rel), 0.2)
def slot_coverage(mdx_analysis, template):
"""rough v1: 0.5 (cardinality 범위 내) + 0.3 (라벨) + 0.2 (본문)."""
n = mdx_analysis['item_count']['detected']
card = template['visual_pattern']['cardinality']
within = card['min'] <= n <= card['max']
candidates = mdx_analysis.get('slot_candidates', [])
has_labels = bool(candidates) and all(sc.get('label') for sc in candidates)
has_bodies = bool(candidates) and all(sc.get('body') for sc in candidates)
score = 0.0
if within: score += 0.5
if has_labels: score += 0.3
if has_bodies: score += 0.2
return score
def adaptation_cost(mdx_analysis, template):
"""Cardinality mismatch 시만 비용 부과. v1에서는 rewrite cost 안 부과 (light_edit 단계 책임)."""
card = template['visual_pattern']['cardinality']
allowed = template['adaptation_allowed']
n = mdx_analysis['item_count']['detected']
ops = []
cost = 0.0
if n < card['min']:
if allowed.get('split'):
ops.append(f'split({n}{card["min"]})')
cost += ADAPT_COST['split']
elif allowed.get('infer_missing_slot'):
ops.append('infer_missing')
cost += ADAPT_COST['infer_missing_slot']
else:
ops.append('forbidden:n<min') # 실제 점수는 cardinality=0으로 반영됨
elif n > card['max']:
if allowed.get('merge'):
ops.append(f'merge({n}{card["max"]})')
cost += ADAPT_COST['merge']
else:
ops.append('forbidden:n>max')
return min(CAP_ADAPTATION, cost), ops
def not_suits_penalty(mdx_analysis, template):
not_suits = template.get('fit_notes', {}).get('not_suits', [])
hits = 0
matched = []
for pattern in not_suits:
fired = False
# 가장 구체적인 키 먼저 매치
for key, rule in sorted(NOT_SUITS_RULES.items(), key=lambda x: -len(x[0])):
if key in pattern:
if rule(mdx_analysis):
fired = True
break
if fired:
hits += 1
matched.append(pattern)
penalty = min(CAP_NOT_SUITS, hits * NOT_SUITS_HIT_PENALTY)
return penalty, matched
# ═══ 최종 confidence 계산 ═══
def compute_template_fit(mdx_analysis, template, content_embedding):
anchor_s, anchor_set_id, anchor_terms, anchor_note = anchor_match(
mdx_analysis['detected_terms'], template['anchor_sets']
)
card_s = cardinality_match(
mdx_analysis['item_count']['detected'],
template['visual_pattern']['cardinality'],
template['adaptation_allowed'],
)
rel_s = relation_match(
mdx_analysis['relation_type']['value'],
template['visual_pattern']['relation_type'],
)
slot_s = slot_coverage(mdx_analysis, template)
content_s = content_embedding
# structure_intent 호환도 (score 에 반영하지 않고 gate 용으로만 저장)
mdx_intents = mdx_analysis.get('structure_intent', [])
frame_intents = template.get('visual_pattern', {}).get('structure_intent', [])
ic, ic_source = intent_compat_with_source(mdx_intents, frame_intents)
base = (W_ANCHOR * anchor_s + W_CARDINALITY * card_s + W_RELATION * rel_s
+ W_SLOT * slot_s + W_CONTENT * content_s)
adapt_pen, adapt_ops = adaptation_cost(mdx_analysis, template)
ns_pen, ns_matched = not_suits_penalty(mdx_analysis, template)
total_pen = min(CAP_TOTAL, adapt_pen + ns_pen)
confidence = max(0.0, base - total_pen)
return {
'confidence': confidence,
'base': base,
'total_penalty': total_pen,
'axes': {
'anchor': {'score': anchor_s, 'set_id': anchor_set_id,
'terms': sorted(anchor_terms), 'note': anchor_note},
'cardinality': card_s,
'relation': rel_s,
'slot': slot_s,
'content': content_s,
'intent': {'compat': ic, 'source': ic_source,
'mdx': mdx_intents, 'frame': frame_intents},
},
'adaptation': {'penalty': adapt_pen, 'ops': adapt_ops},
'not_suits': {'penalty': ns_pen, 'matched': ns_matched},
}
def route(confidence, axes=None, adaptation=None, not_suits=None):
"""Multi-gate routing.
1. Intent gate (tagged mismatch 만 강하게 차단)
2. Route_v2 multi-constraint (anchor/content/adaptation/not_suits)
axes/adaptation/not_suits 미제공 시 legacy confidence-only 로 fallback.
"""
# Legacy fallback (구 호출자 호환)
if axes is None:
if confidence >= 0.90: return 'use_as_is'
if confidence >= 0.75: return 'light_edit'
if confidence >= 0.60: return 'restructure'
return 'reject'
a = axes['anchor']['score']
c = axes['content']
adapt_pen = adaptation['penalty'] if adaptation else 0.0
ns_hits = len(not_suits['matched']) if not_suits else 0
intent = axes.get('intent', {})
ic = intent.get('compat', 0.5)
ic_source = intent.get('source', 'neutral')
# ─── Intent gate (tagged mismatch 만 강하게) ────────
if ic_source == 'tagged':
if ic < 0.4:
return 'reject'
if ic < 0.7:
# use_as_is/light_edit 금지, restructure 이하만 가능
if (confidence >= 0.60
and (a >= 0.35 or (c >= 0.55 and adapt_pen < 0.20))
and ns_hits <= 1):
return 'restructure'
return 'reject'
# ─── Route_v2 multi-constraint (normal path) ──────
# use_as_is: 엄격
if (confidence >= 0.90
and a >= 0.70
and c >= 0.55
and ns_hits == 0):
return 'use_as_is'
# light_edit: 중간
if (confidence >= 0.75
and a >= 0.50
and c >= 0.45
and adapt_pen < 0.10):
return 'light_edit'
# restructure: 최소 증거
if (confidence >= 0.60
and (a >= 0.35 or (c >= 0.55 and adapt_pen < 0.20))
and ns_hits <= 1):
return 'restructure'
return 'reject'
# ═══ anchor_vocab 수집 ═══
def collect_anchor_vocab(templates):
vocab = set()
for tpl in templates.values():
for s in tpl['anchor_sets']:
vocab.update(s['terms'])
return vocab
# ═══ 4 TARGET MDX analysis ═══
# v1 fixture 방식 (참고용, 실제 실행은 load_mdx_analyses 사용)
_LEGACY_FIXTURE = {
'MDX01-2-details': {
'title': 'BIM과 DX의 이해',
'summary': 'BIM과 DX 용어 혼용 정리. 범위·S/W·프로세스·성과품·활용·확장성·수행개념·주체 관점별 비교.',
'detected_terms': ['BIM', 'DX', '범위', '성과품', '확장성', '수행개념', '수행주체',
'프로세스', '활용', '비교', '관점별', '용어비교', '상호관계', '혼용'],
'item_count': {'detected': 2, 'source': 'table_columns'},
'relation_type': {'value': 'compare', 'confidence': 'high'},
'content_shape': {'has_table': True, 'has_subsections': False, 'has_bullets': False},
'slot_candidates': [
{'label': 'BIM', 'body': 'Building Information Modeling (범위/S·W/프로세스 ...)'},
{'label': 'DX', 'body': 'Digital Transformation (범위/S·W/프로세스 ...)'},
],
},
'MDX02-2.2-table': {
'title': '주체별 기대효과',
'summary': 'DX 시행 주체별 기대효과. 발주자·시공자·설계자 각각의 역할별 목표와 기대 효과.',
'detected_terms': ['발주자', '시공자', '설계자', '주체별', '기대효과', '역할', '역량목표',
'소통', '협업', '품질향상', '생산성향상'],
'item_count': {'detected': 3, 'source': 'table_rows'},
'relation_type': {'value': 'parallel', 'confidence': 'high'},
'content_shape': {'has_table': True, 'has_subsections': False, 'has_bullets': True},
'slot_candidates': [
{'label': '발주자', 'body': '소통·행정자동화·공사Risk 최소화'},
{'label': '시공자', 'body': '품질향상·생산성향상·현장실효성'},
{'label': '설계자', 'body': '오류예방·리스크최소화·민원예방'},
],
},
'MDX03-1': {
'title': 'DX 시행을 위한 필수요건',
'summary': 'DX 시행을 위한 3대 필수조건. 기술·사람·자연 여건의 조화.',
'detected_terms': ['DX', '기술', '사람', '자연', '필수조건', '역량', '여건',
'디지털기술', '3요소'],
'item_count': {'detected': 3, 'source': 'bullets'},
'relation_type': {'value': 'parallel', 'confidence': 'high'},
'content_shape': {'has_table': False, 'has_subsections': False, 'has_bullets': True},
'slot_candidates': [
{'label': '기술', 'body': '디지털기술과 기반지식'},
{'label': '사람', 'body': '역량과 창의성'},
{'label': '자연', 'body': '여건과 투자 기반'},
],
},
'MDX03-2': {
'title': 'Process/Product 혁신',
'summary': 'Process 혁신과 Product 혁신. 과정의 Analogue→Digital Transformation, 결과의 2D도면→3D모델 전환.',
'detected_terms': ['과정혁신', '결과혁신', 'Process', 'Product', 'Analogue', 'Digital',
'2D', '3D', 'Transformation', 'AS-IS', 'TO-BE', '2D도면', '3D모델'],
'item_count': {'detected': 2, 'source': 'subsections'},
'relation_type': {'value': 'compare', 'confidence': 'high'},
'content_shape': {'has_table': False, 'has_subsections': True, 'has_bullets': True},
'slot_candidates': [
{'label': '과정혁신', 'body': 'Analogue → Digital Transformation'},
{'label': '결과혁신', 'body': '2D도면 → 3D모델'},
],
},
}
# ═══ Mock content embedding ═══
# 정답 페어 0.90, 관련 0.45~0.55, 무관 0.40
CONTENT_EMBEDDING_MOCK = {
'MDX01-2-details': {
'bim_dx_comparison_table': 0.90,
'process_product_two_way': 0.55,
'sw_reality_three_emphasis': 0.50,
'three_persona_benefits': 0.45,
'three_parallel_requirements':0.40,
},
'MDX02-2.2-table': {
'three_persona_benefits': 0.90,
'three_parallel_requirements':0.50,
'sw_reality_three_emphasis': 0.45,
'bim_dx_comparison_table': 0.40,
'process_product_two_way': 0.40,
},
'MDX03-1': {
'three_parallel_requirements':0.90,
'sw_reality_three_emphasis': 0.55,
'three_persona_benefits': 0.50,
'process_product_two_way': 0.50,
'bim_dx_comparison_table': 0.45,
},
'MDX03-2': {
'process_product_two_way': 0.90,
'bim_dx_comparison_table': 0.55,
'sw_reality_three_emphasis': 0.50,
'three_parallel_requirements':0.45,
'three_persona_benefits': 0.40,
},
}
GROUND_TRUTH = {
'MDX01-2-details': 'bim_dx_comparison_table',
'MDX02-2.2-table': 'three_persona_benefits',
'MDX03-1': 'three_parallel_requirements',
'MDX03-2': 'process_product_two_way',
}
def load_mdx_analyses(anchor_vocab):
"""실제 MDX 소스에서 detect_mdx_analysis() 로 4 TARGET 분석 생성."""
units_full, units_title = load_target_units()
out = {}
for t in TARGET_UNITS:
uid = t[0]
out[uid] = detect_mdx_analysis(units_full[uid], units_title[uid], anchor_vocab)
return out
def compute_content_sim(mdx_analyses, tpl_by_id):
"""ko-sroberta cosine: MDX summary ↔ template description."""
print(f" [embedding] ko-sroberta load + {len(tpl_by_id)} template + {len(mdx_analyses)} MDX encode ...")
tpl_descs = {tid: tpl['description'] for tid, tpl in tpl_by_id.items()}
tpl_ids = list(tpl_descs.keys())
tpl_vecs = embed_texts([tpl_descs[i] for i in tpl_ids])
mdx_ids = list(mdx_analyses.keys())
mdx_summaries = [mdx_analyses[i]['summary'] for i in mdx_ids]
mdx_vecs = embed_texts(mdx_summaries)
sim = {}
for i, mid in enumerate(mdx_ids):
sim[mid] = {}
for j, tid in enumerate(tpl_ids):
sim[mid][tid] = max(0.0, min(1.0, cosine(mdx_vecs[i], tpl_vecs[j])))
return sim
# ─── 검증 기준 ─────────────────────────────────────────
# 리뷰어 합의 기준:
# 1. 정답 후보는 1위여야 한다
# 2. 정답 아닌 후보는 use_as_is(≥0.90) 라우팅되면 안 된다
# 3. 1위와 2위의 격차는 최소 0.05 이상 (fragile tie 방지)
MIN_GAP = 0.05
USE_AS_IS_FLOOR = 0.90
def main():
templates = load_templates_v1()
tpl_by_id = {t['template_id']: t for t in templates.values()}
vocab = collect_anchor_vocab(templates)
print(f"=== Template-fit v1 scoring — ko-sroberta content embedding ===\n")
MDX_ANALYSES = load_mdx_analyses(vocab)
CONTENT_SIM = compute_content_sim(MDX_ANALYSES, tpl_by_id)
print()
hits = 0
warnings = []
for mdx_id, mdx_analysis in MDX_ANALYSES.items():
gt = GROUND_TRUTH[mdx_id]
print(f"━━ {mdx_id} 정답: {gt}")
print(f" item_count={mdx_analysis['item_count']['detected']:d} "
f"relation={mdx_analysis['relation_type']['value']} "
f"terms={len(mdx_analysis['detected_terms'])}")
results = []
for tpl_id, tpl in tpl_by_id.items():
content_sim = CONTENT_SIM[mdx_id][tpl_id]
r = compute_template_fit(mdx_analysis, tpl, content_sim)
results.append((tpl_id, r))
results.sort(key=lambda x: -x[1]['confidence'])
for rank, (tpl_id, r) in enumerate(results, 1):
a = r['axes']
mark = '' if tpl_id == gt else ' '
route_label = route(r['confidence'])
a_score = a['anchor']['score']
a_set = a['anchor']['set_id'] or '-'
a_terms = a['anchor']['terms']
a_note = a['anchor'].get('note', '')
print(f" [{rank}] {mark} {tpl_id:32s} conf={r['confidence']:.3f} ({route_label})")
suffix = f" {a_note}" if a_note else ''
print(f" anchor={a_score:.2f}({a_set}: {a_terms}){suffix}")
print(f" card={a['cardinality']:.2f} rel={a['relation']:.2f} slot={a['slot']:.2f} content={a['content']:.2f}")
print(f" base={r['base']:.3f} "
f"adapt=-{r['adaptation']['penalty']:.2f}{r['adaptation']['ops']} "
f"nsuits=-{r['not_suits']['penalty']:.2f}{r['not_suits']['matched']}")
# 검증 체크
top1_tpl, top1_r = results[0]
top2_tpl, top2_r = results[1]
if top1_tpl == gt:
hits += 1
else:
warnings.append(f"[{mdx_id}] top1={top1_tpl} ≠ gt={gt}")
# 정답 아닌 후보가 use_as_is 인지
for tpl_id, r in results:
if tpl_id != gt and r['confidence'] >= USE_AS_IS_FLOOR:
warnings.append(
f"[{mdx_id}] 오답 후보 use_as_is: {tpl_id} conf={r['confidence']:.3f}"
)
# 1-2위 격차
gap = top1_r['confidence'] - top2_r['confidence']
if gap < MIN_GAP:
warnings.append(
f"[{mdx_id}] 격차 {gap:.3f} < 기준 {MIN_GAP} "
f"(top1={top1_tpl} {top1_r['confidence']:.3f} / "
f"top2={top2_tpl} {top2_r['confidence']:.3f})"
)
print()
print(f"=== Result: {hits}/{len(MDX_ANALYSES)} ===\n")
if warnings:
print("⚠️ 경고")
for w in warnings:
print(f" - {w}")
else:
print(f"✓ 검증 기준 통과 ({hits}/{len(MDX_ANALYSES)}, "
f"오답 use_as_is 없음, 1-2위 격차 ≥ {MIN_GAP})")
if __name__ == '__main__':
main()