📦 Initialize Geulbeot structure and merge Prompts & test projects
This commit is contained in:
5
03. Code/geulbeot_7th/handlers/__init__.py
Normal file
5
03. Code/geulbeot_7th/handlers/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
handlers 패키지
|
||||
문서 유형별 처리 로직을 분리하여 관리
|
||||
"""
|
||||
5
03. Code/geulbeot_7th/handlers/briefing/__init__.py
Normal file
5
03. Code/geulbeot_7th/handlers/briefing/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
기획서(briefing) 처리 모듈
|
||||
"""
|
||||
from .processor import BriefingProcessor
|
||||
279
03. Code/geulbeot_7th/handlers/briefing/processor.py
Normal file
279
03. Code/geulbeot_7th/handlers/briefing/processor.py
Normal file
@@ -0,0 +1,279 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
기획서(briefing) 처리 로직
|
||||
- 1~2페이지 압축형 보고서
|
||||
- Navy 양식
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
from flask import jsonify, session
|
||||
|
||||
from handlers.common import call_claude, extract_json, extract_html, load_prompt, client
|
||||
|
||||
|
||||
class BriefingProcessor:
|
||||
"""기획서 처리 클래스"""
|
||||
|
||||
def __init__(self):
|
||||
self.prompts_dir = Path(__file__).parent / 'prompts'
|
||||
|
||||
def _load_prompt(self, filename: str) -> str:
|
||||
"""프롬프트 로드"""
|
||||
return load_prompt(str(self.prompts_dir), filename)
|
||||
|
||||
def _get_step1_prompt(self) -> str:
|
||||
"""1단계: 구조 추출 프롬프트"""
|
||||
prompt = self._load_prompt('step1_extract.txt')
|
||||
if prompt:
|
||||
return prompt
|
||||
return """HTML 문서를 분석하여 JSON 구조로 추출하세요.
|
||||
원본 텍스트를 그대로 보존하고, 구조만 정확히 파악하세요."""
|
||||
|
||||
def _get_step1_5_prompt(self) -> str:
|
||||
"""1.5단계: 배치 계획 프롬프트"""
|
||||
prompt = self._load_prompt('step1_5_plan.txt')
|
||||
if prompt:
|
||||
return prompt
|
||||
return """JSON 구조를 분석하여 페이지 배치 계획을 수립하세요."""
|
||||
|
||||
def _get_step2_prompt(self) -> str:
|
||||
"""2단계: HTML 생성 프롬프트"""
|
||||
prompt = self._load_prompt('step2_generate.txt')
|
||||
if prompt:
|
||||
return prompt
|
||||
return """JSON 구조를 각인된 양식의 HTML로 변환하세요.
|
||||
Navy 색상 테마, A4 크기, Noto Sans KR 폰트를 사용하세요."""
|
||||
|
||||
def _content_too_long(self, html: str, max_sections_per_page: int = 4) -> bool:
|
||||
"""페이지당 콘텐츠 양 체크"""
|
||||
from bs4 import BeautifulSoup
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
|
||||
sheets = soup.find_all('div', class_='sheet')
|
||||
for sheet in sheets:
|
||||
sections = sheet.find_all('div', class_='section')
|
||||
if len(sections) > max_sections_per_page:
|
||||
return True
|
||||
|
||||
all_li = sheet.find_all('li')
|
||||
if len(all_li) > 12:
|
||||
return True
|
||||
|
||||
steps = sheet.find_all('div', class_='process-step')
|
||||
if len(steps) > 6:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def generate(self, content: str, options: dict) -> dict:
|
||||
"""기획서 생성"""
|
||||
try:
|
||||
if not content.strip():
|
||||
return {'error': '내용을 입력하거나 파일을 업로드해주세요.'}
|
||||
|
||||
page_option = options.get('page_option', '1')
|
||||
department = options.get('department', '총괄기획실')
|
||||
additional_prompt = options.get('instruction', '')
|
||||
|
||||
# ============== 1단계: 구조 추출 ==============
|
||||
step1_prompt = self._get_step1_prompt()
|
||||
step1_message = f"""다음 HTML 문서의 구조를 분석하여 JSON으로 추출해주세요.
|
||||
|
||||
## 원본 HTML
|
||||
{content}
|
||||
|
||||
---
|
||||
위 문서를 분석하여 JSON 구조로 출력하세요. 설명 없이 JSON만 출력."""
|
||||
|
||||
step1_response = call_claude(step1_prompt, step1_message, max_tokens=4000)
|
||||
structure_json = extract_json(step1_response)
|
||||
|
||||
if not structure_json:
|
||||
structure_json = {"raw_content": content, "parse_failed": True}
|
||||
|
||||
# ============== 1.5단계: 배치 계획 ==============
|
||||
step1_5_prompt = self._get_step1_5_prompt()
|
||||
step1_5_message = f"""다음 JSON 구조를 분석하여 페이지 배치 계획을 수립해주세요.
|
||||
|
||||
## 문서 구조 (JSON)
|
||||
{json.dumps(structure_json, ensure_ascii=False, indent=2)}
|
||||
|
||||
## 페이지 수
|
||||
{page_option}페이지
|
||||
|
||||
---
|
||||
배치 계획 JSON만 출력하세요. 설명 없이 JSON만."""
|
||||
|
||||
step1_5_response = call_claude(step1_5_prompt, step1_5_message, max_tokens=4000)
|
||||
page_plan = extract_json(step1_5_response)
|
||||
|
||||
if not page_plan:
|
||||
page_plan = {"page_plan": {}, "parse_failed": True}
|
||||
|
||||
# ============== 2단계: HTML 생성 ==============
|
||||
page_instructions = {
|
||||
'1': '1페이지로 핵심 내용만 압축하여 작성하세요.',
|
||||
'2': '2페이지로 작성하세요. 1페이지는 본문, 2페이지는 [첨부]입니다.',
|
||||
'n': '여러 페이지로 작성하세요. 1페이지는 본문, 나머지는 [첨부] 형태로 분할합니다.'
|
||||
}
|
||||
|
||||
step2_prompt = self._get_step2_prompt()
|
||||
step2_message = f"""다음 배치 계획과 문서 구조를 기반으로 각인된 양식의 HTML 보고서를 생성해주세요.
|
||||
|
||||
## 배치 계획
|
||||
{json.dumps(page_plan, ensure_ascii=False, indent=2)}
|
||||
|
||||
## 문서 구조 (JSON)
|
||||
{json.dumps(structure_json, ensure_ascii=False, indent=2)}
|
||||
|
||||
## 페이지 옵션
|
||||
{page_instructions.get(page_option, page_instructions['1'])}
|
||||
|
||||
## 부서명
|
||||
{department}
|
||||
|
||||
## 추가 요청사항
|
||||
{additional_prompt if additional_prompt else '없음'}
|
||||
|
||||
---
|
||||
위 JSON을 바탕으로 완전한 HTML 문서를 생성하세요.
|
||||
코드 블록(```) 없이 <!DOCTYPE html>부터 </html>까지 순수 HTML만 출력."""
|
||||
|
||||
step2_response = call_claude(step2_prompt, step2_message, max_tokens=8000)
|
||||
html_content = extract_html(step2_response)
|
||||
|
||||
# 후처리 검증
|
||||
if self._content_too_long(html_content):
|
||||
compress_message = f"""다음 HTML이 페이지당 콘텐츠가 너무 많습니다.
|
||||
각 페이지당 섹션 3~4개, 리스트 항목 8개 이하로 압축해주세요.
|
||||
|
||||
{html_content}
|
||||
|
||||
코드 블록 없이 압축된 완전한 HTML만 출력하세요."""
|
||||
|
||||
compress_response = call_claude(step2_prompt, compress_message, max_tokens=8000)
|
||||
html_content = extract_html(compress_response)
|
||||
|
||||
# 세션에 저장
|
||||
session['original_html'] = content
|
||||
session['current_html'] = html_content
|
||||
session['structure_json'] = json.dumps(structure_json, ensure_ascii=False)
|
||||
session['conversation'] = []
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'html': html_content,
|
||||
'structure': structure_json
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return {'error': str(e), 'trace': traceback.format_exc()}
|
||||
|
||||
def refine(self, feedback: str, current_html: str, original_html: str = '') -> dict:
|
||||
"""피드백 반영"""
|
||||
try:
|
||||
if not feedback.strip():
|
||||
return {'error': '피드백 내용을 입력해주세요.'}
|
||||
|
||||
if not current_html:
|
||||
return {'error': '수정할 HTML이 없습니다.'}
|
||||
|
||||
refine_prompt = f"""당신은 HTML 보고서 수정 전문가입니다.
|
||||
|
||||
사용자의 피드백을 반영하여 현재 HTML을 수정합니다.
|
||||
|
||||
## 규칙
|
||||
1. 피드백에서 언급된 부분만 정확히 수정
|
||||
2. 나머지 구조와 스타일은 그대로 유지
|
||||
3. 완전한 HTML 문서로 출력 (<!DOCTYPE html> ~ </html>)
|
||||
4. 코드 블록(```) 없이 순수 HTML만 출력
|
||||
5. 원본 문서의 텍스트를 참조하여 누락된 내용 복구 가능
|
||||
|
||||
## 원본 HTML (참고용)
|
||||
{original_html[:3000] if original_html else '없음'}...
|
||||
|
||||
## 현재 HTML
|
||||
{current_html}
|
||||
|
||||
## 사용자 피드백
|
||||
{feedback}
|
||||
|
||||
---
|
||||
위 피드백을 반영하여 수정된 완전한 HTML을 출력하세요."""
|
||||
|
||||
response = call_claude("", refine_prompt, max_tokens=8000)
|
||||
new_html = extract_html(response)
|
||||
|
||||
session['current_html'] = new_html
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'html': new_html
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
|
||||
def refine_selection(self, current_html: str, selected_text: str, user_request: str) -> dict:
|
||||
"""선택된 부분만 수정"""
|
||||
try:
|
||||
if not current_html or not selected_text or not user_request:
|
||||
return {'error': '필수 데이터가 없습니다.'}
|
||||
|
||||
message = client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=8000,
|
||||
messages=[{
|
||||
"role": "user",
|
||||
"content": f"""HTML 문서에서 지정된 부분만 수정해주세요.
|
||||
|
||||
## 전체 문서 (컨텍스트 파악용)
|
||||
{current_html}
|
||||
|
||||
## 수정 대상 텍스트
|
||||
"{selected_text}"
|
||||
|
||||
## 수정 요청
|
||||
{user_request}
|
||||
|
||||
## 규칙
|
||||
1. 요청을 분석하여 수정 유형을 판단:
|
||||
- TEXT: 텍스트 내용만 수정 (요약, 문장 변경, 단어 수정, 번역 등)
|
||||
- STRUCTURE: HTML 구조 변경 필요 (표 생성, 박스 추가, 레이아웃 변경 등)
|
||||
|
||||
2. 반드시 다음 형식으로만 출력:
|
||||
|
||||
TYPE: (TEXT 또는 STRUCTURE)
|
||||
CONTENT:
|
||||
(수정된 내용)
|
||||
|
||||
3. TEXT인 경우: 순수 텍스트만 출력 (HTML 태그 없이)
|
||||
4. STRUCTURE인 경우: 완전한 HTML 요소 출력 (기존 클래스명 유지)
|
||||
5. 개조식 문체 유지 (~임, ~함, ~필요)
|
||||
"""
|
||||
}]
|
||||
)
|
||||
|
||||
result = message.content[0].text
|
||||
result = result.replace('```html', '').replace('```', '').strip()
|
||||
|
||||
edit_type = 'TEXT'
|
||||
content = result
|
||||
|
||||
if 'TYPE:' in result and 'CONTENT:' in result:
|
||||
type_line = result.split('CONTENT:')[0]
|
||||
if 'STRUCTURE' in type_line:
|
||||
edit_type = 'STRUCTURE'
|
||||
content = result.split('CONTENT:')[1].strip()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'type': edit_type,
|
||||
'html': content
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
104
03. Code/geulbeot_7th/handlers/briefing/prompts/step1_5_plan.txt
Normal file
104
03. Code/geulbeot_7th/handlers/briefing/prompts/step1_5_plan.txt
Normal file
@@ -0,0 +1,104 @@
|
||||
당신은 임원보고용 문서 구성 전문가입니다.
|
||||
step1에서 추출된 JSON 구조를 분석하여, 각 요소의 역할을 분류하고 페이지 배치 계획을 수립합니다.
|
||||
|
||||
## 입력
|
||||
- step1에서 추출된 JSON 구조 데이터
|
||||
|
||||
## 출력
|
||||
- 페이지별 배치 계획 JSON (설명 없이 JSON만 출력)
|
||||
|
||||
---
|
||||
|
||||
## 배치 원칙
|
||||
|
||||
### 1페이지 (본문) - "왜? 무엇이 문제?"
|
||||
- **lead-box**: 문서 전체의 핵심 명제/주제 문장 선정
|
||||
- **본문 섹션**: 논리, 근거, 리스크, 주의사항 중심
|
||||
- **bottom-box**: 문서 전체를 관통하는 핵심 결론 (1~2문장)
|
||||
|
||||
### 2페이지~ (첨부) - "어떻게? 상세 기준"
|
||||
- **첨부 제목**: 해당 페이지 내용을 대표하는 제목
|
||||
- **본문 섹션**: 프로세스, 절차, 표, 체크리스트, 상세 가이드
|
||||
- **bottom-box**: 해당 페이지 내용 요약
|
||||
|
||||
---
|
||||
|
||||
## 요소 역할 분류 기준
|
||||
|
||||
| 역할 | 설명 | 배치 |
|
||||
|------|------|------|
|
||||
| 핵심명제 | 문서 전체 주제를 한 문장으로 | 1p lead-box |
|
||||
| 논리/근거 | 왜 그런가? 정당성, 법적 근거 | 1p 본문 |
|
||||
| 리스크 | 주의해야 할 세무/법적 위험 | 1p 본문 |
|
||||
| 주의사항 | 실무상 유의점, 제언 | 1p 본문 |
|
||||
| 핵심결론 | 문서 요약 한 문장 | 1p bottom-box |
|
||||
| 프로세스 | 단계별 절차, Step | 첨부 |
|
||||
| 기준표 | 할인율, 판정 기준 등 표 | 첨부 |
|
||||
| 체크리스트 | 항목별 점검사항 | 첨부 |
|
||||
| 상세가이드 | 세부 설명, 예시 | 첨부 |
|
||||
| 실무멘트 | 대응 스크립트, 방어 논리 | 첨부 bottom-box |
|
||||
|
||||
---
|
||||
|
||||
## 출력 JSON 스키마
|
||||
```json
|
||||
{
|
||||
"page_plan": {
|
||||
"page_1": {
|
||||
"type": "본문",
|
||||
"lead": {
|
||||
"source_section": "원본 섹션명 또는 null",
|
||||
"text": "lead-box에 들어갈 핵심 명제 문장"
|
||||
},
|
||||
"sections": [
|
||||
{
|
||||
"source": "원본 섹션 제목",
|
||||
"role": "논리/근거 | 리스크 | 주의사항",
|
||||
"new_title": "변환 후 섹션 제목 (필요시 수정)"
|
||||
}
|
||||
],
|
||||
"bottom": {
|
||||
"label": "핵심 결론",
|
||||
"source": "원본에서 가져올 문장 또는 조합할 키워드",
|
||||
"text": "bottom-box에 들어갈 최종 문장"
|
||||
}
|
||||
},
|
||||
"page_2": {
|
||||
"type": "첨부",
|
||||
"title": "[첨부] 페이지 제목",
|
||||
"sections": [
|
||||
{
|
||||
"source": "원본 섹션 제목",
|
||||
"role": "프로세스 | 기준표 | 체크리스트 | 상세가이드",
|
||||
"new_title": "변환 후 섹션 제목"
|
||||
}
|
||||
],
|
||||
"bottom": {
|
||||
"label": "라벨 (예: 실무 핵심, 체크포인트 등)",
|
||||
"source": "원본에서 가져올 문장",
|
||||
"text": "bottom-box에 들어갈 최종 문장"
|
||||
}
|
||||
}
|
||||
},
|
||||
"page_count": 2
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 판단 규칙
|
||||
|
||||
1. **프로세스/Step 있으면** → 무조건 첨부로
|
||||
2. **표(table) 있으면** → 가능하면 첨부로 (단, 핵심 리스크 표는 1p 가능)
|
||||
3. **"~입니다", "~합니다" 종결문** → 개조식으로 변환 표시
|
||||
4. **핵심 결론 선정**: "그래서 뭐?" 에 대한 답이 되는 문장
|
||||
5. **첨부 bottom-box**: 해당 페이지 실무 적용 시 핵심 포인트
|
||||
|
||||
---
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. 원본에 없는 내용 추가/추론 금지
|
||||
2. 원본 문장을 선별/조합만 허용
|
||||
3. 개조식 변환 필요한 문장 표시 (is_formal: true)
|
||||
4. JSON만 출력 (설명 없이)
|
||||
@@ -0,0 +1,122 @@
|
||||
당신은 HTML 문서 구조 분석 전문가입니다.
|
||||
사용자가 제공하는 HTML 문서를 분석하여 **구조화된 JSON**으로 추출합니다.
|
||||
|
||||
## 규칙
|
||||
|
||||
1. 원본 텍스트를 **그대로** 보존 (요약/수정 금지)
|
||||
2. 문서의 논리적 구조를 정확히 파악
|
||||
3. 반드시 유효한 JSON만 출력 (마크다운 코드블록 없이)
|
||||
|
||||
## 출력 JSON 스키마
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "문서 제목 (원문 그대로)",
|
||||
"title_en": "영문 제목 (원어민 수준 비즈니스 영어로 번역)",
|
||||
"department": "부서명 (있으면 추출, 없으면 '총괄기획실')",
|
||||
"lead": {
|
||||
"text": "핵심 요약/기조 텍스트 (원문 그대로)",
|
||||
"highlight_keywords": ["강조할 키워드1", "키워드2"]
|
||||
},
|
||||
"sections": [
|
||||
{
|
||||
"number": 1,
|
||||
"title": "섹션 제목 (원문 그대로)",
|
||||
"type": "list | table | grid | process | qa | text",
|
||||
"content": {
|
||||
// type에 따라 다름 (아래 참조)
|
||||
}
|
||||
}
|
||||
],
|
||||
"conclusion": {
|
||||
"label": "라벨 (예: 핵심 결론, 요약 등)",
|
||||
"text": "결론 텍스트 (원문 그대로, 한 문장)"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 섹션 type별 content 구조
|
||||
|
||||
### type: "list"
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{"keyword": "키워드", "text": "설명 텍스트", "highlight": ["강조할 부분"]},
|
||||
{"keyword": null, "text": "키워드 없는 항목", "highlight": []}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### type: "table"
|
||||
```json
|
||||
{
|
||||
"columns": ["컬럼1", "컬럼2", "컬럼3"],
|
||||
"rows": [
|
||||
{
|
||||
"cells": [
|
||||
{"text": "셀내용", "rowspan": 1, "colspan": 1, "highlight": false, "badge": null},
|
||||
{"text": "강조", "rowspan": 2, "colspan": 1, "highlight": true, "badge": null},
|
||||
{"text": "안전", "rowspan": 1, "colspan": 1, "highlight": false, "badge": "safe"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"footnote": "표 하단 주석 (있으면)"
|
||||
}
|
||||
```
|
||||
- badge 값: "safe" | "caution" | "risk" | null
|
||||
- highlight: true면 빨간색 강조
|
||||
|
||||
### type: "grid"
|
||||
```json
|
||||
{
|
||||
"columns": 2,
|
||||
"items": [
|
||||
{"title": "① 항목 제목", "text": "설명", "highlight": ["강조 부분"]},
|
||||
{"title": "② 항목 제목", "text": "설명", "highlight": []}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### type: "two-column"
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{"title": "① 제목", "text": "내용", "highlight": ["강조"]},
|
||||
{"title": "② 제목", "text": "내용", "highlight": []}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### type: "process"
|
||||
```json
|
||||
{
|
||||
"steps": [
|
||||
{"number": 1, "title": "단계명", "text": "설명"},
|
||||
{"number": 2, "title": "단계명", "text": "설명"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### type: "qa"
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{"question": "질문?", "answer": "답변"},
|
||||
{"question": "질문?", "answer": "답변"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### type: "text"
|
||||
```json
|
||||
{
|
||||
"paragraphs": ["문단1 텍스트", "문단2 텍스트"]
|
||||
}
|
||||
```
|
||||
|
||||
## 중요
|
||||
|
||||
1. **원본 텍스트 100% 보존** - 요약하거나 바꾸지 말 것
|
||||
2. **구조 정확히 파악** - 테이블 열 수, rowspan/colspan 정확히
|
||||
3. **JSON만 출력** - 설명 없이 순수 JSON만
|
||||
4. **badge 판단** - "안전", "위험", "주의" 등의 표현 보고 적절히 매핑
|
||||
@@ -0,0 +1,440 @@
|
||||
당신은 HTML 보고서 생성 전문가입니다.
|
||||
사용자가 제공하는 **JSON 구조 데이터**를 받아서 **각인된 양식의 HTML 보고서**를 생성합니다.
|
||||
|
||||
## 출력 규칙
|
||||
|
||||
1. 완전한 HTML 문서 출력 (<!DOCTYPE html> ~ </html>)
|
||||
2. 코드 블록(```) 없이 **순수 HTML만** 출력
|
||||
3. JSON의 텍스트를 **그대로** 사용 (수정 금지)
|
||||
4. 아래 CSS를 **정확히** 사용
|
||||
|
||||
## 페이지 옵션
|
||||
|
||||
- **1페이지**: 모든 내용을 1페이지에 (텍스트/줄간 조정)
|
||||
- **2페이지**: 1페이지 본문 + 2페이지 [첨부]
|
||||
- **N페이지**: 1페이지 본문 + 나머지 [첨부 1], [첨부 2]...
|
||||
|
||||
## HTML 템플릿 구조
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{title}}</title>
|
||||
<style>
|
||||
/* 아래 CSS 전체 포함 */
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="sheet">
|
||||
<header class="page-header">
|
||||
<div class="header-left">{{department}}</div>
|
||||
<div class="header-right">{{title_en}}</div>
|
||||
</header>
|
||||
<div class="title-block">
|
||||
<h1 class="header-title">{{title}}</h1>
|
||||
<div class="title-divider"></div>
|
||||
</div>
|
||||
<div class="body-content">
|
||||
<div class="lead-box">
|
||||
<div>{{lead.text}} - <b>키워드</b> 강조</div>
|
||||
</div>
|
||||
<!-- sections -->
|
||||
<div class="bottom-box">
|
||||
<div class="bottom-left">{{conclusion.label}}</div>
|
||||
<div class="bottom-right">{{conclusion.text}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="page-footer">- 1 -</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## 섹션 type별 HTML 변환
|
||||
|
||||
### list → ul/li
|
||||
```html
|
||||
<div class="section">
|
||||
<div class="section-title">{{section.title}}</div>
|
||||
<ul>
|
||||
<li><span class="keyword">{{item.keyword}}:</span> {{item.text}} <b>{{highlight}}</b></li>
|
||||
</ul>
|
||||
</div>
|
||||
```
|
||||
|
||||
### table → data-table
|
||||
```html
|
||||
<div class="section">
|
||||
<div class="section-title">{{section.title}}</div>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="25%">{{col1}}</th>
|
||||
<th width="25%">{{col2}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowspan="2"><strong>{{text}}</strong></td>
|
||||
<td class="highlight-red">{{text}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
```
|
||||
- badge가 있으면: `<span class="badge badge-{{badge}}">{{text}}</span>`
|
||||
- highlight가 true면: `class="highlight-red"`
|
||||
|
||||
### grid → strategy-grid
|
||||
```html
|
||||
<div class="section">
|
||||
<div class="section-title">{{section.title}}</div>
|
||||
<div class="strategy-grid">
|
||||
<div class="strategy-item">
|
||||
<div class="strategy-title">{{item.title}}</div>
|
||||
<p>{{item.text}} <b>{{highlight}}</b></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### two-column → two-col
|
||||
```html
|
||||
<div class="section">
|
||||
<div class="section-title">{{section.title}}</div>
|
||||
<div class="two-col">
|
||||
<div class="info-box">
|
||||
<div class="info-box-title">{{item.title}}</div>
|
||||
<p>{{item.text}} <b>{{highlight}}</b></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### process → process-container
|
||||
```html
|
||||
<div class="section">
|
||||
<div class="section-title">{{section.title}}</div>
|
||||
<div class="process-container">
|
||||
<div class="process-step">
|
||||
<div class="step-num">{{step.number}}</div>
|
||||
<div class="step-content"><strong>{{step.title}}:</strong> {{step.text}}</div>
|
||||
</div>
|
||||
<div class="arrow">▼</div>
|
||||
<!-- 반복 -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### qa → qa-grid
|
||||
```html
|
||||
<div class="section">
|
||||
<div class="section-title">{{section.title}}</div>
|
||||
<div class="qa-grid">
|
||||
<div class="qa-item">
|
||||
<strong>Q. {{question}}</strong><br>
|
||||
A. {{answer}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 완전한 CSS (반드시 이대로 사용)
|
||||
|
||||
```css
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap');
|
||||
|
||||
:root {
|
||||
--primary-navy: #1a365d;
|
||||
--secondary-navy: #2c5282;
|
||||
--accent-navy: #3182ce;
|
||||
--dark-gray: #2d3748;
|
||||
--medium-gray: #4a5568;
|
||||
--light-gray: #e2e8f0;
|
||||
--bg-light: #f7fafc;
|
||||
--text-black: #1a202c;
|
||||
--border-color: #cbd5e0;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; }
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans KR', sans-serif;
|
||||
background-color: #f0f0f0;
|
||||
color: var(--text-black);
|
||||
line-height: 1.55;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
gap: 20px;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
background-color: white;
|
||||
width: 210mm;
|
||||
height: 297mm;
|
||||
padding: 20mm;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body { background: none; padding: 0; gap: 0; }
|
||||
.sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; }
|
||||
.sheet:last-child { page-break-after: auto; }
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
font-size: 9pt;
|
||||
color: var(--medium-gray);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 23pt;
|
||||
font-weight: 900;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: -1px;
|
||||
color: var(--primary-navy);
|
||||
line-height: 1.25;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title-divider {
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--primary-navy) 0%, var(--secondary-navy) 100%);
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.lead-box {
|
||||
background-color: var(--bg-light);
|
||||
border-left: 4px solid var(--primary-navy);
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.lead-box div {
|
||||
font-size: 11.5pt;
|
||||
font-weight: 500;
|
||||
color: var(--dark-gray);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.lead-box b { color: var(--primary-navy); font-weight: 700; }
|
||||
|
||||
.body-content { flex: 1; display: flex; flex-direction: column; }
|
||||
|
||||
.section { margin-bottom: 16px; }
|
||||
|
||||
.section-title {
|
||||
font-size: 12pt;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
color: var(--primary-navy);
|
||||
}
|
||||
|
||||
.section-title::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--secondary-navy);
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.attachment-title {
|
||||
font-size: 19pt;
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
color: var(--primary-navy);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
ul { list-style: none; padding-left: 10px; }
|
||||
|
||||
li {
|
||||
font-size: 10.5pt;
|
||||
position: relative;
|
||||
margin-bottom: 6px;
|
||||
padding-left: 14px;
|
||||
color: var(--dark-gray);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
li::before {
|
||||
content: "•";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--secondary-navy);
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
.bottom-box {
|
||||
border: 1.5px solid var(--border-color);
|
||||
display: flex;
|
||||
margin-top: auto;
|
||||
min-height: 50px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.bottom-left {
|
||||
width: 18%;
|
||||
background-color: var(--primary-navy);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
font-size: 10.5pt;
|
||||
color: #fff;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.bottom-right {
|
||||
width: 82%;
|
||||
background-color: var(--bg-light);
|
||||
padding: 12px 18px;
|
||||
font-size: 10.5pt;
|
||||
line-height: 1.6;
|
||||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
.bottom-right b { display: inline; }
|
||||
|
||||
.page-footer {
|
||||
position: absolute;
|
||||
bottom: 10mm;
|
||||
left: 20mm;
|
||||
right: 20mm;
|
||||
padding-top: 8px;
|
||||
text-align: center;
|
||||
font-size: 8.5pt;
|
||||
color: var(--medium-gray);
|
||||
border-top: 1px solid var(--light-gray);
|
||||
}
|
||||
|
||||
b { font-weight: 700; color: var(--primary-navy); display: inline; }
|
||||
.keyword { font-weight: 600; color: var(--text-black); }
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 9.5pt;
|
||||
border-top: 2px solid var(--primary-navy);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background-color: var(--primary-navy);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
padding: 8px 6px;
|
||||
border: 1px solid var(--secondary-navy);
|
||||
text-align: center;
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 7px 10px;
|
||||
vertical-align: middle;
|
||||
color: var(--dark-gray);
|
||||
line-height: 1.45;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.data-table td:first-child {
|
||||
background-color: var(--bg-light);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.highlight-red { color: #c53030; font-weight: 600; }
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
|
||||
.badge-safe { background-color: #e6f4ea; color: #1e6f3f; border: 1px solid #a8d5b8; }
|
||||
.badge-caution { background-color: #fef3e2; color: #9a5b13; border: 1px solid #f5d9a8; }
|
||||
.badge-risk { background-color: #fce8e8; color: #a12b2b; border: 1px solid #f5b8b8; }
|
||||
|
||||
.strategy-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; }
|
||||
.strategy-item { background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; }
|
||||
.strategy-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; border-bottom: 1px solid var(--light-gray); padding-bottom: 4px; }
|
||||
.strategy-item p { font-size: 9.5pt; color: var(--dark-gray); line-height: 1.5; }
|
||||
|
||||
.qa-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; }
|
||||
.qa-item { background: var(--bg-light); border-left: 3px solid var(--secondary-navy); padding: 8px 12px; font-size: 9.5pt; }
|
||||
.qa-item strong { color: var(--primary-navy); }
|
||||
|
||||
.two-col { display: flex; gap: 12px; margin-top: 6px; }
|
||||
.info-box { flex: 1; background: var(--bg-light); border: 1px solid var(--border-color); padding: 10px 12px; }
|
||||
.info-box-title { font-weight: 700; color: var(--primary-navy); font-size: 10pt; margin-bottom: 4px; }
|
||||
.info-box p { font-size: 10pt; color: var(--dark-gray); line-height: 1.5; }
|
||||
|
||||
.process-container { background: var(--bg-light); padding: 14px 16px; border: 1px solid var(--border-color); margin-top: 8px; }
|
||||
.process-step { display: flex; align-items: flex-start; margin-bottom: 5px; }
|
||||
.step-num { background: var(--primary-navy); color: #fff; width: 22px; height: 22px; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 10pt; margin-right: 10px; flex-shrink: 0; }
|
||||
.step-content { font-size: 11pt; line-height: 1.55; color: var(--dark-gray); }
|
||||
.step-content strong { color: var(--primary-navy); font-weight: 600; }
|
||||
.arrow { text-align: center; color: var(--border-color); font-size: 10pt; margin: 2px 0 2px 32px; line-height: 1; }
|
||||
```
|
||||
|
||||
## 1페이지 본문 구성 논리
|
||||
|
||||
1. **lead-box**: 원본에서 전체 주제/핵심 명제를 대표하는 문장을 찾아 배치
|
||||
2. **본문 섹션**: 원본의 논리 흐름에 따라 재구성 (근거, 방안, 전략 등)
|
||||
3. **bottom-box**: 해당 페이지 본문 내용을 대표하는 문장 선별 또는 핵심 키워드 조합
|
||||
|
||||
## 첨부 페이지 구성
|
||||
|
||||
1. **제목**: `<h1 class="attachment-title">[첨부] 해당 내용에 맞는 제목</h1>`
|
||||
2. **본문**: 1페이지를 뒷받침하는 상세 자료 (표, 프로세스, 체크리스트 등)
|
||||
3. **bottom-box**: 해당 첨부 페이지 내용의 핵심 요약
|
||||
|
||||
## 중요 규칙
|
||||
|
||||
1. **원문 기반 재구성** - 추가/추론 금지, 단 아래는 허용:
|
||||
- 위치 재편성, 통합/분할
|
||||
- 표 ↔ 본문 ↔ 리스트 형식 변환
|
||||
|
||||
2. **개조식 필수 (전체 적용)** - 모든 텍스트는 명사형/체언 종결:
|
||||
- lead-box, bottom-box, 표 내부, 리스트, 모든 문장
|
||||
- ❌ "~입니다", "~합니다", "~됩니다"
|
||||
- ✅ "~임", "~함", "~필요", "~대상", "~가능"
|
||||
- 예시:
|
||||
- ❌ "부당행위계산 부인 및 증여세 부과 대상이 됩니다"
|
||||
- ✅ "부당행위계산 부인 및 증여세 부과 대상"
|
||||
|
||||
3. **페이지 경계 준수** - 모든 콘텐츠는 page-footer 위에 위치
|
||||
|
||||
4. **bottom-box** - 1~2줄, 핵심 키워드만 <b>로 강조
|
||||
|
||||
5. **섹션 번호 독립** - 본문과 첨부 번호 연계 불필요
|
||||
|
||||
6. **표 정렬** - 제목셀/구분열은 가운데, 설명은 좌측 정렬
|
||||
|
||||
## 첨부 페이지 규칙
|
||||
- 제목: `<h1 class="attachment-title">[첨부] 해당 페이지 내용에 맞는 제목</h1>`
|
||||
- 제목은 좌측 정렬, 16pt
|
||||
- 각 첨부 페이지도 마지막에 bottom-box로 해당 페이지 요약 포함
|
||||
84
03. Code/geulbeot_7th/handlers/common.py
Normal file
84
03. Code/geulbeot_7th/handlers/common.py
Normal file
@@ -0,0 +1,84 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
공통 유틸리티 함수
|
||||
- Claude API 호출
|
||||
- JSON/HTML 추출
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import anthropic
|
||||
from api_config import API_KEYS
|
||||
|
||||
# Claude API 클라이언트
|
||||
client = anthropic.Anthropic(
|
||||
api_key=API_KEYS.get('CLAUDE_API_KEY', '')
|
||||
)
|
||||
|
||||
|
||||
def call_claude(system_prompt: str, user_message: str, max_tokens: int = 8000) -> str:
|
||||
"""Claude API 호출"""
|
||||
response = client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=max_tokens,
|
||||
system=system_prompt,
|
||||
messages=[{"role": "user", "content": user_message}]
|
||||
)
|
||||
return response.content[0].text
|
||||
|
||||
|
||||
def extract_json(text: str) -> dict:
|
||||
"""텍스트에서 JSON 추출"""
|
||||
# 코드 블록 제거
|
||||
if '```json' in text:
|
||||
text = text.split('```json')[1].split('```')[0]
|
||||
elif '```' in text:
|
||||
text = text.split('```')[1].split('```')[0]
|
||||
|
||||
text = text.strip()
|
||||
|
||||
# JSON 파싱 시도
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
# JSON 부분만 추출 시도
|
||||
match = re.search(r'\{[\s\S]*\}', text)
|
||||
if match:
|
||||
try:
|
||||
return json.loads(match.group())
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def extract_html(text: str) -> str:
|
||||
"""텍스트에서 HTML 추출"""
|
||||
# 코드 블록 제거
|
||||
if '```html' in text:
|
||||
text = text.split('```html')[1].split('```')[0]
|
||||
elif '```' in text:
|
||||
parts = text.split('```')
|
||||
if len(parts) >= 2:
|
||||
text = parts[1]
|
||||
|
||||
text = text.strip()
|
||||
|
||||
# <!DOCTYPE 또는 <html로 시작하는지 확인
|
||||
if not text.startswith('<!DOCTYPE') and not text.startswith('<html'):
|
||||
# HTML 부분만 추출
|
||||
match = re.search(r'(<!DOCTYPE html[\s\S]*</html>)', text, re.IGNORECASE)
|
||||
if match:
|
||||
text = match.group(1)
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def load_prompt(prompts_dir: str, filename: str) -> str:
|
||||
"""프롬프트 파일 로드"""
|
||||
prompt_path = os.path.join(prompts_dir, filename)
|
||||
try:
|
||||
with open(prompt_path, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
5
03. Code/geulbeot_7th/handlers/report/__init__.py
Normal file
5
03. Code/geulbeot_7th/handlers/report/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
보고서(report) 처리 모듈
|
||||
"""
|
||||
from .processor import ReportProcessor
|
||||
161
03. Code/geulbeot_7th/handlers/report/processor.py
Normal file
161
03. Code/geulbeot_7th/handlers/report/processor.py
Normal file
@@ -0,0 +1,161 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
보고서(report) 처리 로직
|
||||
- 다페이지 보고서
|
||||
- 원본 구조 유지
|
||||
- RAG 파이프라인 연동 (긴 문서)
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from flask import session
|
||||
|
||||
from handlers.common import call_claude, extract_html, load_prompt, client
|
||||
from converters.pipeline.router import process_document, convert_image_paths
|
||||
|
||||
|
||||
class ReportProcessor:
|
||||
"""보고서 처리 클래스"""
|
||||
|
||||
def __init__(self):
|
||||
self.prompts_dir = Path(__file__).parent / 'prompts'
|
||||
|
||||
def _load_prompt(self, filename: str) -> str:
|
||||
"""프롬프트 로드"""
|
||||
return load_prompt(str(self.prompts_dir), filename)
|
||||
|
||||
def generate(self, content: str, options: dict) -> dict:
|
||||
"""보고서 생성"""
|
||||
try:
|
||||
if not content.strip():
|
||||
return {'error': '내용이 비어있습니다.'}
|
||||
|
||||
# ⭐ 템플릿 스타일 로드
|
||||
template_id = options.get('template_id')
|
||||
if template_id:
|
||||
from handlers.template import TemplateProcessor
|
||||
template_processor = TemplateProcessor()
|
||||
style = template_processor.get_style(template_id)
|
||||
if style and style.get('css'):
|
||||
options['template_css'] = style['css']
|
||||
|
||||
# 이미지 경로 변환
|
||||
processed_html = convert_image_paths(content)
|
||||
|
||||
# router를 통해 분량에 따라 파이프라인 분기
|
||||
result = process_document(processed_html, options)
|
||||
|
||||
if result.get('success'):
|
||||
session['original_html'] = content
|
||||
session['current_html'] = result.get('html', '')
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return {'error': str(e), 'trace': traceback.format_exc()}
|
||||
|
||||
def refine(self, feedback: str, current_html: str, original_html: str = '') -> dict:
|
||||
"""피드백 반영"""
|
||||
try:
|
||||
if not feedback.strip():
|
||||
return {'error': '피드백 내용을 입력해주세요.'}
|
||||
|
||||
if not current_html:
|
||||
return {'error': '수정할 HTML이 없습니다.'}
|
||||
|
||||
refine_prompt = f"""당신은 HTML 보고서 수정 전문가입니다.
|
||||
|
||||
사용자의 피드백을 반영하여 현재 HTML을 수정합니다.
|
||||
|
||||
## 규칙
|
||||
1. 피드백에서 언급된 부분만 정확히 수정
|
||||
2. **페이지 구조(sheet, body-content, page-header 등)는 절대 변경하지 마세요**
|
||||
3. 완전한 HTML 문서로 출력 (<!DOCTYPE html> ~ </html>)
|
||||
4. 코드 블록(```) 없이 순수 HTML만 출력
|
||||
|
||||
## 현재 HTML
|
||||
{current_html}
|
||||
|
||||
## 사용자 피드백
|
||||
{feedback}
|
||||
|
||||
---
|
||||
위 피드백을 반영하여 수정된 완전한 HTML을 출력하세요."""
|
||||
|
||||
response = call_claude("", refine_prompt, max_tokens=8000)
|
||||
new_html = extract_html(response)
|
||||
|
||||
session['current_html'] = new_html
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'html': new_html
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
|
||||
def refine_selection(self, current_html: str, selected_text: str, user_request: str) -> dict:
|
||||
"""선택된 부분만 수정 (보고서용 - 페이지 구조 보존)"""
|
||||
try:
|
||||
if not current_html or not selected_text or not user_request:
|
||||
return {'error': '필수 데이터가 없습니다.'}
|
||||
|
||||
message = client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=8000,
|
||||
messages=[{
|
||||
"role": "user",
|
||||
"content": f"""HTML 문서에서 지정된 부분만 수정해주세요.
|
||||
|
||||
## 전체 문서 (컨텍스트 파악용)
|
||||
{current_html[:5000]}
|
||||
|
||||
## 수정 대상 텍스트
|
||||
"{selected_text}"
|
||||
|
||||
## 수정 요청
|
||||
{user_request}
|
||||
|
||||
## 규칙
|
||||
1. **절대로 페이지 구조(sheet, body-content, page-header, page-footer)를 변경하지 마세요**
|
||||
2. 선택된 텍스트만 수정하고, 주변 HTML 태그는 그대로 유지
|
||||
3. 요청을 분석하여 수정 유형을 판단:
|
||||
- TEXT: 텍스트 내용만 수정 (요약, 문장 변경, 단어 수정, 번역 등)
|
||||
- STRUCTURE: HTML 구조 변경 필요 (표 생성, 박스 추가 등)
|
||||
|
||||
4. 반드시 다음 형식으로만 출력:
|
||||
|
||||
TYPE: (TEXT 또는 STRUCTURE)
|
||||
CONTENT:
|
||||
(수정된 내용만 - 선택된 텍스트의 수정본만)
|
||||
|
||||
5. TEXT인 경우: 순수 텍스트만 출력 (HTML 태그 없이, 선택된 텍스트의 수정본만)
|
||||
6. STRUCTURE인 경우: 해당 요소만 출력 (전체 페이지 구조 X)
|
||||
7. 개조식 문체 유지 (~임, ~함, ~필요)
|
||||
"""
|
||||
}]
|
||||
)
|
||||
|
||||
result = message.content[0].text
|
||||
result = result.replace('```html', '').replace('```', '').strip()
|
||||
|
||||
edit_type = 'TEXT'
|
||||
content = result
|
||||
|
||||
if 'TYPE:' in result and 'CONTENT:' in result:
|
||||
type_line = result.split('CONTENT:')[0]
|
||||
if 'STRUCTURE' in type_line:
|
||||
edit_type = 'STRUCTURE'
|
||||
content = result.split('CONTENT:')[1].strip()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'type': edit_type,
|
||||
'html': content
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
@@ -0,0 +1,104 @@
|
||||
당신은 임원보고용 문서 구성 전문가입니다.
|
||||
step1에서 추출된 JSON 구조를 분석하여, 각 요소의 역할을 분류하고 페이지 배치 계획을 수립합니다.
|
||||
|
||||
## 입력
|
||||
- step1에서 추출된 JSON 구조 데이터
|
||||
|
||||
## 출력
|
||||
- 페이지별 배치 계획 JSON (설명 없이 JSON만 출력)
|
||||
|
||||
---
|
||||
|
||||
## 배치 원칙
|
||||
|
||||
### 1페이지 (본문) - "왜? 무엇이 문제?"
|
||||
- **lead-box**: 문서 전체의 핵심 명제/주제 문장 선정
|
||||
- **본문 섹션**: 논리, 근거, 리스크, 주의사항 중심
|
||||
- **bottom-box**: 문서 전체를 관통하는 핵심 결론 (1~2문장)
|
||||
|
||||
### 2페이지~ (첨부) - "어떻게? 상세 기준"
|
||||
- **첨부 제목**: 해당 페이지 내용을 대표하는 제목
|
||||
- **본문 섹션**: 프로세스, 절차, 표, 체크리스트, 상세 가이드
|
||||
- **bottom-box**: 해당 페이지 내용 요약
|
||||
|
||||
---
|
||||
|
||||
## 요소 역할 분류 기준
|
||||
|
||||
| 역할 | 설명 | 배치 |
|
||||
|------|------|------|
|
||||
| 핵심명제 | 문서 전체 주제를 한 문장으로 | 1p lead-box |
|
||||
| 논리/근거 | 왜 그런가? 정당성, 법적 근거 | 1p 본문 |
|
||||
| 리스크 | 주의해야 할 세무/법적 위험 | 1p 본문 |
|
||||
| 주의사항 | 실무상 유의점, 제언 | 1p 본문 |
|
||||
| 핵심결론 | 문서 요약 한 문장 | 1p bottom-box |
|
||||
| 프로세스 | 단계별 절차, Step | 첨부 |
|
||||
| 기준표 | 할인율, 판정 기준 등 표 | 첨부 |
|
||||
| 체크리스트 | 항목별 점검사항 | 첨부 |
|
||||
| 상세가이드 | 세부 설명, 예시 | 첨부 |
|
||||
| 실무멘트 | 대응 스크립트, 방어 논리 | 첨부 bottom-box |
|
||||
|
||||
---
|
||||
|
||||
## 출력 JSON 스키마
|
||||
```json
|
||||
{
|
||||
"page_plan": {
|
||||
"page_1": {
|
||||
"type": "본문",
|
||||
"lead": {
|
||||
"source_section": "원본 섹션명 또는 null",
|
||||
"text": "lead-box에 들어갈 핵심 명제 문장"
|
||||
},
|
||||
"sections": [
|
||||
{
|
||||
"source": "원본 섹션 제목",
|
||||
"role": "논리/근거 | 리스크 | 주의사항",
|
||||
"new_title": "변환 후 섹션 제목 (필요시 수정)"
|
||||
}
|
||||
],
|
||||
"bottom": {
|
||||
"label": "핵심 결론",
|
||||
"source": "원본에서 가져올 문장 또는 조합할 키워드",
|
||||
"text": "bottom-box에 들어갈 최종 문장"
|
||||
}
|
||||
},
|
||||
"page_2": {
|
||||
"type": "첨부",
|
||||
"title": "[첨부] 페이지 제목",
|
||||
"sections": [
|
||||
{
|
||||
"source": "원본 섹션 제목",
|
||||
"role": "프로세스 | 기준표 | 체크리스트 | 상세가이드",
|
||||
"new_title": "변환 후 섹션 제목"
|
||||
}
|
||||
],
|
||||
"bottom": {
|
||||
"label": "라벨 (예: 실무 핵심, 체크포인트 등)",
|
||||
"source": "원본에서 가져올 문장",
|
||||
"text": "bottom-box에 들어갈 최종 문장"
|
||||
}
|
||||
}
|
||||
},
|
||||
"page_count": 2
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 판단 규칙
|
||||
|
||||
1. **프로세스/Step 있으면** → 무조건 첨부로
|
||||
2. **표(table) 있으면** → 가능하면 첨부로 (단, 핵심 리스크 표는 1p 가능)
|
||||
3. **"~입니다", "~합니다" 종결문** → 개조식으로 변환 표시
|
||||
4. **핵심 결론 선정**: "그래서 뭐?" 에 대한 답이 되는 문장
|
||||
5. **첨부 bottom-box**: 해당 페이지 실무 적용 시 핵심 포인트
|
||||
|
||||
---
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. 원본에 없는 내용 추가/추론 금지
|
||||
2. 원본 문장을 선별/조합만 허용
|
||||
3. 개조식 변환 필요한 문장 표시 (is_formal: true)
|
||||
4. JSON만 출력 (설명 없이)
|
||||
3
03. Code/geulbeot_7th/handlers/template/__init__.py
Normal file
3
03. Code/geulbeot_7th/handlers/template/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .processor import TemplateProcessor
|
||||
|
||||
__all__ = ['TemplateProcessor']
|
||||
625
03. Code/geulbeot_7th/handlers/template/processor.py
Normal file
625
03. Code/geulbeot_7th/handlers/template/processor.py
Normal file
@@ -0,0 +1,625 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
템플릿 처리 로직 (v3 - 실제 구조 정확 분석)
|
||||
- HWPX 파일의 실제 표 구조, 이미지 배경, 테두리 정확히 추출
|
||||
- ARGB 8자리 색상 정규화
|
||||
- NONE 테두리 색상 제외
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
import shutil
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
# 템플릿 저장 경로
|
||||
TEMPLATES_DIR = Path(__file__).parent.parent.parent / 'templates_store'
|
||||
TEMPLATES_DIR.mkdir(exist_ok=True)
|
||||
|
||||
# HWP 명세서 기반 상수
|
||||
LINE_TYPES = {
|
||||
'NONE': '없음',
|
||||
'SOLID': '실선',
|
||||
'DASH': '긴 점선',
|
||||
'DOT': '점선',
|
||||
'DASH_DOT': '-.-.-.-.',
|
||||
'DASH_DOT_DOT': '-..-..-..',
|
||||
'DOUBLE_SLIM': '2중선',
|
||||
'SLIM_THICK': '가는선+굵은선',
|
||||
'THICK_SLIM': '굵은선+가는선',
|
||||
'SLIM_THICK_SLIM': '가는선+굵은선+가는선',
|
||||
'WAVE': '물결',
|
||||
'DOUBLE_WAVE': '물결 2중선',
|
||||
}
|
||||
|
||||
|
||||
class TemplateProcessor:
|
||||
"""템플릿 처리 클래스 (v3)"""
|
||||
|
||||
NS = {
|
||||
'hh': 'http://www.hancom.co.kr/hwpml/2011/head',
|
||||
'hc': 'http://www.hancom.co.kr/hwpml/2011/core',
|
||||
'hp': 'http://www.hancom.co.kr/hwpml/2011/paragraph',
|
||||
'hs': 'http://www.hancom.co.kr/hwpml/2011/section',
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self.templates_dir = TEMPLATES_DIR
|
||||
self.templates_dir.mkdir(exist_ok=True)
|
||||
|
||||
# =========================================================================
|
||||
# 공개 API
|
||||
# =========================================================================
|
||||
|
||||
def get_list(self) -> Dict[str, Any]:
|
||||
"""저장된 템플릿 목록"""
|
||||
templates = []
|
||||
for item in self.templates_dir.iterdir():
|
||||
if item.is_dir():
|
||||
meta_path = item / 'meta.json'
|
||||
if meta_path.exists():
|
||||
try:
|
||||
meta = json.loads(meta_path.read_text(encoding='utf-8'))
|
||||
templates.append({
|
||||
'id': meta.get('id', item.name),
|
||||
'name': meta.get('name', item.name),
|
||||
'features': meta.get('features', []),
|
||||
'created_at': meta.get('created_at', '')
|
||||
})
|
||||
except:
|
||||
pass
|
||||
templates.sort(key=lambda x: x.get('created_at', ''), reverse=True)
|
||||
return {'templates': templates}
|
||||
|
||||
def analyze(self, file, name: str) -> Dict[str, Any]:
|
||||
"""템플릿 파일 분석 및 저장"""
|
||||
filename = file.filename
|
||||
ext = Path(filename).suffix.lower()
|
||||
|
||||
if ext not in ['.hwpx', '.hwp', '.pdf']:
|
||||
return {'error': f'지원하지 않는 파일 형식: {ext}'}
|
||||
|
||||
template_id = str(uuid.uuid4())[:8]
|
||||
template_dir = self.templates_dir / template_id
|
||||
template_dir.mkdir(exist_ok=True)
|
||||
|
||||
try:
|
||||
original_path = template_dir / f'original{ext}'
|
||||
file.save(str(original_path))
|
||||
|
||||
if ext == '.hwpx':
|
||||
style_data = self._analyze_hwpx(original_path, template_dir)
|
||||
else:
|
||||
style_data = self._analyze_fallback(ext)
|
||||
|
||||
if 'error' in style_data:
|
||||
shutil.rmtree(template_dir)
|
||||
return style_data
|
||||
|
||||
# 특징 추출
|
||||
features = self._extract_features(style_data)
|
||||
|
||||
# 메타 저장
|
||||
meta = {
|
||||
'id': template_id,
|
||||
'name': name,
|
||||
'original_file': filename,
|
||||
'file_type': ext,
|
||||
'features': features,
|
||||
'created_at': datetime.now().isoformat()
|
||||
}
|
||||
(template_dir / 'meta.json').write_text(
|
||||
json.dumps(meta, ensure_ascii=False, indent=2), encoding='utf-8'
|
||||
)
|
||||
|
||||
# 스타일 저장
|
||||
(template_dir / 'style.json').write_text(
|
||||
json.dumps(style_data, ensure_ascii=False, indent=2), encoding='utf-8'
|
||||
)
|
||||
|
||||
# CSS 저장
|
||||
css = style_data.get('css', '')
|
||||
css_dir = template_dir / 'css'
|
||||
css_dir.mkdir(exist_ok=True)
|
||||
(css_dir / 'template.css').write_text(css, encoding='utf-8')
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'template': {
|
||||
'id': template_id,
|
||||
'name': name,
|
||||
'features': features,
|
||||
'created_at': meta['created_at']
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
if template_dir.exists():
|
||||
shutil.rmtree(template_dir)
|
||||
raise e
|
||||
|
||||
def delete(self, template_id: str) -> Dict[str, Any]:
|
||||
"""템플릿 삭제"""
|
||||
template_dir = self.templates_dir / template_id
|
||||
if not template_dir.exists():
|
||||
return {'error': '템플릿을 찾을 수 없습니다'}
|
||||
shutil.rmtree(template_dir)
|
||||
return {'success': True, 'deleted': template_id}
|
||||
|
||||
def get_style(self, template_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""템플릿 스타일 반환"""
|
||||
style_path = self.templates_dir / template_id / 'style.json'
|
||||
if not style_path.exists():
|
||||
return None
|
||||
return json.loads(style_path.read_text(encoding='utf-8'))
|
||||
|
||||
# =========================================================================
|
||||
# HWPX 분석 (핵심)
|
||||
# =========================================================================
|
||||
|
||||
def _analyze_hwpx(self, file_path: Path, template_dir: Path) -> Dict[str, Any]:
|
||||
"""HWPX 분석 - 실제 구조 정확히 추출"""
|
||||
extract_dir = template_dir / 'extracted'
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(file_path, 'r') as zf:
|
||||
zf.extractall(extract_dir)
|
||||
|
||||
result = {
|
||||
'version': 'v3',
|
||||
'fonts': {},
|
||||
'colors': {
|
||||
'background': [],
|
||||
'border': [],
|
||||
'text': []
|
||||
},
|
||||
'border_fills': {},
|
||||
'tables': [],
|
||||
'special_borders': [],
|
||||
'style_summary': {},
|
||||
'css': ''
|
||||
}
|
||||
|
||||
# 1. header.xml 분석
|
||||
header_path = extract_dir / 'Contents' / 'header.xml'
|
||||
if header_path.exists():
|
||||
self._parse_header(header_path, result)
|
||||
|
||||
# 2. section0.xml 분석
|
||||
section_path = extract_dir / 'Contents' / 'section0.xml'
|
||||
if section_path.exists():
|
||||
self._parse_section(section_path, result)
|
||||
|
||||
# 3. 스타일 요약 생성
|
||||
result['style_summary'] = self._create_style_summary(result)
|
||||
|
||||
# 4. CSS 생성
|
||||
result['css'] = self._generate_css(result)
|
||||
|
||||
return result
|
||||
|
||||
finally:
|
||||
if extract_dir.exists():
|
||||
shutil.rmtree(extract_dir)
|
||||
|
||||
def _parse_header(self, header_path: Path, result: Dict):
|
||||
"""header.xml 파싱 - 폰트, borderFill"""
|
||||
tree = ET.parse(header_path)
|
||||
root = tree.getroot()
|
||||
|
||||
# 폰트
|
||||
for fontface in root.findall('.//hh:fontface', self.NS):
|
||||
if fontface.get('lang') == 'HANGUL':
|
||||
for font in fontface.findall('hh:font', self.NS):
|
||||
result['fonts'][font.get('id')] = font.get('face')
|
||||
|
||||
# borderFill
|
||||
for bf in root.findall('.//hh:borderFill', self.NS):
|
||||
bf_id = bf.get('id')
|
||||
bf_data = self._parse_border_fill(bf, result)
|
||||
result['border_fills'][bf_id] = bf_data
|
||||
|
||||
def _parse_border_fill(self, bf, result: Dict) -> Dict:
|
||||
"""개별 borderFill 파싱"""
|
||||
bf_id = bf.get('id')
|
||||
data = {
|
||||
'id': bf_id,
|
||||
'type': 'empty',
|
||||
'background': None,
|
||||
'image': None,
|
||||
'borders': {}
|
||||
}
|
||||
|
||||
# 이미지 배경
|
||||
img_brush = bf.find('.//hc:imgBrush', self.NS)
|
||||
if img_brush is not None:
|
||||
img = img_brush.find('hc:img', self.NS)
|
||||
if img is not None:
|
||||
data['type'] = 'image'
|
||||
data['image'] = {
|
||||
'ref': img.get('binaryItemIDRef'),
|
||||
'effect': img.get('effect')
|
||||
}
|
||||
|
||||
# 단색 배경
|
||||
win_brush = bf.find('.//hc:winBrush', self.NS)
|
||||
if win_brush is not None:
|
||||
face_color = self._normalize_color(win_brush.get('faceColor'))
|
||||
if face_color and face_color != 'none':
|
||||
if data['type'] == 'empty':
|
||||
data['type'] = 'solid'
|
||||
data['background'] = face_color
|
||||
if face_color not in result['colors']['background']:
|
||||
result['colors']['background'].append(face_color)
|
||||
|
||||
# 4방향 테두리
|
||||
for side in ['top', 'bottom', 'left', 'right']:
|
||||
border = bf.find(f'hh:{side}Border', self.NS)
|
||||
if border is not None:
|
||||
border_type = border.get('type', 'NONE')
|
||||
width = border.get('width', '0.1 mm')
|
||||
color = self._normalize_color(border.get('color', '#000000'))
|
||||
|
||||
data['borders'][side] = {
|
||||
'type': border_type,
|
||||
'type_name': LINE_TYPES.get(border_type, border_type),
|
||||
'width': width,
|
||||
'width_mm': self._parse_width(width),
|
||||
'color': color
|
||||
}
|
||||
|
||||
# 보이는 테두리만 색상 수집
|
||||
if border_type != 'NONE':
|
||||
if data['type'] == 'empty':
|
||||
data['type'] = 'border_only'
|
||||
if color and color not in result['colors']['border']:
|
||||
result['colors']['border'].append(color)
|
||||
|
||||
# 특수 테두리 수집
|
||||
if border_type not in ['SOLID', 'NONE']:
|
||||
result['special_borders'].append({
|
||||
'bf_id': bf_id,
|
||||
'side': side,
|
||||
'type': border_type,
|
||||
'type_name': LINE_TYPES.get(border_type, border_type),
|
||||
'width': width,
|
||||
'color': color
|
||||
})
|
||||
|
||||
return data
|
||||
|
||||
def _parse_section(self, section_path: Path, result: Dict):
|
||||
"""section0.xml 파싱 - 표 구조"""
|
||||
tree = ET.parse(section_path)
|
||||
root = tree.getroot()
|
||||
|
||||
border_fills = result['border_fills']
|
||||
|
||||
for tbl in root.findall('.//{http://www.hancom.co.kr/hwpml/2011/paragraph}tbl'):
|
||||
table_data = {
|
||||
'rows': int(tbl.get('rowCnt', 0)),
|
||||
'cols': int(tbl.get('colCnt', 0)),
|
||||
'cells': [],
|
||||
'structure': {
|
||||
'header_row_style': None,
|
||||
'first_col_style': None,
|
||||
'body_style': None,
|
||||
'has_image_cells': False
|
||||
}
|
||||
}
|
||||
|
||||
# 셀별 분석
|
||||
cell_by_position = {}
|
||||
for tc in tbl.findall('.//{http://www.hancom.co.kr/hwpml/2011/paragraph}tc'):
|
||||
cell_addr = tc.find('{http://www.hancom.co.kr/hwpml/2011/paragraph}cellAddr')
|
||||
if cell_addr is None:
|
||||
continue
|
||||
|
||||
row = int(cell_addr.get('rowAddr', 0))
|
||||
col = int(cell_addr.get('colAddr', 0))
|
||||
bf_id = tc.get('borderFillIDRef')
|
||||
bf_info = border_fills.get(bf_id, {})
|
||||
|
||||
# 텍스트 추출
|
||||
text = ''
|
||||
for t in tc.findall('.//{http://www.hancom.co.kr/hwpml/2011/paragraph}t'):
|
||||
if t.text:
|
||||
text += t.text
|
||||
|
||||
cell_data = {
|
||||
'row': row,
|
||||
'col': col,
|
||||
'bf_id': bf_id,
|
||||
'bf_type': bf_info.get('type'),
|
||||
'background': bf_info.get('background'),
|
||||
'image': bf_info.get('image'),
|
||||
'text_preview': text[:30] if text else ''
|
||||
}
|
||||
|
||||
table_data['cells'].append(cell_data)
|
||||
cell_by_position[(row, col)] = cell_data
|
||||
|
||||
if bf_info.get('type') == 'image':
|
||||
table_data['structure']['has_image_cells'] = True
|
||||
|
||||
# 구조 분석: 헤더행, 첫열 스타일
|
||||
self._analyze_table_structure(table_data, cell_by_position, border_fills)
|
||||
|
||||
result['tables'].append(table_data)
|
||||
|
||||
def _analyze_table_structure(self, table_data: Dict, cells: Dict, border_fills: Dict):
|
||||
"""표 구조 분석 - 헤더행/첫열 스타일 파악"""
|
||||
rows = table_data['rows']
|
||||
cols = table_data['cols']
|
||||
|
||||
if rows == 0 or cols == 0:
|
||||
return
|
||||
|
||||
# 첫 행 (헤더) 분석
|
||||
header_styles = []
|
||||
for c in range(cols):
|
||||
cell = cells.get((0, c))
|
||||
if cell:
|
||||
header_styles.append(cell.get('bf_id'))
|
||||
|
||||
if header_styles:
|
||||
# 가장 많이 쓰인 스타일
|
||||
most_common = Counter(header_styles).most_common(1)
|
||||
if most_common:
|
||||
bf_id = most_common[0][0]
|
||||
bf = border_fills.get(bf_id)
|
||||
if bf and bf.get('background'):
|
||||
table_data['structure']['header_row_style'] = {
|
||||
'bf_id': bf_id,
|
||||
'background': bf.get('background'),
|
||||
'borders': bf.get('borders', {})
|
||||
}
|
||||
|
||||
# 첫 열 분석 (행 1부터)
|
||||
first_col_styles = []
|
||||
for r in range(1, rows):
|
||||
cell = cells.get((r, 0))
|
||||
if cell:
|
||||
first_col_styles.append(cell.get('bf_id'))
|
||||
|
||||
if first_col_styles:
|
||||
most_common = Counter(first_col_styles).most_common(1)
|
||||
if most_common:
|
||||
bf_id = most_common[0][0]
|
||||
bf = border_fills.get(bf_id)
|
||||
if bf and bf.get('background'):
|
||||
table_data['structure']['first_col_style'] = {
|
||||
'bf_id': bf_id,
|
||||
'background': bf.get('background')
|
||||
}
|
||||
|
||||
# 본문 셀 스타일 (첫열 제외)
|
||||
body_styles = []
|
||||
for r in range(1, rows):
|
||||
for c in range(1, cols):
|
||||
cell = cells.get((r, c))
|
||||
if cell:
|
||||
body_styles.append(cell.get('bf_id'))
|
||||
|
||||
if body_styles:
|
||||
most_common = Counter(body_styles).most_common(1)
|
||||
if most_common:
|
||||
bf_id = most_common[0][0]
|
||||
bf = border_fills.get(bf_id)
|
||||
table_data['structure']['body_style'] = {
|
||||
'bf_id': bf_id,
|
||||
'background': bf.get('background') if bf else None
|
||||
}
|
||||
|
||||
def _create_style_summary(self, result: Dict) -> Dict:
|
||||
"""AI 프롬프트용 스타일 요약"""
|
||||
summary = {
|
||||
'폰트': list(result['fonts'].values())[:3],
|
||||
'색상': {
|
||||
'배경색': result['colors']['background'],
|
||||
'테두리색': result['colors']['border']
|
||||
},
|
||||
'표_스타일': [],
|
||||
'특수_테두리': []
|
||||
}
|
||||
|
||||
# 표별 스타일 요약
|
||||
for i, tbl in enumerate(result['tables']):
|
||||
tbl_summary = {
|
||||
'표번호': i + 1,
|
||||
'크기': f"{tbl['rows']}행 × {tbl['cols']}열",
|
||||
'이미지셀': tbl['structure']['has_image_cells']
|
||||
}
|
||||
|
||||
header = tbl['structure'].get('header_row_style')
|
||||
if header:
|
||||
tbl_summary['헤더행'] = f"배경={header.get('background')}"
|
||||
|
||||
first_col = tbl['structure'].get('first_col_style')
|
||||
if first_col:
|
||||
tbl_summary['첫열'] = f"배경={first_col.get('background')}"
|
||||
|
||||
body = tbl['structure'].get('body_style')
|
||||
if body:
|
||||
tbl_summary['본문'] = f"배경={body.get('background') or '없음'}"
|
||||
|
||||
summary['표_스타일'].append(tbl_summary)
|
||||
|
||||
# 특수 테두리 요약
|
||||
seen = set()
|
||||
for sb in result['special_borders']:
|
||||
key = f"{sb['type_name']} {sb['width']} {sb['color']}"
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
summary['특수_테두리'].append(key)
|
||||
|
||||
return summary
|
||||
|
||||
def _generate_css(self, result: Dict) -> str:
|
||||
"""CSS 생성 - 실제 구조 반영"""
|
||||
fonts = list(result['fonts'].values())[:2]
|
||||
font_family = f"'{fonts[0]}'" if fonts else "'맑은 고딕'"
|
||||
|
||||
bg_colors = result['colors']['background']
|
||||
header_bg = bg_colors[0] if bg_colors else '#D6D6D6'
|
||||
|
||||
# 특수 테두리에서 2중선 찾기
|
||||
double_border = None
|
||||
for sb in result['special_borders']:
|
||||
if 'DOUBLE' in sb['type']:
|
||||
double_border = sb
|
||||
break
|
||||
|
||||
css = f"""/* 템플릿 스타일 v3 - HWPX 구조 기반 */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap');
|
||||
|
||||
:root {{
|
||||
--font-primary: 'Noto Sans KR', {font_family}, sans-serif;
|
||||
--color-header-bg: {header_bg};
|
||||
--color-border: #000000;
|
||||
}}
|
||||
|
||||
body {{
|
||||
font-family: var(--font-primary);
|
||||
font-size: 10pt;
|
||||
line-height: 1.6;
|
||||
color: #000000;
|
||||
}}
|
||||
|
||||
.sheet {{
|
||||
width: 210mm;
|
||||
min-height: 297mm;
|
||||
padding: 20mm;
|
||||
margin: 10px auto;
|
||||
background: white;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||
}}
|
||||
|
||||
@media print {{
|
||||
.sheet {{ margin: 0; box-shadow: none; page-break-after: always; }}
|
||||
}}
|
||||
|
||||
/* 표 기본 */
|
||||
table {{
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1em 0;
|
||||
font-size: 9pt;
|
||||
}}
|
||||
|
||||
th, td {{
|
||||
border: 0.12mm solid var(--color-border);
|
||||
padding: 6px 8px;
|
||||
vertical-align: middle;
|
||||
}}
|
||||
|
||||
/* 헤더 행 */
|
||||
thead th, tr:first-child th, tr:first-child td {{
|
||||
background-color: var(--color-header-bg);
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}}
|
||||
|
||||
/* 첫 열 (구분 열) - 배경색 */
|
||||
td:first-child {{
|
||||
background-color: var(--color-header-bg);
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}}
|
||||
|
||||
/* 본문 셀 - 배경 없음 */
|
||||
td:not(:first-child) {{
|
||||
background-color: transparent;
|
||||
}}
|
||||
|
||||
/* 2중선 테두리 (헤더 하단) */
|
||||
thead tr:last-child th,
|
||||
thead tr:last-child td,
|
||||
tr:first-child th,
|
||||
tr:first-child td {{
|
||||
border-bottom: 0.5mm double var(--color-border);
|
||||
}}
|
||||
"""
|
||||
return css
|
||||
|
||||
# =========================================================================
|
||||
# 유틸리티
|
||||
# =========================================================================
|
||||
|
||||
def _normalize_color(self, color: str) -> str:
|
||||
"""ARGB 8자리 → RGB 6자리"""
|
||||
if not color or color == 'none':
|
||||
return color
|
||||
color = color.strip()
|
||||
# #AARRGGBB → #RRGGBB
|
||||
if color.startswith('#') and len(color) == 9:
|
||||
return '#' + color[3:]
|
||||
return color
|
||||
|
||||
def _parse_width(self, width_str: str) -> float:
|
||||
"""너비 문자열 → mm"""
|
||||
if not width_str:
|
||||
return 0.1
|
||||
try:
|
||||
return float(width_str.split()[0])
|
||||
except:
|
||||
return 0.1
|
||||
|
||||
def _extract_features(self, data: Dict) -> List[str]:
|
||||
"""특징 목록"""
|
||||
features = []
|
||||
|
||||
fonts = list(data.get('fonts', {}).values())
|
||||
if fonts:
|
||||
features.append(f"폰트: {', '.join(fonts[:2])}")
|
||||
|
||||
bg_colors = data.get('colors', {}).get('background', [])
|
||||
if bg_colors:
|
||||
features.append(f"배경색: {', '.join(bg_colors[:2])}")
|
||||
|
||||
tables = data.get('tables', [])
|
||||
if tables:
|
||||
has_img = any(t['structure']['has_image_cells'] for t in tables)
|
||||
if has_img:
|
||||
features.append("이미지 배경 셀")
|
||||
|
||||
special = data.get('special_borders', [])
|
||||
if special:
|
||||
types = set(s['type_name'] for s in special)
|
||||
features.append(f"특수 테두리: {', '.join(list(types)[:2])}")
|
||||
|
||||
return features if features else ['기본 템플릿']
|
||||
|
||||
def _analyze_fallback(self, ext: str) -> Dict:
|
||||
"""HWP, PDF 기본 분석"""
|
||||
return {
|
||||
'version': 'v3',
|
||||
'fonts': {'0': '맑은 고딕'},
|
||||
'colors': {'background': [], 'border': ['#000000'], 'text': ['#000000']},
|
||||
'border_fills': {},
|
||||
'tables': [],
|
||||
'special_borders': [],
|
||||
'style_summary': {
|
||||
'폰트': ['맑은 고딕'],
|
||||
'색상': {'배경색': [], '테두리색': ['#000000']},
|
||||
'표_스타일': [],
|
||||
'특수_테두리': []
|
||||
},
|
||||
'css': self._get_default_css(),
|
||||
'note': f'{ext} 파일은 기본 분석만 지원. HWPX 권장.'
|
||||
}
|
||||
|
||||
def _get_default_css(self) -> str:
|
||||
return """/* 기본 스타일 */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap');
|
||||
|
||||
body { font-family: 'Noto Sans KR', sans-serif; font-size: 10pt; }
|
||||
.sheet { width: 210mm; min-height: 297mm; padding: 20mm; margin: 10px auto; background: white; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { border: 0.5pt solid #000; padding: 8px; }
|
||||
th { background: #D6D6D6; }
|
||||
"""
|
||||
@@ -0,0 +1,28 @@
|
||||
당신은 문서 템플릿 분석 전문가입니다.
|
||||
|
||||
주어진 HWPX/HWP/PDF 템플릿의 구조를 분석하여 다음 정보를 추출해주세요:
|
||||
|
||||
1. 제목 스타일 (H1~H6)
|
||||
- 폰트명, 크기(pt), 굵기, 색상
|
||||
- 정렬 방식
|
||||
- 번호 체계 (제1장, 1.1, 가. 등)
|
||||
|
||||
2. 본문 스타일
|
||||
- 기본 폰트, 크기, 줄간격
|
||||
- 들여쓰기
|
||||
|
||||
3. 표 스타일
|
||||
- 헤더 배경색
|
||||
- 테두리 스타일 (선 두께, 색상)
|
||||
- 이중선 사용 여부
|
||||
|
||||
4. 그림/캡션 스타일
|
||||
- 캡션 위치 (상/하)
|
||||
- 캡션 형식
|
||||
|
||||
5. 페이지 구성
|
||||
- 표지 유무
|
||||
- 목차 유무
|
||||
- 머리말/꼬리말
|
||||
|
||||
분석 결과를 JSON 형식으로 출력해주세요.
|
||||
Reference in New Issue
Block a user