@open330/kiwimu 0.4.1 β†’ 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.8.0");
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,53 @@ 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.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);
156
+ }
157
+ console.log(`\x1b[34mπŸ“₯ 파일 처리 쀑: ${source}\x1b[0m`);
158
+ const { ingestFile } = await import("./services/ingest");
159
+ const result = await ingestFile(root, store, absPath, source, config.llm, persona, (s) => console.log(` ${s}`));
160
+ console.log(`\x1b[32mβœ… πŸ“– ${result.sourceCount}개 원본 + πŸ“ ${result.conceptCount}개 κ°œλ… λ¬Έμ„œ 생성\x1b[0m`);
161
+ console.log(`\x1b[34mπŸ“Š LLM: ${result.usage.totalCalls}회 호좜, ~$${result.usage.estimatedCostUsd.toFixed(4)}\x1b[0m`);
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);
167
+ } finally {
109
168
  store.close();
110
- return;
111
169
  }
112
-
113
- store.close();
114
170
  });
115
171
 
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
172
  // --- expand ---
183
173
  program
184
174
  .command("expand")
@@ -190,43 +180,45 @@ program
190
180
  const root = findProjectRoot();
191
181
  const config = loadConfig(root);
192
182
  const store = new Store(join(root, DB_FILE));
183
+ try {
184
+ const provider: string = opts.provider || (config as Record<string, unknown>).expand?.provider;
185
+ if (!provider) {
186
+ console.log("\x1b[33mν™•μž₯ ν”„λ‘œλ°”μ΄λ”κ°€ μ„€μ •λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.\x1b[0m");
187
+ console.log("μ‚¬μš©λ²•: kiwimu expand --provider anthropic");
188
+ return;
189
+ }
193
190
 
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");
191
+ const allPages = store.listPages();
192
+ let pages = allPages;
193
+ if (opts.pages) {
194
+ pages = allPages.filter((p) => (opts.pages as string[]).includes(p.slug));
195
+ }
212
196
 
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`);
197
+ console.log(`\x1b[34m🧠 ${pages.length}개 λ¬Έμ„œλ₯Ό ν™•μž₯ν•©λ‹ˆλ‹€...\x1b[0m`);
198
+
199
+ const isCli = provider === "claude-cli" || provider === "codex-cli";
200
+ const { expandWithApi, expandWithCli } = await import("./expand/llm");
201
+
202
+ for (let i = 0; i < pages.length; i++) {
203
+ const page = pages[i];
204
+ console.log(` [${i + 1}/${pages.length}] ${page.title}`);
205
+ try {
206
+ const newContent = isCli
207
+ ? await expandWithCli(page, allPages, provider.replace("-cli", ""))
208
+ : await expandWithApi(page, allPages, provider, opts.model);
209
+ store.updatePageContent(page.id, newContent);
210
+ } catch (e: unknown) {
211
+ const message = e instanceof Error ? e.message : String(e);
212
+ console.error(` \x1b[31m❌ μ‹€νŒ¨: ${message}\x1b[0m`);
213
+ }
223
214
  }
224
- }
225
215
 
226
- const { autoLinkPages } = await import("./pipeline/linker");
227
- const linkCount = autoLinkPages(store);
228
- console.log(`\x1b[32mβœ… ν™•μž₯ μ™„λ£Œ! (${linkCount}개 링크 κ°±μ‹ )\x1b[0m`);
229
- store.close();
216
+ const { autoLinkPages } = await import("./pipeline/linker");
217
+ const linkCount = autoLinkPages(store);
218
+ console.log(`\x1b[32mβœ… ν™•μž₯ μ™„λ£Œ! (${linkCount}개 링크 κ°±μ‹ )\x1b[0m`);
219
+ } finally {
220
+ store.close();
221
+ }
230
222
  });
231
223
 
232
224
  // --- build ---
@@ -237,14 +229,15 @@ program
237
229
  const root = findProjectRoot();
238
230
  const config = loadConfig(root);
239
231
  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();
232
+ try {
233
+ const { buildSite } = await import("./build/renderer");
234
+ console.log("\x1b[34mπŸ”¨ μœ„ν‚€ λΉŒλ“œ 쀑...\x1b[0m");
235
+ const count = await buildSite(store, config, root);
236
+ console.log(`\x1b[32mβœ… ${count}개 νŽ˜μ΄μ§€κ°€ λΉŒλ“œλ˜μ—ˆμŠ΅λ‹ˆλ‹€!\x1b[0m`);
237
+ console.log(` 좜λ ₯: ${join(root, config.build.output_dir)}/`);
238
+ } finally {
239
+ store.close();
240
+ }
248
241
  });
249
242
 
250
243
  // --- deploy ---
@@ -258,13 +251,15 @@ program
258
251
  const config = loadConfig(root);
259
252
  const siteDir = join(root, config.build.output_dir);
260
253
 
261
- // Auto-build before deploy
262
254
  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();
255
+ try {
256
+ const { buildSite } = await import("./build/renderer");
257
+ console.log("\x1b[34mπŸ”¨ λΉŒλ“œ 쀑...\x1b[0m");
258
+ const count = await buildSite(store, config, root);
259
+ console.log(`\x1b[32m ${count}개 νŽ˜μ΄μ§€ λΉŒλ“œ μ™„λ£Œ\x1b[0m`);
260
+ } finally {
261
+ store.close();
262
+ }
268
263
 
269
264
  console.log(`\x1b[34mπŸš€ ${opts.target}에 배포 쀑...\x1b[0m`);
270
265
 
@@ -272,7 +267,6 @@ program
272
267
  const { deployGhPages } = await import("./deploy");
273
268
  await deployGhPages(siteDir, opts.message);
274
269
  console.log("\x1b[32mβœ… GitHub Pages에 λ°°ν¬λ˜μ—ˆμŠ΅λ‹ˆλ‹€!\x1b[0m");
275
- // Try to get the pages URL
276
270
  try {
277
271
  const proc = Bun.spawn(["gh", "repo", "view", "--json", "url", "-q", ".url"], { stdout: "pipe" });
278
272
  const repoUrl = (await new Response(proc.stdout).text()).trim();
@@ -287,7 +281,8 @@ program
287
281
  await deployVercel(siteDir);
288
282
  console.log("\x1b[32mβœ… Vercel에 λ°°ν¬λ˜μ—ˆμŠ΅λ‹ˆλ‹€!\x1b[0m");
289
283
  } else {
290
- console.log(`\x1b[31mμ§€μ›ν•˜μ§€ μ•ŠλŠ” 배포 λŒ€μƒ: ${opts.target}\x1b[0m`);
284
+ console.error(`\x1b[31m❌ μ§€μ›ν•˜μ§€ μ•ŠλŠ” 배포 λŒ€μƒ: ${opts.target}\x1b[0m`);
285
+ process.exit(1);
291
286
  }
292
287
  });
293
288
 
@@ -303,8 +298,6 @@ program
303
298
  const siteDir = join(root, config.build.output_dir);
304
299
 
305
300
  const { existsSync } = await import("fs");
306
-
307
- // Auto-build if needed
308
301
  if (!existsSync(siteDir)) {
309
302
  const store = new Store(join(root, DB_FILE));
310
303
  const { buildSite } = await import("./build/renderer");
@@ -312,318 +305,100 @@ program
312
305
  store.close();
313
306
  }
314
307
 
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
- }
308
+ const { startServer } = await import("./server");
309
+ startServer(root, parseInt(opts.port), opts.host);
310
+ });
416
311
 
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
- }
312
+ // --- quiz ---
313
+ program
314
+ .command("quiz")
315
+ .description("ν•™μŠ΅ ν€΄μ¦ˆλ₯Ό ν’€μ–΄λ΄…λ‹ˆλ‹€")
316
+ .option("-n, --count <count>", "문제 수", "5")
317
+ .action(async (opts) => {
318
+ const root = findProjectRoot();
319
+ const store = new Store(join(root, DB_FILE));
320
+ try {
321
+ store.initSchema(); // ensure quizzes table exists
322
+ const count = parseInt(opts.count) || 5;
323
+ const quizzes = store.getSmartQuizzes(count);
324
+ if (quizzes.length === 0) {
325
+ console.log("\x1b[33mν€΄μ¦ˆκ°€ μ—†μŠ΅λ‹ˆλ‹€. λ¨Όμ € λ¬Έμ„œλ₯Ό μΆ”κ°€ν•˜μ„Έμš”.\x1b[0m");
326
+ return;
327
+ }
469
328
 
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
- }
329
+ const p = await import("@clack/prompts");
330
+ p.intro("πŸ“ ν•™μŠ΅ ν€΄μ¦ˆ");
331
+ console.log(` ${quizzes.length}개 문제λ₯Ό ν’€μ–΄λ΄…λ‹ˆλ‹€.\n`);
332
+
333
+ let score = 0;
334
+ for (let i = 0; i < quizzes.length; i++) {
335
+ const q = quizzes[i];
336
+ const typeLabel = q.quiz_type === "fill_blank" ? "빈칸 μ±„μš°κΈ°" : q.quiz_type === "ox" ? "OX ν€΄μ¦ˆ" : "λ‹¨λ‹΅ν˜•";
498
337
 
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);
338
+ console.log(`\x1b[1m[${i + 1}/${quizzes.length}] ${typeLabel}\x1b[0m`);
339
+ console.log(` ${q.question}`);
340
+ if (q.page_title) {
341
+ console.log(` \x1b[2m좜처: ${q.page_title}\x1b[0m`);
504
342
  }
505
343
 
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 || "",
344
+ let userAnswer: string | symbol;
345
+ if (q.quiz_type === "ox") {
346
+ userAnswer = await p.select({
347
+ message: "정닡은?",
348
+ options: [
349
+ { value: "O", label: "β­• O" },
350
+ { value: "X", label: "❌ X" },
351
+ ],
352
+ });
353
+ } else {
354
+ userAnswer = await p.text({
355
+ message: "정닡을 μž…λ ₯ν•˜μ„Έμš”",
356
+ placeholder: "...",
512
357
  });
513
358
  }
514
359
 
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 });
360
+ if (p.isCancel(userAnswer)) {
361
+ p.cancel("ν€΄μ¦ˆλ₯Ό μ’…λ£Œν•©λ‹ˆλ‹€.");
362
+ return;
549
363
  }
550
364
 
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: "λΉŒλ“œ μ‹œμž‘" });
573
- }
365
+ const norm = (s: string) => s.trim().toLowerCase().replace(/\s+/g, ' ');
366
+ const isCorrect = norm(userAnswer as string) === norm(q.answer);
574
367
 
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
- }
368
+ store.addQuizAttempt(q.id, isCorrect);
593
369
 
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
- });
370
+ if (isCorrect) {
371
+ score++;
372
+ console.log(` \x1b[32mβœ… μ •λ‹΅!\x1b[0m`);
373
+ } else {
374
+ console.log(` \x1b[31m❌ μ˜€λ‹΅! μ •λ‹΅: ${q.answer}\x1b[0m`);
612
375
  }
376
+ if (q.explanation) {
377
+ console.log(` \x1b[36mπŸ’‘ ${q.explanation}\x1b[0m`);
378
+ }
379
+ console.log();
380
+ }
613
381
 
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);
620
-
621
- if (await file.exists()) {
622
- return new Response(file);
382
+ const pct = Math.round((score / quizzes.length) * 100);
383
+ console.log(`\x1b[1mπŸ“Š κ²°κ³Ό: ${score}/${quizzes.length} (${pct}%)\x1b[0m`);
384
+ if (pct >= 90) console.log(" πŸ† 완벽에 κ°€κΉμŠ΅λ‹ˆλ‹€!");
385
+ else if (pct >= 70) console.log(" πŸ‘ 잘 ν•˜μ…¨μŠ΅λ‹ˆλ‹€!");
386
+ else if (pct >= 50) console.log(" πŸ“š 쑰금 더 λ³΅μŠ΅ν•΄λ³΄μ„Έμš”!");
387
+ else console.log(" πŸ’ͺ λ‹€μ‹œ λ„μ „ν•΄λ³΄μ„Έμš”!");
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}개`);
623
395
  }
624
- return new Response("Not Found", { status: 404 });
625
- },
626
- });
396
+ }
397
+
398
+ p.outro("ν•™μŠ΅μ„ κ³„μ†ν•˜μ„Έμš”! πŸ₯");
399
+ } finally {
400
+ store.close();
401
+ }
627
402
  });
628
403
 
629
404
  // --- status ---
@@ -634,35 +409,36 @@ program
634
409
  const root = findProjectRoot();
635
410
  const config = loadConfig(root);
636
411
  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`);
412
+ try {
413
+ const sources = store.listSources();
414
+ const sourcePages = store.listSourcePages();
415
+ const conceptPages = store.listConceptPages();
416
+ const links = store.getAllLinks();
417
+
418
+ console.log(`\n\x1b[1mπŸ₯ ${config.project.name}\x1b[0m\n`);
419
+ console.log(` μ†ŒμŠ€ ${sources.length}`);
420
+ console.log(` πŸ“– 원본 ${sourcePages.length}`);
421
+ console.log(` πŸ“ κ°œλ… ${conceptPages.length}`);
422
+ console.log(` πŸ”— 링크 ${links.length}`);
423
+ console.log(` λΉŒλ“œ ${config.build.output_dir}`);
424
+ console.log(` 배포 ${config.deploy.target}`);
425
+
426
+ if (sourcePages.length) {
427
+ console.log("\n\x1b[1mπŸ“– 원본 λ¬Έμ„œ:\x1b[0m");
428
+ for (const p of sourcePages) {
429
+ console.log(` β€’ ${p.title} \x1b[2m(${p.slug})\x1b[0m`);
430
+ }
655
431
  }
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`);
432
+ if (conceptPages.length) {
433
+ console.log("\n\x1b[1mπŸ“ κ°œλ… λ¬Έμ„œ:\x1b[0m");
434
+ for (const p of conceptPages) {
435
+ console.log(` β€’ ${p.title} \x1b[2m(${p.slug})\x1b[0m`);
436
+ }
661
437
  }
438
+ console.log();
439
+ } finally {
440
+ store.close();
662
441
  }
663
-
664
- console.log();
665
- store.close();
666
442
  });
667
443
 
668
444
  program.parse();