aiaiteam Claude Sonnet 4.6 commited on
Commit
031c1c3
·
1 Parent(s): 3f4dcc3

공문 검토·작성 시스템으로 전환

Browse files

- app.py: 제목 업데이트 (Gongmun API → 한전 공문 검토·작성 API)
- static/index.html: 작성/검토 탭 UI 추가, 검토 시스템 프롬프트 3종 내장
- README.md: 설명 및 제목 업데이트

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (3) hide show
  1. README.md +23 -2
  2. app.py +4 -15
  3. static/index.html +493 -343
README.md CHANGED
@@ -1,8 +1,29 @@
1
  ---
2
- title: Gongmun API
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
- """HuggingFace Space - 공문 성 API (FastAPI + onnxruntime)
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="Gongmun API", version="0.1")
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 파일로 다운로드 (UTF-8 텍스트, 확장자 .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") # UTF-8 with BOM
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
- <meta http-equiv="Cache-Control" content="no-store">
7
- <title>공문 생성기</title>
8
  <style>
9
- :root {
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
- margin: 0;
22
- font-family: "Pretendard", "Malgun Gothic", "맑은 고딕", -apple-system, BlinkMacSystemFont, sans-serif;
23
- background: var(--bg);
24
- color: var(--text);
25
- line-height: 1.6;
26
- }
27
- .container {
28
- max-width: 1100px;
29
- margin: 0 auto;
30
- padding: 20px 16px 60px;
31
  }
32
- header h1 { margin: 0 0 4px; font-size: 22px; }
33
- header p { margin: 0 0 20px; color: var(--muted); font-size: 13px; }
34
- .grid {
35
- display: grid;
36
- grid-template-columns: 1fr 1fr;
37
- gap: 16px;
 
38
  }
39
- @media (max-width: 800px) {
40
- .grid { grid-template-columns: 1fr; }
 
 
 
 
 
 
41
  }
42
- .panel {
43
- background: var(--panel);
44
- border: 1px solid var(--border);
 
 
45
  border-radius: 8px;
46
- padding: 16px;
 
 
 
 
 
 
 
 
 
 
47
  }
48
- .panel h2 {
49
- margin: 0 0 12px;
 
 
 
 
50
  font-size: 13px;
51
- color: var(--muted);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  font-weight: 600;
53
- letter-spacing: 0.5px;
54
- text-transform: uppercase;
 
 
55
  }
56
- label {
 
 
 
 
 
 
57
  display: block;
58
  font-size: 12px;
59
- color: var(--muted);
60
- margin: 8px 0 4px;
 
61
  }
62
- input[type=text], select, textarea {
63
  width: 100%;
64
- padding: 8px 10px;
65
- border: 1px solid var(--border);
66
  border-radius: 6px;
 
67
  font-family: inherit;
68
- font-size: 14px;
69
- background: #fff;
70
- }
71
- textarea { resize: vertical; min-height: 100px; }
72
- .row { display: flex; gap: 8px; flex-wrap: wrap; }
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
- button.secondary:hover:not(:disabled) { background: #f5f5f4; }
92
- .btnrow {
 
 
 
 
93
  display: flex;
 
94
  gap: 8px;
95
  flex-wrap: wrap;
96
- margin-top: 12px;
97
  }
98
- .btnrow > button:first-child { flex: 1; }
99
- .status {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  font-size: 12px;
101
- color: var(--muted);
102
- margin-top: 10px;
103
- min-height: 18px;
104
  }
105
- .status.error { color: var(--error); }
106
- .output {
107
- white-space: pre-wrap;
108
- font-family: "D2Coding", "Consolas", "Malgun Gothic", monospace;
109
- font-size: 14px;
110
- background: #fafaf9;
111
- border: 1px solid var(--border);
 
 
 
 
 
 
 
112
  border-radius: 6px;
113
- padding: 14px;
114
- min-height: 360px;
 
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
- <label for="prompt">내용 요점</label>
143
- <textarea id="prompt" rows="10" placeholder="예: 5.13(수) 부사장 회의 참석 요청, 9~10시, 본사 회의실. 전 부서장 참석 필요. 회의 자료 5.12까지 제출."></textarea>
 
 
 
144
 
145
- <div class="btnrow">
146
- <button id="generate" disabled>로딩 중...</button>
147
- <button id="stop" class="secondary" disabled>중지</button>
148
- </div>
149
- <div class="status" id="status">서버 깨우는 ...</div>
150
- </section>
151
-
152
- <section class="panel">
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
- <script type="module">
164
- const $ = (id) => document.getElementById(id);
165
- const statusEl = $('status');
166
- const outputEl = $('output');
167
- const genBtn = $('generate');
168
- const stopBtn = $('stop');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
 
170
- let abortCtrl = null;
 
 
 
 
 
 
 
 
 
 
171
 
172
- const setStatus = (msg, isError = false) => {
173
- statusEl.textContent = msg;
174
- statusEl.classList.toggle('error', isError);
175
- };
176
 
177
- const SYSTEM_PROMPT_LETTER = `당신은 한국전력공사(한전, KEPCO) 공문 작성 전문가입니다. 현재는 2026년입니다.
 
 
 
 
 
178
  사용자가 요청한 내용을 아래 양식에 정확히 맞춰 작성하세요.
179
 
180
  [조직 정보]
181
  - 회사: 한국전력공사 (본문에서 "우리 회사" 또는 "한전"으로 지칭)
182
  - 시점: 2026년 (날짜에 별도 연도 없으면 2026년 기준)
183
- - 부서명 예시: 전략경영부, ICT운부, 협력사업기획부, 디털진단부, 인사관리부, 영업본부 등
184
- - 직책: 처장 / 부장 / 차장 / 과장 등 한전 직제
185
 
186
  [양식]
187
  수신 (수신처)
188
  제목 (문서 제목)
189
 
190
- (본문 도입 — 한 단락으로 작성. 문서 목적·일정·요청사항 2~3문장으로 압축. 따로 번호 안 매김.)
191
 
192
  1. (세부 항목 1)
193
  2. (세부 항목 2)
194
- 3. (세부 항목 3)
 
195
 
196
- ※ (추가 안내가 필요할 경우 ※ 마커로 시작)
197
 
198
- 붙임 1. (붙임 파일명 N부.) 끝.
199
 
200
  [규칙]
201
- - 본문 도입부는 "(주체)는 (목적/일정)을 (어떻게) 진행/실시/요청합니다. 협조하여 주시기 바랍니다." 형태
202
- - 세부 항목은 도입부 안의 "아래와 같이" 이후 1./2./3. 번호로 나열 (가./나./다. 사용 안 함)
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
- 우리 회사는 2026년 상반기 정기 안교육을 5월 20일에 실시할 예정이며 아래와 같이 진행되오소속 직원이 모두 참석할 수 있도록 협조하여 주시기 바랍니다.
 
218
 
219
- 1. 일시: 2026. 5. 20.(수) 14:00 ~ 16:00
220
- 2. 장소: 본관 대강당
221
- 3. 대상: 전 직원
222
 
223
- 부서 미참석자는 별도 보강 교육 일정에 편성될 예정입니다.
224
 
225
- 붙임 1. 안전교육 일정표 1부. 끝.`;
 
 
226
 
227
- const SYSTEM_PROMPT_REPORT = `당신은 한국전력공사(한전, KEPCO) 내부 보고서 작성 전문가입니다. 현재는 2026년입니다.
228
- 사용자 요청한 내용을 표준 보고서 양식에 맞춰 작성하세요.
 
229
 
230
- [조직 정보]
231
- - 회사: 한국전력공사
232
- - 시점: 2026년 (별도 연도 없으면 2026년 기준)
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
- - 수신/발신/끝. 같은 공문 letter 양식 요소 사용 금지
307
- - 결재선(담당·과장·부장 재자 목록) 작성 금지 — 기안부서장표기
308
- - 본문 섹션: "1. 2. 3." + 제목, 세부: "가. 나. .", 세부: "-" 글머리
309
- - 날짜: "YYYY. M. D."
310
- - 문체: 본문은 명사형 종결, 마지막 결문만 "위와 같이 품의합니다" 정중체
311
- - 마크다운 기호(**, *, #) 절대 금지
312
- - 사용자가 준 내용만 사용, 추측·가공 금지`;
313
 
314
- function getMode() {
315
- return document.querySelector('input[name="doc-mode"]:checked')?.value || '공문';
316
- }
317
 
318
- function getSystemPrompt() {
319
- const mode = getMode();
320
- if (mode === '보고서') return SYSTEM_PROMPT_REPORT;
321
- if (mode === '내부품의') return SYSTEM_PROMPT_APPROVAL;
322
- return SYSTEM_PROMPT_LETTER;
323
- }
324
 
325
- function buildUserPrompt() {
326
- return $('prompt').value.trim();
327
- }
328
 
329
- function stripMarkdown(text) {
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
- function ensureEndMark(text) {
338
- if (!text) return text;
339
- const stripped = text.replace(/\s+$/, '');
340
- if (/끝\.?$/.test(stripped)) return text;
341
- return stripped + ' 끝.';
342
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
 
344
- async function checkHealth() {
345
- for (let i = 0; i < 60; i++) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
  try {
347
- const r = await fetch('/health');
348
- if (r.ok) {
349
- const j = await r.json();
350
- if (j.ready) {
351
- setStatus('준비 완료. 입력 후 [생성]을 누르세요.');
352
- genBtn.disabled = false;
353
- genBtn.textContent = '생성';
354
- return;
355
- }
356
- setStatus(`로딩 중: ${j.status || '...'}`);
357
  }
 
358
  } catch (_) {
359
- setStatus('서버 응답 대기 중...');
360
  }
361
- await new Promise(r => setTimeout(r, 3000));
362
  }
363
- setStatus('서버 준비 안 됨 (3분 타임아웃)', true);
364
  }
365
 
366
- async function generate() {
367
- if (!$('prompt').value.trim()) {
368
- setStatus('내용 요점을 입력하세요.', true);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
369
  return;
370
  }
371
- outputEl.textContent = '';
372
- genBtn.disabled = true;
373
- stopBtn.disabled = false;
374
- setStatus('생성 중...');
375
 
376
- abortCtrl = new AbortController();
377
- let buffer = '';
 
 
 
 
 
 
 
 
 
378
 
379
  try {
380
- const r = await fetch('/generate', {
381
- method: 'POST',
382
- headers: { 'Content-Type': 'application/json' },
383
  body: JSON.stringify({
384
  messages: [
385
- { role: 'system', content: getSystemPrompt() },
386
- { role: 'user', content: buildUserPrompt() },
387
  ],
388
- max_new_tokens: 600,
389
  temperature: 0.7,
390
  top_p: 0.8,
391
- top_k: 20,
392
  }),
393
- signal: abortCtrl.signal,
394
  });
395
- if (!r.ok) {
396
- const t = await r.text();
397
- throw new Error(`HTTP ${r.status}: ${t}`);
 
398
  }
399
 
400
- const reader = r.body.getReader();
401
  const decoder = new TextDecoder();
402
- let leftover = '';
403
- let chunkCount = 0;
404
- let tokenCount = 0;
405
- let doneSeen = false;
406
  while (true) {
407
- const { value, done } = await reader.read();
408
  if (done) break;
409
- chunkCount++;
410
- const decoded = decoder.decode(value, { stream: true });
411
- console.log('[SSE chunk #' + chunkCount + ']', decoded.slice(0, 200));
412
- const chunk = leftover + decoded;
413
- const lines = chunk.split('\n');
414
- leftover = lines.pop() || '';
415
  for (const line of lines) {
416
- if (!line.startsWith('data:')) continue;
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
- return;
431
  }
432
- if (obj.token !== undefined) {
433
- tokenCount++;
434
  buffer += obj.token;
435
- outputEl.textContent = stripMarkdown(buffer);
436
  outputEl.scrollTop = outputEl.scrollHeight;
437
  }
438
  } catch (e) {
439
- console.warn('[SSE parse fail]', line, e.message);
 
 
 
 
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 === 'AbortError') {
451
- setStatus('중지됨.');
452
- } else {
453
- setStatus('오류: ' + e.message, true);
454
- }
455
  } finally {
456
- genBtn.disabled = false;
457
- stopBtn.disabled = true;
458
- abortCtrl = null;
459
  }
460
  }
461
 
462
- genBtn.addEventListener('click', generate);
463
- stopBtn.addEventListener('click', () => abortCtrl?.abort());
464
- $('copy').addEventListener('click', async () => {
465
- await navigator.clipboard.writeText(outputEl.textContent);
466
- setStatus('복사됨.');
467
- });
468
- $('download-hwpx').addEventListener('click', async () => {
 
 
 
 
 
 
 
469
  const text = outputEl.textContent;
470
- if (!text.trim()) { setStatus('저장할 내용 없음.', true); return; }
 
 
 
 
 
 
471
  try {
472
- setStatus('.hwpx 생성 중...');
473
- const r = await fetch('/download/hwpx', {
474
- method: 'POST',
475
- headers: { 'Content-Type': 'application/json' },
476
- body: JSON.stringify({ text }),
477
  });
478
- if (!r.ok) throw new Error(`서버 오류 ${r.status}`);
479
- const blob = await r.blob();
480
- const m = text.match(/^제\s*목\s+(.+)$/m);
481
- const title = (m ? m[1].trim() : getMode()).replace(/[\\/:*?"<>|]/g, '_').slice(0, 50);
 
482
  const url = URL.createObjectURL(blob);
483
- const a = document.createElement('a');
484
- a.href = url; a.download = title + '.hwpx'; a.click();
485
  URL.revokeObjectURL(url);
486
- setStatus('저장됨: ' + title + '.hwpx');
487
  } catch (e) {
488
- setStatus('저장 실패: ' + e.message, true);
489
  }
490
  });
491
 
492
- checkHealth();
 
 
 
 
 
 
 
 
 
 
 
 
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>