v1:글벗 초기 기획안_20260121

This commit is contained in:
2026-02-13 11:35:05 +09:00
commit 42bd3fbbc6
12 changed files with 2008 additions and 0 deletions

32
.gitignore vendored Normal file
View File

@@ -0,0 +1,32 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
ENV/
# IDE
.vscode/
.idea/
*.swp
*.swo
# Environment
.env
.env.local
# OS
.DS_Store
Thumbs.db
# Logs
*.log
# Temp files
*.tmp
*.temp
# API Keys - Gitea에 올리지 않기!
api_keys.json

1
Procfile Normal file
View File

@@ -0,0 +1 @@
web: gunicorn app:app

82
README.md Normal file
View File

@@ -0,0 +1,82 @@
# 글벗 Light v1.0
상시 업무용 HTML 보고서 자동 생성기
## 🎯 기능
- **문서 입력**: HTML 파일 업로드 또는 텍스트 직접 입력
- **페이지 옵션**: 1페이지 / 2페이지 / N페이지 선택
- **Claude API**: 각인된 양식으로 자동 변환
- **다운로드**: HTML, PDF 지원
- **HWP 변환**: 로컬 스크립트 제공
## 🚀 Railway 배포
### 1. GitHub에 푸시
```bash
git init
git add .
git commit -m "Initial commit"
git remote add origin https://github.com/YOUR_USERNAME/geulbeot-light.git
git push -u origin main
```
### 2. Railway 연동
1. [Railway](https://railway.app) 접속
2. "New Project" → "Deploy from GitHub repo"
3. 저장소 선택
4. 환경변수 설정:
- `ANTHROPIC_API_KEY`: Claude API 키
- `SECRET_KEY`: 임의의 비밀 키
### 3. 배포 완료
Railway가 자동으로 빌드 및 배포합니다.
## 🖥️ 로컬 실행
```bash
# 가상환경 생성
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# 패키지 설치
pip install -r requirements.txt
# 환경변수 설정
export ANTHROPIC_API_KEY="your-api-key"
# 실행
python app.py
```
http://localhost:5000 접속
## 📁 프로젝트 구조
```
geulbeot-light/
├── app.py # Flask 메인 앱
├── templates/
│ ├── index.html # 메인 페이지
│ └── hwp_guide.html # HWP 변환 가이드
├── prompts/
│ └── system_prompt.txt # Claude 시스템 프롬프트
├── requirements.txt
├── Procfile
├── railway.json
└── README.md
```
## 🎨 각인된 양식
- A4 인쇄 최적화 (210mm × 297mm)
- Noto Sans KR 폰트
- Navy 계열 색상 (#1a365d 기본)
- 구성요소: page-header, lead-box, section, data-table, bottom-box 등
## 📝 라이선스
Private - GPD 내부 사용

17
api_config.py Normal file
View File

@@ -0,0 +1,17 @@
"""API 키 관리 - api_keys.json에서 읽기"""
import json
from pathlib import Path
def load_api_keys():
"""프로젝트 폴더의 api_keys.json에서 API 키 로딩"""
search_path = Path(__file__).resolve().parent
for _ in range(5):
key_file = search_path / 'api_keys.json'
if key_file.exists():
with open(key_file, 'r', encoding='utf-8') as f:
return json.load(f)
search_path = search_path.parent
print("warning: api_keys.json not found")
return {}
API_KEYS = load_api_keys()

422
app.py Normal file
View File

@@ -0,0 +1,422 @@
# -*- coding: utf-8 -*-
"""
글벗 Light v2.0
2단계 API 변환 + 대화형 피드백 시스템
Flask + Claude API + Railway
"""
import os
import json
import anthropic
from flask import Flask, render_template, request, jsonify, Response, session
from datetime import datetime
import io
import re
from api_config import API_KEYS
app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'geulbeot-light-secret-key-v2')
# Claude API 클라이언트
client = anthropic.Anthropic(api_key=API_KEYS.get('CLAUDE_API_KEY', ''))
# ============== 프롬프트 로드 ==============
def load_prompt(filename):
"""프롬프트 파일 로드"""
prompt_path = os.path.join(os.path.dirname(__file__), 'prompts', filename)
try:
with open(prompt_path, 'r', encoding='utf-8') as f:
return f.read()
except FileNotFoundError:
return None
def get_step1_prompt():
"""1단계: 구조 추출 프롬프트"""
prompt = load_prompt('step1_extract.txt')
if prompt:
return prompt
# 기본 프롬프트 (파일 없을 경우)
return """HTML 문서를 분석하여 JSON 구조로 추출하세요.
원본 텍스트를 그대로 보존하고, 구조만 정확히 파악하세요."""
def get_step2_prompt():
"""2단계: HTML 생성 프롬프트"""
prompt = load_prompt('step2_generate.txt')
if prompt:
return prompt
# 기본 프롬프트 (파일 없을 경우)
return """JSON 구조를 각인된 양식의 HTML로 변환하세요.
Navy 색상 테마, A4 크기, Noto Sans KR 폰트를 사용하세요."""
def get_step1_5_prompt():
"""1.5단계: 배치 계획 프롬프트"""
prompt = load_prompt('step1_5_plan.txt')
if prompt:
return prompt
return """JSON 구조를 분석하여 페이지 배치 계획을 수립하세요."""
def get_refine_prompt():
"""피드백 반영 프롬프트"""
return """당신은 HTML 보고서 수정 전문가입니다.
사용자의 피드백을 반영하여 현재 HTML을 수정합니다.
## 규칙
1. 피드백에서 언급된 부분만 정확히 수정
2. 나머지 구조와 스타일은 그대로 유지
3. 완전한 HTML 문서로 출력 (<!DOCTYPE html> ~ </html>)
4. 코드 블록(```) 없이 순수 HTML만 출력
## 현재 HTML
{current_html}
## 사용자 피드백
{feedback}
위 피드백을 반영하여 수정된 완전한 HTML을 출력하세요."""
# ============== API 호출 함수 ==============
def call_claude(system_prompt, user_message, max_tokens=8000):
"""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):
"""텍스트에서 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):
"""텍스트에서 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 content_too_long(html, max_sections_per_page=4):
"""페이지당 콘텐츠 양 체크"""
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
# ============== 라우트 ==============
@app.route('/')
def index():
"""메인 페이지"""
return render_template('index.html')
@app.route('/generate', methods=['POST'])
def generate():
"""보고서 생성 API (2단계 처리)"""
try:
# 입력 받기
content = ""
if 'file' in request.files and request.files['file'].filename:
file = request.files['file']
content = file.read().decode('utf-8')
elif 'content' in request.form:
content = request.form.get('content', '')
if not content.strip():
return jsonify({'error': '내용을 입력하거나 파일을 업로드해주세요.'}), 400
# 옵션
page_option = request.form.get('page_option', '1')
department = request.form.get('department', '총괄기획실')
additional_prompt = request.form.get('additional_prompt', '')
# ============== 1단계: 구조 추출 ==============
step1_prompt = 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:
# JSON 추출 실패 시 원본 그대로 전달
structure_json = {"raw_content": content, "parse_failed": True}
# ============== 1.5단계: 배치 계획 ==============
step1_5_prompt = 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페이지는 본문, 나머지는 [첨부 1], [첨부 2] 형태로 분할합니다.'
}
step2_prompt = 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 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 jsonify({
'success': True,
'html': html_content,
'structure': structure_json
})
except anthropic.APIError as e:
return jsonify({'error': f'Claude API 오류: {str(e)}'}), 500
except Exception as e:
import traceback
return jsonify({'error': f'서버 오류: {str(e)}', 'trace': traceback.format_exc()}), 500
@app.route('/refine', methods=['POST'])
def refine():
"""피드백 반영 API (대화형)"""
try:
feedback = request.json.get('feedback', '')
current_html = request.json.get('current_html', '') or session.get('current_html', '')
if not feedback.strip():
return jsonify({'error': '피드백 내용을 입력해주세요.'}), 400
if not current_html:
return jsonify({'error': '수정할 HTML이 없습니다. 먼저 변환을 실행해주세요.'}), 400
# 원본 HTML도 컨텍스트에 포함
original_html = session.get('original_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
# 대화 히스토리 저장
conversation = session.get('conversation', [])
conversation.append({'role': 'user', 'content': feedback})
conversation.append({'role': 'assistant', 'content': '수정 완료'})
session['conversation'] = conversation
return jsonify({
'success': True,
'html': new_html
})
except anthropic.APIError as e:
return jsonify({'error': f'Claude API 오류: {str(e)}'}), 500
except Exception as e:
return jsonify({'error': f'서버 오류: {str(e)}'}), 500
@app.route('/download/html', methods=['POST'])
def download_html():
"""HTML 파일 다운로드"""
html_content = request.form.get('html', '')
if not html_content:
return "No content", 400
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f'report_{timestamp}.html'
return Response(
html_content,
mimetype='text/html',
headers={'Content-Disposition': f'attachment; filename={filename}'}
)
@app.route('/download/pdf', methods=['POST'])
def download_pdf():
"""PDF 파일 다운로드"""
try:
from weasyprint import HTML
html_content = request.form.get('html', '')
if not html_content:
return "No content", 400
pdf_buffer = io.BytesIO()
HTML(string=html_content).write_pdf(pdf_buffer)
pdf_buffer.seek(0)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f'report_{timestamp}.pdf'
return Response(
pdf_buffer.getvalue(),
mimetype='application/pdf',
headers={'Content-Disposition': f'attachment; filename={filename}'}
)
except ImportError:
return jsonify({'error': 'PDF 변환 미지원. HTML 다운로드 후 브라우저에서 인쇄하세요.'}), 501
except Exception as e:
return jsonify({'error': f'PDF 변환 오류: {str(e)}'}), 500
@app.route('/hwp-script')
def hwp_script():
"""HWP 변환 스크립트 안내"""
return render_template('hwp_guide.html')
@app.route('/health')
def health():
"""헬스 체크"""
return jsonify({'status': 'healthy', 'version': '2.0.0'})
if __name__ == '__main__':
port = int(os.environ.get('PORT', 5000))
debug = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true'
app.run(host='0.0.0.0', port=port, debug=debug)

104
prompts/step1_5_plan.txt Normal file
View 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만 출력 (설명 없이)

122
prompts/step1_extract.txt Normal file
View File

@@ -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 판단** - "안전", "위험", "주의" 등의 표현 보고 적절히 매핑

440
prompts/step2_generate.txt Normal file
View File

@@ -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로 해당 페이지 요약 포함

13
railway.json Normal file
View File

@@ -0,0 +1,13 @@
{
"$schema": "https://railway.app/railway.schema.json",
"build": {
"builder": "NIXPACKS"
},
"deploy": {
"startCommand": "gunicorn app:app",
"healthcheckPath": "/health",
"healthcheckTimeout": 100,
"restartPolicyType": "ON_FAILURE",
"restartPolicyMaxRetries": 10
}
}

5
requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
flask==3.0.0
anthropic==0.39.0
gunicorn==21.2.0
python-dotenv==1.0.0
weasyprint==60.1

343
templates/hwp_guide.html Normal file
View File

@@ -0,0 +1,343 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HWP 변환 가이드 - 글벗 Light</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
body { font-family: 'Noto Sans KR', sans-serif; }
.gradient-bg { background: linear-gradient(135deg, #1a365d 0%, #2c5282 100%); }
pre { background: #1e293b; color: #e2e8f0; padding: 1rem; border-radius: 0.5rem; overflow-x: auto; }
code { font-family: 'Consolas', 'Monaco', monospace; }
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<!-- 헤더 -->
<header class="gradient-bg text-white py-6 shadow-lg">
<div class="container mx-auto px-4">
<div class="flex items-center justify-between">
<div>
<a href="/" class="text-blue-200 hover:text-white text-sm">← 메인으로</a>
<h1 class="text-2xl font-bold mt-2">HWP 변환 가이드</h1>
</div>
</div>
</div>
</header>
<main class="container mx-auto px-4 py-8 max-w-4xl">
<!-- 안내 -->
<div class="bg-yellow-50 border border-yellow-200 rounded-xl p-6 mb-8">
<h2 class="font-bold text-yellow-800 mb-2">⚠️ HWP 변환 요구사항</h2>
<ul class="text-yellow-700 text-sm space-y-1">
<li>• Windows 운영체제</li>
<li>• 한글 프로그램 (한컴오피스) 설치</li>
<li>• Python 3.8 이상</li>
</ul>
</div>
<!-- 설치 방법 -->
<div class="bg-white rounded-xl shadow-md p-6 mb-6">
<h2 class="text-xl font-bold text-gray-800 mb-4">1. 필요 라이브러리 설치</h2>
<pre><code>pip install pyhwpx beautifulsoup4</code></pre>
</div>
<!-- 사용 방법 -->
<div class="bg-white rounded-xl shadow-md p-6 mb-6">
<h2 class="text-xl font-bold text-gray-800 mb-4">2. 사용 방법</h2>
<ol class="list-decimal list-inside space-y-2 text-gray-700">
<li>글벗 Light에서 HTML 파일을 다운로드합니다.</li>
<li>아래 Python 스크립트를 다운로드합니다.</li>
<li>스크립트 내 경로를 수정합니다.</li>
<li>스크립트를 실행합니다.</li>
</ol>
</div>
<!-- 스크립트 -->
<div class="bg-white rounded-xl shadow-md p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold text-gray-800">3. HWP 변환 스크립트</h2>
<button onclick="copyScript()" class="px-4 py-2 bg-blue-900 text-white rounded hover:bg-blue-800 text-sm">
📋 복사
</button>
</div>
<pre id="scriptCode"><code># -*- coding: utf-8 -*-
"""
글벗 Light - HTML → HWP 변환기
Windows + 한글 프로그램 필요
"""
from pyhwpx import Hwp
from bs4 import BeautifulSoup
import os
class HtmlToHwpConverter:
def __init__(self, visible=True):
self.hwp = Hwp(visible=visible)
self.colors = {}
def _init_colors(self):
self.colors = {
'primary-navy': self.hwp.RGBColor(26, 54, 93),
'secondary-navy': self.hwp.RGBColor(44, 82, 130),
'dark-gray': self.hwp.RGBColor(45, 55, 72),
'medium-gray': self.hwp.RGBColor(74, 85, 104),
'bg-light': self.hwp.RGBColor(247, 250, 252),
'white': self.hwp.RGBColor(255, 255, 255),
'black': self.hwp.RGBColor(0, 0, 0),
}
def _mm(self, mm):
return self.hwp.MiliToHwpUnit(mm)
def _font(self, size=10, color='black', bold=False):
self.hwp.set_font(
FaceName='맑은 고딕',
Height=size,
Bold=bold,
TextColor=self.colors.get(color, self.colors['black'])
)
def _align(self, align):
actions = {'left': 'ParagraphShapeAlignLeft', 'center': 'ParagraphShapeAlignCenter', 'right': 'ParagraphShapeAlignRight'}
if align in actions:
self.hwp.HAction.Run(actions[align])
def _para(self, text='', size=10, color='black', bold=False, align='left'):
self._align(align)
self._font(size, color, bold)
if text:
self.hwp.insert_text(text)
self.hwp.BreakPara()
def _exit_table(self):
self.hwp.HAction.Run("Cancel")
self.hwp.HAction.Run("CloseEx")
self.hwp.HAction.Run("MoveDocEnd")
self.hwp.BreakPara()
def _set_cell_bg(self, color_name):
self.hwp.HAction.GetDefault("CellBorderFill", self.hwp.HParameterSet.HCellBorderFill.HSet)
pset = self.hwp.HParameterSet.HCellBorderFill
pset.FillAttr.type = self.hwp.BrushType("NullBrush|WinBrush")
pset.FillAttr.WinBrushFaceStyle = self.hwp.HatchStyle("None")
pset.FillAttr.WinBrushHatchColor = self.hwp.RGBColor(0, 0, 0)
pset.FillAttr.WinBrushFaceColor = self.colors.get(color_name, self.colors['white'])
pset.FillAttr.WindowsBrush = 1
self.hwp.HAction.Execute("CellBorderFill", pset.HSet)
def _create_header(self, left_text, right_text):
try:
self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterStyle", 0)
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterCtrlType", 0)
self.hwp.HAction.Execute("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
self._font(9, 'medium-gray')
self.hwp.insert_text(left_text)
self.hwp.insert_text("\t" * 12)
self.hwp.insert_text(right_text)
self.hwp.HAction.Run("CloseEx")
except Exception as e:
print(f"머리말 생성 실패: {e}")
def _create_footer(self, text):
try:
self.hwp.HAction.GetDefault("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterStyle", 0)
self.hwp.HParameterSet.HHeaderFooter.HSet.SetItem("HeaderFooterCtrlType", 1)
self.hwp.HAction.Execute("HeaderFooter", self.hwp.HParameterSet.HHeaderFooter.HSet)
self._align('center')
self._font(8.5, 'medium-gray')
self.hwp.insert_text(text)
self.hwp.HAction.Run("CloseEx")
except Exception as e:
print(f"꼬리말 생성 실패: {e}")
def _convert_lead_box(self, elem):
content = elem.find("div")
if not content:
return
text = ' '.join(content.get_text().split())
self.hwp.create_table(1, 1, treat_as_char=True)
self._set_cell_bg('bg-light')
self._font(11.5, 'dark-gray', False)
self.hwp.insert_text(text)
self._exit_table()
def _convert_bottom_box(self, elem):
left = elem.find(class_="bottom-left")
right = elem.find(class_="bottom-right")
if not left or not right:
return
left_text = ' '.join(left.get_text().split())
right_text = right.get_text(strip=True)
self.hwp.create_table(1, 2, treat_as_char=True)
self._set_cell_bg('primary-navy')
self._font(10.5, 'white', True)
self._align('center')
self.hwp.insert_text(left_text)
self.hwp.HAction.Run("MoveRight")
self._set_cell_bg('bg-light')
self._font(10.5, 'primary-navy', True)
self._align('center')
self.hwp.insert_text(right_text)
self._exit_table()
def _convert_section(self, section):
title = section.find(class_="section-title")
if title:
self._para("■ " + title.get_text(strip=True), 12, 'primary-navy', True)
ul = section.find("ul")
if ul:
for li in ul.find_all("li", recursive=False):
keyword = li.find(class_="keyword")
if keyword:
kw_text = keyword.get_text(strip=True)
full = li.get_text(strip=True)
rest = full.replace(kw_text, '', 1).strip()
self._font(10.5, 'primary-navy', True)
self.hwp.insert_text(" • " + kw_text + " ")
self._font(10.5, 'dark-gray', False)
self.hwp.insert_text(rest)
self.hwp.BreakPara()
else:
self._para(" • " + li.get_text(strip=True), 10.5, 'dark-gray')
self._para()
def _convert_sheet(self, sheet, is_first_page=False):
if is_first_page:
header = sheet.find(class_="page-header")
if header:
left = header.find(class_="header-left")
right = header.find(class_="header-right")
left_text = left.get_text(strip=True) if left else ""
right_text = right.get_text(strip=True) if right else ""
if left_text or right_text:
self._create_header(left_text, right_text)
footer = sheet.find(class_="page-footer")
if footer:
self._create_footer(footer.get_text(strip=True))
title = sheet.find(class_="header-title")
if title:
title_text = title.get_text(strip=True)
if '[첨부]' in title_text:
self._para(title_text, 15, 'primary-navy', True, 'left')
else:
self._para(title_text, 23, 'primary-navy', True, 'center')
self._font(10, 'secondary-navy')
self._align('center')
self.hwp.insert_text("━" * 45)
self.hwp.BreakPara()
self._para()
lead_box = sheet.find(class_="lead-box")
if lead_box:
self._convert_lead_box(lead_box)
self._para()
for section in sheet.find_all(class_="section"):
self._convert_section(section)
bottom_box = sheet.find(class_="bottom-box")
if bottom_box:
self._para()
self._convert_bottom_box(bottom_box)
def convert(self, html_path, output_path):
print(f"[입력] {html_path}")
with open(html_path, 'r', encoding='utf-8') as f:
soup = BeautifulSoup(f.read(), 'html.parser')
self.hwp.FileNew()
self._init_colors()
# 페이지 설정
try:
self.hwp.HAction.GetDefault("PageSetup", self.hwp.HParameterSet.HSecDef.HSet)
sec = self.hwp.HParameterSet.HSecDef
sec.PageDef.LeftMargin = self._mm(20)
sec.PageDef.RightMargin = self._mm(20)
sec.PageDef.TopMargin = self._mm(20)
sec.PageDef.BottomMargin = self._mm(20)
sec.PageDef.HeaderLen = self._mm(10)
sec.PageDef.FooterLen = self._mm(10)
self.hwp.HAction.Execute("PageSetup", sec.HSet)
except Exception as e:
print(f"페이지 설정 실패: {e}")
sheets = soup.find_all(class_="sheet")
total = len(sheets)
print(f"[변환] 총 {total} 페이지")
for i, sheet in enumerate(sheets, 1):
print(f"[{i}/{total}] 페이지 처리 중...")
self._convert_sheet(sheet, is_first_page=(i == 1))
if i < total:
self.hwp.HAction.Run("BreakPage")
self.hwp.SaveAs(output_path)
print(f" 저장 완료: {output_path}")
def close(self):
try:
self.hwp.Quit()
except:
pass
def main():
# ====================================
# 경로 설정 (본인 환경에 맞게 수정)
# ====================================
html_path = r"C:\Users\User\Downloads\report.html"
output_path = r"C:\Users\User\Downloads\report.hwp"
print("=" * 50)
print("글벗 Light - HTML HWP 변환기")
print("=" * 50)
try:
converter = HtmlToHwpConverter(visible=True)
converter.convert(html_path, output_path)
print("\n 변환 완료!")
input("Enter를 누르면 HWP가 닫힙니다...")
converter.close()
except FileNotFoundError:
print(f"\n[에러] 파일을 찾을 없습니다: {html_path}")
except Exception as e:
print(f"\n[에러] {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
main()</code></pre>
</div>
<!-- 경로 수정 안내 -->
<div class="bg-white rounded-xl shadow-md p-6">
<h2 class="text-xl font-bold text-gray-800 mb-4">4. 경로 수정</h2>
<p class="text-gray-700 mb-4">스크립트 하단의 <code class="bg-gray-100 px-2 py-1 rounded">main()</code> 함수에서 경로를 수정하세요:</p>
<pre><code>html_path = r"C:\다운로드경로\report.html"
output_path = r"C:\저장경로\report.hwp"</code></pre>
</div>
</main>
<script>
function copyScript() {
const code = document.getElementById('scriptCode').innerText;
navigator.clipboard.writeText(code).then(() => {
alert('스크립트가 클립보드에 복사되었습니다!');
});
}
</script>
</body>
</html>

427
templates/index.html Normal file
View File

@@ -0,0 +1,427 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>글벗 Light - 상시 업무용 보고서 생성기</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
body { font-family: 'Noto Sans KR', sans-serif; }
.gradient-bg { background: linear-gradient(135deg, #1a365d 0%, #2c5282 100%); }
.card-shadow { box-shadow: 0 4px 20px rgba(0,0,0,0.1); }
.btn-primary {
background: linear-gradient(135deg, #1a365d 0%, #2c5282 100%);
transition: all 0.3s ease;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(26, 54, 93, 0.4);
}
.preview-frame {
background: #f0f0f0;
border: 1px solid #e2e8f0;
}
.loading-spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #1a365d;
border-radius: 50%;
width: 24px;
height: 24px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.tab-active {
border-bottom: 3px solid #1a365d;
color: #1a365d;
font-weight: 600;
}
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<!-- 헤더 -->
<header class="gradient-bg text-white py-6 shadow-lg">
<div class="container mx-auto px-4">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold">글벗 Light</h1>
<p class="text-blue-200 text-sm mt-1">상시 업무용 보고서 자동 생성기 v2.0</p>
</div>
<div class="text-right text-sm text-blue-200">
<p>각인된 양식 기반</p>
<p>A4 인쇄 최적화</p>
</div>
</div>
</div>
</header>
<main class="container mx-auto px-4 py-8">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- 입력 패널 -->
<div class="bg-white rounded-xl card-shadow p-6">
<h2 class="text-xl font-bold text-gray-800 mb-6 flex items-center">
<span class="w-8 h-8 bg-blue-900 text-white rounded flex items-center justify-center mr-3 text-sm">1</span>
문서 입력
</h2>
<!-- 입력 방식 탭 -->
<div class="flex border-b mb-4">
<button id="tab-file" class="px-4 py-2 tab-active" onclick="switchTab('file')">파일 업로드</button>
<button id="tab-text" class="px-4 py-2 text-gray-500" onclick="switchTab('text')">직접 입력</button>
</div>
<form id="generateForm">
<!-- 파일 업로드 -->
<div id="input-file" class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">HTML 파일 업로드</label>
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-blue-500 transition-colors">
<input type="file" id="fileInput" name="file" accept=".html,.htm,.txt" class="hidden" onchange="handleFileSelect(this)">
<label for="fileInput" class="cursor-pointer">
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<p class="mt-2 text-sm text-gray-600">클릭하여 파일 선택</p>
<p class="text-xs text-gray-400">HTML, TXT 지원</p>
</label>
<p id="fileName" class="mt-2 text-sm text-blue-600 font-medium hidden"></p>
</div>
</div>
<!-- 직접 입력 -->
<div id="input-text" class="mb-4 hidden">
<label class="block text-sm font-medium text-gray-700 mb-2">내용 직접 입력</label>
<textarea name="content" id="contentInput" rows="10"
class="w-full border border-gray-300 rounded-lg p-3 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="변환할 문서 내용을 입력하세요..."></textarea>
</div>
<!-- 옵션 설정 -->
<div class="border-t pt-4 mt-4">
<h3 class="font-medium text-gray-800 mb-3">옵션 설정</h3>
<div class="grid grid-cols-2 gap-4 mb-4">
<!-- 페이지 옵션 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">페이지 구성</label>
<select name="page_option" class="w-full border border-gray-300 rounded-lg p-2 focus:ring-2 focus:ring-blue-500">
<option value="1">1페이지 (핵심 요약)</option>
<option value="2">2페이지 (본문 + 첨부)</option>
<option value="n">N페이지 (본문 + 다중 첨부)</option>
</select>
</div>
<!-- 부서명 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">부서명</label>
<input type="text" name="department" value="총괄기획실"
class="w-full border border-gray-300 rounded-lg p-2 focus:ring-2 focus:ring-blue-500">
</div>
</div>
<!-- 추가 요청사항 -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">추가 요청사항 (선택)</label>
<textarea name="additional_prompt" rows="2"
class="w-full border border-gray-300 rounded-lg p-3 focus:ring-2 focus:ring-blue-500"
placeholder="예: 표를 더 상세하게 만들어주세요, 핵심 결론을 강조해주세요..."></textarea>
</div>
</div>
<!-- 생성 버튼 -->
<button type="submit" id="generateBtn" class="w-full btn-primary text-white py-3 rounded-lg font-medium flex items-center justify-center">
<span id="btnText">보고서 생성</span>
<div id="btnSpinner" class="loading-spinner ml-2 hidden"></div>
</button>
</form>
</div>
<!-- 미리보기 패널 -->
<div class="bg-white rounded-xl card-shadow p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold text-gray-800 flex items-center">
<span class="w-8 h-8 bg-blue-900 text-white rounded flex items-center justify-center mr-3 text-sm">2</span>
미리보기 & 다운로드
</h2>
<!-- 다운로드 버튼 그룹 -->
<div id="downloadBtns" class="flex gap-2 hidden">
<button onclick="downloadHTML()" class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 rounded text-sm font-medium transition-colors">
📄 HTML
</button>
<button onclick="downloadPDF()" class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 rounded text-sm font-medium transition-colors">
📑 PDF
</button>
<button onclick="printPreview()" class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 rounded text-sm font-medium transition-colors">
🖨️ 인쇄
</button>
</div>
</div>
<!-- 미리보기 영역 -->
<div id="previewArea" class="preview-frame rounded-lg overflow-hidden" style="height: 600px;">
<div id="emptyState" class="h-full flex items-center justify-center text-gray-400">
<div class="text-center">
<svg class="mx-auto h-16 w-16 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<p>문서를 입력하고 생성 버튼을 누르세요</p>
</div>
</div>
<iframe id="previewFrame" class="w-full h-full hidden"></iframe>
</div>
<!-- 에러 메시지 -->
<div id="errorMessage" class="mt-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 hidden"></div>
<!-- 피드백 채팅 UI -->
<div id="refineSection" class="mt-6 hidden">
<h3 class="font-bold text-gray-800 mb-3 flex items-center">
<span class="w-6 h-6 bg-green-600 text-white rounded flex items-center justify-center mr-2 text-xs"></span>
수정 요청
</h3>
<div class="flex gap-2">
<input type="text" id="feedbackInput"
class="flex-1 border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="수정할 내용을 입력하세요 (예: 테이블 4열로 맞춰줘, 결론 한 줄로 줄여줘)">
<button onclick="submitFeedback()" id="refineBtn"
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center gap-2">
<span id="refineBtnText">수정</span>
<div id="refineBtnSpinner" class="loading-spinner hidden"></div>
</button>
</div>
<div id="refineHistory" class="mt-3 space-y-2 max-h-32 overflow-y-auto text-sm"></div>
</div>
</div>
</div>
<!-- HWP 변환 안내 -->
<div class="mt-8 bg-blue-50 border border-blue-200 rounded-xl p-6">
<h3 class="font-bold text-blue-900 mb-2">💡 HWP 파일이 필요하신가요?</h3>
<p class="text-blue-800 text-sm mb-3">
HWP 변환은 Windows + 한글 프로그램이 필요합니다. HTML 다운로드 후 제공되는 Python 스크립트로 로컬에서 변환할 수 있습니다.
</p>
<a href="/hwp-script" class="inline-block px-4 py-2 bg-blue-900 text-white rounded-lg text-sm hover:bg-blue-800 transition-colors">
HWP 변환 스크립트 받기 →
</a>
</div>
</main>
<!-- 푸터 -->
<footer class="bg-gray-100 py-6 mt-12">
<div class="container mx-auto px-4 text-center text-gray-500 text-sm">
<p>글벗 Light v2.0 | 2단계 변환 + 대화형 피드백 시스템</p>
<p class="mt-1">Powered by Claude API</p>
</div>
</footer>
<!-- 생성된 HTML 저장용 -->
<script>
let generatedHTML = '';
function switchTab(tab) {
const fileTab = document.getElementById('tab-file');
const textTab = document.getElementById('tab-text');
const fileInput = document.getElementById('input-file');
const textInput = document.getElementById('input-text');
if (tab === 'file') {
fileTab.classList.add('tab-active');
textTab.classList.remove('tab-active');
textTab.classList.add('text-gray-500');
fileInput.classList.remove('hidden');
textInput.classList.add('hidden');
} else {
textTab.classList.add('tab-active');
fileTab.classList.remove('tab-active');
fileTab.classList.add('text-gray-500');
textInput.classList.remove('hidden');
fileInput.classList.add('hidden');
}
}
function handleFileSelect(input) {
const fileName = document.getElementById('fileName');
if (input.files && input.files[0]) {
fileName.textContent = '📎 ' + input.files[0].name;
fileName.classList.remove('hidden');
}
}
document.getElementById('generateForm').addEventListener('submit', async function(e) {
e.preventDefault();
const btn = document.getElementById('generateBtn');
const btnText = document.getElementById('btnText');
const btnSpinner = document.getElementById('btnSpinner');
const errorMessage = document.getElementById('errorMessage');
// 로딩 상태
btn.disabled = true;
btnText.textContent = '생성 중...';
btnSpinner.classList.remove('hidden');
errorMessage.classList.add('hidden');
try {
const formData = new FormData(this);
// 파일 또는 텍스트 내용 확인
const file = document.getElementById('fileInput').files[0];
const content = document.getElementById('contentInput').value;
if (!file && !content.trim()) {
throw new Error('파일을 업로드하거나 내용을 입력해주세요.');
}
const response = await fetch('/generate', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
if (data.success && data.html) {
generatedHTML = data.html;
// 미리보기 표시
const emptyState = document.getElementById('emptyState');
const previewFrame = document.getElementById('previewFrame');
const downloadBtns = document.getElementById('downloadBtns');
const refineSection = document.getElementById('refineSection');
emptyState.classList.add('hidden');
previewFrame.classList.remove('hidden');
downloadBtns.classList.remove('hidden');
refineSection.classList.remove('hidden');
// iframe에 HTML 로드
previewFrame.srcdoc = generatedHTML;
}
} catch (error) {
errorMessage.textContent = error.message;
errorMessage.classList.remove('hidden');
} finally {
btn.disabled = false;
btnText.textContent = '보고서 생성';
btnSpinner.classList.add('hidden');
}
});
function downloadHTML() {
if (!generatedHTML) return;
const form = document.createElement('form');
form.method = 'POST';
form.action = '/download/html';
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'html';
input.value = generatedHTML;
form.appendChild(input);
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
}
function downloadPDF() {
const printWindow = window.open('', '_blank');
printWindow.document.write(generatedHTML);
printWindow.document.close();
printWindow.onload = function() {
printWindow.print();
};
}
function printPreview() {
const previewFrame = document.getElementById('previewFrame');
if (previewFrame.contentWindow) {
previewFrame.contentWindow.print();
}
}
async function submitFeedback() {
const feedbackInput = document.getElementById('feedbackInput');
const feedback = feedbackInput.value.trim();
if (!feedback) {
alert('수정 내용을 입력해주세요.');
return;
}
if (!generatedHTML) {
alert('먼저 보고서를 생성해주세요.');
return;
}
const btn = document.getElementById('refineBtn');
const btnText = document.getElementById('refineBtnText');
const btnSpinner = document.getElementById('refineBtnSpinner');
const history = document.getElementById('refineHistory');
// 로딩 상태
btn.disabled = true;
btnText.textContent = '수정 중...';
btnSpinner.classList.remove('hidden');
try {
const response = await fetch('/refine', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
feedback: feedback,
current_html: generatedHTML
})
});
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
if (data.success && data.html) {
generatedHTML = data.html;
// 미리보기 업데이트
const previewFrame = document.getElementById('previewFrame');
previewFrame.srcdoc = generatedHTML;
// 히스토리에 추가
const historyItem = document.createElement('div');
historyItem.className = 'p-2 bg-green-50 border border-green-200 rounded text-green-800';
historyItem.textContent = '✓ ' + feedback;
history.appendChild(historyItem);
// 입력창 초기화
feedbackInput.value = '';
}
} catch (error) {
alert('수정 오류: ' + error.message);
} finally {
btn.disabled = false;
btnText.textContent = '수정';
btnSpinner.classList.add('hidden');
}
}
// Enter 키로 피드백 제출
document.getElementById('feedbackInput')?.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
submitFeedback();
}
});
</script>
</body>
</html>