Files

539 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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)