wip: phase_z2 evidence 파이프라인 + matching 실험(phase2~26) + 프론트 trace 패널 진행분 스냅샷
- src: phase_z2 composition/mapper/pipeline/placement_planner/retry, ai_fallback(prompts/schema/validate), mdx_text_atoms 신규 - Front: PipelineTracePanel 신규, FramePanel/SlideCanvas/Home/designAgentApi 등 갱신 + 테스트 4종 추가 - templates/phase_z2: catalog(component_expansion_registry, node_slot_mapping 신규), frames, families, slide_base 갱신 - tests/matching: phase2~26 매칭 실험 스크립트·리포트·온톨로지 전체 (미커밋 진행분) - tests: b4_v4 evidence, task5~28.5 시리즈, regression(imp95 baseline) 등 신규 테스트 대량 추가 - docs/reference: MDX 구조 인벤토리, MDX→Frame 구조 계약 문서 - scripts: mdx 계약/parity/coverage/viewport 체크, gitea comment, run sync 유틸 - .gitignore: tmp*.json, chromedriver, .orchestrator, *.pkl, Front_test* 등 임시/스냅샷 제외 미완성 작업의 보존용 스냅샷 커밋 (2026-07-02) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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()
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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))}
|
||||
@@ -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
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user