@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 +98 -49
- package/package.json +1 -1
- package/src/build/renderer.ts +1 -0
- package/src/build/templates.ts +58 -2
- package/src/demo/sample-data.ts +8 -8
- package/src/demo/setup.ts +1 -1
- package/src/index.ts +37 -11
- package/src/pipeline/llm-chunker.ts +18 -11
- package/src/server.ts +1 -1
- package/src/services/ingest.ts +7 -7
- package/src/store.ts +108 -4
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
전공책, PDF, 웹 콘텐츠를 넣으면 — LLM이 챕터/개념별로 분석하여 상호 링크된 학습 위키를 자동 생성합니다.
|
|
10
10
|
|
|
11
|
+
[](https://www.npmjs.com/package/@open330/kiwimu)
|
|
11
12
|
[](https://bun.sh)
|
|
12
13
|
[](https://typescriptlang.org)
|
|
13
14
|
[](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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
|
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]
|
|
161
|
-
| `kiwimu
|
|
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
|
|
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 ]
|
|
226
|
+
[ Ingest ] ── Cheerio / pdf-parse / mammoth / jszip / textutil
|
|
190
227
|
↓
|
|
191
|
-
[ Phase 1 ]
|
|
228
|
+
[ Phase 1 ] ── LLM: 원본 구조 추출 (📖 원본 페이지) — 병렬 처리 (concurrency=3)
|
|
192
229
|
↓
|
|
193
|
-
[ Phase 2 ]
|
|
230
|
+
[ Phase 2 ] ── LLM: 개념 추출 (📝 개념 페이지)
|
|
194
231
|
↓
|
|
195
|
-
[ Phase
|
|
232
|
+
[ Phase 2.5 ] ── LLM: 학습 퀴즈 자동 생성 (📝 퀴즈) — 병렬 처리
|
|
196
233
|
↓
|
|
197
|
-
[
|
|
234
|
+
[ Phase 3 ] ── [[wiki link]] 해석 + 원본↔개념 cross-link
|
|
198
235
|
↓
|
|
199
|
-
[
|
|
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 # 홈 (문서
|
|
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
package/src/build/renderer.ts
CHANGED
|
@@ -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,
|
package/src/build/templates.ts
CHANGED
|
@@ -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
|
package/src/demo/sample-data.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
148
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
354
|
-
const
|
|
355
|
-
|
|
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
|
|
372
|
+
console.log(` \x1b[32m✅ 정답!\x1b[0m`);
|
|
360
373
|
} else {
|
|
361
|
-
console.log(` \x1b[31m❌ 오답! 정답: ${q.answer}\x1b[0m
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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.
|
|
175
|
+
console.error(`\x1b[31m❌ 리빌드 실패: ${message}\x1b[0m`);
|
|
176
176
|
} finally {
|
|
177
177
|
store.close();
|
|
178
178
|
}
|
package/src/services/ingest.ts
CHANGED
|
@@ -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?.(
|
|
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
|
|
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 {
|