Cleanup: Deleting 03.Code/업로드용/handlers/doc/doc_type_analyzer.py
This commit is contained in:
@@ -1,903 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Doc Type Analyzer (Phase 1 & 2 – Layer A)
|
||||
- HWPX 파일에서 구조 및 스타일 정보 추출 (XML 파싱)
|
||||
- AI를 사용하지 않는 순수 로직 기반 분석
|
||||
"""
|
||||
|
||||
import zipfile
|
||||
import re
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
# 공통 유틸리티 (hwpx_utils가 있다고 가정하거나 내부 구현)
|
||||
# 여기서는 필요한 변환 로직을 내부에 포함
|
||||
|
||||
|
||||
def hwpunit_to_mm(unit):
|
||||
"""HWP단위 -> mm 변환 (1mm = 283.465 unit)"""
|
||||
return round(int(unit) / 283.465, 1)
|
||||
|
||||
|
||||
def charsize_to_pt(size):
|
||||
"""HWP 글자크기 -> pt 변환 (1pt = 100 size)"""
|
||||
return round(int(size) / 100, 1)
|
||||
|
||||
|
||||
def mm_format(unit):
|
||||
"""mm 문자열 포맷팅"""
|
||||
return f"{hwpunit_to_mm(unit)}mm"
|
||||
|
||||
|
||||
class DocTypeAnalyzer:
|
||||
"""HWPX 문서를 분석하여 구조 정보를 추출하는 클래수"""
|
||||
|
||||
def parse(self, file_path: str) -> dict:
|
||||
"""HWPX 파일을 파싱하여 원본 데이터 추출"""
|
||||
if not os.path.exists(file_path):
|
||||
raise FileNotFoundError(f"파일이 없습니다: {file_path}")
|
||||
|
||||
return self._extract_from_hwpx(file_path)
|
||||
|
||||
# ================================================================
|
||||
# Step 1: HWPX 데이터 추출 (XML 파싱)
|
||||
# ================================================================
|
||||
|
||||
def _extract_from_hwpx(self, file_path: str) -> dict:
|
||||
"""HWPX 압축 해제 및 주요 XML 구조 보존"""
|
||||
content = {
|
||||
"text": "",
|
||||
"raw_xml": {},
|
||||
"paragraphs": [],
|
||||
"tables": [],
|
||||
"images": [],
|
||||
"header_xml": "",
|
||||
"footer_xml": "",
|
||||
"section_xml": "",
|
||||
}
|
||||
|
||||
with zipfile.ZipFile(file_path, 'r') as zf:
|
||||
for name in zf.namelist():
|
||||
if name.endswith('.xml'):
|
||||
try:
|
||||
xml_content = zf.read(name).decode('utf-8')
|
||||
content["raw_xml"][name] = xml_content
|
||||
|
||||
if 'section' in name.lower():
|
||||
content["section_xml"] = xml_content
|
||||
content["text"] = self._extract_all_text(
|
||||
xml_content)
|
||||
content["paragraphs"] = (
|
||||
self._extract_paragraphs(xml_content))
|
||||
content["tables"] = (
|
||||
self._extract_tables_detailed(xml_content))
|
||||
content["page_header_xml"] = self._extract_header(xml_content)
|
||||
content["page_footer_xml"] = self._extract_footer(xml_content)
|
||||
content["images"] = self._extract_images(
|
||||
xml_content)
|
||||
except Exception as e:
|
||||
print(f"[WARN] XML 파싱 실패 ({name}): {e}")
|
||||
|
||||
return content
|
||||
|
||||
def _extract_all_text(self, xml: str) -> str:
|
||||
"""모든 텍스트 추출"""
|
||||
texts = re.findall(r'<hp:t>([^<]*)</hp:t>', xml)
|
||||
return ' '.join(texts)
|
||||
|
||||
def _extract_paragraphs(self, xml: str) -> List[dict]:
|
||||
"""문단별 구조 추출"""
|
||||
paragraphs = []
|
||||
p_pattern = re.compile(r'<hp:p[^>]*>(.*?)</hp:p>', re.DOTALL)
|
||||
|
||||
for match in p_pattern.finditer(xml):
|
||||
p_content = match.group(1)
|
||||
texts = re.findall(r'<hp:t>([^<]*)</hp:t>', p_content)
|
||||
text = ' '.join(texts).strip()
|
||||
|
||||
if not text:
|
||||
continue
|
||||
|
||||
style_ref = re.search(r'styleIDRef="(\d+)"', match.group(0))
|
||||
char_ref = re.search(r'charPrIDRef="(\d+)"', p_content)
|
||||
has_image = '<hp:pic' in p_content
|
||||
|
||||
paragraphs.append({
|
||||
"text": text,
|
||||
"styleRef": style_ref.group(1) if style_ref else None,
|
||||
"charRef": char_ref.group(1) if char_ref else None,
|
||||
"hasImage": has_image,
|
||||
"raw": p_content[:500]
|
||||
})
|
||||
|
||||
return paragraphs
|
||||
|
||||
def _extract_tables_detailed(self, xml: str) -> List[dict]:
|
||||
"""표 및 셀 구조 추출 - 위치별 분리"""
|
||||
tables = []
|
||||
|
||||
# header 영역
|
||||
header_xml = self._extract_header(xml)
|
||||
for tbl in self._parse_tables_in_region(header_xml):
|
||||
tbl["location"] = "header"
|
||||
tbl["isLayoutTable"] = True
|
||||
tables.append(tbl)
|
||||
|
||||
# footer 영역
|
||||
footer_xml = self._extract_footer(xml)
|
||||
for tbl in self._parse_tables_in_region(footer_xml):
|
||||
tbl["location"] = "footer"
|
||||
tbl["isLayoutTable"] = True
|
||||
tables.append(tbl)
|
||||
|
||||
# 본문 (header/footer 제거)
|
||||
body_xml = re.sub(
|
||||
r'<hp:header[^>]*>.*?</hp:header>', '',
|
||||
xml, flags=re.DOTALL)
|
||||
body_xml = re.sub(
|
||||
r'<hp:footer[^>]*>.*?</hp:footer>', '',
|
||||
body_xml, flags=re.DOTALL)
|
||||
|
||||
for tbl in self._parse_tables_in_region(body_xml):
|
||||
tbl["location"] = "body"
|
||||
tbl["isLayoutTable"] = self._is_layout_table(tbl["cells"])
|
||||
tables.append(tbl)
|
||||
|
||||
return tables
|
||||
|
||||
def _parse_tables_in_region(self, xml: str) -> List[dict]:
|
||||
"""특정 영역 내의 표 파싱
|
||||
|
||||
★ v3.1: <hp:tc> 태그 내 속성 colSpan/rowSpan/width 추출.
|
||||
참조 맵핑 짠6.3: <hp:tc colAddr="0" rowAddr="0"
|
||||
colSpan="2" width="17008" borderFillIDRef="3">
|
||||
"""
|
||||
tables = []
|
||||
tbl_pattern = re.compile(
|
||||
r'<hp:tbl[^>]*>(.*?)</hp:tbl>', re.DOTALL)
|
||||
|
||||
for match in tbl_pattern.finditer(xml):
|
||||
tbl_tag = match.group(0)
|
||||
tbl_content = match.group(1)
|
||||
|
||||
row_cnt = re.search(r'rowCnt="(\d+)"', tbl_tag)
|
||||
col_cnt = re.search(r'colCnt="(\d+)"', tbl_tag)
|
||||
|
||||
cells = []
|
||||
row_pattern = re.compile(
|
||||
r'<hp:tr>(.*?)</hp:tr>', re.DOTALL)
|
||||
|
||||
for row_match in row_pattern.finditer(tbl_content):
|
||||
row_content = row_match.group(1)
|
||||
row_cells = []
|
||||
|
||||
cell_pattern = re.compile(
|
||||
r'<hp:tc\b([^>]*)>(.*?)</hp:tc>', re.DOTALL)
|
||||
|
||||
for cell_match in cell_pattern.finditer(row_content):
|
||||
tc_attrs = cell_match.group(1)
|
||||
cell_content = cell_match.group(2)
|
||||
|
||||
# 셀 내부 텍스트 + 이미지 코드 모듈식 파싱
|
||||
p_texts = []
|
||||
cell_paras = re.findall(
|
||||
r'<hp:p[^>]*>(.*?)</hp:p>',
|
||||
cell_content, re.DOTALL)
|
||||
for cp in cell_paras:
|
||||
cp_text = ' '.join(
|
||||
re.findall(r'<hp:t>([^<]*)</hp:t>', cp)
|
||||
).strip()
|
||||
if cp_text:
|
||||
p_texts.append(cp_text)
|
||||
|
||||
# 셀 1순위: <hp:tc> 태그 속성 (짠6.3)
|
||||
cs_m = re.search(r'colSpan="(\d+)"', tc_attrs)
|
||||
rs_m = re.search(r'rowSpan="(\d+)"', tc_attrs)
|
||||
w_m = re.search(r'width="(\d+)"', tc_attrs)
|
||||
|
||||
# 2순위: 하위 fallback
|
||||
if not cs_m:
|
||||
cs_m = re.search(
|
||||
r'<hp:cellSpan[^>]*colSpan="(\d+)"',
|
||||
cell_content)
|
||||
if not rs_m:
|
||||
rs_m = re.search(
|
||||
r'<hp:cellSpan[^>]*rowSpan="(\d+)"',
|
||||
cell_content)
|
||||
if not w_m:
|
||||
w_m = re.search(
|
||||
r'<hp:cellSz[^>]*width="(\d+)"',
|
||||
cell_content)
|
||||
|
||||
row_cells.append({
|
||||
"text": ' '.join(p_texts),
|
||||
"lines": p_texts,
|
||||
"colSpan": int(cs_m.group(1)) if cs_m else 1,
|
||||
"rowSpan": int(rs_m.group(1)) if rs_m else 1,
|
||||
"width": int(w_m.group(1)) if w_m else 0,
|
||||
})
|
||||
|
||||
if row_cells:
|
||||
cells.append(row_cells)
|
||||
|
||||
tables.append({
|
||||
"rowCount": (int(row_cnt.group(1))
|
||||
if row_cnt else len(cells)),
|
||||
"colCount": (int(col_cnt.group(1))
|
||||
if col_cnt else 0),
|
||||
"cells": cells,
|
||||
})
|
||||
|
||||
return tables
|
||||
|
||||
def _is_layout_table(self, cells: List[List]) -> bool:
|
||||
"""본문 표 중 정보 표(제목 블록) 판별"""
|
||||
if not cells or len(cells) != 1:
|
||||
return False
|
||||
|
||||
row = cells[0]
|
||||
total_text = ' '.join(
|
||||
c["text"] if isinstance(c, dict) else str(c)
|
||||
for c in row)
|
||||
|
||||
return len(row) <= 3 and len(total_text) < 200
|
||||
|
||||
def _extract_header(self, xml: str) -> str:
|
||||
"""머리말 추출"""
|
||||
m = re.search(
|
||||
r'<hp:header[^>]*>(.*?)</hp:header>', xml, re.DOTALL)
|
||||
return m.group(1) if m else ""
|
||||
|
||||
def _extract_footer(self, xml: str) -> str:
|
||||
"""꼬리말 추출"""
|
||||
m = re.search(
|
||||
r'<hp:footer[^>]*>(.*?)</hp:footer>', xml, re.DOTALL)
|
||||
return m.group(1) if m else ""
|
||||
|
||||
def _extract_images(self, xml: str) -> List[dict]:
|
||||
"""이미지 참조 추출"""
|
||||
images = []
|
||||
pic_pattern = re.compile(
|
||||
r'<hp:pic[^>]*>(.*?)</hp:pic>', re.DOTALL)
|
||||
|
||||
for match in pic_pattern.finditer(xml):
|
||||
img_ref = re.search(
|
||||
r'binaryItemIDRef="([^"]+)"', match.group(1))
|
||||
if img_ref:
|
||||
images.append({
|
||||
"ref": img_ref.group(1),
|
||||
"raw": match.group(0)[:300]
|
||||
})
|
||||
|
||||
return images
|
||||
|
||||
# ================================================================
|
||||
# Step 2: 구조 분석 추출 (순수 코드 기반 분석)
|
||||
# ================================================================
|
||||
|
||||
def _extract_layout(self, parsed: dict) -> dict:
|
||||
"""코드 기반 구조 추출 - HWPX 실제 데이터를 직접 추출"""
|
||||
header_xml = parsed.get("page_header_xml", "")
|
||||
footer_xml = parsed.get("page_footer_xml", "")
|
||||
tables = parsed.get("tables", [])
|
||||
paragraphs = parsed.get("paragraphs", [])
|
||||
|
||||
# 영역별 표 분리
|
||||
header_tables = [t for t in tables
|
||||
if t.get("location") == "header"]
|
||||
footer_tables = [t for t in tables
|
||||
if t.get("location") == "footer"]
|
||||
body_tables = [t for t in tables
|
||||
if t.get("location") == "body"]
|
||||
|
||||
return {
|
||||
"hasHeader": bool(header_xml.strip()),
|
||||
"headerLayout": self._code_header_layout(
|
||||
header_xml, header_tables),
|
||||
"hasFooter": bool(footer_xml.strip()),
|
||||
"footerLayout": self._code_footer_layout(
|
||||
footer_xml, footer_tables),
|
||||
"titleBlock": self._code_title_block(body_tables),
|
||||
"sections": self._code_sections(parsed.get("section_xml", ""), body_tables),
|
||||
"overallStyle": self._code_overall_style(
|
||||
paragraphs, body_tables),
|
||||
}
|
||||
|
||||
def _code_header_layout(self, header_xml: str,
|
||||
header_tables: list) -> dict:
|
||||
"""헤더 레이아웃 - 실제 HWPX 데이터 직접 반영"""
|
||||
if not header_xml.strip() and not header_tables:
|
||||
return {"structure": "없음"}
|
||||
|
||||
if header_tables:
|
||||
ht = header_tables[0]
|
||||
cells = ht.get("cells", [])
|
||||
|
||||
# 실제 표 텍스트/구조 추출
|
||||
cell_texts = []
|
||||
cell_lines = []
|
||||
if cells:
|
||||
for row in cells:
|
||||
for cell in row:
|
||||
text = (cell.get("text", "")
|
||||
if isinstance(cell, dict)
|
||||
else str(cell))
|
||||
lines = (cell.get("lines", [text])
|
||||
if isinstance(cell, dict)
|
||||
else [str(cell)])
|
||||
cell_texts.append(text)
|
||||
cell_lines.append(lines)
|
||||
|
||||
return {
|
||||
"structure": "표",
|
||||
"colCount": (ht.get("colCount")
|
||||
or (len(cells[0]) if cells else 0)),
|
||||
"rowCount": len(cells),
|
||||
"cellTexts": cell_texts,
|
||||
"cellLines": cell_lines,
|
||||
}
|
||||
|
||||
# 텍스트만 있는 헤더
|
||||
texts = re.findall(r'<hp:t>([^<]*)</hp:t>', header_xml)
|
||||
return {
|
||||
"structure": "텍스트",
|
||||
"texts": texts,
|
||||
}
|
||||
|
||||
def _code_footer_layout(self, footer_xml: str,
|
||||
footer_tables: list) -> dict:
|
||||
"""꼬리말 레이아웃 - 실제 HWPX 데이터 직접 반영"""
|
||||
if not footer_xml.strip() and not footer_tables:
|
||||
return {"structure": "없음"}
|
||||
|
||||
if footer_tables:
|
||||
ft = footer_tables[0]
|
||||
cells = ft.get("cells", [])
|
||||
|
||||
cell_texts = []
|
||||
cell_lines = []
|
||||
if cells:
|
||||
for row in cells:
|
||||
for cell in row:
|
||||
text = (cell.get("text", "")
|
||||
if isinstance(cell, dict)
|
||||
else str(cell))
|
||||
lines = (cell.get("lines", [text])
|
||||
if isinstance(cell, dict)
|
||||
else [str(cell)])
|
||||
cell_texts.append(text)
|
||||
cell_lines.append(lines)
|
||||
|
||||
return {
|
||||
"structure": "표",
|
||||
"colCount": (ft.get("colCount")
|
||||
or (len(cells[0]) if cells else 0)),
|
||||
"rowCount": len(cells),
|
||||
"cellTexts": cell_texts,
|
||||
"cellLines": cell_lines,
|
||||
}
|
||||
|
||||
texts = re.findall(r'<hp:t>([^<]*)</hp:t>', footer_xml)
|
||||
return {
|
||||
"structure": "텍스트",
|
||||
"texts": texts,
|
||||
}
|
||||
|
||||
def _code_title_block(self, body_tables: list) -> dict:
|
||||
"""제목 블록 본문 첫 표 정보 추출"""
|
||||
layout_tables = [t for t in body_tables
|
||||
if t.get("isLayoutTable")]
|
||||
|
||||
if layout_tables:
|
||||
lt = layout_tables[0]
|
||||
cells = lt.get("cells", [])
|
||||
text = ""
|
||||
if cells and cells[0]:
|
||||
text = ' '.join(
|
||||
c.get("text", "") if isinstance(c, dict) else str(c)
|
||||
for c in cells[0]
|
||||
).strip()
|
||||
return {
|
||||
"type": "표",
|
||||
"colCount": (lt.get("colCount")
|
||||
or (len(cells[0]) if cells else 1)),
|
||||
"text": text,
|
||||
}
|
||||
|
||||
return {"type": "없음"}
|
||||
|
||||
def _code_sections(self, section_xml: str,
|
||||
body_tables: list) -> list:
|
||||
"""섹션 추출 - section_xml에서 문단 패턴 매칭"""
|
||||
sections = []
|
||||
data_tables = [t for t in body_tables
|
||||
if not t.get("isLayoutTable")]
|
||||
table_idx = 0
|
||||
current_section = None
|
||||
|
||||
# 헤더/푸터 제거
|
||||
clean_xml = re.sub(
|
||||
r'<hp:header[^>]*>.*?</hp:header>', '',
|
||||
section_xml, flags=re.DOTALL)
|
||||
clean_xml = re.sub(
|
||||
r'<hp:footer[^>]*>.*?</hp:footer>', '',
|
||||
clean_xml, flags=re.DOTALL)
|
||||
|
||||
paragraphs = re.findall(
|
||||
r'<hp:p[^>]*>(.*?)</hp:p>', clean_xml, re.DOTALL)
|
||||
|
||||
for p_content in paragraphs:
|
||||
texts = re.findall(r'<hp:t>([^<]*)</hp:t>', p_content)
|
||||
text = ' '.join(texts).strip()
|
||||
if not text:
|
||||
continue
|
||||
|
||||
is_section_title = False
|
||||
|
||||
if re.match(r'^\d+\.\s+', text):
|
||||
is_section_title = True
|
||||
elif '<hp:pic' in p_content and len(text) < 30:
|
||||
is_section_title = True
|
||||
elif re.match(r'^[□■○△●™bㅲβ╈㎮ⓥ◎]\.\s*', text):
|
||||
is_section_title = True
|
||||
elif re.match(r'^第\d+[장섹션]\s*', text):
|
||||
is_section_title = True
|
||||
|
||||
if is_section_title:
|
||||
current_section = {
|
||||
"name": text.strip(),
|
||||
"hasBulletIcon": '<hp:pic' in p_content,
|
||||
"hasTable": False,
|
||||
"tableIndex": None,
|
||||
}
|
||||
sections.append(current_section)
|
||||
|
||||
# 표 연결
|
||||
if '<hp:tbl' in p_content and table_idx < len(data_tables):
|
||||
if current_section:
|
||||
current_section["hasTable"] = True
|
||||
current_section["tableIndex"] = table_idx
|
||||
table_idx += 1
|
||||
|
||||
return sections
|
||||
|
||||
def _code_overall_style(self, paragraphs: list,
|
||||
body_tables: list) -> dict:
|
||||
"""전체 문체/불렛/표 사용 패턴 - 코드 기반 분석"""
|
||||
bullet_chars = {
|
||||
'·': 0, '●': 0, '○': 0, '■': 0,
|
||||
'□': 0, '-': 0, '▶': 0, '•': 0,
|
||||
}
|
||||
bullet_total = 0
|
||||
prose_count = 0
|
||||
|
||||
for p in paragraphs:
|
||||
text = p.get("text", "").strip()
|
||||
if not text:
|
||||
continue
|
||||
|
||||
found_bullet = False
|
||||
for char in bullet_chars:
|
||||
if text.startswith(char) or text.startswith(f' {char}'):
|
||||
bullet_chars[char] += 1
|
||||
bullet_total += 1
|
||||
found_bullet = True
|
||||
break
|
||||
|
||||
if not found_bullet and len(text) > 50:
|
||||
prose_count += 1
|
||||
|
||||
# 가장 많이 사용된 불렛
|
||||
most_common = None
|
||||
if any(v > 0 for v in bullet_chars.values()):
|
||||
most_common = max(bullet_chars, key=bullet_chars.get)
|
||||
|
||||
# 문체 판정
|
||||
writing_style = "일반"
|
||||
if bullet_total > prose_count * 2:
|
||||
writing_style = "개조식"
|
||||
elif prose_count > bullet_total * 2:
|
||||
writing_style = "서술식"
|
||||
|
||||
# 표 사용량
|
||||
data_tables = [t for t in body_tables
|
||||
if not t.get("isLayoutTable")]
|
||||
table_count = len(data_tables)
|
||||
if table_count >= 3:
|
||||
table_usage = "많음"
|
||||
elif table_count >= 1:
|
||||
table_usage = "보통"
|
||||
else:
|
||||
table_usage = "없음"
|
||||
|
||||
return {
|
||||
"writingStyle": writing_style,
|
||||
"bulletType": most_common or "·",
|
||||
"tableUsage": table_usage,
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# Step 3: 맵핑 (의미 분석 부)
|
||||
# ================================================================
|
||||
|
||||
def _analyze_context(self, parsed: dict, layout: dict) -> dict:
|
||||
"""문서 맥락 (정의, 유형, 목적)
|
||||
|
||||
★ v3.1: 물리적 구조는 코드 추출 완료 상태.
|
||||
AI는 텍스트+섹션명을 주고 핵심 정보(문서정의/목적)만 매칭 요청.
|
||||
"""
|
||||
text = parsed.get("text", "")[:4000]
|
||||
sections = layout.get("sections", [])
|
||||
section_names = [s.get("name", "") for s in sections]
|
||||
|
||||
prompt = f"""당신은 문서 유형 분석 전문가입니다.
|
||||
|
||||
## 문서 텍스트 (일부)
|
||||
{text}
|
||||
|
||||
## 문서에서 추출된 섹션 목록
|
||||
{json.dumps(section_names, ensure_ascii=False)}
|
||||
|
||||
## 핵심 과제
|
||||
이 문서를 보고서 전문가로서 "아하! 이 문서는 OOO을 하기 위한 OOO 문서구나!"라고
|
||||
한 문장으로 정의하십시오.
|
||||
|
||||
예시:
|
||||
- "발표를 하기 위한 기획서"
|
||||
- "보고를 받기 위한 제안서"
|
||||
- "성과를 공유하기 위한 회의록"
|
||||
- "업무 현황을 보고하기 위한 보고서"
|
||||
|
||||
## 주어지는 문서의 성격과 내용 무관하게 (공공기관, 프로젝트, 기술적 내용 등)
|
||||
- 문서 속성/정의**만 분석
|
||||
|
||||
JSON으로 응답:
|
||||
{{
|
||||
"documentDefinition": "OOO를 하기 위한 OOO 문서",
|
||||
"documentType": "문서 유형명 (기획서, 보고서, 제안서 등)",
|
||||
"purpose": "작성 목적",
|
||||
"perspective": "어떤 내용들이 들어가야 이 관점으로 작성된다고 볼 수 있는지",
|
||||
"audience": "주 대상 (의사결정권자)",
|
||||
"tone": "어조 (보고형/제안형/공유형)"
|
||||
}}"""
|
||||
|
||||
try:
|
||||
response = call_claude(
|
||||
"문서 맥락 분석 전문가입니다.",
|
||||
prompt,
|
||||
max_tokens=1000
|
||||
)
|
||||
result = extract_json(response)
|
||||
if result:
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"[WARN] 맥락 분석 오류: {e}")
|
||||
|
||||
return {
|
||||
"documentDefinition": "",
|
||||
"documentType": "일반 문서",
|
||||
"purpose": "",
|
||||
"perspective": "",
|
||||
"audience": "의사결정권자",
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# Step 4: 구조 분석 (섹션 역할 분석)
|
||||
# ================================================================
|
||||
|
||||
def _analyze_structure(self, parsed: dict, layout: dict,
|
||||
context: dict) -> dict:
|
||||
"""구조 상세 분석 - 섹션별 역할, 문체, 표 구조
|
||||
|
||||
★ v3.1: layout.sections가 코드 추출로 전달된 상태.
|
||||
AI는 각 섹션이 무엇인지와 작성 가이드만 생성.
|
||||
"""
|
||||
text = parsed.get("text", "")[:4000]
|
||||
tables = parsed.get("tables", [])
|
||||
sections = layout.get("sections", [])
|
||||
|
||||
# 표 상세 정보 (본문 데이터 표만)
|
||||
table_details = []
|
||||
for i, t in enumerate(tables):
|
||||
if t.get("location") == "body" and not t.get("isLayoutTable"):
|
||||
cells = t.get("cells", [])
|
||||
headers = cells[0] if cells else []
|
||||
sample_rows = cells[1:3] if len(cells) > 1 else []
|
||||
|
||||
table_details.append({
|
||||
"index": i + 1,
|
||||
"rows": t["rowCount"],
|
||||
"cols": t["colCount"],
|
||||
"headers": [
|
||||
(c.get("text", "") if isinstance(c, dict)
|
||||
else str(c))
|
||||
for c in headers
|
||||
],
|
||||
"sampleData": [
|
||||
[(c.get("text", "")[:50] if isinstance(c, dict)
|
||||
else str(c)[:50])
|
||||
for c in row]
|
||||
for row in sample_rows
|
||||
]
|
||||
})
|
||||
|
||||
prompt = f"""당신은 문서 구조 분석 전문가입니다.
|
||||
|
||||
## 문서 정의
|
||||
{context.get('documentDefinition', '문서')}
|
||||
|
||||
## 문서에서 추출된 섹션들
|
||||
{json.dumps(sections, ensure_ascii=False, indent=2)}
|
||||
|
||||
## 본문 텍스트
|
||||
{text}
|
||||
|
||||
## 표 상세 정보
|
||||
{json.dumps(table_details, ensure_ascii=False, indent=2)}
|
||||
|
||||
## 분석 과제
|
||||
각 섹션이 **무엇인지와 작성 가이드**를 분석하십시오.
|
||||
|
||||
### 필수 항목
|
||||
1. **섹션명**: 원본 섹션 명
|
||||
2. **역할**: 이 섹션이 문서에서 하는 역할
|
||||
3. **문체**: 개조식/서술식 등의 가이드
|
||||
4. **표 포함 여부**: 표가 있다면 구조까지 상세히
|
||||
|
||||
### 표가 있는 섹션 분석:
|
||||
- 컬럼 개수, 각 컬럼의 역할
|
||||
- 각 컬럼이 어떤 형식으로 내용이 채워지는지
|
||||
|
||||
## 주어지는 문서의 성격과 내용 무관하게 글짓기 금지
|
||||
- 섹션별 성격과 작성 가이드**만 분석
|
||||
|
||||
JSON:
|
||||
{{
|
||||
"sectionGuides": [
|
||||
{{
|
||||
"name": "섹션명",
|
||||
"role": "이 섹션의 역할",
|
||||
"writingStyle": "개조식/서술식 등 가이드",
|
||||
"hasTable": false
|
||||
}},
|
||||
{{
|
||||
"name": "섹션명",
|
||||
"role": "역할",
|
||||
"writingStyle": "개조식",
|
||||
"hasTable": true,
|
||||
"tableStructure": {{
|
||||
"columns": 3,
|
||||
"columnDefs": [
|
||||
{{"name": "컬럼명", "role": "역할", "style": "스타일"}},
|
||||
{{"name": "컬럼명", "role": "역할", "style": "스타일"}},
|
||||
{{"name": "컬럼명", "role": "역할", "style": "스타일"}}
|
||||
],
|
||||
"rowGuide": "각 행 설명"
|
||||
}}
|
||||
}}
|
||||
],
|
||||
"writingPrinciples": [
|
||||
"이 문서 전체를 작성할 때의 원칙1",
|
||||
"원칙2"
|
||||
],
|
||||
"pageEstimate": 1
|
||||
}}"""
|
||||
|
||||
try:
|
||||
response = call_claude(
|
||||
"문서 구조 분석 전문가입니다. "
|
||||
"섹션별 성격과 표 구조를 상세히 분석합니다.",
|
||||
prompt,
|
||||
max_tokens=3000
|
||||
)
|
||||
result = extract_json(response)
|
||||
if result:
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"[WARN] 구조 분석 오류: {e}")
|
||||
|
||||
return {
|
||||
"sectionGuides": [],
|
||||
"writingPrinciples": [],
|
||||
"pageEstimate": 1,
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# Step 5: 스타일 추출 (hwpx_utils 연동)
|
||||
# ================================================================
|
||||
|
||||
def _extract_style(self, parsed: dict) -> dict:
|
||||
"""스타일 추출 - XML 직접 파싱
|
||||
|
||||
★ v3.1 FIX:
|
||||
- 여백: hwpunit_to_mm() 사용
|
||||
이전 v3.0: int(val) / 100 -> 5668이 56.7mm (오차)
|
||||
이후 v3.1: hwpunit_to_mm(5668) -> 20.0mm (정확)
|
||||
- 폰트 크기: charsize_to_pt() 사용
|
||||
- 폰트 이름: fontface에서 실제 face 추출 (ID가 아님)
|
||||
- 도메인 가이드 맵핑 짠1, 짠3, 짠4, 짠7 참조
|
||||
"""
|
||||
raw_xml = parsed.get("raw_xml", {})
|
||||
section_xml = parsed.get("section_xml", "")
|
||||
|
||||
result = {
|
||||
"font": {"name": None, "size": None},
|
||||
"colors": {"primary": None, "secondary": None},
|
||||
"margins": {
|
||||
"top": None, "bottom": None,
|
||||
"left": None, "right": None},
|
||||
"lineHeight": None,
|
||||
"headingStyle": {"h1": None, "h2": None},
|
||||
"bulletStyle": None,
|
||||
"alignment": None,
|
||||
}
|
||||
|
||||
for xml_name, xml_content in raw_xml.items():
|
||||
# ★ 짠7.2: 용지 마진 (HWPUNIT -> mm)
|
||||
for tag in ['margin', 'pageMargin']:
|
||||
# 정규식으로 속성 무관 가변 추출
|
||||
t_m = re.search(
|
||||
rf'<(?:\w+:)?{tag}\b[^>]*\btop="(\d+)"',
|
||||
xml_content)
|
||||
if t_m:
|
||||
b_m = re.search(
|
||||
rf'<(?:\w+:)?{tag}\b[^>]*\bbottom="(\d+)"',
|
||||
xml_content)
|
||||
l_m = re.search(
|
||||
rf'<(?:\w+:)?{tag}\b[^>]*\bleft="(\d+)"',
|
||||
xml_content)
|
||||
r_m = re.search(
|
||||
rf'<(?:\w+:)?{tag}\b[^>]*\bright="(\d+)"',
|
||||
xml_content)
|
||||
result["margins"] = {
|
||||
"top": mm_format(int(t_m.group(1))),
|
||||
"bottom": (mm_format(int(b_m.group(1)))
|
||||
if b_m else "15.0mm"),
|
||||
"left": (mm_format(int(l_m.group(1)))
|
||||
if l_m else "20.0mm"),
|
||||
"right": (mm_format(int(r_m.group(1)))
|
||||
if r_m else "20.0mm"),
|
||||
}
|
||||
break
|
||||
|
||||
# 짠3.2: 폰트 - fontface에서 실제 face 명 추출
|
||||
if not result["font"]["name"]:
|
||||
font_match = re.search(
|
||||
r'<(?:\w+:)?fontface[^>]*lang="HANGUL"[^>]*>.*?'
|
||||
r'<(?:\w+:)?font[^>]*face="([^"]+)"',
|
||||
xml_content, re.DOTALL)
|
||||
if font_match:
|
||||
result["font"]["name"] = font_match.group(1)
|
||||
|
||||
# fallback: fontRef hangul (속성, ID면 무시)
|
||||
if not result["font"]["name"]:
|
||||
fr_match = re.search(
|
||||
r'<(?:\w+:)?fontRef[^>]*hangul="([^"]+)"',
|
||||
xml_content)
|
||||
if fr_match:
|
||||
val = fr_match.group(1)
|
||||
if not val.isdigit():
|
||||
result["font"]["name"] = val
|
||||
|
||||
# 짠4.1: 글자 크기 (charPr height -> pt)
|
||||
if not result["font"]["size"]:
|
||||
size_match = re.search(
|
||||
r'<(?:\w+:)?charPr[^>]*height="(\d+)"',
|
||||
xml_content)
|
||||
if size_match:
|
||||
pt = charsize_to_pt(int(size_match.group(1)))
|
||||
result["font"]["size"] = f"{pt:.0f}pt"
|
||||
|
||||
# 짠1.3: 색상 (HWPX는 #RRGGBB)
|
||||
if not result["colors"]["primary"]:
|
||||
color_match = re.search(
|
||||
r'\bcolor="(#[0-9a-fA-F]{6})"', xml_content)
|
||||
if color_match:
|
||||
result["colors"]["primary"] = color_match.group(1)
|
||||
|
||||
# 줄 간격 (lineSpacing 속성)
|
||||
line_match = re.search(r'lineSpacing="(\d+)"', section_xml)
|
||||
if line_match:
|
||||
result["lineHeight"] = int(line_match.group(1))
|
||||
|
||||
# 정렬 (align 속성)
|
||||
align_match = re.search(r'\balign="([A-Z]+)"', section_xml)
|
||||
if align_match:
|
||||
result["alignment"] = align_match.group(1)
|
||||
|
||||
return result
|
||||
|
||||
# ================================================================
|
||||
# Step 6: Config 생성
|
||||
# ================================================================
|
||||
|
||||
def _generate_config(self, doc_name: str, description: str,
|
||||
result: dict) -> dict:
|
||||
"""config.json 생성"""
|
||||
doc_id = f"user_{int(time.time())}"
|
||||
context = result.get("context", {})
|
||||
structure = result.get("structure", {})
|
||||
layout = result.get("layout", {})
|
||||
|
||||
features = []
|
||||
features.append({
|
||||
"icon": "📄",
|
||||
"text": context.get("documentType", "문서")
|
||||
})
|
||||
|
||||
purpose = context.get("purpose", "")
|
||||
purpose_short = (
|
||||
(purpose[:15] + "...") if len(purpose) > 15 else purpose)
|
||||
if purpose_short:
|
||||
features.append({"icon": "🎯", "text": purpose_short})
|
||||
|
||||
features.append({
|
||||
"icon": "👥",
|
||||
"text": context.get("audience", "일반")
|
||||
})
|
||||
features.append({
|
||||
"icon": "📏",
|
||||
"text": f"약 {structure.get('pageEstimate', '?')}p"
|
||||
})
|
||||
|
||||
return {
|
||||
"id": doc_id,
|
||||
"name": doc_name,
|
||||
"icon": "📝",
|
||||
"description": (description
|
||||
or context.get("documentType", "")),
|
||||
"features": features[:4],
|
||||
"thumbnailType": "custom",
|
||||
"enabled": True,
|
||||
"isDefault": False,
|
||||
"order": 100,
|
||||
"template_id": result.get("template_id"),
|
||||
|
||||
"context": {
|
||||
"documentDefinition": context.get(
|
||||
"documentDefinition", ""),
|
||||
"documentType": context.get("documentType", ""),
|
||||
"purpose": context.get("purpose", ""),
|
||||
"perspective": context.get("perspective", ""),
|
||||
"audience": context.get("audience", ""),
|
||||
"tone": context.get("tone", ""),
|
||||
},
|
||||
|
||||
"layout": layout,
|
||||
"structure": structure,
|
||||
"content_prompt": result.get("content_prompt", {}),
|
||||
"options": {},
|
||||
|
||||
"createdAt": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"updatedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# 저장
|
||||
# ================================================================
|
||||
|
||||
def save_doc_type(self, config: dict, template: str,
|
||||
base_path: str = "templates/user/doc_types") -> str:
|
||||
"""분석 결과 저장 (config.json 및 템플릿 template_manager가 관리)"""
|
||||
doc_path = Path(base_path) / config["id"]
|
||||
doc_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
config_path = doc_path / "config.json"
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# ★ content_prompt.json 별도 저장 가능!
|
||||
content_prompt = config.pop("content_prompt", {})
|
||||
if content_prompt:
|
||||
with open(doc_path / "content_prompt.json", "w", encoding="utf-8") as f:
|
||||
json.dump(content_prompt, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# template_id 없는 경우(fallback)만 template.html 직접 저장
|
||||
if not config.get("template_id") and template:
|
||||
template_path = doc_path / "template.html"
|
||||
with open(template_path, "w", encoding="utf-8") as f:
|
||||
f.write(template)
|
||||
|
||||
return str(doc_path)
|
||||
Reference in New Issue
Block a user