@open330/kiwimu 0.7.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 CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  전공책, PDF, 웹 콘텐츠를 넣으면 — LLM이 챕터/개념별로 분석하여 상호 링크된 학습 위키를 자동 생성합니다.
10
10
 
11
+ [![npm](https://img.shields.io/npm/v/@open330/kiwimu?style=flat-square&color=cb3837&logo=npm)](https://www.npmjs.com/package/@open330/kiwimu)
11
12
  [![Bun](https://img.shields.io/badge/Bun-1.0+-fbf0df?style=flat-square&logo=bun&logoColor=black)](https://bun.sh)
12
13
  [![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat-square&logo=typescript&logoColor=white)](https://typescriptlang.org)
13
14
  [![License](https://img.shields.io/badge/License-MIT-green?style=flat-square)](LICENSE)
@@ -16,16 +17,28 @@
16
17
 
17
18
  ---
18
19
 
20
+ ## 30초 데모 체험
21
+
22
+ API key 없이 바로 체험할 수 있습니다:
23
+
24
+ ```bash
25
+ mkdir my-wiki && cd my-wiki
26
+ bunx @open330/kiwimu init --demo
27
+ # → 양자역학 샘플 위키 + 학습 퀴즈가 즉시 생성됩니다
28
+ # → http://localhost:8000 에서 확인하세요
29
+ ```
30
+
31
+ ---
32
+
19
33
  ## Quickstart for Agents
20
34
 
21
35
  <div><img src="https://quickstart-for-agents.vercel.app/api/header.svg?theme=claude-code&logo=kiwimu&title=Set+up+a+learning+wiki+from+any+textbook+or+URL&lang=Agents&font=mono&mascot=hat" width="100%" /></div>
22
36
 
23
37
  ```
24
- git clone https://github.com/Open330/kiwimu.git && cd kiwimu && bun install
25
38
  mkdir my-wiki && cd my-wiki
26
- bunx kiwimu init
27
- bunx kiwimu add "<YOUR_URL_OR_PDF>"
28
- bunx kiwimu serve
39
+ bunx @open330/kiwimu init
40
+ bunx @open330/kiwimu add "<YOUR_URL_OR_PDF>"
41
+ bunx @open330/kiwimu serve
29
42
  ```
30
43
 
31
44
  <div><img src="https://quickstart-for-agents.vercel.app/api/footer.svg?theme=claude-code&text=copy+this+prompt+%C2%B7+paste+into+your+agent+%C2%B7+get+a+learning+wiki&font=mono" width="100%" /></div>
@@ -40,10 +53,14 @@ Kiwi Mu는 LLM을 활용해 이 연결을 **자동으로** 만들어, 지식을
40
53
  - **LLM 기반 문서 분석** — 챕터/섹션 구조를 보존한 원본 페이지 + 핵심 개념별 자동 생성 페이지
41
54
  - **원본/개념 분리** — 📖 원본 문서와 📝 개념 문서를 시각적으로 구분
42
55
  - **자동 상호 링크** — 원본↔개념 간 유기적 cross-link + 외부 참고 자료 (Wikipedia 등)
56
+ - **학습 퀴즈** — 개념 페이지 기반 빈칸 채우기 / OX / 단답형 퀴즈 자동 생성
43
57
  - **지식 그래프** — D3.js 인터랙티브 그래프 (원본: 파란색, 개념: 초록색)
58
+ - **데모 모드** — API key 없이 `--demo`로 즉시 체험
59
+ - **다양한 파일 지원** — URL, PDF, DOCX, PPTX, PPT, DOC, KEY, RTF
60
+ - **4개 LLM 프로바이더** — Google Gemini, Azure OpenAI, OpenAI, Anthropic
61
+ - **다크 모드** — 시스템 테마에 자동 대응
62
+ - **모바일 지원** — 햄버거 메뉴 + 슬라이드 사이드바
44
63
  - **웹 UI** — 브라우저에서 문서 추가, 설정 변경, 빌드 실행
45
- - **다양한 파일 지원** — URL, PDF, DOCX, PPTX, PPT, DOC, KEY
46
- - **다중 LLM 프로바이더** — Google Gemini, Azure OpenAI, OpenAI, Anthropic
47
64
  - **토큰 사용량 추적** — API 호출 수, 토큰, 예상 비용을 웹에서 확인
48
65
  - **원클릭 배포** — GitHub Pages / Vercel
49
66
 
@@ -52,15 +69,32 @@ Kiwi Mu는 LLM을 활용해 이 연결을 **자동으로** 만들어, 지식을
52
69
  ### 설치
53
70
 
54
71
  ```bash
55
- git clone https://github.com/Open330/kiwimu.git
56
- cd kiwimu && bun install
72
+ # npm/bunx로 바로 사용 (설치 불필요)
73
+ bunx @open330/kiwimu init
74
+
75
+ # 또는 글로벌 설치
76
+ bun add -g @open330/kiwimu
77
+ ```
78
+
79
+ ### 데모 모드 (API key 불필요)
80
+
81
+ ```bash
82
+ mkdir my-wiki && cd my-wiki
83
+ bunx @open330/kiwimu init --demo
57
84
  ```
58
85
 
86
+ 양자역학 샘플 위키가 생성되어 바로 체험할 수 있습니다:
87
+ - 📖 원본 문서 + 📝 개념 페이지
88
+ - 🔗 자동 상호 링크
89
+ - 📊 지식 그래프
90
+ - 📝 학습 퀴즈
91
+ - 🎲 임의 문서 탐험
92
+
59
93
  ### 프로젝트 생성 (Interactive CLI)
60
94
 
61
95
  ```bash
62
96
  mkdir my-wiki && cd my-wiki
63
- bunx kiwimu init
97
+ bunx @open330/kiwimu init
64
98
  ```
65
99
 
66
100
  Interactive 프롬프트가 실행됩니다:
@@ -86,68 +120,68 @@ Interactive 프롬프트가 실행됩니다:
86
120
  🥝 'Radio Astronomy Wiki' 위키가 생성되었습니다!
87
121
  ```
88
122
 
89
- 이름을 바로 지정할 수도 있습니다:
90
-
91
- ```bash
92
- bunx kiwimu init "My Study Wiki"
93
- ```
94
-
95
123
  ### 문서 추가
96
124
 
97
125
  ```bash
98
126
  # URL 추가
99
- bunx kiwimu add "https://www.cv.nrao.edu/~sransom/web/Ch1.html"
127
+ bunx @open330/kiwimu add "https://www.cv.nrao.edu/~sransom/web/Ch1.html"
100
128
 
101
- # PDF 추가
102
- bunx kiwimu add textbook.pdf
129
+ # 파일 추가 (PDF, DOCX, PPTX, DOC, PPT, KEY, RTF)
130
+ bunx @open330/kiwimu add textbook.pdf
131
+ bunx @open330/kiwimu add lecture.pptx
103
132
  ```
104
133
 
105
134
  LLM이 문서를 분석하여:
106
135
  1. 📖 **원본 페이지** — 원래 챕터/섹션 구조 보존
107
136
  2. 📝 **개념 페이지** — 핵심 용어·정의·법칙 자동 생성
108
137
  3. 🔗 **Cross-link** — 원본↔개념 간 유기적 연결
138
+ 4. 📝 **퀴즈** — 개념별 학습 퀴즈 자동 생성
139
+
140
+ ### 학습 퀴즈
141
+
142
+ ```bash
143
+ # 터미널에서 퀴즈 풀기
144
+ bunx @open330/kiwimu quiz
145
+
146
+ # 문제 수 지정
147
+ bunx @open330/kiwimu quiz -n 10
148
+ ```
149
+
150
+ 웹에서도 `http://localhost:8000/quiz.html`에서 카드 플립 방식으로 퀴즈를 풀 수 있습니다.
109
151
 
110
152
  ### 빌드 및 서버
111
153
 
112
154
  ```bash
113
155
  # 정적 사이트 빌드
114
- bunx kiwimu build
156
+ bunx @open330/kiwimu build
115
157
 
116
158
  # 로컬 서버 실행 (웹에서 문서 추가 가능)
117
- bunx kiwimu serve
159
+ bunx @open330/kiwimu serve
118
160
  # → http://localhost:8000
119
161
 
120
162
  # 포트 변경
121
- bunx kiwimu serve -p 3000
122
-
123
- # 네트워크에 공개 (0.0.0.0)
124
- bunx kiwimu serve --host 0.0.0.0
163
+ bunx @open330/kiwimu serve -p 3000
125
164
  ```
126
165
 
127
- ### 웹 UI에서 문서 추가
128
-
129
- `kiwimu serve` 실행 후 http://localhost:8000 에서:
130
- - **🔗 URL 탭** — URL 입력 후 추가
131
- - **📄 파일 업로드 탭** — PDF, DOCX, PPTX 등 드래그앤드롭 업로드
132
- - 진행 상태 실시간 표시, 완료 시 자동 새로고침
133
-
134
166
  ### 관리 페이지
135
167
 
136
- http://localhost:8000/admin 에서:
168
+ `kiwimu serve` 실행 후 콘솔에 표시되는 admin URL로 접속:
137
169
  - 위키 이름 변경
138
170
  - LLM 프로바이더/모델/API Key 설정
139
171
  - 토큰 사용량 및 예상 비용 확인
140
- - 등록된 소스 목록
172
+ - 파일 업로드 (PDF, DOCX, PPTX 등)
173
+ - URL 추가
141
174
  - 수동 빌드 실행
175
+ - 페르소나 관리
142
176
 
143
177
  ### 배포
144
178
 
145
179
  ```bash
146
180
  # GitHub Pages (기본)
147
- bunx kiwimu deploy
181
+ bunx @open330/kiwimu deploy
148
182
 
149
183
  # Vercel
150
- bunx kiwimu deploy --target vercel
184
+ bunx @open330/kiwimu deploy --target vercel
151
185
  ```
152
186
 
153
187
  ## Commands
@@ -155,10 +189,13 @@ bunx kiwimu deploy --target vercel
155
189
  | 명령 | 설명 |
156
190
  |------|------|
157
191
  | `kiwimu init [name]` | 새 위키 프로젝트 생성 (interactive CLI) |
158
- | `kiwimu add <source>` | URL 또는 파일 추가 (LLM 분석 + 링크 생성) |
192
+ | `kiwimu init --demo` | 샘플 데이터로 즉시 체험 (API key 불필요) |
193
+ | `kiwimu add <source>` | URL 또는 파일 추가 (PDF, DOCX, PPTX, DOC, PPT, KEY, RTF) |
159
194
  | `kiwimu build` | 정적 위키 사이트 빌드 |
160
- | `kiwimu serve [-p port] [--host host]` | 웹 서버 실행 (문서 추가/관리 가능) |
161
- | `kiwimu deploy` | GitHub Pages / Vercel에 배포 |
195
+ | `kiwimu serve [-p port]` | 웹 서버 실행 (문서 추가/관리 가능) |
196
+ | `kiwimu quiz [-n count]` | 터미널에서 학습 퀴즈 풀기 |
197
+ | `kiwimu expand [--provider]` | LLM으로 문서 내용 확장 (선택) |
198
+ | `kiwimu deploy [--target]` | GitHub Pages / Vercel에 배포 |
162
199
  | `kiwimu status` | 현재 위키 상태 표시 |
163
200
 
164
201
  ## Supported File Formats
@@ -178,36 +215,40 @@ bunx kiwimu deploy --target vercel
178
215
  |-----------|----------|------|
179
216
  | **Google Gemini** | `gemini-2.0-flash-lite` | [무료 API key](https://aistudio.google.com/) |
180
217
  | Azure OpenAI | `gpt-5-nano` | Azure 구독 필요 |
181
- | OpenAI | `gpt-4o-mini` | API key 필요 |
218
+ | OpenAI | `gpt-4o` | API key 필요 |
182
219
  | Anthropic | `claude-sonnet-4-20250514` | API key 필요 |
183
220
 
184
221
  ## Architecture
185
222
 
186
223
  ```
187
- 소스 (URL / PDF / DOCX / PPTX)
224
+ 소스 (URL / PDF / DOCX / PPTX / DOC / PPT / KEY / RTF)
188
225
 
189
- [ Ingest ] ── Cheerio / pdf-parse / mammoth / jszip
226
+ [ Ingest ] ── Cheerio / pdf-parse / mammoth / jszip / textutil
190
227
 
191
- [ Phase 1 ] ── LLM: 원본 구조 추출 (📖 원본 페이지)
228
+ [ Phase 1 ] ── LLM: 원본 구조 추출 (📖 원본 페이지) — 병렬 처리 (concurrency=3)
192
229
 
193
- [ Phase 2 ] ── LLM: 개념 추출 (📝 개념 페이지)
230
+ [ Phase 2 ] ── LLM: 개념 추출 (📝 개념 페이지)
194
231
 
195
- [ Phase 3 ] ── [[wiki link]] 해석 + 원본↔개념 cross-link
232
+ [ Phase 2.5 ] ── LLM: 학습 퀴즈 자동 생성 (📝 퀴즈) — 병렬 처리
196
233
 
197
- [ Build ] ── 정적 HTML 생성 (탭 사이드바, KaTeX, 지식 그래프)
234
+ [ Phase 3 ] ── [[wiki link]] 해석 + 원본↔개념 cross-link
198
235
 
199
- [ Deploy ] ── GitHub Pages / Vercel
236
+ [ Build ] ── 정적 HTML (사이드바, KaTeX, 지식 그래프, 퀴즈, 다크 모드)
237
+
238
+ [ Deploy ] ── GitHub Pages / Vercel
200
239
  ```
201
240
 
202
241
  ```
203
242
  project-dir/
204
243
  ├── kiwi.toml # 프로젝트 + LLM 설정
205
- ├── kiwi.db # SQLite (문서, 링크, 사용량)
244
+ ├── kiwi.db # SQLite (문서, 링크, 퀴즈, 사용량)
206
245
  ├── uploads/ # 업로드된 파일
207
246
  └── _site/ # 빌드 결과
208
- ├── index.html # 홈 (문서 추가 UI + 사용량 대시보드)
247
+ ├── index.html # 홈 (문서 목록)
209
248
  ├── graph.html # 지식 그래프
249
+ ├── quiz.html # 학습 퀴즈
210
250
  ├── wiki/ # 각 문서 페이지
251
+ │ └── random.html # 임의 문서
211
252
  ├── static/ # CSS, JS, 로고
212
253
  └── search-index.json
213
254
  ```
@@ -220,11 +261,19 @@ project-dir/
220
261
  - **Cheerio** — 웹 페이지 파싱
221
262
  - **Mammoth** — DOCX 파싱
222
263
  - **JSZip** — PPTX 파싱
223
- - **Marked** — Markdown → HTML
264
+ - **Marked** + **sanitize-html** — Markdown → 안전한 HTML
224
265
  - **D3.js** — 지식 그래프
225
266
  - **KaTeX** — 수학 수식 렌더링
226
267
  - **gh-pages** — GitHub Pages 배포
227
268
 
269
+ ## Security
270
+
271
+ - Bearer 토큰 인증 (serve 모드)
272
+ - SSRF 방지 (프라이빗 IP 차단, 리다이렉트 재검증)
273
+ - Path Traversal 방지 (resolve 검증)
274
+ - XSS 방지 (sanitize-html, CSP 헤더, escapeHtml)
275
+ - 파일 업로드 제한 (50MB)
276
+
228
277
  ## License
229
278
 
230
279
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open330/kiwimu",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Turn textbooks, PDFs, and web content into your own interlinked learning wiki powered by LLM",
5
5
  "type": "module",
6
6
  "bin": {
@@ -141,6 +141,7 @@ export async function buildSite(store: Store, config: KiwiConfig, projectRoot: s
141
141
  id: q.id,
142
142
  question: q.question,
143
143
  answer: q.answer,
144
+ explanation: q.explanation || "",
144
145
  quiz_type: q.quiz_type,
145
146
  page_title: q.page_title,
146
147
  page_slug: q.page_slug,
@@ -289,7 +289,7 @@ export function renderGraph(opts: {
289
289
 
290
290
  export function renderQuizPage(opts: {
291
291
  wikiName: string;
292
- quizzes: Array<{ id: number; question: string; answer: string; quiz_type: string; page_title?: string; page_slug?: string }>;
292
+ quizzes: Array<{ id: number; question: string; answer: string; explanation?: string; quiz_type: string; page_title?: string; page_slug?: string }>;
293
293
  sourcePages: PageLink[];
294
294
  conceptPages: PageLink[];
295
295
  }): string {
@@ -327,6 +327,9 @@ export function renderQuizPage(opts: {
327
327
  <div id="quiz-result-icon" class="quiz-result-icon"></div>
328
328
  <p class="quiz-answer-label">정답</p>
329
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>
330
333
  <p class="quiz-source" id="quiz-source"></p>
331
334
  <button id="quiz-next-btn" class="quiz-btn primary">다음 문제 →</button>
332
335
  </div>
@@ -343,6 +346,11 @@ export function renderQuizPage(opts: {
343
346
  <div id="quiz-score-bar" class="quiz-score-bar"></div>
344
347
  </div>
345
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>
346
354
  <button id="quiz-restart-btn" class="quiz-btn primary">🔄 다시 풀기</button>
347
355
  </div>
348
356
  </div>
@@ -395,6 +403,11 @@ export function renderQuizPage(opts: {
395
403
  .quiz-score-bar-container { height: 8px; background: var(--border); border-radius: 4px; overflow: hidden; margin: 16px 0 20px; }
396
404
  .quiz-score-bar { height: 100%; background: var(--accent, #4caf50); border-radius: 4px; transition: width 0.5s ease; }
397
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; }
398
411
  </style>
399
412
  <script>
400
413
  (function() {
@@ -469,15 +482,29 @@ export function renderQuizPage(opts: {
469
482
 
470
483
  if (isCorrect) score++;
471
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
+
472
490
  document.getElementById('quiz-result-icon').textContent = isCorrect ? '🎉' : '😅';
473
491
  document.getElementById('quiz-answer-text').innerHTML = esc(q.answer);
474
492
  document.getElementById('quiz-answer-text').style.color = isCorrect ? 'var(--accent, #4caf50)' : '#e53935';
475
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
+
476
503
  const sourceEl = document.getElementById('quiz-source');
477
504
  if (q.page_slug) {
478
505
  const a = document.createElement('a');
479
506
  a.href = '/wiki/' + encodeURIComponent(q.page_slug) + '.html';
480
- a.textContent = q.page_title || q.page_slug;
507
+ a.textContent = '📖 ' + (q.page_title || q.page_slug) + ' 보기';
481
508
  sourceEl.textContent = '출처: ';
482
509
  sourceEl.appendChild(a);
483
510
  } else {
@@ -509,6 +536,35 @@ export function renderQuizPage(opts: {
509
536
 
510
537
  const msgs = pct >= 90 ? '🏆 완벽에 가깝습니다!' : pct >= 70 ? '👏 잘 하셨습니다!' : pct >= 50 ? '📚 조금 더 복습해보세요!' : '💪 다시 도전해보세요!';
511
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
+ }
512
568
  }
513
569
 
514
570
  // Event listeners
@@ -46,14 +46,14 @@ export const DEMO_PAGES = [
46
46
  ];
47
47
 
48
48
  export const DEMO_QUIZZES = [
49
- { page_slug: "하이젠베르크", question: "___은 위치와 운동량을 동시에 정확히 측정할 수 없다는 양자역학의 원리이다.", answer: "불확정성 원리", quiz_type: "fill_blank" },
50
- { page_slug: "슈뢰딩거-방정식", question: "슈뢰딩거 방정식은 1926년에 발표되었다.", answer: "O", quiz_type: "ox" },
51
- { page_slug: "양자-중첩", question: "양자 중첩에서 관측하면 어떤 현상이 일어나는가?", answer: "중첩이 붕괴하여 하나의 상태만 남는다", quiz_type: "short_answer" },
52
- { page_slug: "양자-얽힘", question: "아인슈타인은 양자 얽힘을 '___한 원격 작용'이라 불렀다.", answer: "으스스", quiz_type: "fill_blank" },
53
- { page_slug: "양자-중첩", question: "슈뢰딩거의 고양이 사고실험에서 고양이는 살아있는 상태와 죽어있는 상태의 ___에 놓인다.", answer: "중첩", quiz_type: "fill_blank" },
54
- { page_slug: "양자-얽힘", question: "2022년 노벨 물리학상은 양자 얽힘의 벨 부등식 실험 검증과 관련이 있다.", answer: "O", quiz_type: "ox" },
55
- { page_slug: "슈뢰딩거-방정식", question: "시간 독립 슈뢰딩거 방정식에서 E는 무엇을 나타내는가?", answer: "에너지 고유값", quiz_type: "short_answer" },
56
- { page_slug: "하이젠베르크", question: "불확정성 원리에서 Δx·Δp ≥ ℏ/2 이다.", answer: "O", quiz_type: "ox" },
49
+ { page_slug: "하이젠베르크", question: "___은 위치와 운동량을 동시에 정확히 측정할 수 없다는 양자역학의 원리이다.", answer: "불확정성 원리", explanation: "하이젠베르크가 1927년에 제안한 이 원리는 양자역학의 근본적 한계를 보여줍니다. 위치를 정확히 측정하면 운동량의 불확정성이 커지고, 그 반대도 마찬가지입니다.", quiz_type: "fill_blank" },
50
+ { page_slug: "슈뢰딩거-방정식", question: "슈뢰딩거 방정식은 1926년에 발표되었다.", answer: "O", explanation: "에르빈 슈뢰딩거가 1926년에 발표한 이 방정식은 양자계의 시간 변화를 기술하는 기본 방정식입니다.", quiz_type: "ox" },
51
+ { page_slug: "양자-중첩", question: "양자 중첩에서 관측하면 어떤 현상이 일어나는가?", answer: "중첩이 붕괴하여 하나의 상태만 남는다", explanation: "관측 행위가 양자 시스템에 영향을 주어 여러 가능한 상태 중 하나로 '붕괴'됩니다. 이를 파동함수 붕괴라고 합니다.", quiz_type: "short_answer" },
52
+ { page_slug: "양자-얽힘", question: "아인슈타인은 양자 얽힘을 '___한 원격 작용'이라 불렀다.", answer: "으스스", explanation: "아인슈타인은 양자 얽힘이 국소적 실재론에 위배된다고 생각하여 'spooky action at a distance(으스스한 원격 작용)'이라 비판했습니다.", quiz_type: "fill_blank" },
53
+ { page_slug: "양자-중첩", question: "슈뢰딩거의 고양이 사고실험에서 고양이는 살아있는 상태와 죽어있는 상태의 ___에 놓인다.", answer: "중첩", explanation: "이 사고실험은 양자 중첩의 개념을 거시적 세계에 적용하여 양자역학의 해석 문제를 드러내기 위해 고안되었습니다.", quiz_type: "fill_blank" },
54
+ { page_slug: "양자-얽힘", question: "2022년 노벨 물리학상은 양자 얽힘의 벨 부등식 실험 검증과 관련이 있다.", answer: "O", explanation: "알랭 아스페, 존 클라우저, 안톤 차일링거가 벨 부등식 위반을 실험적으로 증명하여 양자 얽힘의 실재성을 확인한 공로로 수상했습니다.", quiz_type: "ox" },
55
+ { page_slug: "슈뢰딩거-방정식", question: "시간 독립 슈뢰딩거 방정식에서 E는 무엇을 나타내는가?", answer: "에너지 고유값", explanation: "시간 독립 슈뢰딩거 방정식 Hψ = Eψ에서 E는 시스템의 에너지 고유값으로, 허용된 에너지 준위를 나타냅니다.", quiz_type: "short_answer" },
56
+ { page_slug: "하이젠베르크", question: "불확정성 원리에서 Δx·Δp ≥ ℏ/2 이다.", answer: "O", explanation: "이 부등식은 위치의 불확정성(Δx)과 운동량의 불확정성(Δp)의 곱이 플랑크 상수의 절반 이상임을 나타냅니다.", quiz_type: "ox" },
57
57
  ];
58
58
 
59
59
  export const DEMO_LINKS = [
package/src/demo/setup.ts CHANGED
@@ -25,7 +25,7 @@ export async function setupDemo(store: Store): Promise<void> {
25
25
  for (const quiz of DEMO_QUIZZES) {
26
26
  const page = store.getPage(quiz.page_slug);
27
27
  if (page) {
28
- store.addQuiz(page.id, quiz.question, quiz.answer, quiz.quiz_type);
28
+ store.addQuiz(page.id, quiz.question, quiz.answer, quiz.quiz_type, (quiz as any).explanation || "");
29
29
  }
30
30
  }
31
31
  }
package/src/index.ts CHANGED
@@ -8,7 +8,7 @@ import { Store } from "./store";
8
8
  const program = new Command()
9
9
  .name("kiwimu")
10
10
  .description("🥝 Kiwi Mu — 나만의 학습 위키를 만드세요")
11
- .version("0.7.1");
11
+ .version("0.8.0");
12
12
 
13
13
  // --- init ---
14
14
  program
@@ -144,8 +144,15 @@ program
144
144
  const absPath = resolve(source);
145
145
  const file = Bun.file(absPath);
146
146
  if (!(await file.exists())) {
147
- console.log(`\x1b[31m파일을 찾을 수 없습니다: ${source}\x1b[0m`);
148
- return;
147
+ console.error(`\x1b[31m파일을 찾을 수 없습니다: ${source}\x1b[0m`);
148
+ process.exit(1);
149
+ }
150
+ const ext = source.split(".").pop()?.toLowerCase() || "";
151
+ const SUPPORTED_EXTENSIONS = ['pdf', 'docx', 'pptx', 'doc', 'ppt', 'key', 'rtf'];
152
+ if (!SUPPORTED_EXTENSIONS.includes(ext)) {
153
+ console.error(`\x1b[31m❌ 지원하지 않는 파일 형식입니다: .${ext}\x1b[0m`);
154
+ console.error(` 지원 형식: ${SUPPORTED_EXTENSIONS.join(', ')}`);
155
+ process.exit(1);
149
156
  }
150
157
  console.log(`\x1b[34m📥 파일 처리 중: ${source}\x1b[0m`);
151
158
  const { ingestFile } = await import("./services/ingest");
@@ -153,6 +160,10 @@ program
153
160
  console.log(`\x1b[32m✅ 📖 ${result.sourceCount}개 원본 + 📝 ${result.conceptCount}개 개념 문서 생성\x1b[0m`);
154
161
  console.log(`\x1b[34m📊 LLM: ${result.usage.totalCalls}회 호출, ~$${result.usage.estimatedCostUsd.toFixed(4)}\x1b[0m`);
155
162
  }
163
+ } catch (e: unknown) {
164
+ const message = e instanceof Error ? e.message : String(e);
165
+ console.error(`\x1b[31m❌ ${message}\x1b[0m`);
166
+ process.exit(1);
156
167
  } finally {
157
168
  store.close();
158
169
  }
@@ -198,7 +209,7 @@ program
198
209
  store.updatePageContent(page.id, newContent);
199
210
  } catch (e: unknown) {
200
211
  const message = e instanceof Error ? e.message : String(e);
201
- console.log(` \x1b[31m실패: ${message}\x1b[0m`);
212
+ console.error(` \x1b[31m실패: ${message}\x1b[0m`);
202
213
  }
203
214
  }
204
215
 
@@ -270,7 +281,8 @@ program
270
281
  await deployVercel(siteDir);
271
282
  console.log("\x1b[32m✅ Vercel에 배포되었습니다!\x1b[0m");
272
283
  } else {
273
- console.log(`\x1b[31m지원하지 않는 배포 대상: ${opts.target}\x1b[0m`);
284
+ console.error(`\x1b[31m지원하지 않는 배포 대상: ${opts.target}\x1b[0m`);
285
+ process.exit(1);
274
286
  }
275
287
  });
276
288
 
@@ -308,7 +320,7 @@ program
308
320
  try {
309
321
  store.initSchema(); // ensure quizzes table exists
310
322
  const count = parseInt(opts.count) || 5;
311
- const quizzes = store.getRandomQuizzes(count);
323
+ const quizzes = store.getSmartQuizzes(count);
312
324
  if (quizzes.length === 0) {
313
325
  console.log("\x1b[33m퀴즈가 없습니다. 먼저 문서를 추가하세요.\x1b[0m");
314
326
  return;
@@ -350,16 +362,21 @@ program
350
362
  return;
351
363
  }
352
364
 
353
- const correct = q.answer.trim().toLowerCase();
354
- const user = (userAnswer as string).trim().toLowerCase();
355
- const isCorrect = user === correct || (correct.includes(user) && user.length > 0);
365
+ const norm = (s: string) => s.trim().toLowerCase().replace(/\s+/g, ' ');
366
+ const isCorrect = norm(userAnswer as string) === norm(q.answer);
367
+
368
+ store.addQuizAttempt(q.id, isCorrect);
356
369
 
357
370
  if (isCorrect) {
358
371
  score++;
359
- console.log(` \x1b[32m✅ 정답!\x1b[0m\n`);
372
+ console.log(` \x1b[32m✅ 정답!\x1b[0m`);
360
373
  } else {
361
- console.log(` \x1b[31m❌ 오답! 정답: ${q.answer}\x1b[0m\n`);
374
+ console.log(` \x1b[31m❌ 오답! 정답: ${q.answer}\x1b[0m`);
375
+ }
376
+ if (q.explanation) {
377
+ console.log(` \x1b[36m💡 ${q.explanation}\x1b[0m`);
362
378
  }
379
+ console.log();
363
380
  }
364
381
 
365
382
  const pct = Math.round((score / quizzes.length) * 100);
@@ -369,6 +386,15 @@ program
369
386
  else if (pct >= 50) console.log(" 📚 조금 더 복습해보세요!");
370
387
  else console.log(" 💪 다시 도전해보세요!");
371
388
 
389
+ const stats = store.getQuizStats();
390
+ if (stats.total > 0) {
391
+ const overallPct = Math.round(stats.correct / stats.total * 100);
392
+ console.log(`\n📊 전체 통계: ${stats.correct}/${stats.total} 정답 (${overallPct}%)`);
393
+ if (stats.unattempted > 0) {
394
+ console.log(` 📋 미시도 퀴즈: ${stats.unattempted}개`);
395
+ }
396
+ }
397
+
372
398
  p.outro("학습을 계속하세요! 🥝");
373
399
  } finally {
374
400
  store.close();
@@ -233,9 +233,10 @@ export async function llmChunkDocument(
233
233
  if (persona) {
234
234
  console.log(`\x1b[35m🎭 페르소나: ${persona.name}\x1b[0m`);
235
235
  }
236
- console.log(`\x1b[34m🧠 Phase 1: 원본 구조 추출 (${chunks.length}개 청크)...\x1b[0m`);
236
+ console.log(`\x1b[34m Phase 1: 원본 구조 추출 중... (${chunks.length}개 청크)\x1b[0m`);
237
237
 
238
238
  // ── Phase 1: Extract source pages (parallel LLM calls) ──
239
+ const phase1Start = performance.now();
239
240
  let completedCount = 0;
240
241
  const structureSystem = getStructureSystem(persona);
241
242
 
@@ -289,10 +290,12 @@ export async function llmChunkDocument(
289
290
  }
290
291
 
291
292
  const sourceCount = orderCounter;
292
- console.log(`\x1b[32m 📖 ${sourceCount}개 원본 페이지 생성 완료\x1b[0m`);
293
+ const phase1Sec = ((performance.now() - phase1Start) / 1000).toFixed(1);
294
+ console.log(`\x1b[32m✅ Phase 1 완료 (${phase1Sec}초) — 📖 ${sourceCount}개 원본 페이지 생성\x1b[0m`);
293
295
 
294
296
  // ── Phase 2: Extract concept pages ──
295
- console.log(`\x1b[34m🧠 Phase 2: 개념 페이지 추출...\x1b[0m`);
297
+ const phase2Start = performance.now();
298
+ console.log(`\x1b[34m⏳ Phase 2: 개념 추출 중...\x1b[0m`);
296
299
 
297
300
  // Process source pages in small batches for concept extraction
298
301
  const batchSize = 5;
@@ -340,21 +343,24 @@ export async function llmChunkDocument(
340
343
  }
341
344
  }
342
345
 
343
- console.log(`\x1b[32m 📝 ${conceptCount}개 개념 페이지 생성 완료\x1b[0m`);
346
+ const phase2Sec = ((performance.now() - phase2Start) / 1000).toFixed(1);
347
+ console.log(`\x1b[32m✅ Phase 2 완료 (${phase2Sec}초) — 📝 ${conceptCount}개 개념 페이지 생성\x1b[0m`);
344
348
 
345
349
  // ── Phase 2.5: Generate quizzes from concept pages ──
346
350
  let quizCount = 0;
347
351
  try {
348
352
  const conceptPagesForQuiz = store.listConceptPages();
349
353
  if (conceptPagesForQuiz.length > 0) {
350
- console.log(`\x1b[34m🧠 Phase 2.5: 퀴즈 생성 (${conceptPagesForQuiz.length}개 개념 페이지)...\x1b[0m`);
354
+ console.log(`\x1b[34m Phase 2.5: 퀴즈 생성 중... (${conceptPagesForQuiz.length}개 개념 페이지)\x1b[0m`);
351
355
 
352
- const quizSystem = `You are a quiz generator for a study wiki. Generate quiz questions based on wiki content.
356
+ const quizSystem = `You are a quiz generator for a study wiki. Generate quiz questions that test UNDERSTANDING, not just memorization.
357
+ Focus on higher-order thinking: "왜?", "어떻게?", "비교하라", "설명하라" style questions.
353
358
  Return valid JSON only. No markdown fences.`;
354
359
 
355
360
  await parallelMap(conceptPagesForQuiz, 3, async (page, i) => {
356
361
  try {
357
- const quizPrompt = `Based on this wiki content, generate 2-3 quiz questions in JSON format.
362
+ const quizPrompt = `Based on this wiki content, generate 2-3 quiz questions that test UNDERSTANDING, not just memorization.
363
+ Include questions that ask "왜?", "어떻게?", "비교하라" etc.
358
364
  Types: "fill_blank" (빈칸 채우기), "ox" (OX 퀴즈 - true/false), "short_answer" (단답형)
359
365
 
360
366
  Content title: ${page.title}
@@ -362,21 +368,22 @@ Content:
362
368
  ${page.content.slice(0, 3000)}
363
369
 
364
370
  Respond with a JSON array only:
365
- [{"question": "___은 양자역학에서 위치와 운동량을 동시에 측정할 수 없다는 원리이다.", "answer": "불확정성 원리", "type": "fill_blank"}]
371
+ [{"question": "___은 양자역학에서 위치와 운동량을 동시에 측정할 수 없다는 원리이다.", "answer": "불확정성 원리", "explanation": "이 원리는 양자역학의 근본적 한계를 보여주며, 측정 행위 자체가 시스템에 영향을 주기 때문입니다.", "type": "fill_blank"}]
366
372
 
367
373
  Rules:
368
374
  - For fill_blank: use ___ to mark the blank in the question
369
375
  - For ox: question should be a statement, answer should be "O" or "X"
370
376
  - For short_answer: question should be answerable in 1-3 words
371
- - Questions should test understanding, not just recall
377
+ - Include "explanation" field: a brief 1-2 sentence explanation of WHY the answer is correct
378
+ - Questions should test understanding, application, or analysis — not just recall
372
379
  - Write questions in Korean when the content is in Korean`;
373
380
 
374
381
  const raw = await chat(quizSystem, quizPrompt, 2048);
375
- const quizzes = parseJSON<Array<{ question: string; answer: string; type: string }>>(raw);
382
+ const quizzes = parseJSON<Array<{ question: string; answer: string; explanation?: string; type: string }>>(raw);
376
383
 
377
384
  for (const q of quizzes) {
378
385
  if (q.question && q.answer && q.type) {
379
- store.addQuiz(page.id, q.question, q.answer, q.type);
386
+ store.addQuiz(page.id, q.question, q.answer, q.type, q.explanation || "");
380
387
  quizCount++;
381
388
  }
382
389
  }
package/src/server.ts CHANGED
@@ -172,7 +172,7 @@ export function startServer(root: string, port: number, host: string): void {
172
172
  console.log("\x1b[32m✅ 설정 변경 후 사이트 리빌드 완료\x1b[0m");
173
173
  } catch (e: unknown) {
174
174
  const message = e instanceof Error ? e.message : String(e);
175
- console.log(`\x1b[31m리빌드 실패: ${message}\x1b[0m`);
175
+ console.error(`\x1b[31m리빌드 실패: ${message}\x1b[0m`);
176
176
  } finally {
177
177
  store.close();
178
178
  }
@@ -23,13 +23,13 @@ export async function ingestUrl(
23
23
  const { fetchPage } = await import("../ingest/web");
24
24
  const { llmChunkDocument, htmlToRawText } = await import("../pipeline/llm-chunker");
25
25
 
26
- onProgress?.("URL 가져오는 중...");
26
+ onProgress?.("URL 가져오는 중...");
27
27
  const { title, html } = await fetchPage(url);
28
28
 
29
29
  const source = store.addSource(url, "web", title, html);
30
30
  const rawText = htmlToRawText(html);
31
31
 
32
- onProgress?.("LLM 분석 중...");
32
+ onProgress?.("LLM 분석 시작...");
33
33
  const { sourceCount, conceptCount } = await llmChunkDocument(rawText, title, source.id, store, 0, persona, client);
34
34
 
35
35
  const u = client.getUsageStats();
@@ -65,26 +65,26 @@ export async function ingestFile(
65
65
 
66
66
  if (ext === "pdf") {
67
67
  const { extractTextFromPdf } = await import("../ingest/pdf");
68
- onProgress?.("PDF 텍스트 추출 중...");
68
+ onProgress?.("PDF 텍스트 추출 중...");
69
69
  ({ title, text } = await extractTextFromPdf(filePath));
70
70
  } else if (ext === "docx") {
71
71
  const { extractTextFromDocx } = await import("../ingest/docx");
72
- onProgress?.("DOCX 텍스트 추출 중...");
72
+ onProgress?.("DOCX 텍스트 추출 중...");
73
73
  ({ title, text } = await extractTextFromDocx(filePath));
74
74
  } else if (ext === "pptx") {
75
75
  const { extractTextFromPptx } = await import("../ingest/pptx");
76
- onProgress?.("PPTX 텍스트 추출 중...");
76
+ onProgress?.("PPTX 텍스트 추출 중...");
77
77
  ({ title, text } = await extractTextFromPptx(filePath));
78
78
  } else {
79
79
  const { extractWithTextutil } = await import("../ingest/legacy");
80
- onProgress?.(`${ext.toUpperCase()} 텍스트 추출 중...`);
80
+ onProgress?.(`⏳ ${ext.toUpperCase()} 텍스트 추출 중...`);
81
81
  ({ title, text } = await extractWithTextutil(filePath));
82
82
  }
83
83
 
84
84
  const source = store.addSource(filePath, ext, title, "(file)");
85
85
  store.deletePagesBySource(source.id);
86
86
 
87
- onProgress?.("LLM 분석 중...");
87
+ onProgress?.("LLM 분석 시작...");
88
88
  const { sourceCount, conceptCount } = await llmChunkDocument(text, title, source.id, store, 0, persona, client);
89
89
 
90
90
  const u = client.getUsageStats();
package/src/store.ts CHANGED
@@ -39,6 +39,7 @@ export interface Quiz {
39
39
  page_id: number;
40
40
  question: string;
41
41
  answer: string;
42
+ explanation: string;
42
43
  quiz_type: string; // 'fill_blank' | 'ox' | 'short_answer'
43
44
  created_at: string;
44
45
  page_title?: string;
@@ -87,11 +88,20 @@ CREATE TABLE IF NOT EXISTS quizzes (
87
88
  page_id INTEGER NOT NULL,
88
89
  question TEXT NOT NULL,
89
90
  answer TEXT NOT NULL,
91
+ explanation TEXT DEFAULT '',
90
92
  quiz_type TEXT NOT NULL DEFAULT 'fill_blank',
91
93
  created_at TEXT DEFAULT (datetime('now')),
92
94
  FOREIGN KEY (page_id) REFERENCES pages(id)
93
95
  );
96
+ CREATE TABLE IF NOT EXISTS quiz_attempts (
97
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
98
+ quiz_id INTEGER NOT NULL,
99
+ is_correct INTEGER NOT NULL DEFAULT 0,
100
+ attempted_at TEXT DEFAULT (datetime('now')),
101
+ FOREIGN KEY (quiz_id) REFERENCES quizzes(id)
102
+ );
94
103
  CREATE INDEX IF NOT EXISTS idx_pages_source_id ON pages(source_id);
104
+ CREATE INDEX IF NOT EXISTS idx_attempts_quiz_id ON quiz_attempts(quiz_id);
95
105
  CREATE INDEX IF NOT EXISTS idx_pages_page_type ON pages(page_type);
96
106
  CREATE INDEX IF NOT EXISTS idx_links_to_page ON links(to_page_id);
97
107
  CREATE INDEX IF NOT EXISTS idx_links_from_page ON links(from_page_id);
@@ -109,6 +119,12 @@ export class Store {
109
119
 
110
120
  initSchema(): void {
111
121
  this.db.exec(SCHEMA);
122
+ // Migrate: add explanation column if missing (for existing databases)
123
+ try {
124
+ this.db.exec("ALTER TABLE quizzes ADD COLUMN explanation TEXT DEFAULT ''");
125
+ } catch {
126
+ // Column already exists — ignore
127
+ }
112
128
  }
113
129
 
114
130
  close(): void {
@@ -184,7 +200,11 @@ export class Store {
184
200
  }
185
201
 
186
202
  deletePagesBySource(sourceId: number): void {
187
- // Delete quizzes for these pages first
203
+ // Delete quiz attempts for quizzes on these pages first
204
+ this.db.prepare(
205
+ "DELETE FROM quiz_attempts WHERE quiz_id IN (SELECT id FROM quizzes WHERE page_id IN (SELECT id FROM pages WHERE source_id = ?))"
206
+ ).run(sourceId);
207
+ // Delete quizzes for these pages
188
208
  this.db.prepare(
189
209
  "DELETE FROM quizzes WHERE page_id IN (SELECT id FROM pages WHERE source_id = ?)"
190
210
  ).run(sourceId);
@@ -196,6 +216,7 @@ export class Store {
196
216
  }
197
217
 
198
218
  deleteAllPages(): void {
219
+ this.db.exec("DELETE FROM quiz_attempts");
199
220
  this.db.exec("DELETE FROM quizzes");
200
221
  this.db.exec("DELETE FROM links");
201
222
  this.db.exec("DELETE FROM pages");
@@ -252,10 +273,10 @@ export class Store {
252
273
 
253
274
  // --- Quizzes ---
254
275
 
255
- addQuiz(pageId: number, question: string, answer: string, quizType: string): void {
276
+ addQuiz(pageId: number, question: string, answer: string, quizType: string, explanation: string = ""): void {
256
277
  this.db
257
- .prepare("INSERT INTO quizzes (page_id, question, answer, quiz_type) VALUES (?, ?, ?, ?)")
258
- .run(pageId, question, answer, quizType);
278
+ .prepare("INSERT INTO quizzes (page_id, question, answer, explanation, quiz_type) VALUES (?, ?, ?, ?, ?)")
279
+ .run(pageId, question, answer, explanation, quizType);
259
280
  }
260
281
 
261
282
  getQuizzesByPage(pageId: number): Quiz[] {
@@ -292,6 +313,89 @@ export class Store {
292
313
  this.db.prepare("DELETE FROM quizzes WHERE page_id = ?").run(pageId);
293
314
  }
294
315
 
316
+ getSmartQuizzes(count: number): Quiz[] {
317
+ return this.db.prepare(`
318
+ SELECT q.*, p.title as page_title, p.slug as page_slug,
319
+ COALESCE(a.last_attempt, '1970-01-01') as last_attempt,
320
+ COALESCE(a.correct_count, 0) as correct_count,
321
+ COALESCE(a.wrong_count, 0) as wrong_count
322
+ FROM quizzes q
323
+ JOIN pages p ON p.id = q.page_id
324
+ LEFT JOIN (
325
+ SELECT quiz_id,
326
+ MAX(attempted_at) as last_attempt,
327
+ SUM(CASE WHEN is_correct = 1 THEN 1 ELSE 0 END) as correct_count,
328
+ SUM(CASE WHEN is_correct = 0 THEN 1 ELSE 0 END) as wrong_count
329
+ FROM quiz_attempts
330
+ GROUP BY quiz_id
331
+ ) a ON a.quiz_id = q.id
332
+ ORDER BY
333
+ CASE WHEN a.last_attempt IS NULL THEN 0 ELSE 1 END,
334
+ CASE WHEN a.wrong_count > 0 THEN 0 ELSE 1 END,
335
+ a.last_attempt ASC
336
+ LIMIT ?
337
+ `).all(count) as Quiz[];
338
+ }
339
+
340
+ // --- Quiz Attempts ---
341
+
342
+ addQuizAttempt(quizId: number, isCorrect: boolean): void {
343
+ this.db
344
+ .prepare("INSERT INTO quiz_attempts (quiz_id, is_correct) VALUES (?, ?)")
345
+ .run(quizId, isCorrect ? 1 : 0);
346
+ }
347
+
348
+ getQuizStats(): { total: number; correct: number; incorrect: number; unattempted: number } {
349
+ const totalQuizzes = (this.db.prepare("SELECT COUNT(*) as cnt FROM quizzes").get() as { cnt: number }).cnt;
350
+ const attemptRow = this.db.prepare(`
351
+ SELECT COUNT(*) as total,
352
+ SUM(CASE WHEN is_correct = 1 THEN 1 ELSE 0 END) as correct,
353
+ SUM(CASE WHEN is_correct = 0 THEN 1 ELSE 0 END) as incorrect
354
+ FROM quiz_attempts
355
+ `).get() as { total: number; correct: number; incorrect: number };
356
+ const attemptedQuizzes = (this.db.prepare("SELECT COUNT(DISTINCT quiz_id) as cnt FROM quiz_attempts").get() as { cnt: number }).cnt;
357
+ return {
358
+ total: attemptRow.total,
359
+ correct: attemptRow.correct,
360
+ incorrect: attemptRow.incorrect,
361
+ unattempted: totalQuizzes - attemptedQuizzes,
362
+ };
363
+ }
364
+
365
+ getWeakQuizzes(limit: number): Quiz[] {
366
+ return this.db.prepare(`
367
+ SELECT q.*, p.title as page_title, p.slug as page_slug
368
+ FROM quizzes q
369
+ JOIN pages p ON p.id = q.page_id
370
+ LEFT JOIN (
371
+ SELECT quiz_id,
372
+ SUM(CASE WHEN is_correct = 0 THEN 1 ELSE 0 END) as wrong_count,
373
+ COUNT(*) as attempt_count
374
+ FROM quiz_attempts
375
+ GROUP BY quiz_id
376
+ ) a ON a.quiz_id = q.id
377
+ ORDER BY
378
+ CASE WHEN a.attempt_count IS NULL THEN 1 ELSE 0 END DESC,
379
+ COALESCE(a.wrong_count, 0) DESC
380
+ LIMIT ?
381
+ `).all(limit) as Quiz[];
382
+ }
383
+
384
+ getQuizHistory(limit: number): Array<{ quiz_id: number; question: string; is_correct: boolean; attempted_at: string }> {
385
+ return this.db.prepare(`
386
+ SELECT qa.quiz_id, q.question, qa.is_correct, qa.attempted_at
387
+ FROM quiz_attempts qa
388
+ JOIN quizzes q ON q.id = qa.quiz_id
389
+ ORDER BY qa.attempted_at DESC
390
+ LIMIT ?
391
+ `).all(limit).map((row: any) => ({
392
+ quiz_id: row.quiz_id,
393
+ question: row.question,
394
+ is_correct: row.is_correct === 1,
395
+ attempted_at: row.attempted_at,
396
+ }));
397
+ }
398
+
295
399
  // --- Usage ---
296
400
 
297
401
  addUsageLog(sourceId: number, calls: number, prompt: number, completion: number, total: number, cost: number): void {