Add Type B slide pipeline and recipe rendering updates
This commit is contained in:
@@ -0,0 +1,573 @@
|
||||
"""Phase Y: 영역 확정 모듈.
|
||||
|
||||
normalized.sections(Stage 0 산출물)를 기반으로 ## 대목차 구조를 파악하고,
|
||||
Kei 꼭지를 대목차에 매핑하여 영역을 확정한다.
|
||||
|
||||
source of truth = normalized.sections (Stage 0)
|
||||
raw MDX는 사용하지 않음 (보존용/증거용으로만 존재).
|
||||
|
||||
용도:
|
||||
- Kei 꼭지를 대목차에 매핑
|
||||
- 대목차별 묶음으로 블록 tag 매칭
|
||||
- 영역 확정 (코드가, Kei가 아님)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def extract_major_sections(normalized_sections: list[dict]) -> list[dict]:
|
||||
"""normalized.sections에서 ## 대목차(level=2)를 추출하고,
|
||||
각 대목차 아래의 소목차(level=3) content를 합쳐서 반환.
|
||||
|
||||
normalized.sections 구조:
|
||||
[{"level": 2, "title": "DX 시행을 위한 필수 요건", "content": ""},
|
||||
{"level": 2, "title": "기술(디지털)", "content": "D1: ..."},
|
||||
{"level": 3, "title": "과정(Process)의 혁신", "content": "D1: ..."}]
|
||||
|
||||
반환:
|
||||
[{"title": "DX 시행을 위한 필수 요건", "content": "기술+사람+자연 합침", "sub_titles": ["기술","사람","자연"]},
|
||||
{"title": "Process의 혁신과 Product의 변화", "content": "과정+결과 합침", "sub_titles": ["과정","결과"]}]
|
||||
"""
|
||||
if not normalized_sections:
|
||||
return []
|
||||
|
||||
# level=2 중 content가 비어있는 것 = 대목차 헤더 (아래 level=2/3이 소속)
|
||||
# level=2 중 content가 있는 것 = 대목차 헤더가 없는 독립 섹션 (소목차)
|
||||
# level=3 = 소목차
|
||||
|
||||
major_sections = []
|
||||
current_major = None
|
||||
|
||||
for sec in normalized_sections:
|
||||
level = sec.get("level", 2)
|
||||
title = sec.get("title", "")
|
||||
content = sec.get("content", "")
|
||||
|
||||
if level == 2 and not content.strip():
|
||||
# 대목차 헤더 (빈 content = 아래 섹션들의 그룹 헤더)
|
||||
if current_major:
|
||||
major_sections.append(current_major)
|
||||
current_major = {
|
||||
"title": title,
|
||||
"content": "",
|
||||
"sub_titles": [],
|
||||
}
|
||||
elif level == 2 and content.strip():
|
||||
# content가 있는 level=2 = 소목차 또는 독립 섹션
|
||||
if current_major:
|
||||
# 현재 대목차 아래의 소목차
|
||||
current_major["content"] += f"\n{content}" if current_major["content"] else content
|
||||
current_major["sub_titles"].append(title)
|
||||
else:
|
||||
# 대목차 없이 시작된 독립 섹션 (도입부)
|
||||
current_major = {
|
||||
"title": title,
|
||||
"content": content,
|
||||
"sub_titles": [title],
|
||||
}
|
||||
elif level == 3:
|
||||
# 소목차 → 현재 대목차에 합침
|
||||
if current_major:
|
||||
current_major["content"] += f"\n{content}" if current_major["content"] else content
|
||||
current_major["sub_titles"].append(title)
|
||||
else:
|
||||
# 대목차 없는 level=3 (비정상이지만 처리)
|
||||
current_major = {
|
||||
"title": title,
|
||||
"content": content,
|
||||
"sub_titles": [title],
|
||||
}
|
||||
|
||||
if current_major:
|
||||
major_sections.append(current_major)
|
||||
|
||||
# 빈 섹션 제거
|
||||
major_sections = [s for s in major_sections if s["content"].strip()]
|
||||
|
||||
logger.info(
|
||||
f"[section_parser] {len(major_sections)}개 대목차: "
|
||||
+ ", ".join(f'"{s["title"]}" (sub: {s["sub_titles"]})' for s in major_sections)
|
||||
)
|
||||
|
||||
return major_sections
|
||||
|
||||
|
||||
def detect_component_popups(raw_content: str, base_path: str = "") -> list[dict]:
|
||||
"""Y-14: MDX에서 import된 Astro 컴포넌트를 감지하고 popup 대상으로 등록.
|
||||
|
||||
Returns:
|
||||
[{"name": "DxEffect", "source": "components/dx.astro",
|
||||
"resolved_path": "실제 파일 경로", "content_html": "astro HTML 내용"}]
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
popups = []
|
||||
# import 문 파싱
|
||||
imports = re.findall(r'import\s+(\w+)\s+from\s+["\']([^"\']+)["\']', raw_content)
|
||||
# self-closing 태그 사용 여부
|
||||
used_tags = set(re.findall(r'<(\w+)\s*/>', raw_content))
|
||||
|
||||
for name, source in imports:
|
||||
if name not in used_tags:
|
||||
continue # import만 하고 사용 안 한 것은 무시
|
||||
|
||||
# astro 파일 경로 해석
|
||||
resolved = ""
|
||||
content_html = ""
|
||||
if base_path:
|
||||
# MDX 기준 상대경로 → 절대경로
|
||||
mdx_dir = Path(base_path)
|
||||
candidate = mdx_dir / source
|
||||
if not candidate.exists():
|
||||
# samples/src/components/ 에서 찾기
|
||||
candidate = Path(base_path).parent.parent / "src" / "components" / Path(source).name
|
||||
if not candidate.exists():
|
||||
# 프로젝트 루트에서 찾기
|
||||
candidate = Path("samples/src/components") / Path(source).name
|
||||
if candidate.exists():
|
||||
resolved = str(candidate)
|
||||
raw = candidate.read_text(encoding="utf-8")
|
||||
# astro frontmatter 제거
|
||||
if raw.startswith("---"):
|
||||
end = raw.find("---", 3)
|
||||
if end > 0:
|
||||
content_html = raw[end + 3:].strip()
|
||||
else:
|
||||
content_html = raw
|
||||
else:
|
||||
content_html = raw
|
||||
|
||||
popups.append({
|
||||
"name": name,
|
||||
"source": source,
|
||||
"resolved_path": resolved,
|
||||
"content_html": content_html,
|
||||
"tag": f"<{name} />",
|
||||
})
|
||||
logger.info(f"[Y-14] 컴포넌트 popup 감지: {name} → {resolved or source}")
|
||||
|
||||
return popups
|
||||
|
||||
|
||||
def _classify_sub_types(
|
||||
sub_titles: list[str], full_content: str,
|
||||
normalized_sections: list[dict] | None = None,
|
||||
popup_sub_titles: list[str] | None = None,
|
||||
) -> list[dict]:
|
||||
"""B-1: 각 sub_title의 콘텐츠 유형을 점수 기반 힌트로 판단.
|
||||
|
||||
점수 항목:
|
||||
- 병렬 소목차 구조 (sub_titles 수, 대등성)
|
||||
- 각 항목 길이 (D2 본문 길이)
|
||||
- D1/D2 패턴 밀도
|
||||
- popup/component 존재 여부 (popup_sub_titles)
|
||||
|
||||
Returns: [{title: str, sub_type: str}]
|
||||
"""
|
||||
results = []
|
||||
lines = full_content.split("\n")
|
||||
norm_secs = normalized_sections or []
|
||||
|
||||
for st in sub_titles:
|
||||
st_key = re.sub(r'\*+', '', st.split("(")[0].strip()).lower()
|
||||
sub_content = ""
|
||||
|
||||
# 1차: normalized_sections에서 섹션 title로 매칭
|
||||
for sec in norm_secs:
|
||||
sec_title = sec.get("title", "").lower()
|
||||
if st_key and len(st_key) >= 2 and st_key in sec_title:
|
||||
sub_content = sec.get("content", "")
|
||||
break
|
||||
|
||||
# 2차: D1: 항목 내 매칭 (sub_title이 D1 항목명인 경우)
|
||||
if not sub_content:
|
||||
capturing = False
|
||||
for line in lines:
|
||||
d1_match = re.match(r'^D1:\s*(.*)', line.strip())
|
||||
if d1_match:
|
||||
d1_text = re.sub(r'\*+', '', d1_match.group(1)).strip().lower()
|
||||
if capturing:
|
||||
break
|
||||
if st_key and len(st_key) >= 2 and st_key in d1_text:
|
||||
capturing = True
|
||||
sub_content += line.strip() + "\n"
|
||||
elif capturing:
|
||||
stripped = line.strip()
|
||||
if stripped:
|
||||
sub_content += stripped + "\n"
|
||||
|
||||
# 점수 계산
|
||||
scores = {
|
||||
"parallel_card_candidate": 0,
|
||||
"text_list_candidate": 0,
|
||||
"visual_detail_candidate": 0,
|
||||
"table_heavy_candidate": 0,
|
||||
}
|
||||
|
||||
d2_lines = re.findall(r'^D2:', sub_content, re.MULTILINE)
|
||||
d2_total_len = sum(len(l) for l in re.findall(r'^D2:\s*(.*)', sub_content, re.MULTILINE))
|
||||
has_table = bool(re.search(r'As-is|To-be|\|.*\|.*\|', sub_content))
|
||||
is_empty = len(sub_content.strip()) < 10
|
||||
|
||||
# parallel_card: 짧은 D2, 항목이 대등
|
||||
if len(d2_lines) >= 1 and d2_total_len < 200:
|
||||
scores["parallel_card_candidate"] += 3
|
||||
if len(sub_titles) >= 3:
|
||||
scores["parallel_card_candidate"] += 2
|
||||
|
||||
# text_list: 긴 D2 본문
|
||||
if d2_total_len >= 100:
|
||||
scores["text_list_candidate"] += 3
|
||||
if len(d2_lines) >= 3:
|
||||
scores["text_list_candidate"] += 2
|
||||
|
||||
# visual_detail: content 비거나 popup/component
|
||||
if is_empty:
|
||||
scores["visual_detail_candidate"] += 5
|
||||
if "컴포넌트" in sub_content or "[팝업:" in sub_content:
|
||||
scores["visual_detail_candidate"] += 3
|
||||
# popup_sub_titles에 포함되면 강하게 visual_detail
|
||||
popup_subs = popup_sub_titles or []
|
||||
if any(st_key in ps.lower() for ps in popup_subs):
|
||||
scores["visual_detail_candidate"] += 6
|
||||
# content가 핵심요약/결론 + D1 1줄 이하면 실질적으로 빈 것 — visual_detail
|
||||
# D1이 2개 이상이면 실제 본문 콘텐츠로 봄
|
||||
d1_lines = re.findall(r'^D1:', sub_content, re.MULTILINE)
|
||||
content_without_markers = re.sub(r'\[핵심요약:[^\]]*\]', '', sub_content).strip()
|
||||
if len(d1_lines) <= 1 and len(content_without_markers) < 50 and sub_content.strip():
|
||||
scores["visual_detail_candidate"] += 4
|
||||
# D1이 여러 개면 본문형 content → text_list 가점
|
||||
if len(d1_lines) >= 2:
|
||||
scores["text_list_candidate"] += 3
|
||||
|
||||
# table_heavy
|
||||
if has_table:
|
||||
scores["table_heavy_candidate"] += 5
|
||||
|
||||
# 최고 점수 candidate 선택
|
||||
best_type = max(scores, key=scores.get)
|
||||
best_score = scores[best_type]
|
||||
|
||||
# 점수가 0이면 content 길이로 fallback
|
||||
if best_score == 0:
|
||||
if sub_content.strip():
|
||||
best_type = "text_list_candidate"
|
||||
else:
|
||||
best_type = "visual_detail_candidate"
|
||||
|
||||
results.append({"title": st, "sub_type": best_type})
|
||||
logger.debug(f"[sub_type] '{st}': {best_type} (scores={scores})")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def classify_group_relations(
|
||||
major_sections: list[dict],
|
||||
topics: list[dict] | None = None,
|
||||
normalized_sections: list[dict] | None = None,
|
||||
popup_sub_titles: list[str] | None = None,
|
||||
) -> list[dict]:
|
||||
"""Y-13b: 각 대목차의 sub_titles 간 관계를 판단하여 group_schema를 부여.
|
||||
|
||||
규칙 기반 판단 (Kei 없이):
|
||||
- sub_titles 3개 + 병렬 → parallel_cluster
|
||||
- sub_titles 2개 + 비대칭 → compare_asymmetric_paired
|
||||
- sub_titles 2개 + 순서/변화 → sequence_list
|
||||
- sub_titles 1개 → single_block
|
||||
- sub_titles 4개+ → card_cluster_N
|
||||
|
||||
Returns: major_sections에 group_schema 필드 추가하여 반환
|
||||
"""
|
||||
for sec in major_sections:
|
||||
sub_titles = sec.get("sub_titles", [])
|
||||
content = sec.get("content", "")
|
||||
content_lower = content.lower()
|
||||
n = len(sub_titles)
|
||||
|
||||
# sub_titles가 1개 이하지만 content에 D1: 항목이 여러 개면 → 실제 병렬 항목 수
|
||||
if n <= 1:
|
||||
d1_items = re.findall(r'^D1:\s*\*?\*?(.+?)\*?\*?\s*$', content, re.MULTILINE)
|
||||
# 이미지/표 관련 D1 제외
|
||||
d1_items = [d for d in d1_items if not d.strip().startswith('!') and not d.strip().startswith('As-is')]
|
||||
if len(d1_items) >= 2:
|
||||
n = len(d1_items)
|
||||
sec["sub_titles"] = [re.sub(r'\*+', '', d).strip() for d in d1_items]
|
||||
sub_titles = sec["sub_titles"]
|
||||
|
||||
if n == 0 or n == 1:
|
||||
sec["group_schema"] = "single_block"
|
||||
elif n == 3:
|
||||
sec["group_schema"] = "parallel_cluster"
|
||||
elif n == 2:
|
||||
has_table = bool(re.search(r'As-is|To-be|\|.*\|.*\|', content))
|
||||
compare_hints = ["vs", "비교", "차이", "반면"]
|
||||
asymmetric_hints = ["혁신", "변화", "변환", "전환"]
|
||||
process_hints = ["과정", "단계", "수행", "주체"]
|
||||
sub_text = " ".join(sub_titles).lower()
|
||||
effect_hints = ["기대효과", "효과", "성과", "결과물"]
|
||||
|
||||
all_text = content_lower + " " + sub_text
|
||||
has_compare = any(h in all_text for h in compare_hints)
|
||||
has_asymmetric = any(h in all_text for h in asymmetric_hints)
|
||||
has_process = any(h in all_text for h in process_hints)
|
||||
has_effect = any(h in all_text for h in effect_hints)
|
||||
|
||||
if has_table and has_asymmetric:
|
||||
sec["group_schema"] = "compare_asymmetric_paired"
|
||||
elif has_process and has_effect:
|
||||
sec["group_schema"] = "sequence_plus_visual"
|
||||
elif has_process:
|
||||
sec["group_schema"] = "sequence_list"
|
||||
elif has_compare:
|
||||
sec["group_schema"] = "compare_paired"
|
||||
else:
|
||||
sec["group_schema"] = "compare_paired"
|
||||
elif n == 4:
|
||||
sec["group_schema"] = "card_cluster_4"
|
||||
else:
|
||||
sec["group_schema"] = f"card_cluster_{n}"
|
||||
|
||||
# 시각 앵커 포함 여부 (이미지, 차트, 컴포넌트 등)
|
||||
has_visual = "이미지" in content or "![" in content or ".png" in content
|
||||
if has_visual:
|
||||
sec["group_schema"] += "_plus_visual"
|
||||
|
||||
# B-1: subsection typing — 각 sub_title의 콘텐츠 유형을 점수 기반으로 판단
|
||||
sec["sub_types"] = _classify_sub_types(sub_titles, content, normalized_sections, popup_sub_titles)
|
||||
|
||||
logger.info(f"[Y-13b] '{sec['title']}': sub={n}개, schema={sec['group_schema']}, sub_types={[s['sub_type'] for s in sec['sub_types']]}")
|
||||
|
||||
return major_sections
|
||||
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# schema alias: 회귀 안전을 위해 old → new 매핑 유지
|
||||
# ══════════════════════════════════════
|
||||
SCHEMA_ALIASES = {
|
||||
"parallel_3": "parallel_cluster",
|
||||
"parallel_3_with_image": "parallel_cluster_plus_visual",
|
||||
"compare_2": "compare_paired",
|
||||
"compare_asymmetric_2col": "compare_asymmetric_paired",
|
||||
"process_plus_visual": "sequence_plus_visual",
|
||||
"process_list": "sequence_list",
|
||||
"single_section": "single_block",
|
||||
"card_list_4": "card_cluster_4",
|
||||
}
|
||||
|
||||
|
||||
def resolve_schema(schema: str) -> str:
|
||||
"""old schema 이름 → new 이름으로 해소. 이미 new면 그대로 반환."""
|
||||
return SCHEMA_ALIASES.get(schema, schema)
|
||||
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# schema → recipe 매핑 (표현 계약)
|
||||
# recipe = 블록 이름이 아닌, 레이아웃 계약
|
||||
# ══════════════════════════════════════
|
||||
SCHEMA_RECIPE_MAP = {
|
||||
"parallel_cluster": {
|
||||
"recipe": "single_block",
|
||||
"block_kind": "parallel_cards",
|
||||
"blocks": ["prerequisites-3col", "card-compare-3col", "card-icon-desc"],
|
||||
},
|
||||
"parallel_cluster_plus_visual": {
|
||||
"recipe": "two_col_text_visual",
|
||||
"left_kind": "parallel_cards",
|
||||
"right_kind": "visual_anchor",
|
||||
"ratio": "7:3",
|
||||
"vertical_align": "center",
|
||||
# direct single-block mapping 금지: p3c는 2층 구조(label+heading)라서
|
||||
# 1층 구조(목표 제목만)인 plus_visual에서는 부적합.
|
||||
# composition으로 쓸 가능성은 열어둠 (향후 blocks_composition에 추가 가능).
|
||||
"blocks_left": ["card-icon-desc", "card-compare-3col", "card-text-grid"],
|
||||
},
|
||||
"compare_paired": {
|
||||
"recipe": "single_block",
|
||||
"block_kind": "compare_cards",
|
||||
"blocks": ["compare-detail-gradient", "comparison-2col"],
|
||||
},
|
||||
"compare_asymmetric_paired": {
|
||||
"recipe": "single_block",
|
||||
"block_kind": "compare_asymmetric",
|
||||
"blocks": ["process-product-2col", "compare-detail-gradient"],
|
||||
},
|
||||
"sequence_list": {
|
||||
"recipe": "single_block",
|
||||
"block_kind": "sequence_cards",
|
||||
"blocks": ["card-step-vertical", "checklist-dark", "card-numbered"],
|
||||
},
|
||||
"sequence_plus_visual": {
|
||||
"recipe": "two_col_text_detail",
|
||||
"left_kind": "text_list",
|
||||
"right_kind": "summary_and_popup",
|
||||
"ratio": "6:4",
|
||||
"vertical_align": "top",
|
||||
"blocks_left": ["card-icon-desc", "card-step-vertical", "card-numbered"],
|
||||
},
|
||||
"single_block": {
|
||||
"recipe": "single_block",
|
||||
"block_kind": "text_list",
|
||||
"blocks": ["dark-bullet-list", "checklist-dark", "card-numbered"],
|
||||
},
|
||||
"card_cluster_4": {
|
||||
"recipe": "single_block",
|
||||
"block_kind": "card_grid",
|
||||
"blocks": ["card-icon-desc", "card-text-grid", "card-numbered"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_recipe_for_schema(schema: str) -> dict:
|
||||
"""schema → recipe 표현 계약 반환. alias 자동 해소."""
|
||||
resolved = resolve_schema(schema)
|
||||
# _plus_visual suffix 분리: base schema에서 recipe 찾고, visual 플래그 추가
|
||||
base = resolved.replace("_plus_visual", "")
|
||||
has_visual = "_plus_visual" in resolved
|
||||
|
||||
recipe = SCHEMA_RECIPE_MAP.get(resolved)
|
||||
if recipe:
|
||||
return recipe
|
||||
|
||||
# base schema로 fallback하되 visual 플래그 추가
|
||||
recipe = SCHEMA_RECIPE_MAP.get(base)
|
||||
if recipe and has_visual:
|
||||
# base recipe를 복사해서 visual 힌트 추가
|
||||
r = dict(recipe)
|
||||
r["has_visual"] = True
|
||||
return r
|
||||
|
||||
# card_cluster_N → card_cluster_4 fallback
|
||||
if base.startswith("card_cluster_"):
|
||||
return SCHEMA_RECIPE_MAP.get("card_cluster_4", {})
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
# C-1: recipe kind ↔ sub_type 호환 규칙
|
||||
KIND_SUBTYPE_COMPAT = {
|
||||
"parallel_cards": ["parallel_card_candidate"],
|
||||
"text_list": ["text_list_candidate"],
|
||||
"visual_anchor": ["visual_detail_candidate"],
|
||||
"summary_and_popup": ["visual_detail_candidate"],
|
||||
"compare_cards": ["parallel_card_candidate", "text_list_candidate"],
|
||||
"compare_asymmetric": ["text_list_candidate", "table_heavy_candidate"],
|
||||
"sequence_cards": ["text_list_candidate"],
|
||||
"card_grid": ["parallel_card_candidate"],
|
||||
}
|
||||
|
||||
|
||||
def check_kind_compatibility(recipe_kind: str, sub_types: list[dict]) -> bool:
|
||||
"""recipe의 left_kind/right_kind가 실제 sub_type과 호환되는지 확인."""
|
||||
compatible = KIND_SUBTYPE_COMPAT.get(recipe_kind, [])
|
||||
if not compatible:
|
||||
return True # 규칙 없으면 호환 가정
|
||||
actual_types = [s.get("sub_type", "") for s in sub_types]
|
||||
return any(t in compatible for t in actual_types)
|
||||
|
||||
|
||||
def get_candidate_blocks_for_schema(group_schema: str) -> list[str]:
|
||||
"""Y-13d: group schema에 맞는 블록 후보 ID 목록 반환. recipe 경유.
|
||||
|
||||
주의: *_plus_visual schema는 direct single-block 매칭 금지.
|
||||
이 함수는 recipe 내부의 블록 후보를 반환할 뿐,
|
||||
실제 선택은 recipe executor가 담당.
|
||||
"""
|
||||
recipe = get_recipe_for_schema(group_schema)
|
||||
if not recipe:
|
||||
return []
|
||||
# recipe 유형에 따라 블록 후보 반환
|
||||
recipe_type = recipe.get("recipe", "")
|
||||
if recipe_type in ("two_col_text_visual", "two_col_text_detail"):
|
||||
return recipe.get("blocks_left", [])
|
||||
else:
|
||||
return recipe.get("blocks", [])
|
||||
|
||||
|
||||
def extract_conclusion_text(raw_content: str) -> str:
|
||||
"""raw MDX에서 :::note[핵심 요약] 텍스트만 추출.
|
||||
이것만 raw MDX에서 가져옴 (normalized에 없을 수 있으므로).
|
||||
"""
|
||||
note_match = re.search(r':::note\[([^\]]*)\]\s*([\s\S]*?):::', raw_content)
|
||||
if note_match:
|
||||
text = note_match.group(2).strip()
|
||||
# 마크다운 볼드/불릿 잔여 제거
|
||||
text = re.sub(r'^\*\s*\*\*', '', text)
|
||||
text = re.sub(r'\*\*$', '', text)
|
||||
text = text.strip("* ")
|
||||
# 선행 불릿 마커(*, •, -) 제거
|
||||
text = re.sub(r'^[\*•\-]\s*', '', text).strip()
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
def map_topics_to_sections(
|
||||
topics: list[dict],
|
||||
sections: list[dict],
|
||||
) -> dict[str, list[int]]:
|
||||
"""Kei 꼭지들을 대목차 섹션에 매핑.
|
||||
|
||||
각 꼭지의 title을 보고 어느 섹션의 content에 포함되는지 판단.
|
||||
|
||||
Returns:
|
||||
{"1. DX 시행을 위한 필수 요건": [1, 2, 3], "2. Process의 혁신과 Product의 변화": [4, 5]}
|
||||
"""
|
||||
section_topics: dict[str, list[int]] = {}
|
||||
for sec in sections:
|
||||
section_topics[sec["title"]] = []
|
||||
|
||||
for topic in topics:
|
||||
tid = topic.get("id", 0)
|
||||
t_title = topic.get("title", "").lower()
|
||||
t_hint = topic.get("source_hint", "").lower()
|
||||
|
||||
best_section = None
|
||||
best_score = 0
|
||||
|
||||
for sec in sections:
|
||||
sec_content = sec["content"].lower()
|
||||
sec_title = sec["title"].lower()
|
||||
# sub_titles에서도 매칭
|
||||
sub_titles_lower = " ".join(s.lower() for s in sec.get("sub_titles", []))
|
||||
score = 0
|
||||
|
||||
# 꼭지 제목이 섹션 content에 포함되는지
|
||||
key = t_title.split("(")[0].strip()
|
||||
if key and len(key) >= 2:
|
||||
if key in sec_content:
|
||||
score += 10
|
||||
if key in sec_title:
|
||||
score += 5
|
||||
if key in sub_titles_lower:
|
||||
score += 8 # sub_title에 직접 매칭
|
||||
|
||||
# source_hint에 섹션 제목 키워드가 포함되는지
|
||||
sec_key = sec_title.split(".")[-1].strip().lower()[:10]
|
||||
if sec_key and len(sec_key) >= 2 and sec_key in t_hint:
|
||||
score += 3
|
||||
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_section = sec["title"]
|
||||
|
||||
if best_section and best_score > 0:
|
||||
section_topics[best_section].append(tid)
|
||||
else:
|
||||
# 매칭 안 되면 첫 번째 섹션에 넣음
|
||||
if sections:
|
||||
section_topics[sections[0]["title"]].append(tid)
|
||||
logger.warning(f"[section_parser] 꼭지 {tid} '{t_title}' 섹션 매핑 실패 → 첫 섹션")
|
||||
|
||||
# 빈 섹션 제거
|
||||
section_topics = {k: v for k, v in section_topics.items() if v}
|
||||
|
||||
logger.info(
|
||||
f"[section_parser] 꼭지-섹션 매핑: "
|
||||
+ ", ".join(f'"{k}": {v}' for k, v in section_topics.items())
|
||||
)
|
||||
|
||||
return section_topics
|
||||
Reference in New Issue
Block a user