From 42bd3fbbc6c622194e66daffe635c60ce8c45bc6 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 13 Feb 2026 11:35:05 +0900 Subject: [PATCH] =?UTF-8?q?v1:=EA=B8=80=EB=B2=97=20=EC=B4=88=EA=B8=B0=20?= =?UTF-8?q?=EA=B8=B0=ED=9A=8D=EC=95=88=5F20260121?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 32 +++ Procfile | 1 + README.md | 82 +++++++ api_config.py | 17 ++ app.py | 422 +++++++++++++++++++++++++++++++++++ prompts/step1_5_plan.txt | 104 +++++++++ prompts/step1_extract.txt | 122 ++++++++++ prompts/step2_generate.txt | 440 +++++++++++++++++++++++++++++++++++++ railway.json | 13 ++ requirements.txt | 5 + templates/hwp_guide.html | 343 +++++++++++++++++++++++++++++ templates/index.html | 427 +++++++++++++++++++++++++++++++++++ 12 files changed, 2008 insertions(+) create mode 100644 .gitignore create mode 100644 Procfile create mode 100644 README.md create mode 100644 api_config.py create mode 100644 app.py create mode 100644 prompts/step1_5_plan.txt create mode 100644 prompts/step1_extract.txt create mode 100644 prompts/step2_generate.txt create mode 100644 railway.json create mode 100644 requirements.txt create mode 100644 templates/hwp_guide.html create mode 100644 templates/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..253e053 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..ca6e941 --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: gunicorn app:app diff --git a/README.md b/README.md new file mode 100644 index 0000000..c853413 --- /dev/null +++ b/README.md @@ -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 내부 사용 diff --git a/api_config.py b/api_config.py new file mode 100644 index 0000000..8efbe7e --- /dev/null +++ b/api_config.py @@ -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() diff --git a/app.py b/app.py new file mode 100644 index 0000000..3081851 --- /dev/null +++ b/app.py @@ -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 문서로 출력 ( ~ ) +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() + + # )', 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 문서를 생성하세요. +코드 블록(```) 없이 부터 까지 순수 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 문서로 출력 ( ~ ) +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) diff --git a/prompts/step1_5_plan.txt b/prompts/step1_5_plan.txt new file mode 100644 index 0000000..a131afa --- /dev/null +++ b/prompts/step1_5_plan.txt @@ -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만 출력 (설명 없이) \ No newline at end of file diff --git a/prompts/step1_extract.txt b/prompts/step1_extract.txt new file mode 100644 index 0000000..48674da --- /dev/null +++ b/prompts/step1_extract.txt @@ -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 판단** - "안전", "위험", "주의" 등의 표현 보고 적절히 매핑 diff --git a/prompts/step2_generate.txt b/prompts/step2_generate.txt new file mode 100644 index 0000000..1d779da --- /dev/null +++ b/prompts/step2_generate.txt @@ -0,0 +1,440 @@ +당신은 HTML 보고서 생성 전문가입니다. +사용자가 제공하는 **JSON 구조 데이터**를 받아서 **각인된 양식의 HTML 보고서**를 생성합니다. + +## 출력 규칙 + +1. 완전한 HTML 문서 출력 ( ~ ) +2. 코드 블록(```) 없이 **순수 HTML만** 출력 +3. JSON의 텍스트를 **그대로** 사용 (수정 금지) +4. 아래 CSS를 **정확히** 사용 + +## 페이지 옵션 + +- **1페이지**: 모든 내용을 1페이지에 (텍스트/줄간 조정) +- **2페이지**: 1페이지 본문 + 2페이지 [첨부] +- **N페이지**: 1페이지 본문 + 나머지 [첨부 1], [첨부 2]... + +## HTML 템플릿 구조 + +```html + + + + + {{title}} + + + +
+ +
+

{{title}}

+
+
+
+
+
{{lead.text}} - 키워드 강조
+
+ +
+
{{conclusion.label}}
+
{{conclusion.text}}
+
+
+
- 1 -
+
+ + +``` + +## 섹션 type별 HTML 변환 + +### list → ul/li +```html +
+
{{section.title}}
+ +
+``` + +### table → data-table +```html +
+
{{section.title}}
+ + + + + + + + + + + + + +
{{col1}}{{col2}}
{{text}}{{text}}
+
+``` +- badge가 있으면: `{{text}}` +- highlight가 true면: `class="highlight-red"` + +### grid → strategy-grid +```html +
+
{{section.title}}
+
+
+
{{item.title}}
+

{{item.text}} {{highlight}}

+
+
+
+``` + +### two-column → two-col +```html +
+
{{section.title}}
+
+
+
{{item.title}}
+

{{item.text}} {{highlight}}

+
+
+
+``` + +### process → process-container +```html +
+
{{section.title}}
+
+
+
{{step.number}}
+
{{step.title}}: {{step.text}}
+
+
+ +
+
+``` + +### qa → qa-grid +```html +
+
{{section.title}}
+
+
+ Q. {{question}}
+ A. {{answer}} +
+
+
+``` + +## 완전한 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. **제목**: `

