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

963 lines
44 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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, select {
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; }
.ai-card {
background: var(--panel-2); border: 1px solid var(--border); border-radius: 8px;
padding: 14px; margin-top: 8px;
}
.ai-card.ai-high { border-color: var(--danger); background: rgba(248, 81, 73, 0.08); }
.ai-card.ai-medium { border-color: var(--warning); background: rgba(210, 153, 34, 0.08); }
.ai-card.ai-low { border-color: var(--success); background: rgba(63, 185, 80, 0.08); }
.ai-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.ai-title { font-size: 14px; font-weight: 600; }
.ai-score { font-size: 24px; font-weight: 700; font-variant-numeric: tabular-nums; }
.ai-note { color: var(--muted); font-size: 11px; margin-top: 8px; line-height: 1.6; }
.ai-warning { color: var(--warning); font-size: 11px; margin-top: 6px; }
.ai-segment {
margin-top: 8px; padding: 8px 10px; background: var(--bg); border-radius: 4px;
font-size: 11px; border-left: 3px solid var(--warning);
}
.ai-segment .meta { color: var(--muted); margin-bottom: 3px; }
.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;
}
.legal-risk-card {
background: var(--panel-2); border: 1px solid var(--border); border-radius: 8px;
padding: 14px; margin-top: 8px;
}
.legal-risk-card.legal-likely { border-color: var(--danger); background: rgba(248, 81, 73, 0.07); }
.legal-risk-card.legal-unlikely { border-color: var(--success); background: rgba(63, 185, 80, 0.07); }
.legal-risk-card.legal-insufficient { border-color: var(--warning); background: rgba(210, 153, 34, 0.07); }
.legal-risk-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
.legal-risk-title { font-size: 15px; font-weight: 700; }
.legal-risk-meta { color: var(--muted); font-size: 11px; margin-top: 3px; }
.legal-risk-confidence { font-size: 22px; font-weight: 700; white-space: nowrap; }
.legal-judgment-summary {
margin-top: 12px; padding: 11px 12px; border-left: 3px solid var(--accent);
background: var(--bg); border-radius: 4px; font-size: 13px; line-height: 1.7;
}
.legal-risk-section { margin-top: 12px; }
.legal-risk-section strong { display: block; color: var(--muted); font-size: 11px; margin-bottom: 5px; }
.legal-risk-list { margin: 0; padding-left: 18px; font-size: 12px; }
.legal-risk-list li { margin: 3px 0; }
.precedent-chip {
display: inline-block; margin: 2px 4px 2px 0; padding: 3px 8px;
color: var(--accent); background: rgba(88, 166, 255, 0.1);
border: 1px solid rgba(88, 166, 255, 0.45); border-radius: 12px; font-size: 11px;
}
.legal-disclaimer { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); color: var(--warning); font-size: 11px; }
.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>
<label style="margin-top: 14px;">판례 검색용 저작물 유형</label>
<select id="legal-work-type">
<option value="literary">문학·출판 (literary)</option>
<option value="musical">음악 (musical)</option>
<option value="visual">미술·사진·영상 (visual)</option>
<option value="software">컴퓨터프로그램 (software)</option>
</select>
<label style="margin-top: 12px;">원저작물 접근·의거 가능성</label>
<select id="legal-access-evidence">
<option value="unknown">미확인</option>
<option value="true">확인됨</option>
<option value="false">확인되지 않음</option>
</select>
<div style="display: flex; flex-wrap: wrap; gap: 12px; margin-top: 10px;">
<label style="display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text); margin: 0;">
<input type="checkbox" id="legal-protected-reviewed"> 보호되는 창작적 표현 검토 완료
</label>
<label style="display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text); margin: 0;">
<input type="checkbox" id="legal-rights-verified"> 권리·허락·인용 요건 확인 완료
</label>
</div>
<div style="font-size: 11px; color: var(--warning); margin-top: 6px;">
사람이 실제로 확인한 사실만 선택하세요. 서버와 GPT는 미제공 사실을 추론하지 않습니다.
</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;">AI 생성 의심도</h3>
<div id="ai-generation"></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="legal-risk"></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,
legal_context: {
work_type: document.getElementById("legal-work-type").value,
access_evidence: document.getElementById("legal-access-evidence").value === "unknown"
? null
: document.getElementById("legal-access-evidence").value === "true",
protected_expression_reviewed: document.getElementById("legal-protected-reviewed").checked,
rights_verified: document.getElementById("legal-rights-verified").checked,
},
};
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");
const displaySimilarity = data.review_summary && Number.isFinite(data.review_summary.similarity_percent)
? data.review_summary.similarity_percent
: Math.round(data.confidence * 100);
if (data.is_infringement) {
verdict.className = "verdict infringement";
verdict.innerHTML = `
<h3>⚠ 저작권 침해 가능성 확인</h3>
<div class="conf-line">보정 유사도</div>
<div class="conf-number">${displaySimilarity}%</div>`;
} else {
verdict.className = "verdict clean";
verdict.innerHTML = `
<h3>✓ 침해 신호 없음</h3>
<div class="conf-line">보정 유사도 · 검색 후보 점수는 침해 확률이 아님</div>
<div class="conf-number">${displaySimilarity}%</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>';
}
renderAiGeneration(data.ai_generation, originalText);
// 매칭 카드
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("");
}
renderLegalRisk(data.legal_risk);
// 추출 요소
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 renderLegalRisk(risk) {
const el = document.getElementById("legal-risk");
if (!risk) {
el.innerHTML = '<div class="legal-risk-card"><div class="legal-risk-meta">판례 기반 검토 결과가 없습니다.</div></div>';
return;
}
const verdict = risk.llm_verdict || "insufficient_evidence";
const verdictLabels = {
likely: "판례에 비추어 저작권 침해 의심",
unlikely: "판례에 비추어 침해 의심 낮음",
insufficient_evidence: "관련 판례에 따른 추가 검토 필요",
};
const verdictClass = {
likely: "legal-likely",
unlikely: "legal-unlikely",
insufficient_evidence: "legal-insufficient",
}[verdict] || "legal-insufficient";
const methodLabels = {
llm: "판례·탐지 증거 비교",
rule_based: "판례 규칙 검색",
rule_fallback: "판례 규칙 검색 · 자동 비교 미완료",
};
const selectedIds = risk.llm_matched_precedent_ids && risk.llm_matched_precedent_ids.length
? risk.llm_matched_precedent_ids
: (risk.precedent_ids || []);
const confidence = Number.isFinite(risk.llm_confidence)
? `<div class="legal-risk-confidence">${(risk.llm_confidence * 100).toFixed(0)}<small style="font-size: 11px; color: var(--muted);"> / 100 자기평가</small></div>`
: "";
const list = (items, emptyText) => items && items.length
? `<ul class="legal-risk-list">${items.map(item => `<li>${escapeHtml(item)}</li>`).join("")}</ul>`
: `<div class="legal-risk-meta">${escapeHtml(emptyText)}</div>`;
const precedents = selectedIds.length
? selectedIds.map(id => `<span class="precedent-chip">${escapeHtml(id)}</span>`).join("")
: '<div class="legal-risk-meta">선택된 관련 판례 없음</div>';
const summary = risk.judgment_summary
|| "관련 판례와 탐지 증거를 바탕으로 추가 검토가 필요합니다.";
el.innerHTML = `
<div class="legal-risk-card ${verdictClass}">
<div class="legal-risk-head">
<div>
<div class="legal-risk-title">${escapeHtml(verdictLabels[verdict] || verdict)}</div>
<div class="legal-risk-meta">${escapeHtml(methodLabels[risk.judgment_method] || risk.judgment_method || "판단 방식 미상")} · 위험도 ${escapeHtml(risk.risk_level || "미정")}</div>
</div>
${confidence}
</div>
<div class="legal-judgment-summary">${escapeHtml(summary)}</div>
<div class="legal-risk-section"><strong>관련 판례 사건번호</strong>${precedents}</div>
<div class="legal-risk-section"><strong>판단을 지지하는 근거</strong>${list(risk.supporting_reasons, "GPT 근거 없음 또는 규칙 기반 결과")}</div>
<div class="legal-risk-section"><strong>반대 근거</strong>${list(risk.counter_reasons, "제공된 반대 근거 없음")}</div>
<div class="legal-risk-section"><strong>추가 확인 필요</strong>${list(risk.missing_factors, "추가 확인사항 없음")}</div>
${risk.judge_note ? `<div class="legal-risk-meta" style="margin-top: 10px;">${escapeHtml(risk.judge_note)}</div>` : ""}
<div class="legal-disclaimer">${escapeHtml(risk.disclaimer || "판례 기반 검토 보조이며 법률상 침해 확정이 아닙니다.")}</div>
</div>`;
}
function renderAiGeneration(ai, originalText) {
const el = document.getElementById("ai-generation");
if (!ai || !ai.available) {
const note = ai && ai.note
? ai.note
: "학습된 AI 생성 탐지 모델이 없어 의심도를 산출하지 않습니다.";
const version = ai && ai.model_version ? ai.model_version : "unavailable";
el.innerHTML = `
<div class="ai-card">
<div class="ai-head">
<span class="ai-title">판정 불가 · 모델 준비 전</span>
<span class="type-badge type-unknown">${escapeHtml(version)}</span>
</div>
<div class="ai-note">${escapeHtml(note)}</div>
${renderAiWarnings(ai && ai.warnings)}
</div>`;
return;
}
const level = ["low", "medium", "high"].includes(ai.suspicion_level)
? ai.suspicion_level
: "unknown";
const labels = { low: "낮음", medium: "중간", high: "높음", unknown: "판정 불가" };
const score = Number.isFinite(ai.score) ? `${(ai.score * 100).toFixed(1)}%` : "—";
const stub = ai.is_stub
? '<span class="type-badge type-plot">미검증 휴리스틱</span>'
: `<span class="type-badge type-unknown">${escapeHtml(ai.model_version || "model")}</span>`;
const provenance = ai.provenance && ai.provenance !== "unknown"
? ` · 추정 유형 ${escapeHtml(ai.provenance)}`
: "";
const segments = (ai.segments || [])
.filter(s => s.scored && Number.isFinite(s.score))
.sort((a, b) => b.score - a.score)
.slice(0, 5)
.map(s => {
const start = Math.max(0, Number(s.start) || 0);
const end = Math.max(start, Number(s.end) || start);
const snippet = originalText.substring(start, end).trim();
return `<div class="ai-segment">
<div class="meta">구간 ${start}${end}자 · 의심도 ${(s.score * 100).toFixed(1)}%</div>
${escapeHtml(snippet.length > 160 ? snippet.slice(0, 160) + "…" : snippet)}
</div>`;
}).join("");
el.innerHTML = `
<div class="ai-card ai-${level}">
<div class="ai-head">
<div><span class="ai-title">AI 생성 의심도 ${labels[level]}</span>${stub}</div>
<span class="ai-score">${score}</span>
</div>
<div class="ai-note">검토 우선순위 신호이며 AI 작성 여부를 확정하지 않습니다.${provenance}</div>
${ai.note ? `<div class="ai-note">${escapeHtml(ai.note)}</div>` : ""}
${renderAiWarnings(ai.warnings)}
${segments}
</div>`;
}
function renderAiWarnings(warnings) {
if (!warnings || warnings.length === 0) return "";
return warnings.map(w => `<div class="ai-warning">⚠ ${escapeHtml(w)}</div>`).join("");
}
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>