539 lines
13 KiB
Python
539 lines
13 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
글벗 Light v1.0
|
||
상시 업무용 HTML 보고서 자동 생성기
|
||
|
||
Flask + Claude API + Railway
|
||
"""
|
||
|
||
import os
|
||
import anthropic
|
||
from flask import Flask, render_template, request, jsonify, send_file, Response
|
||
from werkzeug.utils import secure_filename
|
||
from datetime import datetime
|
||
import tempfile
|
||
import io
|
||
|
||
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')
|
||
|
||
# Claude API 클라이언트
|
||
client = anthropic.Anthropic(api_key='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')
|
||
|
||
# 시스템 프롬프트 로드
|
||
def load_system_prompt():
|
||
prompt_path = os.path.join(os.path.dirname(__file__), 'prompts', 'system_prompt.txt')
|
||
try:
|
||
with open(prompt_path, 'r', encoding='utf-8') as f:
|
||
return f.read()
|
||
except FileNotFoundError:
|
||
return get_default_system_prompt()
|
||
|
||
def get_default_system_prompt():
|
||
"""기본 시스템 프롬프트 (파일이 없을 경우)"""
|
||
return """당신은 전문 비즈니스 보고서 작성 전문가입니다.
|
||
사용자가 제공하는 원본 문서의 내용을 분석하여, 각인된 HTML 양식에 맞게 A4 인쇄용 보고서를 생성합니다.
|
||
|
||
기본 규칙:
|
||
1. 반드시 완전한 HTML 문서로 출력
|
||
2. Noto Sans KR 폰트, Navy 계열 색상 사용
|
||
3. A4 크기 (210mm × 297mm), 여백 20mm
|
||
4. 코드 블록 없이 순수 HTML만 출력
|
||
"""
|
||
|
||
# 각인된 양식 CSS (전체)
|
||
IMPRINTED_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;
|
||
}
|
||
|
||
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;
|
||
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;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
color: var(--dark-gray);
|
||
}
|
||
|
||
.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); }
|
||
.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: center;
|
||
}
|
||
|
||
.data-table td:first-child {
|
||
background-color: var(--bg-light);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.badge {
|
||
display: inline-block;
|
||
padding: 2px 8px;
|
||
border-radius: 3px;
|
||
font-weight: 600;
|
||
font-size: 8.5pt;
|
||
letter-spacing: -0.3px;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
/* 2x2 그리드 */
|
||
.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 박스 */
|
||
.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);
|
||
}
|
||
|
||
/* 프로세스 스타일 */
|
||
.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;
|
||
}
|
||
'''
|
||
|
||
|
||
@app.route('/')
|
||
def index():
|
||
"""메인 페이지"""
|
||
return render_template('index.html')
|
||
|
||
|
||
@app.route('/generate', methods=['POST'])
|
||
def generate():
|
||
"""보고서 생성 API"""
|
||
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')
|
||
additional_prompt = request.form.get('additional_prompt', '')
|
||
department = request.form.get('department', '총괄기획실')
|
||
|
||
# 페이지 옵션에 따른 지시사항
|
||
page_instructions = {
|
||
'1': '1페이지로 핵심 내용만 압축하여 작성하세요.',
|
||
'2': '2페이지로 작성하세요. 1페이지는 본문(개요, 핵심 내용), 2페이지는 [첨부]로 시작하는 상세 내용입니다.',
|
||
'n': f'여러 페이지로 작성하세요. 1페이지는 본문, 나머지는 [첨부 1], [첨부 2] 형태로 상세 내용을 분할합니다.'
|
||
}
|
||
|
||
# Claude API 호출
|
||
system_prompt = load_system_prompt()
|
||
|
||
user_message = f"""다음 원본 문서를 분석하여 각인된 양식의 HTML 보고서로 변환해주세요.
|
||
|
||
## 페이지 옵션
|
||
{page_instructions.get(page_option, page_instructions['1'])}
|
||
|
||
## 부서명
|
||
{department}
|
||
|
||
## 추가 요청사항
|
||
{additional_prompt if additional_prompt else '없음'}
|
||
|
||
## 원본 문서 내용
|
||
{content}
|
||
|
||
---
|
||
위 내용을 바탕으로 완전한 HTML 문서를 생성해주세요.
|
||
코드 블록(```) 없이 <!DOCTYPE html>부터 </html>까지 순수 HTML만 출력하세요."""
|
||
|
||
response = client.messages.create(
|
||
model="claude-sonnet-4-20250514",
|
||
max_tokens=8000,
|
||
messages=[
|
||
{"role": "user", "content": user_message}
|
||
],
|
||
system=system_prompt
|
||
)
|
||
|
||
# 응답에서 HTML 추출
|
||
html_content = response.content[0].text
|
||
|
||
# 코드 블록 제거 (혹시 포함된 경우)
|
||
if '```html' in html_content:
|
||
html_content = html_content.split('```html')[1].split('```')[0]
|
||
elif '```' in html_content:
|
||
html_content = html_content.split('```')[1].split('```')[0]
|
||
|
||
html_content = html_content.strip()
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'html': html_content
|
||
})
|
||
|
||
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 파일 다운로드 (WeasyPrint 사용)"""
|
||
try:
|
||
from weasyprint import HTML, CSS
|
||
|
||
html_content = request.form.get('html', '')
|
||
if not html_content:
|
||
return "No content", 400
|
||
|
||
# PDF 생성
|
||
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로 다운로드 후 브라우저에서 PDF로 인쇄해주세요.'}), 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': '1.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)
|