@open330/kiwimu 0.4.0 β†’ 0.7.1

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/src/index.ts CHANGED
@@ -8,14 +8,46 @@ import { Store } from "./store";
8
8
  const program = new Command()
9
9
  .name("kiwimu")
10
10
  .description("πŸ₯ Kiwi Mu β€” λ‚˜λ§Œμ˜ ν•™μŠ΅ μœ„ν‚€λ₯Ό λ§Œλ“œμ„Έμš”")
11
- .version("0.2.0");
11
+ .version("0.7.1");
12
12
 
13
13
  // --- init ---
14
14
  program
15
15
  .command("init [name]")
16
16
  .description("μƒˆ Kiwi Mu ν”„λ‘œμ νŠΈλ₯Ό μƒμ„±ν•©λ‹ˆλ‹€")
17
- .action(async (name?: string) => {
17
+ .option("--demo", "μƒ˜ν”Œ λ°μ΄ν„°λ‘œ μ¦‰μ‹œ μ²΄ν—˜")
18
+ .action(async (name: string | undefined, opts: { demo?: boolean }) => {
18
19
  const root = process.cwd();
20
+
21
+ if (opts.demo) {
22
+ // Demo mode: skip API key prompt, use sample data
23
+ const demoName = name || "Quantum Wiki Demo";
24
+ console.log(`\x1b[34mπŸ₯ 데λͺ¨ λͺ¨λ“œλ‘œ '${demoName}' μœ„ν‚€λ₯Ό μƒμ„±ν•©λ‹ˆλ‹€...\x1b[0m`);
25
+
26
+ const config = defaultConfig(demoName);
27
+ config.llm.provider = "demo";
28
+ config.llm.model = "";
29
+ config.llm.api_key = "";
30
+ config.llm.endpoint = "";
31
+ saveConfig(root, config);
32
+
33
+ const store = new Store(join(root, DB_FILE));
34
+ store.initSchema();
35
+
36
+ const { setupDemo } = await import("./demo/setup");
37
+ await setupDemo(store);
38
+
39
+ const { buildSite } = await import("./build/renderer");
40
+ const count = await buildSite(store, config, root);
41
+ store.close();
42
+
43
+ console.log(`\x1b[32mβœ… ${count}개 νŽ˜μ΄μ§€κ°€ λΉŒλ“œλ˜μ—ˆμŠ΅λ‹ˆλ‹€!\x1b[0m`);
44
+
45
+ const { startServer } = await import("./server");
46
+ console.log("πŸŽ‰ 데λͺ¨ μœ„ν‚€κ°€ μ€€λΉ„λ˜μ—ˆμŠ΅λ‹ˆλ‹€! http://localhost:8000 μ—μ„œ ν™•μΈν•˜μ„Έμš”");
47
+ startServer(root, 8000, "localhost");
48
+ return;
49
+ }
50
+
19
51
  if (Bun.file(join(root, CONFIG_FILE)).size > 0) {
20
52
  try {
21
53
  require("fs").accessSync(join(root, CONFIG_FILE));
@@ -25,7 +57,6 @@ program
25
57
  }
26
58
 
27
59
  const p = await import("@clack/prompts");
28
-
29
60
  p.intro("πŸ₯ Kiwi Mu β€” μƒˆ ν•™μŠ΅ μœ„ν‚€ λ§Œλ“€κΈ°");
30
61
 
31
62
  const values = await p.group({
@@ -91,94 +122,42 @@ program
91
122
  // --- add ---
92
123
  program
93
124
  .command("add <source>")
94
- .description("URL λ˜λŠ” PDF νŒŒμΌμ„ μΆ”κ°€ν•©λ‹ˆλ‹€")
125
+ .description("URL λ˜λŠ” νŒŒμΌμ„ μΆ”κ°€ν•©λ‹ˆλ‹€ (PDF, DOCX, PPTX, DOC, PPT, KEY, RTF)")
95
126
  .action(async (source: string) => {
96
127
  const root = findProjectRoot();
128
+ const config = loadConfig(root);
129
+ const persona = getActivePersona(config);
97
130
  const store = new Store(join(root, DB_FILE));
98
-
99
- const isUrl = source.startsWith("http://") || source.startsWith("https://");
100
- const isPdf = source.toLowerCase().endsWith(".pdf");
101
-
102
- if (isUrl) {
103
- await addUrl(store, source);
104
- } else if (isPdf) {
105
- await addPdf(store, source);
106
- } else {
107
- console.log(`\x1b[31mμ§€μ›ν•˜μ§€ μ•ŠλŠ” μ†ŒμŠ€ ν˜•μ‹: ${source}\x1b[0m`);
108
- console.log("URL (http/https) λ˜λŠ” PDF νŒŒμΌμ„ μž…λ ₯ν•΄μ£Όμ„Έμš”.");
131
+ try {
132
+ const isUrl = source.startsWith("http://") || source.startsWith("https://");
133
+
134
+ if (isUrl) {
135
+ const { validateUrl } = await import("./ingest/web");
136
+ validateUrl(source);
137
+ console.log(`\x1b[34mπŸ“₯ URL κ°€μ Έμ˜€λŠ” 쀑: ${source}\x1b[0m`);
138
+ const { ingestUrl } = await import("./services/ingest");
139
+ const result = await ingestUrl(root, store, source, config.llm, persona, (s) => console.log(` ${s}`));
140
+ console.log(`\x1b[32mβœ… πŸ“– ${result.sourceCount}개 원본 + πŸ“ ${result.conceptCount}개 κ°œλ… λ¬Έμ„œ 생성\x1b[0m`);
141
+ console.log(`\x1b[34mπŸ“Š LLM: ${result.usage.totalCalls}회 호좜, ~$${result.usage.estimatedCostUsd.toFixed(4)}\x1b[0m`);
142
+ } else {
143
+ const { resolve } = await import("path");
144
+ const absPath = resolve(source);
145
+ const file = Bun.file(absPath);
146
+ if (!(await file.exists())) {
147
+ console.log(`\x1b[31mνŒŒμΌμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€: ${source}\x1b[0m`);
148
+ return;
149
+ }
150
+ console.log(`\x1b[34mπŸ“₯ 파일 처리 쀑: ${source}\x1b[0m`);
151
+ const { ingestFile } = await import("./services/ingest");
152
+ const result = await ingestFile(root, store, absPath, source, config.llm, persona, (s) => console.log(` ${s}`));
153
+ console.log(`\x1b[32mβœ… πŸ“– ${result.sourceCount}개 원본 + πŸ“ ${result.conceptCount}개 κ°œλ… λ¬Έμ„œ 생성\x1b[0m`);
154
+ console.log(`\x1b[34mπŸ“Š LLM: ${result.usage.totalCalls}회 호좜, ~$${result.usage.estimatedCostUsd.toFixed(4)}\x1b[0m`);
155
+ }
156
+ } finally {
109
157
  store.close();
110
- return;
111
158
  }
112
-
113
- store.close();
114
159
  });
115
160
 
116
- async function initLLM(root: string) {
117
- const config = loadConfig(root);
118
- const { setLLMConfig } = await import("./llm-client");
119
- setLLMConfig(config.llm);
120
- }
121
-
122
- async function addUrl(store: Store, url: string) {
123
- const { fetchPage } = await import("./ingest/web");
124
- const { llmChunkDocument, htmlToRawText } = await import("./pipeline/llm-chunker");
125
-
126
- const root = findProjectRoot();
127
- await initLLM(root);
128
- const config = loadConfig(root);
129
- const persona = getActivePersona(config);
130
-
131
- console.log(`\x1b[34mπŸ“₯ URL κ°€μ Έμ˜€λŠ” 쀑: ${url}\x1b[0m`);
132
- const { title, html } = await fetchPage(url);
133
- console.log(` 제λͺ©: ${title}`);
134
-
135
- const source = store.addSource(url, "web", title, html);
136
- const rawText = htmlToRawText(html);
137
-
138
- console.log("\x1b[34mπŸ“„ LLM 기반 λ¬Έμ„œ 뢄석 쀑...\x1b[0m");
139
- const { sourceCount, conceptCount } = await llmChunkDocument(rawText, title, source.id, store, 0, persona);
140
- console.log(`\x1b[32mβœ… πŸ“– ${sourceCount}개 원본 + πŸ“ ${conceptCount}개 κ°œλ… λ¬Έμ„œ 생성\x1b[0m`);
141
-
142
- const { getUsageStats, getEstimatedCost, printUsageSummary } = await import("./llm-client");
143
- printUsageSummary();
144
- const u = getUsageStats();
145
- store.addUsageLog(source.id, u.totalCalls, u.promptTokens, u.completionTokens, u.totalTokens, getEstimatedCost());
146
- }
147
-
148
- async function addPdf(store: Store, pdfPath: string) {
149
- const { extractTextFromPdf } = await import("./ingest/pdf");
150
- const { llmChunkDocument } = await import("./pipeline/llm-chunker");
151
- const { resolve } = await import("path");
152
-
153
- const absPath = resolve(pdfPath);
154
- const file = Bun.file(absPath);
155
- if (!(await file.exists())) {
156
- console.log(`\x1b[31mνŒŒμΌμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€: ${pdfPath}\x1b[0m`);
157
- return;
158
- }
159
-
160
- const root = findProjectRoot();
161
- await initLLM(root);
162
- const config = loadConfig(root);
163
- const persona = getActivePersona(config);
164
-
165
- console.log(`\x1b[34mπŸ“₯ PDF 처리 쀑: ${pdfPath}\x1b[0m`);
166
- const { title, text } = await extractTextFromPdf(absPath);
167
- console.log(` 제λͺ©: ${title}`);
168
- console.log(` ν…μŠ€νŠΈ 길이: ${text.length.toLocaleString()} 자`);
169
-
170
- const source = store.addSource(absPath, "pdf", title, "(PDF)");
171
-
172
- console.log("\x1b[34mπŸ“„ LLM 기반 λ¬Έμ„œ 뢄석 쀑...\x1b[0m");
173
- const { sourceCount, conceptCount } = await llmChunkDocument(text, title, source.id, store, 0, persona);
174
- console.log(`\x1b[32mβœ… πŸ“– ${sourceCount}개 원본 + πŸ“ ${conceptCount}개 κ°œλ… λ¬Έμ„œ 생성\x1b[0m`);
175
-
176
- const { getUsageStats, getEstimatedCost, printUsageSummary } = await import("./llm-client");
177
- printUsageSummary();
178
- const u = getUsageStats();
179
- store.addUsageLog(source.id, u.totalCalls, u.promptTokens, u.completionTokens, u.totalTokens, getEstimatedCost());
180
- }
181
-
182
161
  // --- expand ---
183
162
  program
184
163
  .command("expand")
@@ -190,43 +169,45 @@ program
190
169
  const root = findProjectRoot();
191
170
  const config = loadConfig(root);
192
171
  const store = new Store(join(root, DB_FILE));
172
+ try {
173
+ const provider: string = opts.provider || (config as Record<string, unknown>).expand?.provider;
174
+ if (!provider) {
175
+ console.log("\x1b[33mν™•μž₯ ν”„λ‘œλ°”μ΄λ”κ°€ μ„€μ •λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.\x1b[0m");
176
+ console.log("μ‚¬μš©λ²•: kiwimu expand --provider anthropic");
177
+ return;
178
+ }
193
179
 
194
- const provider: string = opts.provider || config.expand.provider;
195
- if (!provider) {
196
- console.log("\x1b[33mν™•μž₯ ν”„λ‘œλ°”μ΄λ”κ°€ μ„€μ •λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.\x1b[0m");
197
- console.log("μ‚¬μš©λ²•: kiwimu expand --provider anthropic");
198
- store.close();
199
- return;
200
- }
201
-
202
- const allPages = store.listPages();
203
- let pages = allPages;
204
- if (opts.pages) {
205
- pages = allPages.filter((p) => (opts.pages as string[]).includes(p.slug));
206
- }
207
-
208
- console.log(`\x1b[34m🧠 ${pages.length}개 λ¬Έμ„œλ₯Ό ν™•μž₯ν•©λ‹ˆλ‹€...\x1b[0m`);
209
-
210
- const isCli = provider === "claude-cli" || provider === "codex-cli";
211
- const { expandWithApi, expandWithCli } = await import("./expand/llm");
180
+ const allPages = store.listPages();
181
+ let pages = allPages;
182
+ if (opts.pages) {
183
+ pages = allPages.filter((p) => (opts.pages as string[]).includes(p.slug));
184
+ }
212
185
 
213
- for (let i = 0; i < pages.length; i++) {
214
- const page = pages[i];
215
- console.log(` [${i + 1}/${pages.length}] ${page.title}`);
216
- try {
217
- const newContent = isCli
218
- ? await expandWithCli(page, allPages, provider.replace("-cli", ""))
219
- : await expandWithApi(page, allPages, provider, opts.model);
220
- store.updatePageContent(page.id, newContent);
221
- } catch (e: any) {
222
- console.log(` \x1b[31mμ‹€νŒ¨: ${e.message}\x1b[0m`);
186
+ console.log(`\x1b[34m🧠 ${pages.length}개 λ¬Έμ„œλ₯Ό ν™•μž₯ν•©λ‹ˆλ‹€...\x1b[0m`);
187
+
188
+ const isCli = provider === "claude-cli" || provider === "codex-cli";
189
+ const { expandWithApi, expandWithCli } = await import("./expand/llm");
190
+
191
+ for (let i = 0; i < pages.length; i++) {
192
+ const page = pages[i];
193
+ console.log(` [${i + 1}/${pages.length}] ${page.title}`);
194
+ try {
195
+ const newContent = isCli
196
+ ? await expandWithCli(page, allPages, provider.replace("-cli", ""))
197
+ : await expandWithApi(page, allPages, provider, opts.model);
198
+ store.updatePageContent(page.id, newContent);
199
+ } catch (e: unknown) {
200
+ const message = e instanceof Error ? e.message : String(e);
201
+ console.log(` \x1b[31mμ‹€νŒ¨: ${message}\x1b[0m`);
202
+ }
223
203
  }
224
- }
225
204
 
226
- const { autoLinkPages } = await import("./pipeline/linker");
227
- const linkCount = autoLinkPages(store);
228
- console.log(`\x1b[32mβœ… ν™•μž₯ μ™„λ£Œ! (${linkCount}개 링크 κ°±μ‹ )\x1b[0m`);
229
- store.close();
205
+ const { autoLinkPages } = await import("./pipeline/linker");
206
+ const linkCount = autoLinkPages(store);
207
+ console.log(`\x1b[32mβœ… ν™•μž₯ μ™„λ£Œ! (${linkCount}개 링크 κ°±μ‹ )\x1b[0m`);
208
+ } finally {
209
+ store.close();
210
+ }
230
211
  });
231
212
 
232
213
  // --- build ---
@@ -237,14 +218,15 @@ program
237
218
  const root = findProjectRoot();
238
219
  const config = loadConfig(root);
239
220
  const store = new Store(join(root, DB_FILE));
240
-
241
- const { buildSite } = await import("./build/renderer");
242
-
243
- console.log("\x1b[34mπŸ”¨ μœ„ν‚€ λΉŒλ“œ 쀑...\x1b[0m");
244
- const count = await buildSite(store, config, root);
245
- console.log(`\x1b[32mβœ… ${count}개 νŽ˜μ΄μ§€κ°€ λΉŒλ“œλ˜μ—ˆμŠ΅λ‹ˆλ‹€!\x1b[0m`);
246
- console.log(` 좜λ ₯: ${join(root, config.build.output_dir)}/`);
247
- store.close();
221
+ try {
222
+ const { buildSite } = await import("./build/renderer");
223
+ console.log("\x1b[34mπŸ”¨ μœ„ν‚€ λΉŒλ“œ 쀑...\x1b[0m");
224
+ const count = await buildSite(store, config, root);
225
+ console.log(`\x1b[32mβœ… ${count}개 νŽ˜μ΄μ§€κ°€ λΉŒλ“œλ˜μ—ˆμŠ΅λ‹ˆλ‹€!\x1b[0m`);
226
+ console.log(` 좜λ ₯: ${join(root, config.build.output_dir)}/`);
227
+ } finally {
228
+ store.close();
229
+ }
248
230
  });
249
231
 
250
232
  // --- deploy ---
@@ -258,13 +240,15 @@ program
258
240
  const config = loadConfig(root);
259
241
  const siteDir = join(root, config.build.output_dir);
260
242
 
261
- // Auto-build before deploy
262
243
  const store = new Store(join(root, DB_FILE));
263
- const { buildSite } = await import("./build/renderer");
264
- console.log("\x1b[34mπŸ”¨ λΉŒλ“œ 쀑...\x1b[0m");
265
- const count = await buildSite(store, config, root);
266
- console.log(`\x1b[32m ${count}개 νŽ˜μ΄μ§€ λΉŒλ“œ μ™„λ£Œ\x1b[0m`);
267
- store.close();
244
+ try {
245
+ const { buildSite } = await import("./build/renderer");
246
+ console.log("\x1b[34mπŸ”¨ λΉŒλ“œ 쀑...\x1b[0m");
247
+ const count = await buildSite(store, config, root);
248
+ console.log(`\x1b[32m ${count}개 νŽ˜μ΄μ§€ λΉŒλ“œ μ™„λ£Œ\x1b[0m`);
249
+ } finally {
250
+ store.close();
251
+ }
268
252
 
269
253
  console.log(`\x1b[34mπŸš€ ${opts.target}에 배포 쀑...\x1b[0m`);
270
254
 
@@ -272,7 +256,6 @@ program
272
256
  const { deployGhPages } = await import("./deploy");
273
257
  await deployGhPages(siteDir, opts.message);
274
258
  console.log("\x1b[32mβœ… GitHub Pages에 λ°°ν¬λ˜μ—ˆμŠ΅λ‹ˆλ‹€!\x1b[0m");
275
- // Try to get the pages URL
276
259
  try {
277
260
  const proc = Bun.spawn(["gh", "repo", "view", "--json", "url", "-q", ".url"], { stdout: "pipe" });
278
261
  const repoUrl = (await new Response(proc.stdout).text()).trim();
@@ -303,8 +286,6 @@ program
303
286
  const siteDir = join(root, config.build.output_dir);
304
287
 
305
288
  const { existsSync } = await import("fs");
306
-
307
- // Auto-build if needed
308
289
  if (!existsSync(siteDir)) {
309
290
  const store = new Store(join(root, DB_FILE));
310
291
  const { buildSite } = await import("./build/renderer");
@@ -312,318 +293,86 @@ program
312
293
  store.close();
313
294
  }
314
295
 
315
- let isProcessing = false;
316
- let processingStatus = "";
317
-
318
- const port = parseInt(opts.port);
319
- const hostname = opts.host;
320
- console.log(`\x1b[32mπŸ₯ Kiwi Mu μ„œλ²„ μ‹œμž‘!\x1b[0m`);
321
- console.log(` http://${hostname === "0.0.0.0" ? "localhost" : hostname}:${port}`);
322
- if (hostname === "0.0.0.0") console.log(" λ„€νŠΈμ›Œν¬μ— 곡개됨 (0.0.0.0)");
323
- console.log(" μ›Ήμ—μ„œ λ¬Έμ„œ μΆ”κ°€ κ°€λŠ₯ν•©λ‹ˆλ‹€.\n");
324
-
325
- Bun.serve({
326
- port,
327
- hostname,
328
- async fetch(req) {
329
- const url = new URL(req.url);
330
-
331
- // ── API endpoints ──
332
-
333
- // File upload endpoint
334
- if (url.pathname === "/api/upload" && req.method === "POST") {
335
- if (isProcessing) {
336
- return Response.json({ error: "이미 처리 μ€‘μž…λ‹ˆλ‹€", status: processingStatus }, { status: 409 });
337
- }
338
-
339
- const formData = await req.formData();
340
- const file = formData.get("file") as File | null;
341
- if (!file) {
342
- return Response.json({ error: "파일이 ν•„μš”ν•©λ‹ˆλ‹€" }, { status: 400 });
343
- }
344
-
345
- const ext = file.name.split(".").pop()?.toLowerCase() || "";
346
- const supported = ["pdf", "docx", "doc", "pptx", "ppt", "key", "rtf"];
347
- if (!supported.includes(ext)) {
348
- return Response.json({ error: `μ§€μ›ν•˜μ§€ μ•ŠλŠ” ν˜•μ‹: .${ext}. 지원: ${supported.join(", ")}` }, { status: 400 });
349
- }
350
-
351
- // Save uploaded file
352
- const uploadDir = join(root, "uploads");
353
- const { mkdirSync } = await import("fs");
354
- mkdirSync(uploadDir, { recursive: true });
355
- const filePath = join(uploadDir, file.name);
356
- await Bun.write(filePath, await file.arrayBuffer());
357
-
358
- isProcessing = true;
359
- processingStatus = "파일 처리 μ‹œμž‘...";
360
-
361
- (async () => {
362
- try {
363
- const store = new Store(join(root, DB_FILE));
364
- const { setLLMConfig, resetUsageStats, getUsageStats, getEstimatedCost } = await import("./llm-client");
365
- setLLMConfig(loadConfig(root).llm);
366
- const { llmChunkDocument } = await import("./pipeline/llm-chunker");
367
- resetUsageStats();
368
-
369
- let title: string;
370
- let text: string;
371
-
372
- if (ext === "pdf") {
373
- const { extractTextFromPdf } = await import("./ingest/pdf");
374
- processingStatus = "PDF ν…μŠ€νŠΈ μΆ”μΆœ 쀑...";
375
- ({ title, text } = await extractTextFromPdf(filePath));
376
- } else if (ext === "docx") {
377
- const { extractTextFromDocx } = await import("./ingest/docx");
378
- processingStatus = "DOCX ν…μŠ€νŠΈ μΆ”μΆœ 쀑...";
379
- ({ title, text } = await extractTextFromDocx(filePath));
380
- } else if (ext === "pptx") {
381
- const { extractTextFromPptx } = await import("./ingest/pptx");
382
- processingStatus = "PPTX ν…μŠ€νŠΈ μΆ”μΆœ 쀑...";
383
- ({ title, text } = await extractTextFromPptx(filePath));
384
- } else {
385
- const { extractWithTextutil } = await import("./ingest/legacy");
386
- processingStatus = `${ext.toUpperCase()} ν…μŠ€νŠΈ μΆ”μΆœ 쀑...`;
387
- ({ title, text } = await extractWithTextutil(filePath));
388
- }
389
-
390
- const src = store.addSource(filePath, ext, title, "(file)");
391
- // Clean up old pages from previous processing of same source
392
- store.deletePagesBySource(src.id);
393
-
394
- processingStatus = "LLM 뢄석 쀑...";
395
- const currentPersona = getActivePersona(loadConfig(root));
396
- await llmChunkDocument(text, title, src.id, store, 0, currentPersona);
397
-
398
- const u = getUsageStats();
399
- store.addUsageLog(src.id, u.totalCalls, u.promptTokens, u.completionTokens, u.totalTokens, getEstimatedCost());
400
-
401
- processingStatus = "λΉŒλ“œ 쀑...";
402
- const { buildSite } = await import("./build/renderer");
403
- await buildSite(store, config, root);
404
- store.close();
405
-
406
- processingStatus = "μ™„λ£Œ!";
407
- } catch (e: any) {
408
- processingStatus = `였λ₯˜: ${e.message}`;
409
- } finally {
410
- setTimeout(() => { isProcessing = false; }, 2000);
411
- }
412
- })();
413
-
414
- return Response.json({ ok: true, message: "파일 처리 μ‹œμž‘" });
415
- }
296
+ const { startServer } = await import("./server");
297
+ startServer(root, parseInt(opts.port), opts.host);
298
+ });
416
299
 
417
- // URL add endpoint
418
- if (url.pathname === "/api/add" && req.method === "POST") {
419
- if (isProcessing) {
420
- return Response.json({ error: "이미 처리 μ€‘μž…λ‹ˆλ‹€", status: processingStatus }, { status: 409 });
421
- }
422
-
423
- const body = await req.json() as { source: string };
424
- if (!body.source) {
425
- return Response.json({ error: "sourceκ°€ ν•„μš”ν•©λ‹ˆλ‹€" }, { status: 400 });
426
- }
427
-
428
- isProcessing = true;
429
- processingStatus = "μ‹œμž‘ 쀑...";
430
-
431
- (async () => {
432
- try {
433
- const store = new Store(join(root, DB_FILE));
434
- const { setLLMConfig, resetUsageStats, getUsageStats, getEstimatedCost } = await import("./llm-client");
435
- setLLMConfig(loadConfig(root).llm);
436
- resetUsageStats();
437
-
438
- const source = body.source;
439
- const { fetchPage } = await import("./ingest/web");
440
- const { llmChunkDocument, htmlToRawText } = await import("./pipeline/llm-chunker");
441
-
442
- processingStatus = "URL κ°€μ Έμ˜€λŠ” 쀑...";
443
- const { title, html } = await fetchPage(source);
444
- const src = store.addSource(source, "web", title, html);
445
- const rawText = htmlToRawText(html);
446
-
447
- processingStatus = "LLM 뢄석 쀑...";
448
- const currentPersona = getActivePersona(loadConfig(root));
449
- await llmChunkDocument(rawText, title, src.id, store, 0, currentPersona);
450
-
451
- const u = getUsageStats();
452
- store.addUsageLog(src.id, u.totalCalls, u.promptTokens, u.completionTokens, u.totalTokens, getEstimatedCost());
453
-
454
- processingStatus = "λΉŒλ“œ 쀑...";
455
- const { buildSite } = await import("./build/renderer");
456
- await buildSite(store, config, root);
457
- store.close();
458
-
459
- processingStatus = "μ™„λ£Œ!";
460
- } catch (e: any) {
461
- processingStatus = `였λ₯˜: ${e.message}`;
462
- } finally {
463
- setTimeout(() => { isProcessing = false; }, 2000);
464
- }
465
- })();
466
-
467
- return Response.json({ ok: true, message: "처리 μ‹œμž‘" });
468
- }
300
+ // --- quiz ---
301
+ program
302
+ .command("quiz")
303
+ .description("ν•™μŠ΅ ν€΄μ¦ˆλ₯Ό ν’€μ–΄λ΄…λ‹ˆλ‹€")
304
+ .option("-n, --count <count>", "문제 수", "5")
305
+ .action(async (opts) => {
306
+ const root = findProjectRoot();
307
+ const store = new Store(join(root, DB_FILE));
308
+ try {
309
+ store.initSchema(); // ensure quizzes table exists
310
+ const count = parseInt(opts.count) || 5;
311
+ const quizzes = store.getRandomQuizzes(count);
312
+ if (quizzes.length === 0) {
313
+ console.log("\x1b[33mν€΄μ¦ˆκ°€ μ—†μŠ΅λ‹ˆλ‹€. λ¨Όμ € λ¬Έμ„œλ₯Ό μΆ”κ°€ν•˜μ„Έμš”.\x1b[0m");
314
+ return;
315
+ }
469
316
 
470
- // Admin API - update LLM settings
471
- if (url.pathname === "/api/settings" && req.method === "POST") {
472
- const body = await req.json() as any;
473
- const currentConfig = loadConfig(root);
474
- if (body.wiki_name) currentConfig.project.name = body.wiki_name;
475
- if (body.provider) currentConfig.llm.provider = body.provider;
476
- if (body.model) currentConfig.llm.model = body.model;
477
- if (body.api_key !== undefined) currentConfig.llm.api_key = body.api_key;
478
- if (body.endpoint !== undefined) currentConfig.llm.endpoint = body.endpoint;
479
- saveConfig(root, currentConfig);
480
- // Reload config for serve
481
- Object.assign(config, currentConfig);
482
-
483
- // Auto-rebuild site with new settings
484
- (async () => {
485
- try {
486
- const store = new Store(join(root, DB_FILE));
487
- const { buildSite } = await import("./build/renderer");
488
- await buildSite(store, currentConfig, root);
489
- store.close();
490
- console.log("\x1b[32mβœ… μ„€μ • λ³€κ²½ ν›„ μ‚¬μ΄νŠΈ λ¦¬λΉŒλ“œ μ™„λ£Œ\x1b[0m");
491
- } catch (e: any) {
492
- console.log(`\x1b[31mλ¦¬λΉŒλ“œ μ‹€νŒ¨: ${e.message}\x1b[0m`);
493
- }
494
- })();
495
-
496
- return Response.json({ ok: true });
497
- }
317
+ const p = await import("@clack/prompts");
318
+ p.intro("πŸ“ ν•™μŠ΅ ν€΄μ¦ˆ");
319
+ console.log(` ${quizzes.length}개 문제λ₯Ό ν’€μ–΄λ΄…λ‹ˆλ‹€.\n`);
498
320
 
499
- if (url.pathname === "/api/settings" && req.method === "GET") {
500
- const currentConfig = loadConfig(root);
501
- // Mask API key
502
- const masked = { ...currentConfig.llm, api_key: currentConfig.llm.api_key ? "β€’β€’β€’β€’" + currentConfig.llm.api_key.slice(-4) : "" };
503
- return Response.json(masked);
504
- }
321
+ let score = 0;
322
+ for (let i = 0; i < quizzes.length; i++) {
323
+ const q = quizzes[i];
324
+ const typeLabel = q.quiz_type === "fill_blank" ? "빈칸 μ±„μš°κΈ°" : q.quiz_type === "ox" ? "OX ν€΄μ¦ˆ" : "λ‹¨λ‹΅ν˜•";
505
325
 
506
- // Persona API
507
- if (url.pathname === "/api/personas" && req.method === "GET") {
508
- const currentConfig = loadConfig(root);
509
- return Response.json({
510
- personas: currentConfig.personas || [],
511
- active: currentConfig.active_persona || "",
512
- });
326
+ console.log(`\x1b[1m[${i + 1}/${quizzes.length}] ${typeLabel}\x1b[0m`);
327
+ console.log(` ${q.question}`);
328
+ if (q.page_title) {
329
+ console.log(` \x1b[2m좜처: ${q.page_title}\x1b[0m`);
513
330
  }
514
331
 
515
- if (url.pathname === "/api/personas" && req.method === "POST") {
516
- const body = await req.json() as any;
517
- const currentConfig = loadConfig(root);
518
- if (!currentConfig.personas) currentConfig.personas = [];
519
-
520
- if (body.action === "add") {
521
- const { name, description, system_prompt, content_style } = body.persona;
522
- if (!name) return Response.json({ error: "이름이 ν•„μš”ν•©λ‹ˆλ‹€" }, { status: 400 });
523
- if (currentConfig.personas.find(p => p.name === name)) {
524
- return Response.json({ error: "이미 μ‘΄μž¬ν•˜λŠ” 페λ₯΄μ†Œλ‚˜μž…λ‹ˆλ‹€" }, { status: 409 });
525
- }
526
- currentConfig.personas.push({ name, description: description || "", system_prompt: system_prompt || "", content_style: content_style || "" });
527
- } else if (body.action === "update") {
528
- const idx = currentConfig.personas.findIndex(p => p.name === body.original_name);
529
- if (idx === -1) return Response.json({ error: "페λ₯΄μ†Œλ‚˜λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€" }, { status: 404 });
530
- currentConfig.personas[idx] = body.persona;
531
- if (currentConfig.active_persona === body.original_name && body.persona.name !== body.original_name) {
532
- currentConfig.active_persona = body.persona.name;
533
- }
534
- } else if (body.action === "delete") {
535
- currentConfig.personas = currentConfig.personas.filter(p => p.name !== body.name);
536
- if (currentConfig.active_persona === body.name) {
537
- currentConfig.active_persona = currentConfig.personas[0]?.name || "";
538
- }
539
- } else if (body.action === "activate") {
540
- if (!currentConfig.personas.find(p => p.name === body.name)) {
541
- return Response.json({ error: "페λ₯΄μ†Œλ‚˜λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€" }, { status: 404 });
542
- }
543
- currentConfig.active_persona = body.name;
544
- }
545
-
546
- saveConfig(root, currentConfig);
547
- Object.assign(config, currentConfig);
548
- return Response.json({ ok: true, personas: currentConfig.personas, active: currentConfig.active_persona });
332
+ let userAnswer: string | symbol;
333
+ if (q.quiz_type === "ox") {
334
+ userAnswer = await p.select({
335
+ message: "정닡은?",
336
+ options: [
337
+ { value: "O", label: "β­• O" },
338
+ { value: "X", label: "❌ X" },
339
+ ],
340
+ });
341
+ } else {
342
+ userAnswer = await p.text({
343
+ message: "정닡을 μž…λ ₯ν•˜μ„Έμš”",
344
+ placeholder: "...",
345
+ });
549
346
  }
550
347
 
551
- // Build API
552
- if (url.pathname === "/api/build" && req.method === "POST") {
553
- if (isProcessing) {
554
- return Response.json({ error: "이미 처리 μ€‘μž…λ‹ˆλ‹€" }, { status: 409 });
555
- }
556
- isProcessing = true;
557
- processingStatus = "λΉŒλ“œ 쀑...";
558
- (async () => {
559
- try {
560
- const store = new Store(join(root, DB_FILE));
561
- const { buildSite } = await import("./build/renderer");
562
- await buildSite(store, loadConfig(root), root);
563
- store.close();
564
- processingStatus = "λΉŒλ“œ μ™„λ£Œ!";
565
- console.log("\x1b[32mβœ… μˆ˜λ™ λΉŒλ“œ μ™„λ£Œ\x1b[0m");
566
- } catch (e: any) {
567
- processingStatus = `λΉŒλ“œ 였λ₯˜: ${e.message}`;
568
- } finally {
569
- setTimeout(() => { isProcessing = false; }, 2000);
570
- }
571
- })();
572
- return Response.json({ ok: true, message: "λΉŒλ“œ μ‹œμž‘" });
348
+ if (p.isCancel(userAnswer)) {
349
+ p.cancel("ν€΄μ¦ˆλ₯Ό μ’…λ£Œν•©λ‹ˆλ‹€.");
350
+ return;
573
351
  }
574
352
 
575
- // Admin page
576
- if (url.pathname === "/admin") {
577
- const store = new Store(join(root, DB_FILE));
578
- const sources = store.listSources();
579
- const usage = store.getUsageSummary();
580
- const configData = loadConfig(root);
581
- store.close();
582
-
583
- const { renderAdmin } = await import("./build/templates");
584
- return new Response(renderAdmin({
585
- wikiName: configData.project.name,
586
- sources,
587
- usage,
588
- llmConfig: configData.llm,
589
- personas: configData.personas || [],
590
- activePersona: configData.active_persona || "",
591
- }), { headers: { "Content-Type": "text/html" } });
592
- }
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);
593
356
 
594
- if (url.pathname === "/api/status") {
595
- const store = new Store(join(root, DB_FILE));
596
- const sources = store.listSources();
597
- const sourcePages = store.listSourcePages();
598
- const conceptPages = store.listConceptPages();
599
- const links = store.getAllLinks();
600
- const usage = store.getUsageSummary();
601
- store.close();
602
-
603
- return Response.json({
604
- processing: isProcessing,
605
- processingStatus,
606
- sources: sources.length,
607
- sourcePages: sourcePages.length,
608
- conceptPages: conceptPages.length,
609
- links: links.length,
610
- usage,
611
- });
357
+ if (isCorrect) {
358
+ score++;
359
+ console.log(` \x1b[32mβœ… μ •λ‹΅!\x1b[0m\n`);
360
+ } else {
361
+ console.log(` \x1b[31m❌ μ˜€λ‹΅! μ •λ‹΅: ${q.answer}\x1b[0m\n`);
612
362
  }
363
+ }
613
364
 
614
- // ── Static file serving ──
615
- let pathname = url.pathname;
616
- if (pathname === "/") pathname = "/index.html";
617
-
618
- const filePath = join(siteDir, pathname);
619
- const file = Bun.file(filePath);
365
+ const pct = Math.round((score / quizzes.length) * 100);
366
+ console.log(`\x1b[1mπŸ“Š κ²°κ³Ό: ${score}/${quizzes.length} (${pct}%)\x1b[0m`);
367
+ if (pct >= 90) console.log(" πŸ† 완벽에 κ°€κΉμŠ΅λ‹ˆλ‹€!");
368
+ else if (pct >= 70) console.log(" πŸ‘ 잘 ν•˜μ…¨μŠ΅λ‹ˆλ‹€!");
369
+ else if (pct >= 50) console.log(" πŸ“š 쑰금 더 λ³΅μŠ΅ν•΄λ³΄μ„Έμš”!");
370
+ else console.log(" πŸ’ͺ λ‹€μ‹œ λ„μ „ν•΄λ³΄μ„Έμš”!");
620
371
 
621
- if (await file.exists()) {
622
- return new Response(file);
623
- }
624
- return new Response("Not Found", { status: 404 });
625
- },
626
- });
372
+ p.outro("ν•™μŠ΅μ„ κ³„μ†ν•˜μ„Έμš”! πŸ₯");
373
+ } finally {
374
+ store.close();
375
+ }
627
376
  });
628
377
 
629
378
  // --- status ---
@@ -634,35 +383,36 @@ program
634
383
  const root = findProjectRoot();
635
384
  const config = loadConfig(root);
636
385
  const store = new Store(join(root, DB_FILE));
637
-
638
- const sources = store.listSources();
639
- const sourcePages = store.listSourcePages();
640
- const conceptPages = store.listConceptPages();
641
- const links = store.getAllLinks();
642
-
643
- console.log(`\n\x1b[1mπŸ₯ ${config.project.name}\x1b[0m\n`);
644
- console.log(` μ†ŒμŠ€ ${sources.length}`);
645
- console.log(` πŸ“– 원본 ${sourcePages.length}`);
646
- console.log(` πŸ“ κ°œλ… ${conceptPages.length}`);
647
- console.log(` πŸ”— 링크 ${links.length}`);
648
- console.log(` λΉŒλ“œ ${config.build.output_dir}`);
649
- console.log(` 배포 ${config.deploy.target}`);
650
-
651
- if (sourcePages.length) {
652
- console.log("\n\x1b[1mπŸ“– 원본 λ¬Έμ„œ:\x1b[0m");
653
- for (const p of sourcePages) {
654
- console.log(` β€’ ${p.title} \x1b[2m(${p.slug})\x1b[0m`);
386
+ try {
387
+ const sources = store.listSources();
388
+ const sourcePages = store.listSourcePages();
389
+ const conceptPages = store.listConceptPages();
390
+ const links = store.getAllLinks();
391
+
392
+ console.log(`\n\x1b[1mπŸ₯ ${config.project.name}\x1b[0m\n`);
393
+ console.log(` μ†ŒμŠ€ ${sources.length}`);
394
+ console.log(` πŸ“– 원본 ${sourcePages.length}`);
395
+ console.log(` πŸ“ κ°œλ… ${conceptPages.length}`);
396
+ console.log(` πŸ”— 링크 ${links.length}`);
397
+ console.log(` λΉŒλ“œ ${config.build.output_dir}`);
398
+ console.log(` 배포 ${config.deploy.target}`);
399
+
400
+ if (sourcePages.length) {
401
+ console.log("\n\x1b[1mπŸ“– 원본 λ¬Έμ„œ:\x1b[0m");
402
+ for (const p of sourcePages) {
403
+ console.log(` β€’ ${p.title} \x1b[2m(${p.slug})\x1b[0m`);
404
+ }
655
405
  }
656
- }
657
- if (conceptPages.length) {
658
- console.log("\n\x1b[1mπŸ“ κ°œλ… λ¬Έμ„œ:\x1b[0m");
659
- for (const p of conceptPages) {
660
- console.log(` β€’ ${p.title} \x1b[2m(${p.slug})\x1b[0m`);
406
+ if (conceptPages.length) {
407
+ console.log("\n\x1b[1mπŸ“ κ°œλ… λ¬Έμ„œ:\x1b[0m");
408
+ for (const p of conceptPages) {
409
+ console.log(` β€’ ${p.title} \x1b[2m(${p.slug})\x1b[0m`);
410
+ }
661
411
  }
412
+ console.log();
413
+ } finally {
414
+ store.close();
662
415
  }
663
-
664
- console.log();
665
- store.close();
666
416
  });
667
417
 
668
418
  program.parse();