- 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>
400 lines
16 KiB
Python
400 lines
16 KiB
Python
"""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'<[^>]+>')
|
||
RE_H3_LABEL = re.compile(r'<h3[^>]*>([^<]+)</h3>')
|
||
RE_TITLE_BODY = re.compile(r'^\d+(?:\.\d+)*\s+(.+)')
|
||
|
||
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, exclude_title=None):
|
||
"""### X.Y TITLE 라인 목록 → [(number, title), ...]
|
||
|
||
exclude_title 이 주어지면, 본문이 그 title 과 일치하는 ### 라인은 self-header
|
||
로 보고 제외. extract_mdx_raw 가 ### 헤딩 자체부터 본문을 추출하므로,
|
||
자기 자신을 항목 1 개로 카운트하는 것을 방지.
|
||
"""
|
||
out = []
|
||
excl = exclude_title.strip() if exclude_title else None
|
||
for ln in text.split('\n'):
|
||
m = RE_SUBSECTION.match(ln.strip())
|
||
if m:
|
||
body = m.group(2).strip()
|
||
if excl and body == excl:
|
||
continue
|
||
out.append((m.group(1), body))
|
||
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 find_h3_cards(text):
|
||
"""JSX <h3>LABEL</h3> 추출 → [label, ...].
|
||
|
||
04-1 처럼 <div> 카드 그룹 안에 <h3> 가 카드 제목으로 있는 패턴을 카운트.
|
||
markdown bullet/subsection/table 이 모두 없을 때의 fallback 신호.
|
||
"""
|
||
return [m.group(1).strip() for m in RE_H3_LABEL.finditer(text)]
|
||
|
||
|
||
# ═══ 슬롯 후보 추출 ═══
|
||
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
|
||
|
||
|
||
def slots_from_h3_cards(labels):
|
||
"""<h3> 카드 라벨을 슬롯으로 (body 는 일단 label 과 동일 — 카드 본문 추출은 별도)."""
|
||
return [{'label': lab, 'body': lab} for lab in labels]
|
||
|
||
|
||
# ═══ 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
|
||
|
||
|
||
_KOREAN_SYL_RE = re.compile(r'^[가-힣]+$')
|
||
|
||
|
||
def _anchor_term_matches(term, text):
|
||
"""anchor_vocab term 이 text 에 등장하는지 검사.
|
||
|
||
1) 정확 substring 매칭이 있으면 즉시 True.
|
||
2) term 이 4 글자 한글 compound 일 경우 (2+2) 띄어쓰기 변형도 매칭 :
|
||
예) '정책집행' ↔ '정책 집행', '개념부재' ↔ '개념 부재', '이해부족' ↔ '이해 부족'
|
||
MDX 본문이 자연스러운 띄어쓰기 ('정책 집행') 인데 anchor term 이 compound
|
||
('정책집행') 인 경우 의미 매칭 회복용. 영문/숫자/2~3 글자/5+ 글자 term 은 미적용
|
||
(false positive 위험).
|
||
|
||
3 글자 또는 5 글자 이상 한글 compound 까지 확장하지 않는 이유 : 4 글자 (2+2) 가
|
||
한국어 compound 의 가장 흔한 형태이고, 글자 단위 (1+3 / 1+1+1+1 등) 까지 허용하면
|
||
false positive 폭발. 필요 시 별도 룰로 추가.
|
||
"""
|
||
if term in text:
|
||
return True
|
||
if len(term) == 4 and _KOREAN_SYL_RE.match(term):
|
||
pattern = re.compile(re.escape(term[:2]) + r'[ ]?' + re.escape(term[2:]))
|
||
if pattern.search(text):
|
||
return True
|
||
return False
|
||
|
||
|
||
def extract_detected_terms(normalized_text, anchor_vocab):
|
||
"""Kiwi 명사/외국어/숫자 + anchor_vocab substring.
|
||
- HTML/JSX 제거 후 Kiwi noun 추출
|
||
- anchor_vocab substring 은 원본(normalized) 에 대해 수행 (compound term 커버)
|
||
- 4 글자 한글 compound 는 (2+2) 띄어쓰기 변형 도 시도 (`_anchor_term_matches` 참조)
|
||
"""
|
||
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 매칭 (compound terms — Kiwi 가 분해하는 단어 커버)
|
||
for term in (anchor_vocab or []):
|
||
if len(term) >= 2 and _anchor_term_matches(term, 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)
|
||
|
||
# title 본문 (예: "2.1 정책 및 발주 체계" → "정책 및 발주 체계") 추출.
|
||
# extract_mdx_raw 가 ### 헤딩 자체를 본문 첫 줄로 포함하므로, 그 self-header 가
|
||
# subsection 으로 잡혀 item_count=1 이 되는 버그를 막기 위해 사용.
|
||
m_title = RE_TITLE_BODY.match(title.strip())
|
||
self_title = m_title.group(1).strip() if m_title else title.strip()
|
||
|
||
subs = find_subsections(text, exclude_title=self_title)
|
||
table = parse_first_table(text)
|
||
bullets = find_top_bullets(text)
|
||
h3_cards = find_h3_cards(text)
|
||
|
||
# item_count 우선순위: subsections > table > bullets > h3_cards
|
||
# (h3_cards 는 04-1 처럼 markdown 신호가 없는 JSX <div>/<h3> 카드 그룹용 fallback)
|
||
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)
|
||
elif len(h3_cards) >= 2:
|
||
item_count = len(h3_cards)
|
||
item_source = 'h3_cards'
|
||
slot_candidates = slots_from_h3_cards(h3_cards)
|
||
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,
|
||
}
|