# -*- 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 문서를 생성해주세요. 코드 블록(```) 없이 부터 까지 순수 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)