공문 검토·작성 시스템으로 전환
Browse files- app.py: 제목 업데이트 (Gongmun API → 한전 공문 검토·작성 API)
- static/index.html: 작성/검토 탭 UI 추가, 검토 시스템 프롬프트 3종 내장
- README.md: 설명 및 제목 업데이트
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- README.md +23 -2
- app.py +4 -15
- static/index.html +493 -343
README.md
CHANGED
|
@@ -1,8 +1,29 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: indigo
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: 한전 공문 검토·작성 시스템
|
| 3 |
+
emoji: 📋
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: indigo
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
+
|
| 10 |
+
# 한전 공문 검토·작성 시스템
|
| 11 |
+
|
| 12 |
+
한국전력공사(KEPCO) 공식 문서를 AI로 **작성**하고 **검토**하는 시스템입니다.
|
| 13 |
+
|
| 14 |
+
## 기능
|
| 15 |
+
|
| 16 |
+
- **작성**: 내용 요점 입력 → 공문 / 보고서 / 내부품의 자동 생성
|
| 17 |
+
- **검토**: 기존 문서 붙여넣기 → 형식·문체 검토 의견 + 수정본 제공
|
| 18 |
+
|
| 19 |
+
## 문서 양식
|
| 20 |
+
|
| 21 |
+
| 양식 | 특징 |
|
| 22 |
+
|------|------|
|
| 23 |
+
| 공문 | 수신/제목/본문/붙임/끝. 구조, 정중체 |
|
| 24 |
+
| 보고서 | 날짜/부서/섹션 구조, 명사형 종결 |
|
| 25 |
+
| 내부품의 | 품의서/결문 구조, 본문 명사형 종결 |
|
| 26 |
+
|
| 27 |
+
## 모델
|
| 28 |
+
|
| 29 |
+
- **EXAONE-3.5-2.4B-Instruct** (ONNX Q4 양자화, CPU 추론)
|
app.py
CHANGED
|
@@ -1,12 +1,10 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
환경 변수:
|
| 4 |
MODEL_REPO — HF 모델 repo (기본: onnx-community/EXAONE-3.5-2.4B-Instruct)
|
| 5 |
-
나중에 본인 fine-tuned repo로 교체하면 됨
|
| 6 |
"""
|
| 7 |
import json
|
| 8 |
import os
|
| 9 |
-
import queue
|
| 10 |
import threading
|
| 11 |
import time
|
| 12 |
from pathlib import Path
|
|
@@ -20,8 +18,6 @@ from pydantic import BaseModel
|
|
| 20 |
|
| 21 |
MODEL_REPO = os.getenv("MODEL_REPO", "onnx-community/EXAONE-3.5-2.4B-Instruct")
|
| 22 |
|
| 23 |
-
# 무거운 패키지는 generator.load() 안에서 지연 import
|
| 24 |
-
# (uvicorn이 먼저 뜨고 나서 백그라운드 스레드가 로딩)
|
| 25 |
np = None
|
| 26 |
ort = None
|
| 27 |
|
|
@@ -29,7 +25,7 @@ NUM_LAYERS = 30
|
|
| 29 |
NUM_KV_HEADS = 8
|
| 30 |
HEAD_DIM = 80
|
| 31 |
|
| 32 |
-
app = FastAPI(title="
|
| 33 |
app.add_middleware(
|
| 34 |
CORSMiddleware,
|
| 35 |
allow_origins=["*"],
|
|
@@ -78,12 +74,10 @@ class Generator:
|
|
| 78 |
self.status = "creating ONNX session"
|
| 79 |
so = ort.SessionOptions()
|
| 80 |
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
|
| 81 |
-
# 컨테이너 실제 가용 CPU 감지 (cgroup 제한 존중)
|
| 82 |
try:
|
| 83 |
actual_cpus = len(os.sched_getaffinity(0))
|
| 84 |
except AttributeError:
|
| 85 |
actual_cpus = os.cpu_count() or 2
|
| 86 |
-
# 환경변수로 override 가능
|
| 87 |
n_threads = int(os.getenv("OMP_NUM_THREADS", str(actual_cpus)))
|
| 88 |
so.intra_op_num_threads = max(1, n_threads)
|
| 89 |
so.inter_op_num_threads = 1
|
|
@@ -124,7 +118,6 @@ class Generator:
|
|
| 124 |
return int(np.random.choice(len(probs), p=probs))
|
| 125 |
|
| 126 |
def _format_exaone(self, messages):
|
| 127 |
-
"""EXAONE 채팅 포맷 수동 조립 (apply_chat_template 실패 시 폴백)."""
|
| 128 |
parts = []
|
| 129 |
for m in messages:
|
| 130 |
role = m["role"]
|
|
@@ -143,7 +136,6 @@ class Generator:
|
|
| 143 |
messages, return_tensors="np", add_generation_prompt=True
|
| 144 |
).astype(np.int64)
|
| 145 |
except Exception as e:
|
| 146 |
-
# apply_chat_template 충돌 시 EXAONE 포맷 직접 만듦
|
| 147 |
print(f"[WARN] apply_chat_template failed ({e}), using manual format")
|
| 148 |
prompt_str = self._format_exaone(messages)
|
| 149 |
ids = tok(prompt_str, return_tensors="np").input_ids.astype(np.int64)
|
|
@@ -237,8 +229,6 @@ if STATIC_DIR.exists():
|
|
| 237 |
|
| 238 |
@app.get("/")
|
| 239 |
def root():
|
| 240 |
-
# 정적 HTML이 있으면 그것을 반환 (브라우저 접근용),
|
| 241 |
-
# 없으면 JSON 상태 반환 (API 전용 모드)
|
| 242 |
index = STATIC_DIR / "index.html"
|
| 243 |
if index.exists():
|
| 244 |
return FileResponse(str(index))
|
|
@@ -298,7 +288,6 @@ def generate(req: GenerateRequest):
|
|
| 298 |
headers={
|
| 299 |
"Cache-Control": "no-cache",
|
| 300 |
"Connection": "keep-alive",
|
| 301 |
-
# nginx / HF Spaces 프록시 버퍼링 방지 (SSE 핵심)
|
| 302 |
"X-Accel-Buffering": "no",
|
| 303 |
},
|
| 304 |
)
|
|
@@ -306,7 +295,7 @@ def generate(req: GenerateRequest):
|
|
| 306 |
|
| 307 |
@app.post("/download/hwpx")
|
| 308 |
def download_hwpx(req: HwpxRequest):
|
| 309 |
-
"""텍스트를 .hwpx 파일로 다운로드
|
| 310 |
import re
|
| 311 |
from urllib.parse import quote
|
| 312 |
from fastapi.responses import Response
|
|
@@ -316,7 +305,7 @@ def download_hwpx(req: HwpxRequest):
|
|
| 316 |
safe = re.sub(r'[\\/:*?"<>|]', "_", title)[:50]
|
| 317 |
encoded = quote(safe, safe="")
|
| 318 |
|
| 319 |
-
content = req.text.encode("utf-8-sig")
|
| 320 |
|
| 321 |
return Response(
|
| 322 |
content=content,
|
|
|
|
| 1 |
+
"""한전 공문 검토·작성 API (FastAPI + onnxruntime)
|
| 2 |
|
| 3 |
환경 변수:
|
| 4 |
MODEL_REPO — HF 모델 repo (기본: onnx-community/EXAONE-3.5-2.4B-Instruct)
|
|
|
|
| 5 |
"""
|
| 6 |
import json
|
| 7 |
import os
|
|
|
|
| 8 |
import threading
|
| 9 |
import time
|
| 10 |
from pathlib import Path
|
|
|
|
| 18 |
|
| 19 |
MODEL_REPO = os.getenv("MODEL_REPO", "onnx-community/EXAONE-3.5-2.4B-Instruct")
|
| 20 |
|
|
|
|
|
|
|
| 21 |
np = None
|
| 22 |
ort = None
|
| 23 |
|
|
|
|
| 25 |
NUM_KV_HEADS = 8
|
| 26 |
HEAD_DIM = 80
|
| 27 |
|
| 28 |
+
app = FastAPI(title="한전 공문 검토·작성 API", version="0.2")
|
| 29 |
app.add_middleware(
|
| 30 |
CORSMiddleware,
|
| 31 |
allow_origins=["*"],
|
|
|
|
| 74 |
self.status = "creating ONNX session"
|
| 75 |
so = ort.SessionOptions()
|
| 76 |
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
|
|
|
|
| 77 |
try:
|
| 78 |
actual_cpus = len(os.sched_getaffinity(0))
|
| 79 |
except AttributeError:
|
| 80 |
actual_cpus = os.cpu_count() or 2
|
|
|
|
| 81 |
n_threads = int(os.getenv("OMP_NUM_THREADS", str(actual_cpus)))
|
| 82 |
so.intra_op_num_threads = max(1, n_threads)
|
| 83 |
so.inter_op_num_threads = 1
|
|
|
|
| 118 |
return int(np.random.choice(len(probs), p=probs))
|
| 119 |
|
| 120 |
def _format_exaone(self, messages):
|
|
|
|
| 121 |
parts = []
|
| 122 |
for m in messages:
|
| 123 |
role = m["role"]
|
|
|
|
| 136 |
messages, return_tensors="np", add_generation_prompt=True
|
| 137 |
).astype(np.int64)
|
| 138 |
except Exception as e:
|
|
|
|
| 139 |
print(f"[WARN] apply_chat_template failed ({e}), using manual format")
|
| 140 |
prompt_str = self._format_exaone(messages)
|
| 141 |
ids = tok(prompt_str, return_tensors="np").input_ids.astype(np.int64)
|
|
|
|
| 229 |
|
| 230 |
@app.get("/")
|
| 231 |
def root():
|
|
|
|
|
|
|
| 232 |
index = STATIC_DIR / "index.html"
|
| 233 |
if index.exists():
|
| 234 |
return FileResponse(str(index))
|
|
|
|
| 288 |
headers={
|
| 289 |
"Cache-Control": "no-cache",
|
| 290 |
"Connection": "keep-alive",
|
|
|
|
| 291 |
"X-Accel-Buffering": "no",
|
| 292 |
},
|
| 293 |
)
|
|
|
|
| 295 |
|
| 296 |
@app.post("/download/hwpx")
|
| 297 |
def download_hwpx(req: HwpxRequest):
|
| 298 |
+
"""텍스트를 .hwpx 파일로 다운로드."""
|
| 299 |
import re
|
| 300 |
from urllib.parse import quote
|
| 301 |
from fastapi.responses import Response
|
|
|
|
| 305 |
safe = re.sub(r'[\\/:*?"<>|]', "_", title)[:50]
|
| 306 |
encoded = quote(safe, safe="")
|
| 307 |
|
| 308 |
+
content = req.text.encode("utf-8-sig")
|
| 309 |
|
| 310 |
return Response(
|
| 311 |
content=content,
|
static/index.html
CHANGED
|
@@ -2,277 +2,320 @@
|
|
| 2 |
<html lang="ko">
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
-
<
|
| 7 |
-
<title>공문 생성기</title>
|
| 8 |
<style>
|
| 9 |
-
:
|
| 10 |
-
--bg: #f5f5f4;
|
| 11 |
-
--panel: #ffffff;
|
| 12 |
-
--border: #d6d3d1;
|
| 13 |
-
--text: #1c1917;
|
| 14 |
-
--muted: #78716c;
|
| 15 |
-
--accent: #0f172a;
|
| 16 |
-
--accent-hover: #334155;
|
| 17 |
-
--error: #b91c1c;
|
| 18 |
-
}
|
| 19 |
-
* { box-sizing: border-box; }
|
| 20 |
body {
|
| 21 |
-
|
| 22 |
-
font-
|
| 23 |
-
background:
|
| 24 |
-
color:
|
| 25 |
-
|
| 26 |
-
}
|
| 27 |
-
.container {
|
| 28 |
-
max-width: 1100px;
|
| 29 |
-
margin: 0 auto;
|
| 30 |
-
padding: 20px 16px 60px;
|
| 31 |
}
|
| 32 |
-
header
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
| 38 |
}
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
}
|
| 42 |
-
.
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
| 45 |
border-radius: 8px;
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
}
|
| 48 |
-
.
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
font-size: 13px;
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
font-weight: 600;
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
| 55 |
}
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
display: block;
|
| 58 |
font-size: 12px;
|
| 59 |
-
|
| 60 |
-
|
|
|
|
| 61 |
}
|
| 62 |
-
|
| 63 |
width: 100%;
|
| 64 |
-
|
| 65 |
-
border: 1px solid var(--border);
|
| 66 |
border-radius: 6px;
|
|
|
|
| 67 |
font-family: inherit;
|
| 68 |
-
font-size:
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
.row > * { flex: 1; min-width: 0; }
|
| 74 |
-
button {
|
| 75 |
-
padding: 10px 14px;
|
| 76 |
-
background: var(--accent);
|
| 77 |
-
color: #fff;
|
| 78 |
-
border: 0;
|
| 79 |
-
border-radius: 6px;
|
| 80 |
-
font-size: 14px;
|
| 81 |
-
font-weight: 600;
|
| 82 |
-
cursor: pointer;
|
| 83 |
-
}
|
| 84 |
-
button:hover:not(:disabled) { background: var(--accent-hover); }
|
| 85 |
-
button:disabled { background: #a8a29e; cursor: not-allowed; }
|
| 86 |
-
button.secondary {
|
| 87 |
-
background: #fff;
|
| 88 |
-
color: var(--text);
|
| 89 |
-
border: 1px solid var(--border);
|
| 90 |
}
|
| 91 |
-
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
display: flex;
|
|
|
|
| 94 |
gap: 8px;
|
| 95 |
flex-wrap: wrap;
|
| 96 |
-
margin-top: 12px;
|
| 97 |
}
|
| 98 |
-
.
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
font-size: 12px;
|
| 101 |
-
color:
|
| 102 |
-
margin-top: 10px;
|
| 103 |
-
min-height: 18px;
|
| 104 |
}
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
border-radius: 6px;
|
| 113 |
-
padding: 14px;
|
| 114 |
-
|
|
|
|
| 115 |
line-height: 1.8;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
}
|
|
|
|
| 117 |
</style>
|
| 118 |
</head>
|
| 119 |
<body>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
<div class="container">
|
| 121 |
-
<header>
|
| 122 |
-
<h1>공문 생성기</h1>
|
| 123 |
-
</header>
|
| 124 |
-
|
| 125 |
-
<div class="grid">
|
| 126 |
-
<section class="panel">
|
| 127 |
-
<h2>입력</h2>
|
| 128 |
-
|
| 129 |
-
<label>문서 양식</label>
|
| 130 |
-
<div class="row" style="gap:16px; margin-bottom:8px; flex-wrap:wrap;">
|
| 131 |
-
<label style="display:flex; align-items:center; gap:6px; font-size:14px; color:var(--text); cursor:pointer;">
|
| 132 |
-
<input type="radio" name="doc-mode" value="공문" checked> 공문 (수신/제목/끝.)
|
| 133 |
-
</label>
|
| 134 |
-
<label style="display:flex; align-items:center; gap:6px; font-size:14px; color:var(--text); cursor:pointer;">
|
| 135 |
-
<input type="radio" name="doc-mode" value="보고서"> 보고서 (작성일자/부서/섹션)
|
| 136 |
-
</label>
|
| 137 |
-
<label style="display:flex; align-items:center; gap:6px; font-size:14px; color:var(--text); cursor:pointer;">
|
| 138 |
-
<input type="radio" name="doc-mode" value="내부품의"> 내부품의 (품의서/결문)
|
| 139 |
-
</label>
|
| 140 |
-
</div>
|
| 141 |
|
| 142 |
-
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
</
|
| 149 |
-
<
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
<
|
| 153 |
-
<h2>생성 결과</h2>
|
| 154 |
-
<div class="output" id="output">여기에 결과가 표시됩니다.</div>
|
| 155 |
-
<div class="btnrow">
|
| 156 |
-
<button id="copy" class="secondary">복사</button>
|
| 157 |
-
<button id="download-hwpx" class="secondary">.hwpx 저장</button>
|
| 158 |
-
</div>
|
| 159 |
-
</section>
|
| 160 |
</div>
|
| 161 |
-
</div>
|
| 162 |
|
| 163 |
-
<
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
|
| 172 |
-
|
| 173 |
-
statusEl.textContent = msg;
|
| 174 |
-
statusEl.classList.toggle('error', isError);
|
| 175 |
-
};
|
| 176 |
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
사용자가 요청한 내용을 아래 양식에 정확히 맞춰 작성하세요.
|
| 179 |
|
| 180 |
[조직 정보]
|
| 181 |
- 회사: 한국전력공사 (본문에서 "우리 회사" 또는 "한전"으로 지칭)
|
| 182 |
- 시점: 2026년 (날짜에 별도 연도 없으면 2026년 기준)
|
| 183 |
-
- 부서명 예시: 전략경영부,
|
| 184 |
-
- 직책: 처장 / 부장 / 차장 / 과장 등 한전 직제
|
| 185 |
|
| 186 |
[양식]
|
| 187 |
수신 (수신처)
|
| 188 |
제목 (문서 제목)
|
| 189 |
|
| 190 |
-
(본문 도입 — 한 단락으로
|
| 191 |
|
| 192 |
1. (세부 항목 1)
|
| 193 |
2. (세부 항목 2)
|
| 194 |
-
|
|
|
|
| 195 |
|
| 196 |
-
※ (추가 안내
|
| 197 |
|
| 198 |
-
붙임 1. (붙임
|
| 199 |
|
| 200 |
[규칙]
|
| 201 |
-
-
|
| 202 |
-
-
|
| 203 |
-
- 항목 들여쓰기: 3칸 공백 후 "1. 일시:" 식으로
|
| 204 |
-
- 추가 안내는 본문 다음에 "※" 마커로 시작
|
| 205 |
-
- "끝."은 붙임 줄 끝에 같이 표시
|
| 206 |
-
- 발신명의 줄은 생략
|
| 207 |
-
- "관련:" 같은 법령 인용은 쓰지 않음
|
| 208 |
-
- 날짜: "YYYY. M. D.(요일)" 또는 "HH:00 ~ HH:00"
|
| 209 |
- 문체: "~합니다", "~바랍니다" 정중체
|
| 210 |
-
- 마크다운 기호(**, *, #) 절대
|
| 211 |
-
- 사용자가 준 내용만 사용, 추측·가공 금지
|
| 212 |
-
|
| 213 |
-
[예시]
|
| 214 |
-
수신 각 부서장
|
| 215 |
-
제목 2026년 상반기 정기 안전교육 실시 협조 요청
|
| 216 |
|
| 217 |
-
|
|
|
|
| 218 |
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
3. 대상: 전 직원
|
| 222 |
|
| 223 |
-
|
| 224 |
|
| 225 |
-
|
|
|
|
|
|
|
| 226 |
|
| 227 |
-
|
| 228 |
-
|
|
|
|
| 229 |
|
| 230 |
-
[
|
| 231 |
-
-
|
| 232 |
-
-
|
| 233 |
-
-
|
| 234 |
-
|
| 235 |
-
[작성 규칙]
|
| 236 |
-
- 형식: 작성일자(YYYY. M.) / 작성부서 / 본문 섹션
|
| 237 |
-
- 수신/발신/끝. 같은 공문 letter 양식 요소는 사용하지 않음
|
| 238 |
-
- 본문 섹션: "1. 추진 배경 / 2. 개요·결과 / 3. 조치 계획 / 4. 향후 일정" 식으로 번호와 제목
|
| 239 |
-
- 세부 항목은 "가. 나. 다.", 더 세부는 "-" 글머리(들여쓰기)
|
| 240 |
-
- 날짜 표기: "'26. 5월 중", "26. 2월 4주 ~ 4월 3주(9주간)" 등 보고서 관용 표기 허용
|
| 241 |
-
- 항목 안에 괄호로 보조 설명: 예) "(면담기간) ...", "(구 성) ..."
|
| 242 |
-
- 마크다운 기호(**, *, #) 절대 사용 금지
|
| 243 |
-
- 추측이나 가공 정보 추가 금지, 사용자가 준 내용만 정리
|
| 244 |
|
| 245 |
[★ 문체 — 가장 중요]
|
| 246 |
-
-
|
| 247 |
-
- 권장
|
| 248 |
-
- 금지
|
| 249 |
-
- 예시:
|
| 250 |
-
⭕ "신입사원 환영회 시행" ❌ "신입사원 환영회를 시행합니다"
|
| 251 |
-
⭕ "전 부서장 참석 요청" ❌ "전 부서장 참석을 요청드립니다"
|
| 252 |
-
⭕ "이행 계획 수립 필요" ❌ "이행 계획을 수립할 필요가 있습니다"
|
| 253 |
-
⭕ "예산 1,500만원 소요" ❌ "예산은 1,500만원이 소요됩니다"
|
| 254 |
-
|
| 255 |
-
[예시 본문 (문체 참고)]
|
| 256 |
-
1. 추진 배경
|
| 257 |
-
가. 에너지신산업 분야 규제 개선을 통한 혁신생태계 조성
|
| 258 |
-
나. 1:1 방문 면담을 통해 실질적 규제 발굴·해소
|
| 259 |
-
|
| 260 |
-
2. 추진 내용
|
| 261 |
-
가. 면담기간: '26. 2월 4주 ~ 4월 3주 (9주간)
|
| 262 |
-
나. 면담기업: 22개社
|
| 263 |
-
다. 면담내용: 규제·애로사항 청취 및 개선 건의 방법 안내
|
| 264 |
-
|
| 265 |
-
3. 향후 계획
|
| 266 |
-
가. ('26.5월) 규제 설명회 개최
|
| 267 |
-
나. ('26.6월) 사내 위원회 운영계획 수립`;
|
| 268 |
-
|
| 269 |
-
const SYSTEM_PROMPT_APPROVAL = `당신은 한국전력공사(한전, KEPCO) 내부품의서 작성 전문가입니다. 현재는 2026년입니다.
|
| 270 |
-
사용자가 요청한 내용을 아래 양식에 정확히 맞춰 작성하세요.
|
| 271 |
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
- 시점: 2026년 (날짜에 별도 연도 없으면 2026년 기준)
|
| 275 |
-
- 부서명 예시: 전략경영부, 경영지원부, ICT운영부, 인사관리부 등
|
| 276 |
|
| 277 |
[양식]
|
| 278 |
품 의 서
|
|
@@ -281,18 +324,15 @@ const SYSTEM_PROMPT_APPROVAL = `당신은 한국전력공사(한전, KEPCO) 내
|
|
| 281 |
|
| 282 |
제 목 (문서 제목)
|
| 283 |
|
| 284 |
-
(사유·배경 — 1~2문장
|
| 285 |
|
| 286 |
-
1. (섹션명
|
| 287 |
가. 항목
|
| 288 |
나. 항목
|
| 289 |
|
| 290 |
-
2. (섹션명
|
| 291 |
가. 항목
|
| 292 |
- 세부
|
| 293 |
-
나. 항목
|
| 294 |
-
|
| 295 |
-
3. (섹션명 — 예: 소요 예산, 향후 일정) ← 해당 시만 작성
|
| 296 |
|
| 297 |
붙임 1. (첨부파일명 N부.)
|
| 298 |
|
|
@@ -303,193 +343,303 @@ const SYSTEM_PROMPT_APPROVAL = `당신은 한국전력공사(한전, KEPCO) 내
|
|
| 303 |
(기안부서장)
|
| 304 |
|
| 305 |
[규칙]
|
| 306 |
-
-
|
| 307 |
-
-
|
| 308 |
-
-
|
| 309 |
-
|
| 310 |
-
- 문체: 본문은 명사형 종결, 마지막 결문만 "위와 같이 품의합니다" 정중체
|
| 311 |
-
- 마크다운 기호(**, *, #) 절대 금지
|
| 312 |
-
- 사용자가 준 내용만 사용, 추측·가공 금지`;
|
| 313 |
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
|
| 325 |
-
|
| 326 |
-
return $('prompt').value.trim();
|
| 327 |
-
}
|
| 328 |
|
| 329 |
-
|
| 330 |
-
return text
|
| 331 |
-
.replace(/\*\*(.+?)\*\*/gs, '$1')
|
| 332 |
-
.replace(/(?<!\*)\*(?!\s)(.+?)(?<!\s)\*(?!\*)/gs, '$1')
|
| 333 |
-
.replace(/^#{1,6}\s+/gm, '')
|
| 334 |
-
.replace(/^(\s*)[-*]\s+/gm, '$1');
|
| 335 |
-
}
|
| 336 |
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 343 |
|
| 344 |
-
|
| 345 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
try {
|
| 347 |
-
const r = await fetch(
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
}
|
| 356 |
-
setStatus(`로딩 중: ${j.status || '...'}`);
|
| 357 |
}
|
|
|
|
| 358 |
} catch (_) {
|
| 359 |
-
|
| 360 |
}
|
| 361 |
-
await
|
| 362 |
}
|
| 363 |
-
setStatus('서버 준비 안 됨 (3분 타임아웃)', true);
|
| 364 |
}
|
| 365 |
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
return;
|
| 370 |
}
|
| 371 |
-
outputEl.textContent = '';
|
| 372 |
-
genBtn.disabled = true;
|
| 373 |
-
stopBtn.disabled = false;
|
| 374 |
-
setStatus('생성 중...');
|
| 375 |
|
| 376 |
-
|
| 377 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 378 |
|
| 379 |
try {
|
| 380 |
-
const
|
| 381 |
-
method:
|
| 382 |
-
headers: {
|
| 383 |
body: JSON.stringify({
|
| 384 |
messages: [
|
| 385 |
-
{ role:
|
| 386 |
-
{ role:
|
| 387 |
],
|
| 388 |
-
max_new_tokens:
|
| 389 |
temperature: 0.7,
|
| 390 |
top_p: 0.8,
|
| 391 |
-
top_k: 20
|
| 392 |
}),
|
| 393 |
-
signal:
|
| 394 |
});
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
|
|
|
| 398 |
}
|
| 399 |
|
| 400 |
-
const reader =
|
| 401 |
const decoder = new TextDecoder();
|
| 402 |
-
let
|
| 403 |
-
|
| 404 |
-
let tokenCount = 0;
|
| 405 |
-
let doneSeen = false;
|
| 406 |
while (true) {
|
| 407 |
-
const {
|
| 408 |
if (done) break;
|
| 409 |
-
|
| 410 |
-
const
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
const lines = chunk.split('\n');
|
| 414 |
-
leftover = lines.pop() || '';
|
| 415 |
for (const line of lines) {
|
| 416 |
-
if (!line.startsWith(
|
| 417 |
try {
|
| 418 |
const obj = JSON.parse(line.slice(5).trim());
|
| 419 |
-
if (obj.error)
|
| 420 |
-
console.error('[SSE error]', obj.error);
|
| 421 |
-
throw new Error(obj.error);
|
| 422 |
-
}
|
| 423 |
if (obj.done) {
|
| 424 |
-
doneSeen = true;
|
| 425 |
-
console.log('[SSE done]', obj);
|
| 426 |
-
const stripped = stripMarkdown(buffer);
|
| 427 |
-
const final = getMode() === '공문' ? ensureEndMark(stripped) : stripped;
|
| 428 |
-
outputEl.textContent = final;
|
| 429 |
setStatus(`완료 (${obj.tokens} 토큰, ${obj.elapsed}초)`);
|
| 430 |
-
|
| 431 |
}
|
| 432 |
-
if (obj.token
|
| 433 |
-
tokenCount++;
|
| 434 |
buffer += obj.token;
|
| 435 |
-
outputEl.textContent =
|
| 436 |
outputEl.scrollTop = outputEl.scrollHeight;
|
| 437 |
}
|
| 438 |
} catch (e) {
|
| 439 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 440 |
}
|
| 441 |
}
|
| 442 |
}
|
| 443 |
-
console.log('[SSE stream ended]', { chunkCount, tokenCount, doneSeen, bufferLen: buffer.length });
|
| 444 |
-
if (!doneSeen) {
|
| 445 |
-
setStatus(`스트림 종료 (done 이벤트 없음, ${tokenCount} 토큰 수신, buffer=${buffer.length}자)`, true);
|
| 446 |
-
} else {
|
| 447 |
-
setStatus('완료.');
|
| 448 |
-
}
|
| 449 |
} catch (e) {
|
| 450 |
-
if (e.name ==
|
| 451 |
-
setStatus('중지됨.');
|
| 452 |
-
} else {
|
| 453 |
-
setStatus('오류: ' + e.message, true);
|
| 454 |
-
}
|
| 455 |
} finally {
|
| 456 |
-
|
| 457 |
-
stopBtn.disabled = true;
|
| 458 |
-
abortCtrl = null;
|
| 459 |
}
|
| 460 |
}
|
| 461 |
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
const text = outputEl.textContent;
|
| 470 |
-
if (!text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 471 |
try {
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
body: JSON.stringify({ text }),
|
| 477 |
});
|
| 478 |
-
if (!
|
| 479 |
-
const blob = await
|
| 480 |
-
const
|
| 481 |
-
const
|
|
|
|
| 482 |
const url = URL.createObjectURL(blob);
|
| 483 |
-
const a = document.createElement(
|
| 484 |
-
a.href = url; a.download =
|
| 485 |
URL.revokeObjectURL(url);
|
| 486 |
-
setStatus(
|
| 487 |
} catch (e) {
|
| 488 |
-
setStatus(
|
| 489 |
}
|
| 490 |
});
|
| 491 |
|
| 492 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 493 |
</script>
|
| 494 |
</body>
|
| 495 |
</html>
|
|
|
|
| 2 |
<html lang="ko">
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>한전 공문 검토·작성 시스템</title>
|
|
|
|
| 7 |
<style>
|
| 8 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
body {
|
| 10 |
+
font-family: "맑은 고딕", "Malgun Gothic", "Apple SD Gothic Neo", sans-serif;
|
| 11 |
+
font-size: 14px;
|
| 12 |
+
background: #f4f6f9;
|
| 13 |
+
color: #222;
|
| 14 |
+
min-height: 100vh;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
}
|
| 16 |
+
header {
|
| 17 |
+
background: #003087;
|
| 18 |
+
color: #fff;
|
| 19 |
+
padding: 14px 24px;
|
| 20 |
+
display: flex;
|
| 21 |
+
align-items: center;
|
| 22 |
+
gap: 12px;
|
| 23 |
}
|
| 24 |
+
header h1 { font-size: 18px; font-weight: 700; letter-spacing: -0.3px; }
|
| 25 |
+
header .badge {
|
| 26 |
+
background: #ffd700;
|
| 27 |
+
color: #003087;
|
| 28 |
+
font-size: 11px;
|
| 29 |
+
font-weight: 700;
|
| 30 |
+
padding: 2px 8px;
|
| 31 |
+
border-radius: 10px;
|
| 32 |
}
|
| 33 |
+
.container { max-width: 960px; margin: 24px auto; padding: 0 16px 40px; }
|
| 34 |
+
|
| 35 |
+
/* 패널 카드 */
|
| 36 |
+
.card {
|
| 37 |
+
background: #fff;
|
| 38 |
border-radius: 8px;
|
| 39 |
+
box-shadow: 0 1px 4px rgba(0,0,0,.10);
|
| 40 |
+
padding: 20px 24px;
|
| 41 |
+
margin-bottom: 16px;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/* 문서 양식 선택 */
|
| 45 |
+
.doc-type-row {
|
| 46 |
+
display: flex;
|
| 47 |
+
align-items: center;
|
| 48 |
+
gap: 20px;
|
| 49 |
+
flex-wrap: wrap;
|
| 50 |
}
|
| 51 |
+
.doc-type-row .label { font-weight: 600; color: #555; min-width: 64px; }
|
| 52 |
+
.doc-type-row label {
|
| 53 |
+
display: flex;
|
| 54 |
+
align-items: center;
|
| 55 |
+
gap: 6px;
|
| 56 |
+
cursor: pointer;
|
| 57 |
font-size: 13px;
|
| 58 |
+
}
|
| 59 |
+
.doc-type-row input[type=radio] { accent-color: #003087; }
|
| 60 |
+
|
| 61 |
+
/* 탭 */
|
| 62 |
+
.tabs {
|
| 63 |
+
display: flex;
|
| 64 |
+
border-bottom: 2px solid #e0e4ea;
|
| 65 |
+
margin-bottom: 16px;
|
| 66 |
+
gap: 2px;
|
| 67 |
+
}
|
| 68 |
+
.tab-btn {
|
| 69 |
+
padding: 10px 28px;
|
| 70 |
+
border: none;
|
| 71 |
+
background: none;
|
| 72 |
+
cursor: pointer;
|
| 73 |
+
font-size: 14px;
|
| 74 |
font-weight: 600;
|
| 75 |
+
color: #777;
|
| 76 |
+
border-bottom: 3px solid transparent;
|
| 77 |
+
margin-bottom: -2px;
|
| 78 |
+
transition: color .15s, border-color .15s;
|
| 79 |
}
|
| 80 |
+
.tab-btn.active { color: #003087; border-bottom-color: #003087; }
|
| 81 |
+
.tab-btn:hover:not(.active) { color: #333; }
|
| 82 |
+
|
| 83 |
+
/* 패널 */
|
| 84 |
+
.panel { display: none; }
|
| 85 |
+
.panel.active { display: block; }
|
| 86 |
+
.panel label.field-label {
|
| 87 |
display: block;
|
| 88 |
font-size: 12px;
|
| 89 |
+
font-weight: 600;
|
| 90 |
+
color: #555;
|
| 91 |
+
margin-bottom: 6px;
|
| 92 |
}
|
| 93 |
+
textarea {
|
| 94 |
width: 100%;
|
| 95 |
+
border: 1px solid #d0d5dd;
|
|
|
|
| 96 |
border-radius: 6px;
|
| 97 |
+
padding: 10px 12px;
|
| 98 |
font-family: inherit;
|
| 99 |
+
font-size: 13px;
|
| 100 |
+
resize: vertical;
|
| 101 |
+
line-height: 1.6;
|
| 102 |
+
outline: none;
|
| 103 |
+
transition: border-color .15s;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
}
|
| 105 |
+
textarea:focus { border-color: #003087; }
|
| 106 |
+
#write-input { min-height: 140px; }
|
| 107 |
+
#review-input { min-height: 200px; }
|
| 108 |
+
|
| 109 |
+
/* 버튼 행 */
|
| 110 |
+
.btn-row {
|
| 111 |
display: flex;
|
| 112 |
+
align-items: center;
|
| 113 |
gap: 8px;
|
| 114 |
flex-wrap: wrap;
|
|
|
|
| 115 |
}
|
| 116 |
+
.btn {
|
| 117 |
+
padding: 8px 18px;
|
| 118 |
+
border: none;
|
| 119 |
+
border-radius: 6px;
|
| 120 |
+
cursor: pointer;
|
| 121 |
+
font-size: 13px;
|
| 122 |
+
font-weight: 600;
|
| 123 |
+
transition: background .15s, opacity .15s;
|
| 124 |
+
}
|
| 125 |
+
.btn-primary { background: #003087; color: #fff; }
|
| 126 |
+
.btn-primary:hover:not(:disabled) { background: #00246e; }
|
| 127 |
+
.btn-secondary { background: #e8edf5; color: #333; }
|
| 128 |
+
.btn-secondary:hover:not(:disabled) { background: #d5dce8; }
|
| 129 |
+
.btn:disabled { opacity: .4; cursor: default; }
|
| 130 |
+
#status-msg {
|
| 131 |
+
margin-left: auto;
|
| 132 |
font-size: 12px;
|
| 133 |
+
color: #666;
|
|
|
|
|
|
|
| 134 |
}
|
| 135 |
+
|
| 136 |
+
/* 결과 영역 */
|
| 137 |
+
.result-header {
|
| 138 |
+
display: flex;
|
| 139 |
+
align-items: center;
|
| 140 |
+
justify-content: space-between;
|
| 141 |
+
margin-bottom: 8px;
|
| 142 |
+
}
|
| 143 |
+
.result-header span { font-weight: 600; color: #555; font-size: 13px; }
|
| 144 |
+
#output {
|
| 145 |
+
width: 100%;
|
| 146 |
+
min-height: 280px;
|
| 147 |
+
background: #f8f9fb;
|
| 148 |
+
border: 1px solid #e0e4ea;
|
| 149 |
border-radius: 6px;
|
| 150 |
+
padding: 14px 16px;
|
| 151 |
+
font-family: "D2Coding", "Consolas", "맑은 고딕", monospace;
|
| 152 |
+
font-size: 13px;
|
| 153 |
line-height: 1.8;
|
| 154 |
+
white-space: pre-wrap;
|
| 155 |
+
word-break: break-all;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
/* 검토/수정본 섹션 구분 강조 */
|
| 159 |
+
.sep-review { color: #003087; font-weight: 700; }
|
| 160 |
+
.sep-fixed { color: #c00; font-weight: 700; }
|
| 161 |
+
|
| 162 |
+
/* 하단 다운로드 버튼 */
|
| 163 |
+
.download-row { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
| 164 |
+
|
| 165 |
+
/* 로딩 배너 */
|
| 166 |
+
#loading-banner {
|
| 167 |
+
background: #fffbe6;
|
| 168 |
+
border: 1px solid #ffe58f;
|
| 169 |
+
border-radius: 6px;
|
| 170 |
+
padding: 10px 16px;
|
| 171 |
+
font-size: 13px;
|
| 172 |
+
color: #7a5200;
|
| 173 |
+
margin-bottom: 12px;
|
| 174 |
+
display: flex;
|
| 175 |
+
align-items: center;
|
| 176 |
+
gap: 8px;
|
| 177 |
+
}
|
| 178 |
+
#loading-banner.hidden { display: none; }
|
| 179 |
+
.spinner {
|
| 180 |
+
width: 16px; height: 16px;
|
| 181 |
+
border: 2px solid #ffe58f;
|
| 182 |
+
border-top-color: #d48806;
|
| 183 |
+
border-radius: 50%;
|
| 184 |
+
animation: spin .8s linear infinite;
|
| 185 |
+
flex-shrink: 0;
|
| 186 |
}
|
| 187 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 188 |
</style>
|
| 189 |
</head>
|
| 190 |
<body>
|
| 191 |
+
|
| 192 |
+
<header>
|
| 193 |
+
<h1>한전 공문 검토·작성 시스템</h1>
|
| 194 |
+
<span class="badge">KEPCO AI</span>
|
| 195 |
+
</header>
|
| 196 |
+
|
| 197 |
<div class="container">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
|
| 199 |
+
<!-- 로딩 배너 -->
|
| 200 |
+
<div id="loading-banner">
|
| 201 |
+
<div class="spinner"></div>
|
| 202 |
+
<span id="loading-msg">모델 로딩 중... 잠시만 기다려주세요.</span>
|
| 203 |
+
</div>
|
| 204 |
|
| 205 |
+
<!-- 문서 양식 선택 -->
|
| 206 |
+
<div class="card">
|
| 207 |
+
<div class="doc-type-row">
|
| 208 |
+
<span class="label">문서 양식</span>
|
| 209 |
+
<label><input type="radio" name="docType" value="공문" checked> 공문 (수신/제목/끝.)</label>
|
| 210 |
+
<label><input type="radio" name="docType" value="보고서"> 보고서 (작성일자/부서/섹션)</label>
|
| 211 |
+
<label><input type="radio" name="docType" value="내부품의"> 내부품의 (품의서/결문)</label>
|
| 212 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
</div>
|
|
|
|
| 214 |
|
| 215 |
+
<!-- 탭 -->
|
| 216 |
+
<div class="card">
|
| 217 |
+
<div class="tabs">
|
| 218 |
+
<button class="tab-btn active" data-tab="write">작성</button>
|
| 219 |
+
<button class="tab-btn" data-tab="review">검토</button>
|
| 220 |
+
</div>
|
| 221 |
+
|
| 222 |
+
<!-- 작성 패널 -->
|
| 223 |
+
<div id="panel-write" class="panel active">
|
| 224 |
+
<label class="field-label">내용 요점 (작성할 내용의 핵심 사항을 입력하세요)</label>
|
| 225 |
+
<textarea id="write-input" placeholder="예) 안전교육 실시 협조 요청. 일시: 2026.7.15(화) 14:00~16:00, 장소: 본사 대강당, 대상: 전 직원"></textarea>
|
| 226 |
+
</div>
|
| 227 |
+
|
| 228 |
+
<!-- 검토 패널 -->
|
| 229 |
+
<div id="panel-review" class="panel">
|
| 230 |
+
<label class="field-label">원문 입력 (검토할 공문·보고서·품의서 전문을 붙여넣으세요)</label>
|
| 231 |
+
<textarea id="review-input" placeholder="검토할 문서 전체 내용을 여기에 붙여넣으세요..."></textarea>
|
| 232 |
+
</div>
|
| 233 |
+
|
| 234 |
+
<!-- 버튼 행 -->
|
| 235 |
+
<div class="btn-row" style="margin-top:14px">
|
| 236 |
+
<button id="run-btn" class="btn btn-primary" disabled>생성</button>
|
| 237 |
+
<button id="stop-btn" class="btn btn-secondary" disabled>중지</button>
|
| 238 |
+
<span id="status-msg"></span>
|
| 239 |
+
</div>
|
| 240 |
+
</div>
|
| 241 |
|
| 242 |
+
<!-- 결과 -->
|
| 243 |
+
<div class="card">
|
| 244 |
+
<div class="result-header">
|
| 245 |
+
<span>결과</span>
|
| 246 |
+
<div style="display:flex;gap:8px">
|
| 247 |
+
<button id="copy-btn" class="btn btn-secondary">복사</button>
|
| 248 |
+
<button id="dl-hwpx-btn" class="btn btn-secondary">HWPX 저장</button>
|
| 249 |
+
</div>
|
| 250 |
+
</div>
|
| 251 |
+
<div id="output"></div>
|
| 252 |
+
</div>
|
| 253 |
|
| 254 |
+
</div><!-- /container -->
|
|
|
|
|
|
|
|
|
|
| 255 |
|
| 256 |
+
<script>
|
| 257 |
+
// ============================================================
|
| 258 |
+
// 시스템 프롬프트
|
| 259 |
+
// ============================================================
|
| 260 |
+
const PROMPTS_WRITE = {
|
| 261 |
+
"공문": `당신은 한국전력공사(한전, KEPCO) 공문 작성 전문가입니다. 현재는 2026년입니다.
|
| 262 |
사용자가 요청한 내용을 아래 양식에 정확히 맞춰 작성하세요.
|
| 263 |
|
| 264 |
[조직 정보]
|
| 265 |
- 회사: 한국전력공사 (본문에서 "우리 회사" 또는 "한전"으로 지칭)
|
| 266 |
- 시점: 2026년 (날짜에 별도 연도 없으면 2026년 기준)
|
| 267 |
+
- 부서명 예시: 전략경영부, 경영지원부, 요금관리부, ICT운영부 등
|
|
|
|
| 268 |
|
| 269 |
[양식]
|
| 270 |
수신 (수신처)
|
| 271 |
제목 (문서 제목)
|
| 272 |
|
| 273 |
+
(본문 도입 — 한 단락, 2~3문장으로 목적·일정·요청사항 압축)
|
| 274 |
|
| 275 |
1. (세부 항목 1)
|
| 276 |
2. (세부 항목 2)
|
| 277 |
+
가. (서브 항목)
|
| 278 |
+
나. (서브 항목)
|
| 279 |
|
| 280 |
+
※ (추가 안내)
|
| 281 |
|
| 282 |
+
붙임 1. (붙임명 N부.) 끝.
|
| 283 |
|
| 284 |
[규칙]
|
| 285 |
+
- 발신명의 줄 생략
|
| 286 |
+
- 날짜: "YYYY. M. D.(요일)"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
- 문체: "~합니다", "~바랍니다" 정중체
|
| 288 |
+
- 마크다운 기호(**, *, #) 절대 금지
|
| 289 |
+
- 사용자가 준 내용만 사용, 추측·가공 금지`,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
|
| 291 |
+
"보고서": `당신은 한국전력공사(한전, KEPCO) 내부 보고서 작성 전문가입니다. 현재는 2026년입니다.
|
| 292 |
+
사용자가 요청한 내용을 표준 보고서 양식에 맞춰 작성하세요.
|
| 293 |
|
| 294 |
+
[양식]
|
| 295 |
+
YYYY. M.
|
|
|
|
| 296 |
|
| 297 |
+
부서명 / 세부부서
|
| 298 |
|
| 299 |
+
1. (섹션명)
|
| 300 |
+
가. 항목
|
| 301 |
+
��. 항목
|
| 302 |
|
| 303 |
+
2. (섹션명)
|
| 304 |
+
가. 항목
|
| 305 |
+
- 세부
|
| 306 |
|
| 307 |
+
[규칙]
|
| 308 |
+
- 수신/발신/끝. 같은 공문 요소 사용 안 함
|
| 309 |
+
- 마크다운 기호 절대 금지
|
| 310 |
+
- 사용자가 준 내용만 정리, 추측 금지
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
|
| 312 |
[★ 문체 — 가장 중요]
|
| 313 |
+
- 명사형 종결 (정중체 절대 금지)
|
| 314 |
+
- 권장: "시행 예정", "추진 계획", "검토 중", "수립", "협의 완료"
|
| 315 |
+
- 금지: "~합니다", "~입니다", "~예정입니다", "~바랍니다"`,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
|
| 317 |
+
"내부품의": `당신은 한국전력공사(한전, KEPCO) 내부품의서 작성 전문가입니다. 현재는 2026년입니다.
|
| 318 |
+
사용자가 요청한 내용을 아래 양식에 정확히 맞춰 작성하세요.
|
|
|
|
|
|
|
| 319 |
|
| 320 |
[양식]
|
| 321 |
품 의 서
|
|
|
|
| 324 |
|
| 325 |
제 목 (문서 제목)
|
| 326 |
|
| 327 |
+
(사유·배경 — 1~2문장)
|
| 328 |
|
| 329 |
+
1. (섹션명)
|
| 330 |
가. 항목
|
| 331 |
나. 항목
|
| 332 |
|
| 333 |
+
2. (섹션명)
|
| 334 |
가. 항목
|
| 335 |
- 세부
|
|
|
|
|
|
|
|
|
|
| 336 |
|
| 337 |
붙임 1. (첨부파일명 N부.)
|
| 338 |
|
|
|
|
| 343 |
(기안부서장)
|
| 344 |
|
| 345 |
[규칙]
|
| 346 |
+
- 결재선 작성 금지
|
| 347 |
+
- 본문: 명사형 종결, 결문만 정중체 허용
|
| 348 |
+
- 마크다운 기호 절대 금지`
|
| 349 |
+
};
|
|
|
|
|
|
|
|
|
|
| 350 |
|
| 351 |
+
const PROMPTS_REVIEW = {
|
| 352 |
+
"공문": `당신은 한국전력공사(한전, KEPCO) 공문 검토 전문가입니다.
|
| 353 |
+
사용자가 제출한 공문을 아래 기준으로 검토한 뒤, 검토 의견과 수정본을 작성하세요.
|
| 354 |
|
| 355 |
+
[검토 기준]
|
| 356 |
+
1. 구조: 수신/제목/본문/붙임/끝. 순서와 완결성
|
| 357 |
+
2. 문체: 정중체(~합니다, ~바랍니다) 유지 여부
|
| 358 |
+
3. 날짜: "YYYY. M. D.(요일)" 형식 준수
|
| 359 |
+
4. 항목 기호: "1. 2." → "가. 나." → "-" 계층 유지
|
| 360 |
+
5. 금지 사항: 마크다운(*,#), 발신명의, "관련:" 법령 인용
|
| 361 |
|
| 362 |
+
[출력 형식] 마크다운 기호 없이 작성
|
|
|
|
|
|
|
| 363 |
|
| 364 |
+
===검토 의견===
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
|
| 366 |
+
(발견된 문제점 또는 개선 사항. 문제 없으면 "양식 및 문체 이상 없음."으로 기재)
|
| 367 |
+
1. ...
|
| 368 |
+
|
| 369 |
+
===수정본===
|
| 370 |
+
|
| 371 |
+
수신 ...
|
| 372 |
+
제목 ...
|
| 373 |
+
|
| 374 |
+
(수정된 공문 본문)
|
| 375 |
+
|
| 376 |
+
붙임 ... 끝.`,
|
| 377 |
+
|
| 378 |
+
"보고서": `당신은 한국전력공사(한전, KEPCO) 내부 보고서 검토 전문가입니다.
|
| 379 |
+
사용자가 제출한 보고서를 아래 기준으로 검토한 뒤, 검토 의견과 수정본을 작성하세요.
|
| 380 |
+
|
| 381 |
+
[검토 기준]
|
| 382 |
+
1. 구조: 날짜·부서·섹션 번호(1. 2. 3.) 체계 확인
|
| 383 |
+
2. 문체: 명사형 종결 준수 여부 (정중체 금지)
|
| 384 |
+
3. 항목 기호: "가. 나." / "-" 계층 유지
|
| 385 |
+
4. 금지 사항: 마크다운, 수신/끝. 같은 공문 요소
|
| 386 |
+
|
| 387 |
+
[★ 명사형 종결 위반 집중 확인]
|
| 388 |
+
- 위반: "~합니다", "~입니다", "~예정입니다"
|
| 389 |
+
- 올바름: "시행 예정", "추진 계획", "검토 중"
|
| 390 |
+
|
| 391 |
+
[출력 형식] 마크다운 기호 없이 작성
|
| 392 |
+
|
| 393 |
+
===검토 의견===
|
| 394 |
+
|
| 395 |
+
1. ...
|
| 396 |
+
|
| 397 |
+
===수정본===
|
| 398 |
+
|
| 399 |
+
(수정된 보고서 전문)`,
|
| 400 |
+
|
| 401 |
+
"내부품의": `당신은 한국전력공사(한전, KEPCO) 내부품의서 검토 전문가입니다.
|
| 402 |
+
사용자가 제출한 품의서를 아래 기준으로 검토한 뒤, 검토 의견과 수정본을 작성하세요.
|
| 403 |
+
|
| 404 |
+
[검토 기준]
|
| 405 |
+
1. 구조: 품의서/기안부서/제목/본문/결문/날짜/기안부서장 확인
|
| 406 |
+
2. 본문 문체: 명사형 종결 (결문만 정중체 허용)
|
| 407 |
+
3. 금지 사항: 마크다운, 결재선, 수신/끝. 같은 공문 요소
|
| 408 |
+
4. 날짜: "YYYY. M. D." 형식
|
| 409 |
+
|
| 410 |
+
[출력 형식] 마크다운 기호 없이 작성
|
| 411 |
+
|
| 412 |
+
===검토 의견===
|
| 413 |
+
|
| 414 |
+
1. ...
|
| 415 |
|
| 416 |
+
===수정본===
|
| 417 |
+
|
| 418 |
+
품 의 서
|
| 419 |
+
|
| 420 |
+
(기안부서)
|
| 421 |
+
|
| 422 |
+
제 목 ...
|
| 423 |
+
|
| 424 |
+
(수정된 본문)
|
| 425 |
+
|
| 426 |
+
위와 같이 품의합니다.
|
| 427 |
+
|
| 428 |
+
YYYY. M. D.
|
| 429 |
+
|
| 430 |
+
(기안부서장)`
|
| 431 |
+
};
|
| 432 |
+
|
| 433 |
+
// ============================================================
|
| 434 |
+
// 상태
|
| 435 |
+
// ============================================================
|
| 436 |
+
let currentTab = "write";
|
| 437 |
+
let abortController = null;
|
| 438 |
+
let isReady = false;
|
| 439 |
+
|
| 440 |
+
// ============================================================
|
| 441 |
+
// DOM 참조
|
| 442 |
+
// ============================================================
|
| 443 |
+
const runBtn = document.getElementById("run-btn");
|
| 444 |
+
const stopBtn = document.getElementById("stop-btn");
|
| 445 |
+
const copyBtn = document.getElementById("copy-btn");
|
| 446 |
+
const dlHwpxBtn = document.getElementById("dl-hwpx-btn");
|
| 447 |
+
const outputEl = document.getElementById("output");
|
| 448 |
+
const statusMsg = document.getElementById("status-msg");
|
| 449 |
+
const loadingBanner= document.getElementById("loading-banner");
|
| 450 |
+
const loadingMsg = document.getElementById("loading-msg");
|
| 451 |
+
|
| 452 |
+
// ============================================================
|
| 453 |
+
// 탭 전환
|
| 454 |
+
// ============================================================
|
| 455 |
+
document.querySelectorAll(".tab-btn").forEach(btn => {
|
| 456 |
+
btn.addEventListener("click", () => {
|
| 457 |
+
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
|
| 458 |
+
document.querySelectorAll(".panel").forEach(p => p.classList.remove("active"));
|
| 459 |
+
btn.classList.add("active");
|
| 460 |
+
currentTab = btn.dataset.tab;
|
| 461 |
+
document.getElementById(`panel-${currentTab}`).classList.add("active");
|
| 462 |
+
runBtn.textContent = currentTab === "write" ? "생성" : "검토";
|
| 463 |
+
});
|
| 464 |
+
});
|
| 465 |
+
|
| 466 |
+
// ============================================================
|
| 467 |
+
// 모델 로딩 폴링
|
| 468 |
+
// ============================================================
|
| 469 |
+
async function pollReady() {
|
| 470 |
+
while (true) {
|
| 471 |
try {
|
| 472 |
+
const r = await fetch("/health");
|
| 473 |
+
const d = await r.json();
|
| 474 |
+
if (d.ready) {
|
| 475 |
+
isReady = true;
|
| 476 |
+
loadingBanner.classList.add("hidden");
|
| 477 |
+
runBtn.disabled = false;
|
| 478 |
+
setStatus("준비 완료. 입력 후 버튼을 누르세요.");
|
| 479 |
+
return;
|
|
|
|
|
|
|
| 480 |
}
|
| 481 |
+
loadingMsg.textContent = `모델 로딩 중... (${d.status})`;
|
| 482 |
} catch (_) {
|
| 483 |
+
loadingMsg.textContent = "서버 연결 시도 중...";
|
| 484 |
}
|
| 485 |
+
await sleep(4000);
|
| 486 |
}
|
|
|
|
| 487 |
}
|
| 488 |
|
| 489 |
+
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
| 490 |
+
|
| 491 |
+
// ============================================================
|
| 492 |
+
// 생성 / 검토 실행
|
| 493 |
+
// ============================================================
|
| 494 |
+
runBtn.addEventListener("click", run);
|
| 495 |
+
stopBtn.addEventListener("click", () => {
|
| 496 |
+
if (abortController) abortController.abort();
|
| 497 |
+
setStatus("중지됨.");
|
| 498 |
+
setBusy(false);
|
| 499 |
+
});
|
| 500 |
+
|
| 501 |
+
async function run() {
|
| 502 |
+
const docType = document.querySelector("input[name=docType]:checked").value;
|
| 503 |
+
const input = currentTab === "write"
|
| 504 |
+
? document.getElementById("write-input").value.trim()
|
| 505 |
+
: document.getElementById("review-input").value.trim();
|
| 506 |
+
|
| 507 |
+
if (!input) {
|
| 508 |
+
setStatus(currentTab === "write" ? "내용 요점을 입력해 주세요." : "검토할 원문을 입력해 주세요.");
|
| 509 |
return;
|
| 510 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 511 |
|
| 512 |
+
const sysPrompt = currentTab === "write"
|
| 513 |
+
? PROMPTS_WRITE[docType]
|
| 514 |
+
: PROMPTS_REVIEW[docType];
|
| 515 |
+
const maxTokens = currentTab === "write" ? 600 : 900;
|
| 516 |
+
|
| 517 |
+
outputEl.textContent = "";
|
| 518 |
+
setBusy(true);
|
| 519 |
+
setStatus(currentTab === "write" ? "생성 중..." : "검토 중...");
|
| 520 |
+
|
| 521 |
+
abortController = new AbortController();
|
| 522 |
+
let buffer = "";
|
| 523 |
|
| 524 |
try {
|
| 525 |
+
const resp = await fetch("/generate", {
|
| 526 |
+
method: "POST",
|
| 527 |
+
headers: { "Content-Type": "application/json", "Accept": "text/event-stream" },
|
| 528 |
body: JSON.stringify({
|
| 529 |
messages: [
|
| 530 |
+
{ role: "system", content: sysPrompt },
|
| 531 |
+
{ role: "user", content: input }
|
| 532 |
],
|
| 533 |
+
max_new_tokens: maxTokens,
|
| 534 |
temperature: 0.7,
|
| 535 |
top_p: 0.8,
|
| 536 |
+
top_k: 20
|
| 537 |
}),
|
| 538 |
+
signal: abortController.signal
|
| 539 |
});
|
| 540 |
+
|
| 541 |
+
if (!resp.ok) {
|
| 542 |
+
const err = await resp.text();
|
| 543 |
+
throw new Error(`서버 오류 ${resp.status}: ${err}`);
|
| 544 |
}
|
| 545 |
|
| 546 |
+
const reader = resp.body.getReader();
|
| 547 |
const decoder = new TextDecoder();
|
| 548 |
+
let partial = "";
|
| 549 |
+
|
|
|
|
|
|
|
| 550 |
while (true) {
|
| 551 |
+
const { done, value } = await reader.read();
|
| 552 |
if (done) break;
|
| 553 |
+
partial += decoder.decode(value, { stream: true });
|
| 554 |
+
const lines = partial.split("\n");
|
| 555 |
+
partial = lines.pop();
|
| 556 |
+
|
|
|
|
|
|
|
| 557 |
for (const line of lines) {
|
| 558 |
+
if (!line.startsWith("data:")) continue;
|
| 559 |
try {
|
| 560 |
const obj = JSON.parse(line.slice(5).trim());
|
| 561 |
+
if (obj.error) throw new Error(obj.error);
|
|
|
|
|
|
|
|
|
|
| 562 |
if (obj.done) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 563 |
setStatus(`완료 (${obj.tokens} 토큰, ${obj.elapsed}초)`);
|
| 564 |
+
break;
|
| 565 |
}
|
| 566 |
+
if (obj.token) {
|
|
|
|
| 567 |
buffer += obj.token;
|
| 568 |
+
outputEl.textContent = stripMd(buffer);
|
| 569 |
outputEl.scrollTop = outputEl.scrollHeight;
|
| 570 |
}
|
| 571 |
} catch (e) {
|
| 572 |
+
if (e.message.startsWith("완료") || e.message.includes("토큰")) {
|
| 573 |
+
// ignore parse of done line
|
| 574 |
+
} else {
|
| 575 |
+
throw e;
|
| 576 |
+
}
|
| 577 |
}
|
| 578 |
}
|
| 579 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 580 |
} catch (e) {
|
| 581 |
+
if (e.name !== "AbortError") setStatus("오류: " + e.message);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 582 |
} finally {
|
| 583 |
+
setBusy(false);
|
|
|
|
|
|
|
| 584 |
}
|
| 585 |
}
|
| 586 |
|
| 587 |
+
// ============================================================
|
| 588 |
+
// 마크다운 기호 제거
|
| 589 |
+
// ============================================================
|
| 590 |
+
function stripMd(text) {
|
| 591 |
+
return text
|
| 592 |
+
.replace(/\*\*(.+?)\*\*/gs, "$1")
|
| 593 |
+
.replace(/(?<!\*)\*(?!\s)(.+?)(?<!\s)\*(?!\*)/gs, "$1")
|
| 594 |
+
.replace(/^#{1,6}\s+/gm, "");
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
+
// ============================================================
|
| 598 |
+
// 복사 / HWPX 저장
|
| 599 |
+
// ============================================================
|
| 600 |
+
copyBtn.addEventListener("click", () => {
|
| 601 |
const text = outputEl.textContent;
|
| 602 |
+
if (!text) return;
|
| 603 |
+
navigator.clipboard.writeText(text).then(() => setStatus("클립보드에 복사됨."));
|
| 604 |
+
});
|
| 605 |
+
|
| 606 |
+
dlHwpxBtn.addEventListener("click", async () => {
|
| 607 |
+
const text = outputEl.textContent.trim();
|
| 608 |
+
if (!text) return;
|
| 609 |
try {
|
| 610 |
+
const resp = await fetch("/download/hwpx", {
|
| 611 |
+
method: "POST",
|
| 612 |
+
headers: { "Content-Type": "application/json" },
|
| 613 |
+
body: JSON.stringify({ text })
|
|
|
|
| 614 |
});
|
| 615 |
+
if (!resp.ok) throw new Error(await resp.text());
|
| 616 |
+
const blob = await resp.blob();
|
| 617 |
+
const cd = resp.headers.get("Content-Disposition") || "";
|
| 618 |
+
const m = cd.match(/filename\*=UTF-8''(.+)/);
|
| 619 |
+
const filename = m ? decodeURIComponent(m[1]) : "문서.hwpx";
|
| 620 |
const url = URL.createObjectURL(blob);
|
| 621 |
+
const a = document.createElement("a");
|
| 622 |
+
a.href = url; a.download = filename; a.click();
|
| 623 |
URL.revokeObjectURL(url);
|
| 624 |
+
setStatus("다운로드 완료.");
|
| 625 |
} catch (e) {
|
| 626 |
+
setStatus("다운로드 실패: " + e.message);
|
| 627 |
}
|
| 628 |
});
|
| 629 |
|
| 630 |
+
// ============================================================
|
| 631 |
+
// 유틸
|
| 632 |
+
// ============================================================
|
| 633 |
+
function setBusy(busy) {
|
| 634 |
+
runBtn.disabled = busy || !isReady;
|
| 635 |
+
stopBtn.disabled = !busy;
|
| 636 |
+
}
|
| 637 |
+
function setStatus(msg) { statusMsg.textContent = msg; }
|
| 638 |
+
|
| 639 |
+
// ============================================================
|
| 640 |
+
// 시작
|
| 641 |
+
// ============================================================
|
| 642 |
+
pollReady();
|
| 643 |
</script>
|
| 644 |
</body>
|
| 645 |
</html>
|