diff --git a/03.Code/업로드용/handlers/doc/doc_type_analyzer.py b/03.Code/업로드용/handlers/doc/doc_type_analyzer.py deleted file mode 100644 index b0ef72d..0000000 --- a/03.Code/업로드용/handlers/doc/doc_type_analyzer.py +++ /dev/null @@ -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'([^<]*)', 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)