@open330/kiwimu 0.4.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -49
- package/bin/kiwimu +1 -1
- package/package.json +4 -1
- package/personas/namuwiki.json +6 -0
- package/src/build/renderer.ts +50 -2
- package/src/build/static/search.js +33 -2
- package/src/build/static/style.css +84 -1
- package/src/build/templates.ts +353 -167
- package/src/config.ts +35 -29
- package/src/demo/sample-data.ts +70 -0
- package/src/demo/setup.ts +31 -0
- package/src/expand/llm.ts +1 -1
- package/src/index.ts +234 -458
- package/src/ingest/docx.ts +0 -8
- package/src/ingest/legacy.ts +4 -4
- package/src/ingest/pdf.ts +1 -1
- package/src/ingest/pptx.ts +0 -1
- package/src/ingest/web.test.ts +41 -0
- package/src/ingest/web.ts +61 -62
- package/src/llm-client.ts +203 -126
- package/src/pipeline/chunker.test.ts +42 -0
- package/src/pipeline/chunker.ts +1 -48
- package/src/pipeline/llm-chunker.ts +144 -59
- package/src/server.ts +327 -0
- package/src/services/ingest.ts +100 -0
- package/src/store.test.ts +132 -0
- package/src/store.ts +206 -2
- package/src/pipeline/llm-linker.ts +0 -84
package/src/build/templates.ts
CHANGED
|
@@ -53,6 +53,9 @@ function base(opts: {
|
|
|
53
53
|
<meta charset="UTF-8">
|
|
54
54
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
55
55
|
<title>${escapeHtml(opts.title)}</title>
|
|
56
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
57
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
58
|
+
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;600;700;800&display=swap" rel="stylesheet">
|
|
56
59
|
<link rel="stylesheet" href="/static/style.css">
|
|
57
60
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
|
|
58
61
|
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
|
@@ -60,6 +63,7 @@ function base(opts: {
|
|
|
60
63
|
</head>
|
|
61
64
|
<body>
|
|
62
65
|
<nav class="topbar">
|
|
66
|
+
<button class="topbar-menu-btn" aria-label="메뉴">☰</button>
|
|
63
67
|
<a href="/index.html" class="topbar-brand">
|
|
64
68
|
<img src="/static/logo.png" alt="Kiwi Mu" class="topbar-logo">
|
|
65
69
|
${escapeHtml(opts.wikiName)}
|
|
@@ -69,10 +73,13 @@ function base(opts: {
|
|
|
69
73
|
<div id="search-results" class="search-dropdown"></div>
|
|
70
74
|
</div>
|
|
71
75
|
<div class="topbar-links">
|
|
76
|
+
<a href="/wiki/random.html" style="color:#fff;text-decoration:none;font-size:13px;">🎲 임의</a>
|
|
77
|
+
<a href="/quiz.html" class="btn-graph">📝 퀴즈</a>
|
|
72
78
|
<a href="/graph.html" class="btn-graph">📊 그래프</a>
|
|
73
79
|
<a href="/admin" class="btn-graph">⚙️ 관리</a>
|
|
74
80
|
</div>
|
|
75
81
|
</nav>
|
|
82
|
+
<div class="sidebar-overlay"></div>
|
|
76
83
|
<div class="layout">
|
|
77
84
|
<aside class="sidebar">
|
|
78
85
|
${sidebarHtml(opts.sourcePages, opts.conceptPages, opts.activeSlug)}
|
|
@@ -83,6 +90,22 @@ function base(opts: {
|
|
|
83
90
|
</div>
|
|
84
91
|
<script src="/static/search.js"></script>
|
|
85
92
|
<script>
|
|
93
|
+
// Mobile hamburger menu
|
|
94
|
+
(function() {
|
|
95
|
+
const menuBtn = document.querySelector('.topbar-menu-btn');
|
|
96
|
+
const sidebar = document.querySelector('.sidebar');
|
|
97
|
+
const overlay = document.querySelector('.sidebar-overlay');
|
|
98
|
+
if (menuBtn && sidebar) {
|
|
99
|
+
menuBtn.addEventListener('click', () => {
|
|
100
|
+
sidebar.classList.toggle('open');
|
|
101
|
+
overlay?.classList.toggle('active');
|
|
102
|
+
});
|
|
103
|
+
overlay?.addEventListener('click', () => {
|
|
104
|
+
sidebar.classList.remove('open');
|
|
105
|
+
overlay.classList.remove('active');
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
})();
|
|
86
109
|
// Sidebar tabs
|
|
87
110
|
document.querySelectorAll('.sidebar-tab').forEach(tab => {
|
|
88
111
|
tab.addEventListener('click', () => {
|
|
@@ -204,182 +227,29 @@ export function renderIndex(opts: {
|
|
|
204
227
|
</div>
|
|
205
228
|
|
|
206
229
|
<div class="index-grid">
|
|
207
|
-
<!-- Add document
|
|
230
|
+
<!-- Add document link -->
|
|
208
231
|
<section class="index-section add-section">
|
|
209
232
|
<h2>➕ 문서 추가</h2>
|
|
210
|
-
<
|
|
211
|
-
<button class="add-tab active" data-target="url-form">🔗 URL</button>
|
|
212
|
-
<button class="add-tab" data-target="file-form">📄 파일 업로드</button>
|
|
213
|
-
</div>
|
|
214
|
-
<form id="url-form" class="add-form add-panel active">
|
|
215
|
-
<input type="text" id="add-source" placeholder="URL을 입력하세요 (https://...)" autocomplete="off">
|
|
216
|
-
<button type="submit" id="add-btn">추가</button>
|
|
217
|
-
</form>
|
|
218
|
-
<form id="file-form" class="add-form add-panel" enctype="multipart/form-data">
|
|
219
|
-
<div class="file-drop" id="file-drop">
|
|
220
|
-
<input type="file" id="file-input" accept=".pdf,.docx,.doc,.pptx,.ppt,.key,.rtf" hidden>
|
|
221
|
-
<span class="file-drop-text">📁 파일을 드래그하거나 클릭하세요</span>
|
|
222
|
-
<span class="file-drop-hint">PDF, DOCX, PPTX, PPT, KEY, RTF</span>
|
|
223
|
-
</div>
|
|
224
|
-
<button type="submit" id="upload-btn" disabled>업로드</button>
|
|
225
|
-
</form>
|
|
226
|
-
<div id="add-status" class="add-status" style="display:none"></div>
|
|
227
|
-
</section>
|
|
228
|
-
|
|
229
|
-
<!-- Usage stats -->
|
|
230
|
-
<section class="index-section">
|
|
231
|
-
<div id="usage-stats" class="usage-stats"></div>
|
|
233
|
+
<p>문서를 추가하려면 <a href="/admin">관리 페이지</a>에서 문서를 추가하세요.</p>
|
|
232
234
|
</section>
|
|
233
235
|
|
|
234
236
|
<section class="index-section">
|
|
235
237
|
<h2>📖 원본 문서</h2>
|
|
236
|
-
<div class="page-cards">${sourceCards}</div>
|
|
238
|
+
<div class="page-cards">${sourceCards.length > 0 ? sourceCards : '<div class="empty-state">아직 원본 문서가 없습니다. URL이나 파일을 추가해보세요!</div>'}</div>
|
|
237
239
|
</section>
|
|
238
240
|
<section class="index-section">
|
|
239
241
|
<h2>📝 개념 문서</h2>
|
|
240
|
-
<div class="page-cards">${conceptCards}</div>
|
|
242
|
+
<div class="page-cards">${conceptCards.length > 0 ? conceptCards : '<div class="empty-state">아직 개념 문서가 없습니다. 원본 문서를 추가하면 자동으로 생성됩니다.</div>'}</div>
|
|
241
243
|
</section>
|
|
242
244
|
<section class="index-section">
|
|
243
245
|
<div class="quick-links">
|
|
246
|
+
<a href="/quiz.html" class="quick-link">📝 학습 퀴즈</a>
|
|
244
247
|
<a href="/graph.html" class="quick-link">📊 지식 그래프 보기</a>
|
|
245
248
|
</div>
|
|
246
249
|
</section>
|
|
247
250
|
</div>
|
|
248
|
-
</div
|
|
249
|
-
<script>
|
|
250
|
-
// Add tabs
|
|
251
|
-
document.querySelectorAll('.add-tab').forEach(tab => {
|
|
252
|
-
tab.addEventListener('click', () => {
|
|
253
|
-
document.querySelectorAll('.add-tab').forEach(t => t.classList.remove('active'));
|
|
254
|
-
document.querySelectorAll('.add-panel').forEach(p => p.classList.remove('active'));
|
|
255
|
-
tab.classList.add('active');
|
|
256
|
-
document.getElementById(tab.dataset.target).classList.add('active');
|
|
257
|
-
});
|
|
258
|
-
});
|
|
251
|
+
</div>`;
|
|
259
252
|
|
|
260
|
-
// File upload
|
|
261
|
-
const fileInput = document.getElementById('file-input');
|
|
262
|
-
const fileDrop = document.getElementById('file-drop');
|
|
263
|
-
const uploadBtn = document.getElementById('upload-btn');
|
|
264
|
-
|
|
265
|
-
fileInput.addEventListener('change', () => {
|
|
266
|
-
if (fileInput.files.length) {
|
|
267
|
-
fileDrop.querySelector('.file-drop-text').textContent = '📄 ' + fileInput.files[0].name;
|
|
268
|
-
uploadBtn.disabled = false;
|
|
269
|
-
}
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
fileDrop.addEventListener('dragover', (e) => { e.preventDefault(); fileDrop.classList.add('dragover'); });
|
|
273
|
-
fileDrop.addEventListener('dragleave', () => fileDrop.classList.remove('dragover'));
|
|
274
|
-
fileDrop.addEventListener('drop', (e) => {
|
|
275
|
-
e.preventDefault();
|
|
276
|
-
fileDrop.classList.remove('dragover');
|
|
277
|
-
fileInput.files = e.dataTransfer.files;
|
|
278
|
-
if (fileInput.files.length) {
|
|
279
|
-
fileDrop.querySelector('.file-drop-text').textContent = '📄 ' + fileInput.files[0].name;
|
|
280
|
-
uploadBtn.disabled = false;
|
|
281
|
-
}
|
|
282
|
-
});
|
|
283
|
-
fileDrop.addEventListener('click', () => fileInput.click());
|
|
284
|
-
|
|
285
|
-
document.getElementById('file-form').addEventListener('submit', async (e) => {
|
|
286
|
-
e.preventDefault();
|
|
287
|
-
if (!fileInput.files.length) return;
|
|
288
|
-
const status = document.getElementById('add-status');
|
|
289
|
-
uploadBtn.disabled = true;
|
|
290
|
-
uploadBtn.textContent = '처리 중...';
|
|
291
|
-
status.style.display = 'block';
|
|
292
|
-
status.className = 'add-status processing';
|
|
293
|
-
status.textContent = '⏳ 업로드 중...';
|
|
294
|
-
|
|
295
|
-
const formData = new FormData();
|
|
296
|
-
formData.append('file', fileInput.files[0]);
|
|
297
|
-
|
|
298
|
-
try {
|
|
299
|
-
const resp = await fetch('/api/upload', { method: 'POST', body: formData });
|
|
300
|
-
const data = await resp.json();
|
|
301
|
-
if (!resp.ok) { status.className = 'add-status error'; status.textContent = '❌ ' + data.error; uploadBtn.disabled = false; uploadBtn.textContent = '업로드'; return; }
|
|
302
|
-
const poll = setInterval(async () => {
|
|
303
|
-
const r = await fetch('/api/status'); const s = await r.json();
|
|
304
|
-
status.textContent = '⏳ ' + s.processingStatus;
|
|
305
|
-
if (!s.processing) { clearInterval(poll); status.className = 'add-status success'; status.textContent = '✅ 완료! 새로고침 중...'; setTimeout(() => location.reload(), 1500); }
|
|
306
|
-
}, 2000);
|
|
307
|
-
} catch (err) { status.className = 'add-status error'; status.textContent = '❌ 연결 실패'; uploadBtn.disabled = false; uploadBtn.textContent = '업로드'; }
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
// URL add form
|
|
311
|
-
document.getElementById('url-form').addEventListener('submit', async (e) => {
|
|
312
|
-
e.preventDefault();
|
|
313
|
-
const input = document.getElementById('add-source');
|
|
314
|
-
const btn = document.getElementById('add-btn');
|
|
315
|
-
const status = document.getElementById('add-status');
|
|
316
|
-
const source = input.value.trim();
|
|
317
|
-
if (!source) return;
|
|
318
|
-
|
|
319
|
-
btn.disabled = true;
|
|
320
|
-
btn.textContent = '처리 중...';
|
|
321
|
-
status.style.display = 'block';
|
|
322
|
-
status.className = 'add-status processing';
|
|
323
|
-
status.textContent = '⏳ 시작 중...';
|
|
324
|
-
|
|
325
|
-
try {
|
|
326
|
-
const resp = await fetch('/api/add', {
|
|
327
|
-
method: 'POST',
|
|
328
|
-
headers: { 'Content-Type': 'application/json' },
|
|
329
|
-
body: JSON.stringify({ source }),
|
|
330
|
-
});
|
|
331
|
-
const data = await resp.json();
|
|
332
|
-
if (!resp.ok) {
|
|
333
|
-
status.className = 'add-status error';
|
|
334
|
-
status.textContent = '❌ ' + data.error;
|
|
335
|
-
btn.disabled = false;
|
|
336
|
-
btn.textContent = '추가';
|
|
337
|
-
return;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// Poll for completion
|
|
341
|
-
const poll = setInterval(async () => {
|
|
342
|
-
const r = await fetch('/api/status');
|
|
343
|
-
const s = await r.json();
|
|
344
|
-
status.textContent = '⏳ ' + s.processingStatus;
|
|
345
|
-
if (!s.processing) {
|
|
346
|
-
clearInterval(poll);
|
|
347
|
-
status.className = 'add-status success';
|
|
348
|
-
status.textContent = '✅ 완료! 페이지를 새로고침합니다...';
|
|
349
|
-
btn.disabled = false;
|
|
350
|
-
btn.textContent = '추가';
|
|
351
|
-
input.value = '';
|
|
352
|
-
setTimeout(() => location.reload(), 1500);
|
|
353
|
-
}
|
|
354
|
-
}, 2000);
|
|
355
|
-
} catch (err) {
|
|
356
|
-
status.className = 'add-status error';
|
|
357
|
-
status.textContent = '❌ 연결 실패';
|
|
358
|
-
btn.disabled = false;
|
|
359
|
-
btn.textContent = '추가';
|
|
360
|
-
}
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
// Load usage stats
|
|
364
|
-
(async () => {
|
|
365
|
-
try {
|
|
366
|
-
const resp = await fetch('/api/status');
|
|
367
|
-
const data = await resp.json();
|
|
368
|
-
const u = data.usage;
|
|
369
|
-
if (u && u.totalTokens > 0) {
|
|
370
|
-
document.getElementById('usage-stats').innerHTML =
|
|
371
|
-
'<h2>📊 LLM 사용량</h2>' +
|
|
372
|
-
'<div class="stats-grid">' +
|
|
373
|
-
'<div class="stat-card"><div class="stat-value">' + u.totalCalls + '</div><div class="stat-label">API 호출</div></div>' +
|
|
374
|
-
'<div class="stat-card"><div class="stat-value">' + u.promptTokens.toLocaleString() + '</div><div class="stat-label">입력 토큰</div></div>' +
|
|
375
|
-
'<div class="stat-card"><div class="stat-value">' + u.completionTokens.toLocaleString() + '</div><div class="stat-label">출력 토큰</div></div>' +
|
|
376
|
-
'<div class="stat-card"><div class="stat-value">' + u.totalTokens.toLocaleString() + '</div><div class="stat-label">총 토큰</div></div>' +
|
|
377
|
-
'<div class="stat-card accent"><div class="stat-value">$' + u.totalCost.toFixed(4) + '</div><div class="stat-label">예상 비용</div></div>' +
|
|
378
|
-
'</div>';
|
|
379
|
-
}
|
|
380
|
-
} catch {}
|
|
381
|
-
})();
|
|
382
|
-
</script>`;
|
|
383
253
|
|
|
384
254
|
return base({
|
|
385
255
|
title: `${opts.wikiName} - 대문`,
|
|
@@ -417,6 +287,316 @@ export function renderGraph(opts: {
|
|
|
417
287
|
});
|
|
418
288
|
}
|
|
419
289
|
|
|
290
|
+
export function renderQuizPage(opts: {
|
|
291
|
+
wikiName: string;
|
|
292
|
+
quizzes: Array<{ id: number; question: string; answer: string; explanation?: string; quiz_type: string; page_title?: string; page_slug?: string }>;
|
|
293
|
+
sourcePages: PageLink[];
|
|
294
|
+
conceptPages: PageLink[];
|
|
295
|
+
}): string {
|
|
296
|
+
const quizzesJson = JSON.stringify(opts.quizzes).replace(/</g, "\\u003c");
|
|
297
|
+
|
|
298
|
+
const content = `
|
|
299
|
+
<div class="quiz-page">
|
|
300
|
+
<h1>📝 학습 퀴즈</h1>
|
|
301
|
+
<p class="quiz-desc">위키 내용을 기반으로 생성된 퀴즈입니다. 학습한 내용을 확인해보세요!</p>
|
|
302
|
+
|
|
303
|
+
<div id="quiz-container">
|
|
304
|
+
<div id="quiz-empty" style="display:none;">
|
|
305
|
+
<p style="text-align:center;color:var(--text-muted);padding:40px 0;">퀴즈가 없습니다. 먼저 문서를 추가하세요.</p>
|
|
306
|
+
</div>
|
|
307
|
+
<div id="quiz-active" style="display:none;">
|
|
308
|
+
<div class="quiz-progress">
|
|
309
|
+
<span id="quiz-progress-text">1 / 5</span>
|
|
310
|
+
<div class="quiz-progress-bar"><div id="quiz-progress-fill" class="quiz-progress-fill"></div></div>
|
|
311
|
+
</div>
|
|
312
|
+
<div class="quiz-card" id="quiz-card">
|
|
313
|
+
<div class="quiz-card-inner" id="quiz-card-inner">
|
|
314
|
+
<div class="quiz-card-front">
|
|
315
|
+
<span class="quiz-type-badge" id="quiz-type-badge">빈칸 채우기</span>
|
|
316
|
+
<p class="quiz-question" id="quiz-question"></p>
|
|
317
|
+
<div class="quiz-input-area" id="quiz-input-area">
|
|
318
|
+
<input type="text" id="quiz-answer-input" placeholder="정답을 입력하세요..." autocomplete="off">
|
|
319
|
+
<button id="quiz-submit-btn" class="quiz-btn primary">확인</button>
|
|
320
|
+
</div>
|
|
321
|
+
<div class="quiz-ox-area" id="quiz-ox-area" style="display:none;">
|
|
322
|
+
<button class="quiz-btn ox-btn" data-answer="O">⭕ O</button>
|
|
323
|
+
<button class="quiz-btn ox-btn" data-answer="X">❌ X</button>
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
<div class="quiz-card-back">
|
|
327
|
+
<div id="quiz-result-icon" class="quiz-result-icon"></div>
|
|
328
|
+
<p class="quiz-answer-label">정답</p>
|
|
329
|
+
<p class="quiz-answer-text" id="quiz-answer-text"></p>
|
|
330
|
+
<div id="quiz-explanation" class="quiz-explanation" style="display:none;">
|
|
331
|
+
<p id="quiz-explanation-text" class="explanation-text"></p>
|
|
332
|
+
</div>
|
|
333
|
+
<p class="quiz-source" id="quiz-source"></p>
|
|
334
|
+
<button id="quiz-next-btn" class="quiz-btn primary">다음 문제 →</button>
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
</div>
|
|
338
|
+
</div>
|
|
339
|
+
<div id="quiz-done" style="display:none;">
|
|
340
|
+
<div class="quiz-score-card">
|
|
341
|
+
<h2>🎉 퀴즈 완료!</h2>
|
|
342
|
+
<div class="quiz-score">
|
|
343
|
+
<span id="quiz-score-text">0 / 5</span>
|
|
344
|
+
</div>
|
|
345
|
+
<div class="quiz-score-bar-container">
|
|
346
|
+
<div id="quiz-score-bar" class="quiz-score-bar"></div>
|
|
347
|
+
</div>
|
|
348
|
+
<p id="quiz-score-msg" class="quiz-score-msg"></p>
|
|
349
|
+
<div id="quiz-stats" class="quiz-stats" style="display:none;">
|
|
350
|
+
<h3>📊 학습 통계</h3>
|
|
351
|
+
<p id="quiz-stats-summary"></p>
|
|
352
|
+
<p id="quiz-stats-weak" style="display:none;"></p>
|
|
353
|
+
</div>
|
|
354
|
+
<button id="quiz-restart-btn" class="quiz-btn primary">🔄 다시 풀기</button>
|
|
355
|
+
</div>
|
|
356
|
+
</div>
|
|
357
|
+
</div>
|
|
358
|
+
</div>
|
|
359
|
+
<style>
|
|
360
|
+
.quiz-page { max-width: 700px; margin: 0 auto; padding: 24px 16px; }
|
|
361
|
+
.quiz-page h1 { font-size: 24px; margin-bottom: 8px; }
|
|
362
|
+
.quiz-desc { color: var(--text-muted); font-size: 14px; margin-bottom: 24px; }
|
|
363
|
+
.quiz-progress { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
|
|
364
|
+
#quiz-progress-text { font-size: 14px; font-weight: 600; color: var(--text-muted); white-space: nowrap; }
|
|
365
|
+
.quiz-progress-bar { flex: 1; height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; }
|
|
366
|
+
.quiz-progress-fill { height: 100%; background: var(--accent, #4caf50); border-radius: 3px; transition: width 0.3s ease; }
|
|
367
|
+
.quiz-card { perspective: 1000px; min-height: 300px; }
|
|
368
|
+
.quiz-card-inner { position: relative; transition: transform 0.5s ease; transform-style: preserve-3d; }
|
|
369
|
+
.quiz-card-inner.flipped { transform: rotateY(180deg); }
|
|
370
|
+
.quiz-card-front, .quiz-card-back {
|
|
371
|
+
background: var(--bg-alt, #fff); border: 1px solid var(--border); border-radius: 12px;
|
|
372
|
+
padding: 32px 24px; backface-visibility: hidden;
|
|
373
|
+
}
|
|
374
|
+
.quiz-card-back { position: absolute; top: 0; left: 0; right: 0; transform: rotateY(180deg); text-align: center; }
|
|
375
|
+
.quiz-type-badge {
|
|
376
|
+
display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 600;
|
|
377
|
+
background: var(--accent-light, #e8f5e9); color: #2e7d32; margin-bottom: 16px;
|
|
378
|
+
}
|
|
379
|
+
.quiz-question { font-size: 18px; line-height: 1.6; margin-bottom: 24px; font-weight: 500; }
|
|
380
|
+
.quiz-input-area { display: flex; gap: 8px; }
|
|
381
|
+
#quiz-answer-input {
|
|
382
|
+
flex: 1; padding: 10px 14px; border: 2px solid var(--border); border-radius: 8px;
|
|
383
|
+
font-size: 16px; outline: none; transition: border-color 0.2s;
|
|
384
|
+
}
|
|
385
|
+
#quiz-answer-input:focus { border-color: var(--accent, #4caf50); }
|
|
386
|
+
.quiz-btn {
|
|
387
|
+
padding: 10px 20px; border: none; border-radius: 8px; font-size: 15px; font-weight: 600;
|
|
388
|
+
cursor: pointer; transition: all 0.2s;
|
|
389
|
+
}
|
|
390
|
+
.quiz-btn.primary { background: var(--accent, #4caf50); color: white; }
|
|
391
|
+
.quiz-btn.primary:hover { opacity: 0.9; transform: translateY(-1px); }
|
|
392
|
+
.quiz-ox-area { display: flex; gap: 16px; justify-content: center; }
|
|
393
|
+
.ox-btn { padding: 16px 32px; font-size: 20px; border: 2px solid var(--border); border-radius: 12px; background: var(--bg-alt, #fff); }
|
|
394
|
+
.ox-btn:hover { border-color: var(--accent, #4caf50); background: var(--accent-light, #e8f5e9); }
|
|
395
|
+
.quiz-result-icon { font-size: 48px; margin-bottom: 12px; }
|
|
396
|
+
.quiz-answer-label { font-size: 13px; color: var(--text-muted); margin-bottom: 4px; }
|
|
397
|
+
.quiz-answer-text { font-size: 22px; font-weight: 700; color: var(--accent, #4caf50); margin-bottom: 16px; }
|
|
398
|
+
.quiz-source { font-size: 13px; color: var(--text-muted); margin-bottom: 20px; }
|
|
399
|
+
.quiz-source a { color: var(--accent, #4caf50); text-decoration: none; }
|
|
400
|
+
.quiz-source a:hover { text-decoration: underline; }
|
|
401
|
+
.quiz-score-card { text-align: center; background: var(--bg-alt, #fff); border: 1px solid var(--border); border-radius: 12px; padding: 40px 24px; }
|
|
402
|
+
.quiz-score { font-size: 48px; font-weight: 800; color: var(--accent, #4caf50); margin: 16px 0; }
|
|
403
|
+
.quiz-score-bar-container { height: 8px; background: var(--border); border-radius: 4px; overflow: hidden; margin: 16px 0 20px; }
|
|
404
|
+
.quiz-score-bar { height: 100%; background: var(--accent, #4caf50); border-radius: 4px; transition: width 0.5s ease; }
|
|
405
|
+
.quiz-score-msg { font-size: 16px; color: var(--text-muted); margin-bottom: 24px; }
|
|
406
|
+
.quiz-explanation { background: var(--accent-light, #e8f5e9); border-radius: 8px; padding: 12px 16px; margin-bottom: 16px; text-align: left; }
|
|
407
|
+
.explanation-text { font-size: 14px; line-height: 1.6; color: var(--text, #333); margin: 0; }
|
|
408
|
+
.quiz-stats { background: var(--bg-alt, #f5f5f5); border: 1px solid var(--border); border-radius: 8px; padding: 16px; margin-bottom: 20px; text-align: left; }
|
|
409
|
+
.quiz-stats h3 { font-size: 15px; margin: 0 0 8px; }
|
|
410
|
+
.quiz-stats p { font-size: 14px; color: var(--text-muted); margin: 4px 0; }
|
|
411
|
+
</style>
|
|
412
|
+
<script>
|
|
413
|
+
(function() {
|
|
414
|
+
const ALL_QUIZZES = ${quizzesJson};
|
|
415
|
+
const QUIZ_COUNT = Math.min(ALL_QUIZZES.length, 10);
|
|
416
|
+
|
|
417
|
+
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML;}
|
|
418
|
+
|
|
419
|
+
function normalize(s) {
|
|
420
|
+
return s.trim().toLowerCase().replace(/\\s+/g, ' ');
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (ALL_QUIZZES.length === 0) {
|
|
424
|
+
document.getElementById('quiz-empty').style.display = 'block';
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
let quizzes = [];
|
|
429
|
+
let current = 0;
|
|
430
|
+
let score = 0;
|
|
431
|
+
|
|
432
|
+
function shuffle(arr) {
|
|
433
|
+
const a = [...arr];
|
|
434
|
+
for (let i = a.length - 1; i > 0; i--) {
|
|
435
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
436
|
+
[a[i], a[j]] = [a[j], a[i]];
|
|
437
|
+
}
|
|
438
|
+
return a;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function startQuiz() {
|
|
442
|
+
quizzes = shuffle(ALL_QUIZZES).slice(0, QUIZ_COUNT);
|
|
443
|
+
current = 0;
|
|
444
|
+
score = 0;
|
|
445
|
+
document.getElementById('quiz-active').style.display = 'block';
|
|
446
|
+
document.getElementById('quiz-done').style.display = 'none';
|
|
447
|
+
showQuestion();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function typeLabel(t) {
|
|
451
|
+
return t === 'fill_blank' ? '빈칸 채우기' : t === 'ox' ? 'OX 퀴즈' : '단답형';
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function showQuestion() {
|
|
455
|
+
const q = quizzes[current];
|
|
456
|
+
const inner = document.getElementById('quiz-card-inner');
|
|
457
|
+
inner.classList.remove('flipped');
|
|
458
|
+
|
|
459
|
+
document.getElementById('quiz-progress-text').textContent = (current + 1) + ' / ' + quizzes.length;
|
|
460
|
+
document.getElementById('quiz-progress-fill').style.width = ((current + 1) / quizzes.length * 100) + '%';
|
|
461
|
+
document.getElementById('quiz-type-badge').textContent = typeLabel(q.quiz_type);
|
|
462
|
+
document.getElementById('quiz-question').innerHTML = esc(q.question);
|
|
463
|
+
|
|
464
|
+
const inputArea = document.getElementById('quiz-input-area');
|
|
465
|
+
const oxArea = document.getElementById('quiz-ox-area');
|
|
466
|
+
const answerInput = document.getElementById('quiz-answer-input');
|
|
467
|
+
|
|
468
|
+
if (q.quiz_type === 'ox') {
|
|
469
|
+
inputArea.style.display = 'none';
|
|
470
|
+
oxArea.style.display = 'flex';
|
|
471
|
+
} else {
|
|
472
|
+
inputArea.style.display = 'flex';
|
|
473
|
+
oxArea.style.display = 'none';
|
|
474
|
+
answerInput.value = '';
|
|
475
|
+
setTimeout(() => answerInput.focus(), 100);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function checkAnswer(userAnswer) {
|
|
480
|
+
const q = quizzes[current];
|
|
481
|
+
const isCorrect = normalize(userAnswer) === normalize(q.answer) || normalize(q.answer).includes(normalize(userAnswer)) && normalize(userAnswer).length > 0;
|
|
482
|
+
|
|
483
|
+
if (isCorrect) score++;
|
|
484
|
+
|
|
485
|
+
// Record attempt in localStorage
|
|
486
|
+
var attempts = JSON.parse(localStorage.getItem('kiwimu-quiz-attempts') || '[]');
|
|
487
|
+
attempts.push({ quizId: q.id, isCorrect: isCorrect, timestamp: new Date().toISOString() });
|
|
488
|
+
localStorage.setItem('kiwimu-quiz-attempts', JSON.stringify(attempts));
|
|
489
|
+
|
|
490
|
+
document.getElementById('quiz-result-icon').textContent = isCorrect ? '🎉' : '😅';
|
|
491
|
+
document.getElementById('quiz-answer-text').innerHTML = esc(q.answer);
|
|
492
|
+
document.getElementById('quiz-answer-text').style.color = isCorrect ? 'var(--accent, #4caf50)' : '#e53935';
|
|
493
|
+
|
|
494
|
+
// Show explanation if available
|
|
495
|
+
var explanationEl = document.getElementById('quiz-explanation');
|
|
496
|
+
if (q.explanation) {
|
|
497
|
+
document.getElementById('quiz-explanation-text').textContent = '💡 ' + q.explanation;
|
|
498
|
+
explanationEl.style.display = 'block';
|
|
499
|
+
} else {
|
|
500
|
+
explanationEl.style.display = 'none';
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const sourceEl = document.getElementById('quiz-source');
|
|
504
|
+
if (q.page_slug) {
|
|
505
|
+
const a = document.createElement('a');
|
|
506
|
+
a.href = '/wiki/' + encodeURIComponent(q.page_slug) + '.html';
|
|
507
|
+
a.textContent = '📖 ' + (q.page_title || q.page_slug) + ' 보기';
|
|
508
|
+
sourceEl.textContent = '출처: ';
|
|
509
|
+
sourceEl.appendChild(a);
|
|
510
|
+
} else {
|
|
511
|
+
sourceEl.textContent = '';
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
document.getElementById('quiz-card-inner').classList.add('flipped');
|
|
515
|
+
|
|
516
|
+
const nextBtn = document.getElementById('quiz-next-btn');
|
|
517
|
+
nextBtn.textContent = current < quizzes.length - 1 ? '다음 문제 →' : '결과 보기 →';
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function nextQuestion() {
|
|
521
|
+
current++;
|
|
522
|
+
if (current >= quizzes.length) {
|
|
523
|
+
showResults();
|
|
524
|
+
} else {
|
|
525
|
+
showQuestion();
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function showResults() {
|
|
530
|
+
document.getElementById('quiz-active').style.display = 'none';
|
|
531
|
+
document.getElementById('quiz-done').style.display = 'block';
|
|
532
|
+
|
|
533
|
+
const pct = Math.round(score / quizzes.length * 100);
|
|
534
|
+
document.getElementById('quiz-score-text').textContent = score + ' / ' + quizzes.length;
|
|
535
|
+
document.getElementById('quiz-score-bar').style.width = pct + '%';
|
|
536
|
+
|
|
537
|
+
const msgs = pct >= 90 ? '🏆 완벽에 가깝습니다!' : pct >= 70 ? '👏 잘 하셨습니다!' : pct >= 50 ? '📚 조금 더 복습해보세요!' : '💪 다시 도전해보세요!';
|
|
538
|
+
document.getElementById('quiz-score-msg').textContent = msgs;
|
|
539
|
+
|
|
540
|
+
// Show cumulative stats from localStorage
|
|
541
|
+
var allAttempts = JSON.parse(localStorage.getItem('kiwimu-quiz-attempts') || '[]');
|
|
542
|
+
if (allAttempts.length > 0) {
|
|
543
|
+
var totalAttempts = allAttempts.length;
|
|
544
|
+
var correctAttempts = allAttempts.filter(function(a) { return a.isCorrect; }).length;
|
|
545
|
+
var overallPct = Math.round(correctAttempts / totalAttempts * 100);
|
|
546
|
+
|
|
547
|
+
var statsEl = document.getElementById('quiz-stats');
|
|
548
|
+
statsEl.style.display = 'block';
|
|
549
|
+
document.getElementById('quiz-stats-summary').textContent = '전체 시도: ' + totalAttempts + '회 | 정답률: ' + overallPct + '%';
|
|
550
|
+
|
|
551
|
+
// Find weak concepts (most wrong answers by page)
|
|
552
|
+
var wrongByPage = {};
|
|
553
|
+
allAttempts.forEach(function(a) {
|
|
554
|
+
if (!a.isCorrect) {
|
|
555
|
+
var q = ALL_QUIZZES.find(function(quiz) { return quiz.id === a.quizId; });
|
|
556
|
+
if (q && q.page_title) {
|
|
557
|
+
wrongByPage[q.page_title] = (wrongByPage[q.page_title] || 0) + 1;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
var weakConcepts = Object.keys(wrongByPage).sort(function(a, b) { return wrongByPage[b] - wrongByPage[a]; }).slice(0, 3);
|
|
562
|
+
if (weakConcepts.length > 0) {
|
|
563
|
+
var weakEl = document.getElementById('quiz-stats-weak');
|
|
564
|
+
weakEl.style.display = 'block';
|
|
565
|
+
weakEl.textContent = '💪 약한 개념: ' + weakConcepts.join(', ');
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Event listeners
|
|
571
|
+
document.getElementById('quiz-submit-btn').addEventListener('click', function() {
|
|
572
|
+
const val = document.getElementById('quiz-answer-input').value;
|
|
573
|
+
if (val.trim()) checkAnswer(val);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
document.getElementById('quiz-answer-input').addEventListener('keydown', function(e) {
|
|
577
|
+
if (e.key === 'Enter' && this.value.trim()) checkAnswer(this.value);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
document.querySelectorAll('.ox-btn').forEach(function(btn) {
|
|
581
|
+
btn.addEventListener('click', function() { checkAnswer(this.dataset.answer); });
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
document.getElementById('quiz-next-btn').addEventListener('click', nextQuestion);
|
|
585
|
+
document.getElementById('quiz-restart-btn').addEventListener('click', startQuiz);
|
|
586
|
+
|
|
587
|
+
startQuiz();
|
|
588
|
+
})();
|
|
589
|
+
</script>`;
|
|
590
|
+
|
|
591
|
+
return base({
|
|
592
|
+
title: `학습 퀴즈 - ${opts.wikiName}`,
|
|
593
|
+
wikiName: opts.wikiName,
|
|
594
|
+
sourcePages: opts.sourcePages,
|
|
595
|
+
conceptPages: opts.conceptPages,
|
|
596
|
+
content,
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
420
600
|
export function renderAdmin(opts: {
|
|
421
601
|
wikiName: string;
|
|
422
602
|
sources: Array<{ id: number; uri: string; type: string; title: string; fetched_at: string }>;
|
|
@@ -424,6 +604,7 @@ export function renderAdmin(opts: {
|
|
|
424
604
|
llmConfig: { provider: string; model: string; api_key: string; endpoint: string };
|
|
425
605
|
personas: Array<{ name: string; description: string; system_prompt: string; content_style: string }>;
|
|
426
606
|
activePersona: string;
|
|
607
|
+
authToken?: string;
|
|
427
608
|
}): string {
|
|
428
609
|
const maskedKey = opts.llmConfig.api_key ? "••••" + opts.llmConfig.api_key.slice(-4) : "(미설정)";
|
|
429
610
|
const sourceRows = opts.sources
|
|
@@ -439,6 +620,9 @@ export function renderAdmin(opts: {
|
|
|
439
620
|
<meta charset="UTF-8">
|
|
440
621
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
441
622
|
<title>관리 - ${escapeHtml(opts.wikiName)}</title>
|
|
623
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
624
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
625
|
+
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;600;700;800&display=swap" rel="stylesheet">
|
|
442
626
|
<link rel="stylesheet" href="/static/style.css">
|
|
443
627
|
<style>
|
|
444
628
|
.admin-page { max-width: 900px; margin: 80px auto; padding: 0 24px; }
|
|
@@ -617,15 +801,17 @@ export function renderAdmin(opts: {
|
|
|
617
801
|
</div>
|
|
618
802
|
</div>
|
|
619
803
|
<script>
|
|
804
|
+
const AUTH_TOKEN = ${opts.authToken ? JSON.stringify(opts.authToken).replace(/</g, "\\u003c") : "''"};
|
|
805
|
+
const authHeaders = AUTH_TOKEN ? { 'Authorization': 'Bearer ' + AUTH_TOKEN } : {};
|
|
620
806
|
async function runAction(url, label) {
|
|
621
807
|
const status = document.getElementById('action-status');
|
|
622
808
|
status.textContent = '⏳ ' + label + ' 중...';
|
|
623
809
|
status.style.color = '#e65100';
|
|
624
810
|
try {
|
|
625
|
-
const r = await fetch(url, { method: 'POST' });
|
|
811
|
+
const r = await fetch(url, { method: 'POST', headers: authHeaders });
|
|
626
812
|
if (!r.ok) { const d = await r.json(); status.textContent = '❌ ' + (d.error || '실패'); status.style.color = '#c62828'; return; }
|
|
627
813
|
const poll = setInterval(async () => {
|
|
628
|
-
const sr = await fetch('/api/status');
|
|
814
|
+
const sr = await fetch('/api/status', { headers: authHeaders });
|
|
629
815
|
const s = await sr.json();
|
|
630
816
|
status.textContent = '⏳ ' + s.processingStatus;
|
|
631
817
|
if (!s.processing) {
|
|
@@ -643,7 +829,7 @@ export function renderAdmin(opts: {
|
|
|
643
829
|
const name = document.getElementById('wiki-name').value.trim();
|
|
644
830
|
if (!name) return;
|
|
645
831
|
try {
|
|
646
|
-
const r = await fetch('/api/settings', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ wiki_name: name }) });
|
|
832
|
+
const r = await fetch('/api/settings', { method: 'POST', headers: {...authHeaders, 'Content-Type':'application/json'}, body: JSON.stringify({ wiki_name: name }) });
|
|
647
833
|
if (r.ok) { status.textContent = '✅ 저장됨'; status.style.color = '#2e7d32'; setTimeout(() => location.reload(), 1000); }
|
|
648
834
|
else { status.textContent = '❌ 실패'; status.style.color = '#c62828'; }
|
|
649
835
|
} catch { status.textContent = '❌ 연결 실패'; status.style.color = '#c62828'; }
|
|
@@ -663,19 +849,19 @@ export function renderAdmin(opts: {
|
|
|
663
849
|
const ep = document.getElementById('llm-endpoint').value;
|
|
664
850
|
if (ep) body.endpoint = ep;
|
|
665
851
|
try {
|
|
666
|
-
const r = await fetch('/api/settings', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
|
852
|
+
const r = await fetch('/api/settings', { method: 'POST', headers: {...authHeaders, 'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
|
667
853
|
if (r.ok) { status.textContent = '✅ 저장됨'; status.style.color = '#2e7d32'; setTimeout(() => location.reload(), 1000); }
|
|
668
854
|
else { status.textContent = '❌ 실패'; status.style.color = '#c62828'; }
|
|
669
855
|
} catch { status.textContent = '❌ 연결 실패'; status.style.color = '#c62828'; }
|
|
670
856
|
});
|
|
671
857
|
|
|
672
858
|
// ── Persona management ──
|
|
673
|
-
let personaData = ${JSON.stringify(opts.personas)};
|
|
859
|
+
let personaData = ${JSON.stringify(opts.personas).replace(/</g, "\\u003c")};
|
|
674
860
|
|
|
675
861
|
async function activatePersona(name) {
|
|
676
862
|
const status = document.getElementById('persona-activate-status');
|
|
677
863
|
try {
|
|
678
|
-
const r = await fetch('/api/personas', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'activate', name }) });
|
|
864
|
+
const r = await fetch('/api/personas', { method: 'POST', headers: {...authHeaders, 'Content-Type':'application/json'}, body: JSON.stringify({ action: 'activate', name }) });
|
|
679
865
|
if (r.ok) { status.textContent = '✅'; status.style.color = '#2e7d32'; setTimeout(() => location.reload(), 800); }
|
|
680
866
|
else { status.textContent = '❌'; status.style.color = '#c62828'; }
|
|
681
867
|
} catch { status.textContent = '❌'; status.style.color = '#c62828'; }
|
|
@@ -719,7 +905,7 @@ export function renderAdmin(opts: {
|
|
|
719
905
|
? { action: 'update', original_name: originalName, persona }
|
|
720
906
|
: { action: 'add', persona };
|
|
721
907
|
try {
|
|
722
|
-
const r = await fetch('/api/personas', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
|
908
|
+
const r = await fetch('/api/personas', { method: 'POST', headers: {...authHeaders, 'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
|
723
909
|
if (r.ok) { closePersonaModal(); location.reload(); }
|
|
724
910
|
else { const d = await r.json(); alert(d.error || '실패'); }
|
|
725
911
|
} catch { alert('연결 실패'); }
|
|
@@ -728,7 +914,7 @@ export function renderAdmin(opts: {
|
|
|
728
914
|
async function deletePersona(name) {
|
|
729
915
|
if (!confirm(name + ' 페르소나를 삭제하시겠습니까?')) return;
|
|
730
916
|
try {
|
|
731
|
-
const r = await fetch('/api/personas', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'delete', name }) });
|
|
917
|
+
const r = await fetch('/api/personas', { method: 'POST', headers: {...authHeaders, 'Content-Type':'application/json'}, body: JSON.stringify({ action: 'delete', name }) });
|
|
732
918
|
if (r.ok) location.reload();
|
|
733
919
|
else { const d = await r.json(); alert(d.error || '실패'); }
|
|
734
920
|
} catch { alert('연결 실패'); }
|