document / static /index.html
aiaiteam's picture
fix: 시스템 이름에서 '한전' 제거 + 업로드 오류 가시성 개선
20f31cc
Raw
History Blame Contribute Delete
23.3 kB
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>공문 검토·작성 시스템</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: "맑은 고딕", "Malgun Gothic", "Apple SD Gothic Neo", sans-serif;
font-size: 14px;
background: #f4f6f9;
color: #222;
min-height: 100vh;
}
header {
background: #003087;
color: #fff;
padding: 14px 24px;
display: flex;
align-items: center;
gap: 12px;
}
header h1 { font-size: 18px; font-weight: 700; letter-spacing: -0.3px; }
header .badge {
background: #ffd700;
color: #003087;
font-size: 11px;
font-weight: 700;
padding: 2px 8px;
border-radius: 10px;
}
.container { max-width: 960px; margin: 24px auto; padding: 0 16px 40px; }
.card {
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0,0,0,.10);
padding: 20px 24px;
margin-bottom: 16px;
}
/* 문서 양식 선택 (작성 탭) */
.doc-type-row { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.doc-type-row .label { font-weight: 600; color: #555; min-width: 64px; }
.doc-type-row label { display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 13px; }
.doc-type-row input[type=radio] { accent-color: #003087; }
/* 탭 */
.tabs { display: flex; border-bottom: 2px solid #e0e4ea; margin-bottom: 16px; gap: 2px; }
.tab-btn {
padding: 10px 28px;
border: none; background: none; cursor: pointer;
font-size: 14px; font-weight: 600; color: #777;
border-bottom: 3px solid transparent;
margin-bottom: -2px;
transition: color .15s, border-color .15s;
}
.tab-btn.active { color: #003087; border-bottom-color: #003087; }
.tab-btn:hover:not(.active) { color: #333; }
.panel { display: none; }
.panel.active { display: block; }
.panel label.field-label { display: block; font-size: 12px; font-weight: 600; color: #555; margin-bottom: 6px; }
textarea {
width: 100%;
border: 1px solid #d0d5dd; border-radius: 6px;
padding: 10px 12px;
font-family: inherit; font-size: 13px;
resize: vertical; line-height: 1.6;
outline: none; transition: border-color .15s;
}
textarea:focus { border-color: #003087; }
#write-input { min-height: 140px; }
#review-input { min-height: 200px; }
/* 버튼 */
.btn-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.btn {
padding: 8px 18px;
border: none; border-radius: 6px;
cursor: pointer; font-size: 13px; font-weight: 600;
transition: background .15s, opacity .15s;
}
.btn-primary { background: #003087; color: #fff; }
.btn-primary:hover:not(:disabled) { background: #00246e; }
.btn-secondary { background: #e8edf5; color: #333; }
.btn-secondary:hover:not(:disabled) { background: #d5dce8; }
.btn:disabled { opacity: .4; cursor: default; }
#status-msg { margin-left: auto; font-size: 12px; color: #666; }
/* 결과 */
.result-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.result-header span { font-weight: 600; color: #555; font-size: 13px; }
#output {
width: 100%; min-height: 280px;
background: #f8f9fb;
border: 1px solid #e0e4ea; border-radius: 6px;
padding: 14px 16px;
font-family: "D2Coding", "Consolas", "맑은 고딕", monospace;
font-size: 13px; line-height: 1.8;
white-space: pre-wrap; word-break: break-all;
}
/* 로딩 배너 */
#loading-banner {
background: #fffbe6; border: 1px solid #ffe58f; border-radius: 6px;
padding: 10px 16px; font-size: 13px; color: #7a5200;
margin-bottom: 12px; display: flex; align-items: center; gap: 8px;
}
#loading-banner.hidden { display: none; }
.spinner {
width: 16px; height: 16px;
border: 2px solid #ffe58f; border-top-color: #d48806;
border-radius: 50%; animation: spin .8s linear infinite; flex-shrink: 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* 문서 유형 선택 모달 (검토 전용) */
#type-modal {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,.45); z-index: 100;
align-items: center; justify-content: center;
}
#type-modal.open { display: flex; }
.modal-box {
background: #fff; border-radius: 12px;
padding: 28px 32px 24px; width: 320px;
box-shadow: 0 8px 32px rgba(0,0,0,.2); text-align: center;
}
.modal-box h2 { font-size: 16px; font-weight: 700; margin-bottom: 18px; color: #111; }
.modal-type-btn {
display: block; width: 100%; margin-bottom: 10px;
padding: 11px 0; border: 2px solid #e0e4ea; border-radius: 8px;
background: #fff; cursor: pointer; font-size: 14px; font-weight: 600; color: #222;
transition: border-color .15s, background .15s;
}
.modal-type-btn:hover { border-color: #003087; background: #f0f4ff; color: #003087; }
.modal-cancel { font-size: 12px; color: #888; cursor: pointer; margin-top: 4px; }
.modal-cancel:hover { color: #333; }
</style>
</head>
<body>
<!-- 문서 유형 선택 모달 (검토 버튼 클릭 시 표시) -->
<div id="type-modal">
<div class="modal-box">
<h2>검토할 문서 유형 선택</h2>
<button class="modal-type-btn" data-type="공문-외부발송">공문 &mdash; 외부발송</button>
<button class="modal-type-btn" data-type="공문-내부">공문 &mdash; 내부</button>
<button class="modal-type-btn" data-type="보고서">보고서</button>
<div class="modal-cancel" id="modal-cancel">취소</div>
</div>
</div>
<header>
<h1>공문 검토·작성 시스템</h1>
<span class="badge">KEPCO AI</span>
</header>
<div class="container">
<div id="loading-banner">
<div class="spinner"></div>
<span id="loading-msg">모델 로딩 중... 잠시만 기다려주세요.</span>
</div>
<div class="card">
<div class="tabs">
<button class="tab-btn active" data-tab="write">작성</button>
<button class="tab-btn" data-tab="review">검토</button>
</div>
<!-- 작성 패널 -->
<div id="panel-write" class="panel active">
<!-- 문서 양식 선택 (작성 전용) -->
<div class="doc-type-row" style="margin-bottom:14px">
<span class="label">문서 양식</span>
<label><input type="radio" name="docType" value="공문-외부발송" checked> 공문-외부발송</label>
<label><input type="radio" name="docType" value="공문-내부"> 공문-내부</label>
<label><input type="radio" name="docType" value="보고서"> 보고서</label>
</div>
<label class="field-label">내용 요점 (작성할 내용의 핵심 사항을 입력하세요)</label>
<textarea id="write-input" placeholder="예) 안전교육 실시 협조 요청. 일시: 2026.7.15(화) 14:00~16:00, 장소: 본사 대강당, 대상: 전 직원"></textarea>
</div>
<!-- 검토 패널 -->
<div id="panel-review" class="panel">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
<label class="field-label" style="margin:0">원문 입력 (공문·보고서를 붙여넣거나 파일을 불러오세요)</label>
<label class="btn btn-secondary" style="cursor:pointer;padding:6px 14px;font-size:12px">
파일 불러오기...
<input id="file-input" type="file" accept=".hwp,.hwpx,.docx,.pdf,.txt" style="display:none">
</label>
</div>
<div id="upload-status" style="font-size:12px;color:#888;margin-bottom:4px;min-height:16px"></div>
<textarea id="review-input" placeholder="검토할 문서 전체 내용을 여기에 붙여넣으세요..."></textarea>
<div style="font-size:12px;color:#aaa;margin-top:6px">검토 버튼을 누르면 문서 유형을 선택합니다.</div>
</div>
<div class="btn-row" style="margin-top:14px">
<button id="run-btn" class="btn btn-primary" disabled>생성</button>
<button id="stop-btn" class="btn btn-secondary" disabled>중지</button>
<span id="status-msg"></span>
</div>
</div>
<div class="card">
<div class="result-header">
<span>결과</span>
<div style="display:flex;gap:8px">
<button id="copy-btn" class="btn btn-secondary">복사</button>
<button id="dl-hwpx-btn" class="btn btn-secondary">HWPX 저장</button>
</div>
</div>
<div id="output"></div>
</div>
</div>
<script>
// ============================================================
// 시스템 프롬프트
// ============================================================
const PROMPTS_WRITE = {
"공문-외부발송": `당신은 한국전력공사(한전, KEPCO) 외부 공문 작성 전문가입니다. 현재는 2026년입니다.
사용자가 요청한 내용을 아래 양식에 정확히 맞춰 작성하세요.
[외부 발송 공문 특성]
- 수신: 외부 기관·기업·정부부처 등
- 격식체로 정중하게 작성
- 붙임·끝. 구조 필수
[양식]
수신 (외부 기관명/직위)
제목 (문서 제목)
(본문 도입 — 2~3문장으로 목적·일정·요청사항 압축)
1. (세부 항목 1)
2. (세부 항목 2)
가. (서브 항목)
나. (서브 항목)
※ (추가 안내)
붙임 1. (붙임명 N부.) 끝.
[규칙]
- 발신명의 줄 생략
- 날짜: "YYYY. M. D.(요일)"
- 문체: "~합니다", "~바랍니다" 정중체
- 마크다운 기호(**, *, #) 절대 금지
- 사용자가 준 내용만 사용, 추측·가공 금지`,
"공문-내부": `당신은 한국전력공사(한전, KEPCO) 내부 공문 작성 전문가입니다. 현재는 2026년입니다.
사용자가 요청한 내용을 아래 양식에 정확히 맞춰 작성하세요.
[내부 공문 특성]
- 수신: 한전 내부 부서·직위 (예: "전략경영부장", "각 처·부장")
- 외부 공문보다 간결하게 작성 (서론 최소화)
- 붙임은 해당 시에만 작성
[양식]
수신 (내부 부서명 또는 직위)
제목 (문서 제목)
(본문 — 목적·지시·일정을 간결하게 2~3문장)
1. (세부 항목 1)
2. (세부 항목 2)
가. (서브 항목)
붙임 1. (붙임명 N부.) 끝.
[규칙]
- 발신명의 줄 생략
- 날짜: "YYYY. M. D.(요일)"
- 문체: "~합니다", "~바랍니다" 정중체
- 마크다운 기호(**, *, #) 절대 금지
- 붙임 없으면 마지막 문장 뒤에 바로 "끝." 표기
- 사용자가 준 내용만 사용, 추측·가공 금지`,
"보고서": `당신은 한국전력공사(한전, KEPCO) 내부 보고서 작성 전문가입니다. 현재는 2026년입니다.
사용자가 요청한 내용을 표준 보고서 양식에 맞춰 작성하세요.
[양식]
YYYY. M.
부서명 / 세부부서
1. (섹션명 - 예: 추진 배경)
가. 항목
나. 항목
2. (섹션명 - 예: 추진 내용)
가. 항목
- 세부
3. (섹션명 - 예: 향후 계획)
가. 항목
[규칙]
- 수신/발신/끝. 같은 공문 요소 사용 안 함
- 마크다운 기호 절대 금지
- 사용자가 준 내용만 정리, 추측 금지
[★ 문체]
- 명사형 종결 (정중체 절대 금지)
- 권장: "시행 예정", "추진 계획", "검토 중", "수립", "협의 완료"
- 금지: "~합니다", "~입니다", "~예정입니다", "~바랍니다"`
};
const PROMPTS_REVIEW = {
"공문-외부발송": `당신은 한국전력공사(한전, KEPCO) 외부 공문 검토 전문가입니다.
사용자가 제출한 외부 공문을 아래 기준으로 검토한 뒤, 검토 의견과 수정본을 작성하세요.
[검토 기준]
1. 구조: 수신/제목/본문/붙임/끝. 순서와 완결성
2. 수신처: 외부 기관명이 적절히 기재되었는지
3. 문체: 정중체(~합니다, ~바랍니다) 유지 여부
4. 날짜: "YYYY. M. D.(요일)" 형식 준수
5. 항목 기호: "1. 2." → "가. 나." → "-" 계층 유지
6. 금지 사항: 마크다운(*,#), 발신명의 기재
[출력 형식] 마크다운 기호 없이 작성
===검토 의견===
(발견된 문제점 또는 개선 사항. 문제 없으면 "양식 및 문체 이상 없음."으로 기재)
1. ...
===수정본===
수신 ...
제목 ...
(수정된 공문 본문)
붙임 ... 끝.`,
"공문-내부": `당신은 한국전력공사(한전, KEPCO) 내부 공문 검토 전문가입니다.
사용자가 제출한 내부 공문을 아래 기준으로 검토한 뒤, 검토 의견과 수정본을 작성하세요.
[검토 기준]
1. 구조: 수신/제목/본문/끝. 순서와 완결성
2. 수신처: 내부 부서·직위가 명확히 기재되었는지
3. 문체: 정중체(~합니다, ~바랍니다) 유지 여부
4. 간결성: 외부 공문보다 간결한지 (불필요한 서론 없는지)
5. 날짜: "YYYY. M. D.(요일)" 형식 준수
6. 금지 사항: 마크다운(*,#), 발신명의 기재
[출력 형식] 마크다운 기호 없이 작성
===검토 의견===
(발견된 문제점 또는 개선 사항)
1. ...
===수정본===
수신 ...
제목 ...
(수정된 내부 공문 본문)
끝.`,
"보고서": `당신은 한국전력공사(한전, KEPCO) 내부 보고서 검토 전문가입니다.
사용자가 제출한 보고서를 아래 기준으로 검토한 뒤, 검토 의견과 수정본을 작성하세요.
[검토 기준]
1. 구조: 날짜·부서·섹션 번호(1. 2. 3.) 체계 확인
2. 문체: 명사형 종결 준수 여부 (정중체 금지)
3. 항목 기호: "가. 나." / "-" 계층 유지
4. 금지 사항: 마크다운, 수신/끝. 같은 공문 요소
[★ 명사형 종결 위반 집중 확인]
- 위반: "~합니다", "~입니다", "~예정입니다"
- 올바름: "시행 예정", "추진 계획", "검토 중"
[출력 형식] 마크다운 기호 없이 작성
===검토 의견===
1. ...
===수정본===
(수정된 보고서 전문)`
};
// ============================================================
// 상태
// ============================================================
let currentTab = "write";
let abortController = null;
let isReady = false;
const runBtn = document.getElementById("run-btn");
const stopBtn = document.getElementById("stop-btn");
const copyBtn = document.getElementById("copy-btn");
const dlHwpxBtn = document.getElementById("dl-hwpx-btn");
const outputEl = document.getElementById("output");
const statusMsg = document.getElementById("status-msg");
const loadingBanner = document.getElementById("loading-banner");
const loadingMsg = document.getElementById("loading-msg");
// ============================================================
// 탭 전환
// ============================================================
document.querySelectorAll(".tab-btn").forEach(btn => {
btn.addEventListener("click", () => {
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
document.querySelectorAll(".panel").forEach(p => p.classList.remove("active"));
btn.classList.add("active");
currentTab = btn.dataset.tab;
document.getElementById(`panel-${currentTab}`).classList.add("active");
runBtn.textContent = currentTab === "write" ? "생성" : "검토";
});
});
// ============================================================
// 문서 유형 선택 모달 (검토 전용)
// ============================================================
const typeModal = document.getElementById("type-modal");
let resolveTypeModal = null;
document.querySelectorAll(".modal-type-btn").forEach(btn => {
btn.addEventListener("click", () => {
typeModal.classList.remove("open");
if (resolveTypeModal) resolveTypeModal(btn.dataset.type);
});
});
document.getElementById("modal-cancel").addEventListener("click", () => {
typeModal.classList.remove("open");
if (resolveTypeModal) resolveTypeModal(null);
});
function askDocType() {
return new Promise(resolve => {
resolveTypeModal = resolve;
typeModal.classList.add("open");
});
}
// ============================================================
// 모델 로딩 폴링
// ============================================================
async function pollReady() {
while (true) {
try {
const r = await fetch("/health");
const d = await r.json();
if (d.ready) {
isReady = true;
loadingBanner.classList.add("hidden");
runBtn.disabled = false;
setStatus("준비 완료. 입력 후 버튼을 누르세요.");
return;
}
loadingMsg.textContent = `모델 로딩 중... (${d.status})`;
} catch (_) {
loadingMsg.textContent = "서버 연결 시도 중...";
}
await sleep(4000);
}
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
// ============================================================
// 생성 / 검토 실행
// ============================================================
runBtn.addEventListener("click", run);
stopBtn.addEventListener("click", () => {
if (abortController) abortController.abort();
setStatus("중지됨.");
setBusy(false);
});
async function run() {
const input = currentTab === "write"
? document.getElementById("write-input").value.trim()
: document.getElementById("review-input").value.trim();
if (!input) {
const ta = document.getElementById(currentTab === "write" ? "write-input" : "review-input");
ta.style.borderColor = "#c62828";
ta.focus();
setTimeout(() => { ta.style.borderColor = ""; }, 2500);
setStatus(currentTab === "write" ? "내용 요점을 입력해 주세요." : "검토할 원문을 붙여넣거나 파일을 업로드하세요.");
return;
}
let docType;
if (currentTab === "review") {
docType = await askDocType();
if (!docType) return;
} else {
docType = document.querySelector("input[name=docType]:checked").value;
}
let sysPrompt = currentTab === "write" ? PROMPTS_WRITE[docType] : PROMPTS_REVIEW[docType];
const maxTokens = currentTab === "write" ? 600 : 900;
if (currentTab === "write") {
try {
const exResp = await fetch(`/examples/${encodeURIComponent(docType)}`);
if (exResp.ok) {
const exData = await exResp.json();
if (exData.examples) {
sysPrompt += `\n\n[참고 예시 문서 — 아래 양식을 참고하되, 내용은 사용자 입력만 사용할 것]\n\n${exData.examples}`;
}
}
} catch (_) {}
}
outputEl.textContent = "";
setBusy(true);
setStatus(currentTab === "write" ? `생성 중... (${docType})` : `검토 중... (${docType})`);
abortController = new AbortController();
let buffer = "";
try {
const resp = await fetch("/generate", {
method: "POST",
headers: { "Content-Type": "application/json", "Accept": "text/event-stream" },
body: JSON.stringify({
messages: [
{ role: "system", content: sysPrompt },
{ role: "user", content: input }
],
max_new_tokens: maxTokens,
temperature: 0.7, top_p: 0.8, top_k: 20
}),
signal: abortController.signal
});
if (!resp.ok) {
let detail = resp.statusText;
try { detail = (await resp.json()).detail || detail; } catch (_) {}
throw new Error(`서버 오류 ${resp.status}: ${detail}`);
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let partial = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
partial += decoder.decode(value, { stream: true });
const lines = partial.split("\n");
partial = lines.pop();
for (const line of lines) {
if (!line.startsWith("data:")) continue;
try {
const obj = JSON.parse(line.slice(5).trim());
if (obj.error) throw new Error(obj.error);
if (obj.done) { setStatus(`완료 (${obj.tokens} 토큰, ${obj.elapsed}초)`); break; }
if (obj.token) {
buffer += obj.token;
outputEl.textContent = stripMd(buffer);
outputEl.scrollTop = outputEl.scrollHeight;
}
} catch (e) {
if (!e.message.includes("토큰")) throw e;
}
}
}
} catch (e) {
if (e.name !== "AbortError") setStatus("오류: " + e.message);
} finally {
setBusy(false);
}
}
// ============================================================
// 마크다운 기호 제거
// ============================================================
function stripMd(text) {
return text
.replace(/\*\*(.+?)\*\*/gs, "$1")
.replace(/(?<!\*)\*(?!\s)(.+?)(?<!\s)\*(?!\*)/gs, "$1")
.replace(/^#{1,6}\s+/gm, "");
}
// ============================================================
// 파일 업로드 → /extract
// ============================================================
document.getElementById("file-input").addEventListener("change", async (e) => {
const file = e.target.files[0];
if (!file) return;
const uploadStatus = document.getElementById("upload-status");
uploadStatus.textContent = `읽는 중: ${file.name}`;
const formData = new FormData();
formData.append("file", file);
try {
const resp = await fetch("/extract", { method: "POST", body: formData });
if (!resp.ok) {
let detail = resp.statusText;
try { detail = (await resp.json()).detail || detail; } catch (_) {}
throw new Error(detail);
}
const data = await resp.json();
document.getElementById("review-input").value = data.text;
uploadStatus.style.color = "#388e3c";
uploadStatus.textContent = `로드 완료: ${file.name}`;
} catch (err) {
uploadStatus.style.color = "#c62828";
uploadStatus.style.fontWeight = "600";
uploadStatus.textContent = `파일 오류: ${err.message}`;
}
e.target.value = "";
});
// ============================================================
// 복사 / HWPX 저장
// ============================================================
copyBtn.addEventListener("click", () => {
const text = outputEl.textContent;
if (!text) return;
navigator.clipboard.writeText(text).then(() => setStatus("클립보드에 복사됨."));
});
dlHwpxBtn.addEventListener("click", async () => {
const text = outputEl.textContent.trim();
if (!text) return;
try {
const resp = await fetch("/download/hwpx", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text })
});
if (!resp.ok) throw new Error(await resp.text());
const blob = await resp.blob();
const cd = resp.headers.get("Content-Disposition") || "";
const m = cd.match(/filename\*=UTF-8''(.+)/);
const filename = m ? decodeURIComponent(m[1]) : "문서.hwpx";
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
setStatus("다운로드 완료.");
} catch (e) {
setStatus("다운로드 실패: " + e.message);
}
});
// ============================================================
// 유틸
// ============================================================
function setBusy(busy) { runBtn.disabled = busy || !isReady; stopBtn.disabled = !busy; }
function setStatus(msg) { statusMsg.textContent = msg; }
pollReady();
</script>
</body>
</html>