From 089ca80fd2cf96443448f336721aaf9ff515b1de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EA=B2=BD=EB=AF=BC?= Date: Thu, 19 Mar 2026 12:55:43 +0900 Subject: [PATCH] Update handlers/doc/doc_type_analyzer.py --- .../handlers/doc/doc_type_analyzer.py | 904 +++++++++++++++++- 1 file changed, 902 insertions(+), 2 deletions(-) diff --git a/03.Code/업로드용/handlers/doc/doc_type_analyzer.py b/03.Code/업로드용/handlers/doc/doc_type_analyzer.py index 5e2fcfb..b0ef72d 100644 --- a/03.Code/업로드용/handlers/doc/doc_type_analyzer.py +++ b/03.Code/업로드용/handlers/doc/doc_type_analyzer.py @@ -1,3 +1,903 @@ -# 문서 유형 분석기 +# -*- 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: - pass + """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'([^<]*)', xml) + return ' '.join(texts) + + def _extract_paragraphs(self, xml: str) -> List[dict]: + """문단별 구조 추출""" + paragraphs = [] + p_pattern = re.compile(r']*>(.*?)', re.DOTALL) + + for match in p_pattern.finditer(xml): + p_content = match.group(1) + texts = re.findall(r'([^<]*)', 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 = ' 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']*>.*?', '', + xml, flags=re.DOTALL) + body_xml = re.sub( + r']*>.*?', '', + 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: 태그 내 속성 colSpan/rowSpan/width 추출. + 참조 맵핑 짠6.3: + """ + tables = [] + tbl_pattern = re.compile( + r']*>(.*?)', 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'(.*?)', re.DOTALL) + + for row_match in row_pattern.finditer(tbl_content): + row_content = row_match.group(1) + row_cells = [] + + cell_pattern = re.compile( + r']*)>(.*?)', 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']*>(.*?)', + cell_content, re.DOTALL) + for cp in cell_paras: + cp_text = ' '.join( + re.findall(r'([^<]*)', cp) + ).strip() + if cp_text: + p_texts.append(cp_text) + + # 셀 1순위: 태그 속성 (짠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']*colSpan="(\d+)"', + cell_content) + if not rs_m: + rs_m = re.search( + r']*rowSpan="(\d+)"', + cell_content) + if not w_m: + w_m = re.search( + r']*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']*>(.*?)', xml, re.DOTALL) + return m.group(1) if m else "" + + def _extract_footer(self, xml: str) -> str: + """꼬리말 추출""" + m = re.search( + r']*>(.*?)', xml, re.DOTALL) + return m.group(1) if m else "" + + def _extract_images(self, xml: str) -> List[dict]: + """이미지 참조 추출""" + images = [] + pic_pattern = re.compile( + r']*>(.*?)', 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'([^<]*)', 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'([^<]*)', 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']*>.*?', '', + section_xml, flags=re.DOTALL) + clean_xml = re.sub( + r']*>.*?', '', + clean_xml, flags=re.DOTALL) + + paragraphs = re.findall( + r']*>(.*?)', clean_xml, re.DOTALL) + + for p_content in paragraphs: + texts = re.findall(r'([^<]*)', 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 ' 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)