[첨부] 해당 내용에 맞는 제목

` +2. **본문**: 1페이지를 뒷받침하는 상세 자료 (표, 프로세스, 체크리스트 등) +3. **bottom-box**: 해당 첨부 페이지 내용의 핵심 요약 + +## 중요 규칙 + +1. **원문 기반 재구성** - 추가/추론 금지, 단 아래는 허용: + - 위치 재편성, 통합/분할 + - 표 ↔ 본문 ↔ 리스트 형식 변환 + +2. **개조식 필수 (전체 적용)** - 모든 텍스트는 명사형/체언 종결: + - lead-box, bottom-box, 표 내부, 리스트, 모든 문장 + - ❌ "~입니다", "~합니다", "~됩니다" + - ✅ "~임", "~함", "~필요", "~대상", "~가능" + - 예시: + - ❌ "부당행위계산 부인 및 증여세 부과 대상이 됩니다" + - ✅ "부당행위계산 부인 및 증여세 부과 대상" + +3. **페이지 경계 준수** - 모든 콘텐츠는 page-footer 위에 위치 + +4. **bottom-box** - 1~2줄, 핵심 키워드만 로 강조 + +5. **섹션 번호 독립** - 본문과 첨부 번호 연계 불필요 + +6. **표 정렬** - 제목셀/구분열은 가운데, 설명은 좌측 정렬 + +## 첨부 페이지 규칙 +- 제목: `

[첨부] 해당 페이지 내용에 맞는 제목

` +- 제목은 좌측 정렬, 16pt +- 각 첨부 페이지도 마지막에 bottom-box로 해당 페이지 요약 포함 \ No newline at end of file diff --git a/railway.json b/railway.json new file mode 100644 index 0000000..4667ab2 --- /dev/null +++ b/railway.json @@ -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 + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a3a40b8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +flask==3.0.0 +anthropic==0.39.0 +gunicorn==21.2.0 +python-dotenv==1.0.0 +weasyprint==60.1 diff --git a/templates/hwp_guide.html b/templates/hwp_guide.html new file mode 100644 index 0000000..3aa587e --- /dev/null +++ b/templates/hwp_guide.html @@ -0,0 +1,343 @@ + + + + + + HWP 변환 가이드 - 글벗 Light + + + + + + +
+
+
+
+ ← 메인으로 +

HWP 변환 가이드

+
+
+
+
+ +
+ +
+

⚠️ HWP 변환 요구사항

+
    +
  • • Windows 운영체제
  • +
  • • 한글 프로그램 (한컴오피스) 설치
  • +
  • • Python 3.8 이상
  • +
+
+ + +
+

1. 필요 라이브러리 설치

+
pip install pyhwpx beautifulsoup4
+
+ + +
+

2. 사용 방법

+
    +
  1. 글벗 Light에서 HTML 파일을 다운로드합니다.
  2. +
  3. 아래 Python 스크립트를 다운로드합니다.
  4. +
  5. 스크립트 내 경로를 수정합니다.
  6. +
  7. 스크립트를 실행합니다.
  8. +
+
+ + +
+
+

3. HWP 변환 스크립트

+ +
+
# -*- 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()
+
+ + +
+

4. 경로 수정

+

스크립트 하단의 main() 함수에서 경로를 수정하세요:

+
html_path = r"C:\다운로드경로\report.html"
+output_path = r"C:\저장경로\report.hwp"
+
+
+ + + + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..1efcb2c --- /dev/null +++ b/templates/index.html @@ -0,0 +1,427 @@ + + + + + + 글벗 Light - 상시 업무용 보고서 생성기 + + + + + + +
+
+
+
+

글벗 Light

+

상시 업무용 보고서 자동 생성기 v2.0

+
+
+

각인된 양식 기반

+

A4 인쇄 최적화

+
+
+
+
+ +
+
+ +
+

+ 1 + 문서 입력 +

+ + +
+ + +
+ +
+ +
+ +
+ + + +
+
+ + + + + +
+

옵션 설정

+ +
+ +
+ + +
+ + +
+ + +
+
+ + +
+ + +
+
+ + + +
+
+ + +
+
+

+ 2 + 미리보기 & 다운로드 +

+ + + +
+ + +
+
+
+ + + +

문서를 입력하고 생성 버튼을 누르세요

+
+
+ +
+ + + + + + +
+
+ + +
+

💡 HWP 파일이 필요하신가요?

+

+ HWP 변환은 Windows + 한글 프로그램이 필요합니다. HTML 다운로드 후 제공되는 Python 스크립트로 로컬에서 변환할 수 있습니다. +

+ + HWP 변환 스크립트 받기 → + +
+
+ + +
+
+

글벗 Light v2.0 | 2단계 변환 + 대화형 피드백 시스템

+

Powered by Claude API

+
+
+ + + + +