423 lines
14 KiB
Python
423 lines
14 KiB
Python
# -*- 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)
|