o2o-plagiarism-ai/app/static/index.html

752 lines
33 KiB
HTML

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>O2O 저작권 침해 여부 탐지 — 검토 콘솔</title>
<style>
:root {
--bg: #0d1117;
--panel: #161b22;
--panel-2: #1c2128;
--border: #30363d;
--text: #c9d1d9;
--muted: #8b949e;
--accent: #58a6ff;
--danger: #f85149;
--warning: #d29922;
--success: #3fb950;
--copy: #f85149;
--transform: #ff8c42;
--plot: #d29922;
--character: #a371f7;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Apple SD Gothic Neo", "Noto Sans KR", sans-serif;
font-size: 14px; line-height: 1.5;
}
header {
background: var(--panel); border-bottom: 1px solid var(--border);
padding: 18px 28px; display: flex; align-items: center; justify-content: space-between;
}
header h1 { font-size: 18px; margin: 0; font-weight: 600; }
header .subtitle { color: var(--muted); font-size: 12px; margin-top: 4px; }
header .badge {
display: inline-block; padding: 3px 10px; border-radius: 20px;
font-size: 11px; background: var(--panel-2); border: 1px solid var(--border);
margin-left: 10px;
}
header .badge.ok { color: var(--success); border-color: var(--success); }
header .badge.err { color: var(--danger); border-color: var(--danger); }
main { padding: 24px 28px; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); gap: 24px; max-width: 1600px; margin: 0 auto; }
@media (max-width: 980px) { main { grid-template-columns: 1fr; } }
.panel {
background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
padding: 20px;
}
.panel h2 { font-size: 14px; margin: 0 0 14px; color: var(--accent); font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
label { display: block; margin-top: 12px; font-size: 12px; color: var(--muted); }
input[type="text"], textarea {
width: 100%; padding: 10px 12px; margin-top: 4px;
background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 6px;
font-family: inherit; font-size: 13px;
}
textarea { min-height: 220px; resize: vertical; line-height: 1.6; }
input:focus, textarea:focus { outline: none; border-color: var(--accent); }
button {
margin-top: 16px; padding: 10px 22px; background: var(--accent); color: #0d1117;
border: none; border-radius: 6px; font-weight: 600; cursor: pointer; font-size: 13px;
}
button:hover { background: #79b8ff; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--border); }
button.ghost:hover { background: var(--panel-2); }
.sample-buttons { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.sample-buttons button {
margin-top: 0; padding: 4px 10px; font-size: 11px;
background: var(--panel-2); color: var(--muted); border: 1px solid var(--border);
}
.sample-buttons button:hover { color: var(--text); border-color: var(--accent); }
/* 결과 영역 */
.verdict {
padding: 24px; border-radius: 8px; text-align: center; margin-bottom: 18px;
border: 1px solid var(--border);
}
.verdict.infringement { background: rgba(248, 81, 73, 0.08); border-color: var(--danger); }
.verdict.clean { background: rgba(63, 185, 80, 0.08); border-color: var(--success); }
.verdict.idle { background: var(--panel-2); color: var(--muted); }
.verdict h3 { font-size: 22px; margin: 0 0 6px; font-weight: 600; }
.verdict.infringement h3 { color: var(--danger); }
.verdict.clean h3 { color: var(--success); }
.verdict .conf-line { font-size: 13px; color: var(--muted); }
.verdict .conf-number { font-size: 36px; font-weight: 700; margin-top: 6px; }
.scorebar-row { display: grid; grid-template-columns: 100px 1fr 70px; align-items: center; gap: 10px; margin: 6px 0; font-size: 12px; }
.scorebar { height: 8px; background: var(--panel-2); border-radius: 4px; overflow: hidden; }
.scorebar > div { height: 100%; background: linear-gradient(90deg, var(--accent), #79b8ff); }
.scorebar-row .label { color: var(--muted); }
.scorebar-row .value { text-align: right; font-variant-numeric: tabular-nums; }
.match-card {
background: var(--panel-2); border: 1px solid var(--border); border-radius: 6px;
padding: 14px; margin-top: 10px;
}
.match-card .title { font-weight: 600; }
.match-card .meta { font-size: 12px; color: var(--muted); margin-top: 2px; }
.type-badge {
display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px;
font-weight: 600; margin-left: 8px; text-transform: uppercase;
}
.type-copy { background: rgba(248, 81, 73, 0.15); color: var(--copy); border: 1px solid var(--copy); }
.type-transform { background: rgba(255, 140, 66, 0.15); color: var(--transform); border: 1px solid var(--transform); }
.type-plot { background: rgba(210, 153, 34, 0.15); color: var(--plot); border: 1px solid var(--plot); }
.type-character { background: rgba(163, 113, 247, 0.15); color: var(--character); border: 1px solid var(--character); }
.type-unknown { background: var(--panel-2); color: var(--muted); border: 1px solid var(--border); }
/* PDF v1.2 — 다중 법령 태그 표시 */
.tags-row { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
.tag-group { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; }
.tag-group-label { font-size: 10px; color: var(--muted); margin-right: 4px; text-transform: uppercase; letter-spacing: 0.3px; }
.tag-chip-legal {
display: inline-block; padding: 3px 9px; border-radius: 12px;
font-size: 11px; font-weight: 500;
}
.tag-primary { background: rgba(248, 81, 73, 0.15); color: var(--danger); border: 1px solid var(--danger); }
.tag-secondary { background: var(--panel-2); color: var(--muted); border: 1px solid var(--border); }
.case-id-badge {
font-size: 11px; color: var(--accent); margin-left: 8px;
padding: 2px 8px; background: rgba(88, 166, 255, 0.1);
border-radius: 3px; font-weight: 500;
}
.mode-indicator {
display: inline-block; padding: 2px 8px; border-radius: 10px;
font-size: 10px; background: rgba(210, 153, 34, 0.15); color: var(--warning);
border: 1px solid var(--warning); margin-left: 6px;
}
/* Tab navigation */
.tab-nav { display: flex; gap: 4px; }
.tab-btn {
margin: 0; padding: 6px 14px; background: transparent;
color: var(--muted); border: 1px solid var(--border);
border-radius: 6px; font-size: 12px; cursor: pointer;
}
.tab-btn.active { background: var(--accent); color: #0d1117; border-color: var(--accent); }
.tab-btn:hover:not(.active) { color: var(--text); border-color: var(--accent); }
.tab-content { display: none; }
.tab-content.active { display: block; }
/* Corpus management */
.corpus-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); gap: 24px; }
@media (max-width: 980px) { .corpus-grid { grid-template-columns: 1fr; } }
.corpus-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 10px; }
.corpus-table th, .corpus-table td {
text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border);
}
.corpus-table th { color: var(--muted); font-weight: 500; font-size: 11px; text-transform: uppercase; }
.corpus-table tr:hover { background: var(--panel-2); }
.corpus-table .doc-id { font-family: ui-monospace, monospace; font-size: 11px; color: var(--accent); }
.btn-danger {
margin: 0; padding: 3px 10px; background: transparent; color: var(--danger);
border: 1px solid var(--danger); border-radius: 4px; font-size: 11px; cursor: pointer;
}
.btn-danger:hover { background: var(--danger); color: white; }
.upload-status { margin-top: 12px; padding: 10px; border-radius: 4px; font-size: 12px; }
.upload-status.ok { background: rgba(63, 185, 80, 0.1); color: var(--success); border-left: 3px solid var(--success); }
.upload-status.err { background: rgba(248, 81, 73, 0.1); color: var(--danger); border-left: 3px solid var(--danger); }
.evidence-text {
margin-top: 10px; padding: 10px; background: var(--bg); border-radius: 4px;
font-size: 12px; line-height: 1.8; max-height: 200px; overflow-y: auto;
}
.evidence-text mark { background: rgba(248, 81, 73, 0.25); color: var(--text); padding: 1px 2px; border-radius: 2px; }
.chip {
display: inline-block; padding: 3px 8px; margin: 2px;
background: var(--panel-2); border: 1px solid var(--border); border-radius: 12px;
font-size: 11px;
}
.ccl-basis {
padding: 10px 12px; background: rgba(88, 166, 255, 0.08); border-left: 3px solid var(--accent);
border-radius: 4px; font-size: 12px; line-height: 1.6; margin-top: 14px;
}
.loader {
display: inline-block; width: 14px; height: 14px;
border: 2px solid var(--muted); border-top-color: var(--text);
border-radius: 50%; animation: spin 0.8s linear infinite; vertical-align: middle;
}
@keyframes spin { to { transform: rotate(360deg); } }
footer {
text-align: center; padding: 18px; color: var(--muted); font-size: 11px;
border-top: 1px solid var(--border); margin-top: 20px;
}
footer a { color: var(--accent); text-decoration: none; }
pre.raw {
background: var(--bg); padding: 10px; border-radius: 4px; font-size: 11px;
overflow-x: auto; max-height: 200px; color: var(--muted);
}
.row { display: flex; gap: 10px; align-items: center; }
.row > * { flex: 1; }
.toggle-raw { font-size: 11px; color: var(--accent); cursor: pointer; user-select: none; }
</style>
</head>
<body>
<header>
<div>
<h1>O2O 저작권 침해 여부 탐지 — 검토 콘솔</h1>
<div class="subtitle">KOCCA 출판환경변화 과제 · 오투오 1단계 산출물 (콘텐츠 표절 여부 AI 탐지 모듈)</div>
</div>
<div style="display: flex; align-items: center; gap: 12px;">
<nav class="tab-nav">
<button class="tab-btn active" data-tab="detect">탐지 검토</button>
<button class="tab-btn" data-tab="corpus">코퍼스 관리</button>
</nav>
<span id="health-badge" class="badge">엔진 확인 중…</span>
</div>
</header>
<main id="tab-detect" class="tab-content active">
<section class="panel" id="input-panel">
<h2>① 검사 대상 입력</h2>
<label>샘플 시나리오 (클릭하면 자동 채움)</label>
<div class="sample-buttons">
<button data-sample="copy">어미만 변경 표절</button>
<button data-sample="char">인물 치환 표절</button>
<button data-sample="paraphrase">패러프레이즈</button>
<button data-sample="unrelated">무관한 텍스트</button>
<button data-sample="clear">비우기</button>
</div>
<label>문서 ID (선택)</label>
<input type="text" id="doc-id" placeholder="자동 생성됩니다">
<label>제목 (선택)</label>
<input type="text" id="title" placeholder="예: 새 단편 〈잊혀진 사람들〉">
<label>저자 (선택)</label>
<input type="text" id="author" placeholder="예: 김작가">
<label>본문 텍스트 <span style="color: var(--accent)">*</span></label>
<textarea id="text" placeholder="검사할 본문을 붙여넣으세요. 최소 1자."></textarea>
<details style="margin-top: 14px;">
<summary style="cursor: pointer; font-size: 12px; color: var(--accent); user-select: none;">⚙ 고급 옵션 (임계값, 자서전 모드)</summary>
<div style="margin-top: 10px; padding: 12px; background: var(--panel-2); border-radius: 6px;">
<label style="margin-top: 0;">임계값 (similarity threshold) <span id="threshold-value" style="color: var(--accent); font-weight: 600;">0.85</span></label>
<input type="range" id="threshold-slider" min="0.05" max="0.99" step="0.01" value="0.85" style="width: 100%; margin-top: 6px;">
<div style="font-size: 11px; color: var(--muted); margin-top: 4px;">
낮을수록 더 많이 매칭 (재현율↑) · 높을수록 엄격 (정밀도↑) · PDF v1.2 권장 0.85
</div>
<label style="margin-top: 12px;">자서전 모드</label>
<div style="display: flex; gap: 12px; margin-top: 4px;">
<label style="display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text); margin: 0;">
<input type="radio" name="autobio-mode" value="default" checked> 서버 기본값 사용
</label>
<label style="display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text); margin: 0;">
<input type="radio" name="autobio-mode" value="true"> 강제 ON
</label>
<label style="display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text); margin: 0;">
<input type="radio" name="autobio-mode" value="false"> 강제 OFF
</label>
</div>
<div style="font-size: 11px; color: var(--muted); margin-top: 4px;">
ON일 때 공통 표현 사전 + NER 마스킹으로 거짓 양성 방지
</div>
</div>
</details>
<div class="row" style="margin-top: 14px;">
<button id="submit-btn">검사 시작</button>
<button class="ghost" id="clear-btn">결과 초기화</button>
</div>
</section>
<section class="panel" id="result-panel">
<h2>② 검사 결과</h2>
<div id="verdict" class="verdict idle">
<h3>아직 검사하지 않았습니다</h3>
<div class="conf-line">좌측에서 본문을 입력하고 “검사 시작”을 누르세요.</div>
</div>
<div id="result-body" style="display: none;">
<h3 style="font-size: 13px; color: var(--muted); margin: 18px 0 8px;">점수 분석 (삼중 유사도 결합)</h3>
<div id="score-breakdown"></div>
<h3 style="font-size: 13px; color: var(--muted); margin: 18px 0 8px;">매칭된 레퍼런스</h3>
<div id="matches"></div>
<h3 style="font-size: 13px; color: var(--muted); margin: 18px 0 8px;">추출된 구성요소</h3>
<div id="elements"></div>
<div id="ccl-basis"></div>
<div style="margin-top: 18px;">
<span class="toggle-raw" onclick="document.getElementById('raw-json').style.display = document.getElementById('raw-json').style.display === 'none' ? 'block' : 'none'">
원본 JSON 응답 보기/숨기기
</span>
<pre id="raw-json" class="raw" style="display: none;"></pre>
</div>
</div>
</section>
</main>
<main id="tab-corpus" class="tab-content" style="max-width: 1600px; margin: 0 auto; padding: 24px 28px;">
<div class="corpus-grid">
<section class="panel">
<h2>새 자서전 추가</h2>
<p style="color: var(--muted); font-size: 12px; margin-top: 0;">
업로드된 자서전은 검사 비교 기준이 됩니다. 컴북스 측이 직접 자서전을 등록·관리할 수 있도록 설계되었습니다.
업로드 직후 인덱스가 자동 재빌드됩니다.
</p>
<label>문서 ID (선택)</label>
<input type="text" id="corpus-doc-id" placeholder="비우면 자동 생성됩니다">
<label>제목 <span style="color: var(--accent)">*</span></label>
<input type="text" id="corpus-title" placeholder="예: 김OO 자서전">
<label>본문 텍스트 <span style="color: var(--accent)">*</span></label>
<textarea id="corpus-text" placeholder="자서전 본문을 붙여넣으세요." style="min-height: 280px;"></textarea>
<label>.txt 파일 업로드 (또는)</label>
<input type="file" id="corpus-file" accept=".txt">
<div class="row">
<button id="corpus-upload-btn">업로드</button>
<button class="ghost" id="corpus-refresh-btn">목록 새로고침</button>
</div>
<div id="corpus-status"></div>
</section>
<section class="panel">
<h2>현재 코퍼스 (<span id="corpus-count">0</span>건)</h2>
<p style="color: var(--muted); font-size: 12px; margin-top: 0;">
탐지 검사 시 비교 대상이 되는 자서전 목록입니다.
</p>
<table class="corpus-table">
<thead>
<tr>
<th>doc_id</th>
<th>제목</th>
<th>크기</th>
<th></th>
</tr>
</thead>
<tbody id="corpus-tbody">
<tr><td colspan="4" style="color: var(--muted); text-align: center; padding: 20px;">로딩 중…</td></tr>
</tbody>
</table>
</section>
</div>
</main>
<footer>
<a href="docs">API 문서 (Swagger)</a> ·
<a href="openapi.json">OpenAPI 스펙</a> ·
엔진 버전: <span id="engine-version"></span> ·
레퍼런스 코퍼스: <span id="corpus-size"></span>
</footer>
<script>
const SAMPLES = {
copy: {
title: "어미만 변경한 표절 (홍길동전 기반)",
author: "테스트",
text: "홍길동이 서자로 태어나 활빈당을 만들고 탐관오리의 재물을 빼앗으며 가난한 백성에게 나누어 주었다. 조정은 그를 잡으려 했지만 도술로 빠져나갔다. 결국 율도국으로 떠나 왕이 되었다.",
},
char: {
title: "인물 이름만 치환한 표절",
author: "테스트",
text: "김민수는 서자로 태어나 정의단을 만들어 부패한 관리의 재물을 빼앗아 가난한 백성에게 나누어 준다. 조정은 그를 잡으려 하지만 도술로 빠져나간다. 결국 신대륙으로 떠나 왕이 된다.",
},
paraphrase: {
title: "패러프레이즈 (어휘 전체 교체)",
author: "테스트",
text: "한 사생아가 무리를 조직해 부패한 관료들의 돈을 약탈하여 빈민에게 분배했다. 정부는 그를 체포하려 했지만 마법으로 도망쳤다. 결국 새 대륙으로 건너가 군주가 됐다.",
},
unrelated: {
title: "무관한 일상 텍스트",
author: "테스트",
text: "오늘 아침에 커피를 마시면서 신문을 읽었다. 날씨가 좋아서 산책을 나갔다. 공원에서 강아지와 노는 어린이들을 보았다.",
},
clear: { title: "", author: "", text: "" },
};
const TYPE_LABELS = {
copy: "복제 (Copy)",
transform: "변형 (Transform)",
plot: "플롯 차용 (Plot)",
character: "인물 차용 (Character)",
background: "배경 차용 (Background)",
unknown: "확인 필요 (Unknown)",
};
document.querySelectorAll(".sample-buttons button").forEach((btn) => {
btn.addEventListener("click", () => {
const s = SAMPLES[btn.dataset.sample];
document.getElementById("title").value = s.title;
document.getElementById("author").value = s.author;
document.getElementById("text").value = s.text;
document.getElementById("doc-id").value = "";
});
});
document.getElementById("clear-btn").addEventListener("click", () => {
document.getElementById("verdict").className = "verdict idle";
document.getElementById("verdict").innerHTML = `
<h3>아직 검사하지 않았습니다</h3>
<div class="conf-line">좌측에서 본문을 입력하고 “검사 시작”을 누르세요.</div>`;
document.getElementById("result-body").style.display = "none";
});
document.getElementById("submit-btn").addEventListener("click", runDetect);
async function runDetect() {
const text = document.getElementById("text").value.trim();
if (!text) {
alert("본문 텍스트를 입력해주세요.");
return;
}
const docId = document.getElementById("doc-id").value.trim() || `web-${Date.now()}`;
const title = document.getElementById("title").value.trim();
const author = document.getElementById("author").value.trim();
const thresholdVal = parseFloat(document.getElementById("threshold-slider").value);
const autobioMode = document.querySelector('input[name="autobio-mode"]:checked').value;
const options = { threshold: thresholdVal };
if (autobioMode === "true") options.autobiography_mode = true;
else if (autobioMode === "false") options.autobiography_mode = false;
const body = {
doc_id: docId,
text: text,
metadata: (title || author) ? { title: title || null, author: author || null } : null,
options: options,
};
const btn = document.getElementById("submit-btn");
btn.disabled = true;
btn.innerHTML = '<span class="loader"></span> &nbsp;검사 중…';
const verdict = document.getElementById("verdict");
verdict.className = "verdict idle";
verdict.innerHTML = `<h3><span class="loader"></span> 분석 중…</h3>
<div class="conf-line">요소 추출 → 임베딩 → Lemma 분석 → 결합 판정</div>`;
document.getElementById("result-body").style.display = "none";
try {
const resp = await fetch("v1/plagiarism/detect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok) {
const detail = await resp.text();
throw new Error(`HTTP ${resp.status}${detail}`);
}
const data = await resp.json();
renderResult(data, text);
} catch (err) {
verdict.className = "verdict idle";
verdict.innerHTML = `<h3 style="color: var(--danger);">검사 실패</h3>
<div class="conf-line">${escapeHtml(err.message)}</div>`;
} finally {
btn.disabled = false;
btn.textContent = "검사 시작";
}
}
function renderResult(data, originalText) {
const verdict = document.getElementById("verdict");
if (data.is_infringement) {
verdict.className = "verdict infringement";
verdict.innerHTML = `
<h3>⚠ 저작권 침해 가능성 확인</h3>
<div class="conf-line">결합 유사도</div>
<div class="conf-number">${(data.confidence * 100).toFixed(2)}%</div>`;
} else {
verdict.className = "verdict clean";
verdict.innerHTML = `
<h3>✓ 침해 신호 없음</h3>
<div class="conf-line">최상위 유사도</div>
<div class="conf-number">${(data.confidence * 100).toFixed(2)}%</div>`;
}
document.getElementById("result-body").style.display = "block";
// 매칭이 있으면 첫번째 매칭의 breakdown 사용
const breakdownEl = document.getElementById("score-breakdown");
if (data.matches && data.matches.length > 0 && data.matches[0].score_breakdown) {
const sb = data.matches[0].score_breakdown;
breakdownEl.innerHTML = `
${scorebar("표면 유사도 (text)", sb.text_sim)}
${scorebar("Lemma 교집합 (구조)", sb.lemma_sim)}
${scorebar("인물 일치도", sb.character_sim)}
${scorebar("모티프 일치도", sb.motif_sim)}`;
} else {
breakdownEl.innerHTML = '<div style="color: var(--muted); font-size: 12px;">매칭된 레퍼런스가 없어 점수 분석을 표시할 수 없습니다.</div>';
}
// 매칭 카드
const matchesEl = document.getElementById("matches");
if (!data.matches || data.matches.length === 0) {
matchesEl.innerHTML = '<div style="color: var(--muted); font-size: 12px;">레퍼런스 코퍼스에서 임계치를 넘는 매칭이 없습니다.</div>';
} else {
matchesEl.innerHTML = data.matches.map((m) => {
const typeClass = `type-${m.infringement_type || "unknown"}`;
const evidenceHtml = renderEvidence(originalText, m.evidence_spans);
const tagsHtml = renderLegalTags(m.tags || []);
const caseHtml = m.case_id
? `<span class="case-id-badge">케이스 ${escapeHtml(m.case_id)}${m.case_title ? " · " + escapeHtml(m.case_title) : ""}</span>`
: "";
return `
<div class="match-card">
<div>
<span class="title">${escapeHtml(m.source_title || m.source_doc)}</span>
<span class="type-badge ${typeClass}">${TYPE_LABELS[m.infringement_type] || m.infringement_type}</span>
${caseHtml}
</div>
<div class="meta">결합 유사도 ${(m.similarity * 100).toFixed(2)}% · doc_id: ${escapeHtml(m.source_doc)}</div>
${tagsHtml}
${evidenceHtml}
</div>`;
}).join("");
}
// 추출 요소
const e = data.extracted_elements || {};
const chips = (arr) => (arr || []).map(s => `<span class="chip">${escapeHtml(String(s))}</span>`).join("");
document.getElementById("elements").innerHTML = `
<div style="font-size: 12px;">
<div style="margin-bottom: 8px;"><strong>인물:</strong> ${chips(e.characters) || '<span style="color: var(--muted)">없음</span>'}</div>
<div style="margin-bottom: 8px;"><strong>모티프:</strong> ${chips(e.motifs) || '<span style="color: var(--muted)">없음</span>'}</div>
<div style="margin-bottom: 8px;"><strong>장르:</strong> ${e.genre ? `<span class="chip">${escapeHtml(e.genre)}</span>` : '<span style="color: var(--muted)">미상</span>'}</div>
<div><strong>키워드:</strong> ${chips(e.keywords) || '<span style="color: var(--muted)">없음</span>'}</div>
</div>`;
// CCL 사유
const cclEl = document.getElementById("ccl-basis");
cclEl.innerHTML = data.ccl_basis ? `<div class="ccl-basis"><strong>CCL/계약 22조 기반 사유:</strong><br>${escapeHtml(data.ccl_basis)}</div>` : "";
document.getElementById("raw-json").textContent = JSON.stringify(data, null, 2);
}
function renderEvidence(originalText, spans) {
if (!spans || spans.length === 0) return "";
// span 정렬 후 마킹
const sorted = [...spans].sort((a, b) => a.start - b.start);
let html = "";
let cursor = 0;
for (const s of sorted) {
if (s.start < cursor) continue; // 겹침 방지
html += escapeHtml(originalText.substring(cursor, s.start));
html += `<mark>${escapeHtml(originalText.substring(s.start, s.end))}</mark>`;
cursor = s.end;
}
html += escapeHtml(originalText.substring(cursor));
return `<div class="evidence-text"><strong style="display: block; margin-bottom: 6px; font-size: 11px; color: var(--muted);">일치 구간 (${spans.length}개):</strong>${html}</div>`;
}
function renderLegalTags(tags) {
if (!tags || tags.length === 0) return "";
const primary = tags.filter(t => t.role === "primary");
const secondary = tags.filter(t => t.role === "secondary");
let html = '<div class="tags-row">';
if (primary.length) {
html += '<div class="tag-group"><span class="tag-group-label">주 침해</span>';
html += primary.map(t => `<span class="tag-chip-legal tag-primary" title="${escapeHtml(t.tag)}">${escapeHtml(t.label_ko)}</span>`).join("");
html += '</div>';
}
if (secondary.length) {
html += '<div class="tag-group"><span class="tag-group-label">보조</span>';
html += secondary.map(t => `<span class="tag-chip-legal tag-secondary" title="${escapeHtml(t.tag)}">${escapeHtml(t.label_ko)}</span>`).join("");
html += '</div>';
}
html += '</div>';
return html;
}
function scorebar(label, value) {
const pct = Math.min(100, Math.max(0, value * 100));
return `
<div class="scorebar-row">
<span class="label">${label}</span>
<div class="scorebar"><div style="width: ${pct}%;"></div></div>
<span class="value">${value.toFixed(3)}</span>
</div>`;
}
function escapeHtml(s) {
if (s == null) return "";
return String(s).replace(/[&<>"']/g, (c) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
}
// ========== 임계값 슬라이더 ==========
document.getElementById("threshold-slider").addEventListener("input", (e) => {
document.getElementById("threshold-value").textContent = parseFloat(e.target.value).toFixed(2);
});
// ========== 탭 네비게이션 ==========
document.querySelectorAll(".tab-btn").forEach((btn) => {
btn.addEventListener("click", () => {
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
document.querySelectorAll(".tab-content").forEach(c => c.classList.remove("active"));
btn.classList.add("active");
const tab = btn.dataset.tab;
document.getElementById(`tab-${tab}`).classList.add("active");
if (tab === "corpus") loadCorpus();
});
});
// ========== 코퍼스 관리 ==========
async function loadCorpus() {
const tbody = document.getElementById("corpus-tbody");
tbody.innerHTML = '<tr><td colspan="4" style="color: var(--muted); text-align: center; padding: 20px;">로딩 중…</td></tr>';
try {
const resp = await fetch("v1/corpus");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
document.getElementById("corpus-count").textContent = data.total;
if (data.docs.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" style="color: var(--muted); text-align: center; padding: 20px;">아직 등록된 자서전이 없습니다.</td></tr>';
return;
}
tbody.innerHTML = data.docs.map(d => `
<tr>
<td class="doc-id">${escapeHtml(d.doc_id)}</td>
<td>${escapeHtml(d.title)}</td>
<td>${formatBytes(d.size_bytes)}</td>
<td><button class="btn-danger" onclick="deleteDoc('${escapeJs(d.doc_id)}')">삭제</button></td>
</tr>
`).join("");
} catch (err) {
tbody.innerHTML = `<tr><td colspan="4" style="color: var(--danger); padding: 20px;">로딩 실패: ${escapeHtml(err.message)}</td></tr>`;
}
}
document.getElementById("corpus-refresh-btn").addEventListener("click", loadCorpus);
document.getElementById("corpus-upload-btn").addEventListener("click", uploadCorpus);
async function uploadCorpus() {
const docId = document.getElementById("corpus-doc-id").value.trim();
const title = document.getElementById("corpus-title").value.trim();
const text = document.getElementById("corpus-text").value.trim();
const file = document.getElementById("corpus-file").files[0];
const statusEl = document.getElementById("corpus-status");
if (!title) {
showCorpusStatus("err", "제목을 입력해주세요.");
return;
}
if (!text && !file) {
showCorpusStatus("err", "본문 텍스트 또는 .txt 파일을 입력해주세요.");
return;
}
const btn = document.getElementById("corpus-upload-btn");
btn.disabled = true;
btn.innerHTML = '<span class="loader"></span> 업로드 중…';
try {
let resp;
if (file) {
const fd = new FormData();
fd.append("title", title);
if (docId) fd.append("doc_id", docId);
fd.append("file", file);
resp = await fetch("v1/corpus/file", { method: "POST", body: fd });
} else {
resp = await fetch("v1/corpus", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ doc_id: docId || null, title, text }),
});
}
if (!resp.ok) {
const errBody = await resp.text();
throw new Error(`HTTP ${resp.status}${errBody}`);
}
const data = await resp.json();
showCorpusStatus("ok", `✓ 업로드 완료: ${data.doc_id} (${data.title}) · 코퍼스 ${data.corpus_size_after}건. 인덱스 재빌드 완료.`);
document.getElementById("corpus-doc-id").value = "";
document.getElementById("corpus-title").value = "";
document.getElementById("corpus-text").value = "";
document.getElementById("corpus-file").value = "";
await loadCorpus();
await checkHealth();
} catch (err) {
showCorpusStatus("err", `업로드 실패: ${err.message}`);
} finally {
btn.disabled = false;
btn.textContent = "업로드";
}
}
async function deleteDoc(docId) {
if (!confirm(`'${docId}' 문서를 삭제하시겠습니까? 인덱스가 재빌드됩니다.`)) return;
try {
const resp = await fetch(`v1/corpus/${encodeURIComponent(docId)}`, {
method: "DELETE",
});
if (!resp.ok && resp.status !== 204) {
throw new Error(`HTTP ${resp.status}`);
}
showCorpusStatus("ok", `✓ '${docId}' 삭제 완료. 인덱스 재빌드 완료.`);
await loadCorpus();
await checkHealth();
} catch (err) {
showCorpusStatus("err", `삭제 실패: ${err.message}`);
}
}
function showCorpusStatus(level, msg) {
const el = document.getElementById("corpus-status");
el.className = `upload-status ${level}`;
el.textContent = msg;
}
function formatBytes(b) {
if (b < 1024) return `${b} B`;
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
return `${(b / 1024 / 1024).toFixed(1)} MB`;
}
function escapeJs(s) {
return String(s).replace(/['\\]/g, "\\$&");
}
// 헬스 체크 + 코퍼스 정보 표시
async function checkHealth() {
try {
const resp = await fetch("v1/health");
if (!resp.ok) throw new Error("not ok");
const data = await resp.json();
const autobio = data.autobiography_mode ? ' · 자서전모드' : '';
document.getElementById("health-badge").textContent = `● 정상 · 코퍼스 ${data.corpus_size}${autobio}`;
document.getElementById("health-badge").className = "badge ok";
document.getElementById("engine-version").textContent = data.engine_version;
document.getElementById("corpus-size").textContent = data.corpus_size;
if (data.taxonomy_version) {
const footer = document.querySelector("footer");
footer.innerHTML += ` · 분류체계: ${escapeHtml(data.taxonomy_version)}`;
}
} catch (err) {
document.getElementById("health-badge").textContent = "● 연결 실패";
document.getElementById("health-badge").className = "badge err";
}
}
checkHealth();
</script>
</body>
</html>