752 lines
33 KiB
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> 검사 중…';
|
|
|
|
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) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[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>
|