@open330/kiwimu 0.7.1 β†’ 1.1.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
@@ -2,13 +2,13 @@
2
2
 
3
3
  import { Command } from "commander";
4
4
  import { join } from "path";
5
- import { CONFIG_FILE, DB_FILE, defaultConfig, findProjectRoot, getActivePersona, loadConfig, saveConfig } from "./config";
5
+ import { CONFIG_FILE, DB_FILE, SUPPORTED_EXTENSIONS, defaultConfig, findProjectRoot, getActivePersona, loadConfig, saveConfig } from "./config";
6
6
  import { Store } from "./store";
7
7
 
8
8
  const program = new Command()
9
9
  .name("kiwimu")
10
10
  .description("πŸ₯ Kiwi Mu β€” λ‚˜λ§Œμ˜ ν•™μŠ΅ μœ„ν‚€λ₯Ό λ§Œλ“œμ„Έμš”")
11
- .version("0.7.1");
11
+ .version("0.8.0");
12
12
 
13
13
  // --- init ---
14
14
  program
@@ -43,14 +43,16 @@ program
43
43
  console.log(`\x1b[32mβœ… ${count}개 νŽ˜μ΄μ§€κ°€ λΉŒλ“œλ˜μ—ˆμŠ΅λ‹ˆλ‹€!\x1b[0m`);
44
44
 
45
45
  const { startServer } = await import("./server");
46
- console.log("πŸŽ‰ 데λͺ¨ μœ„ν‚€κ°€ μ€€λΉ„λ˜μ—ˆμŠ΅λ‹ˆλ‹€! http://localhost:8000 μ—μ„œ ν™•μΈν•˜μ„Έμš”");
47
- startServer(root, 8000, "localhost");
46
+ const demoPort = parseInt(process.env.KIWI_PORT || '8000', 10);
47
+ console.log(`πŸŽ‰ 데λͺ¨ μœ„ν‚€κ°€ μ€€λΉ„λ˜μ—ˆμŠ΅λ‹ˆλ‹€! http://localhost:${demoPort} μ—μ„œ ν™•μΈν•˜μ„Έμš”`);
48
+ startServer(root, demoPort, "localhost");
48
49
  return;
49
50
  }
50
51
 
51
52
  if (Bun.file(join(root, CONFIG_FILE)).size > 0) {
52
53
  try {
53
- require("fs").accessSync(join(root, CONFIG_FILE));
54
+ const { accessSync } = await import("fs");
55
+ accessSync(join(root, CONFIG_FILE));
54
56
  console.log("\x1b[33m이미 μ΄ˆκΈ°ν™”λœ ν”„λ‘œμ νŠΈμž…λ‹ˆλ‹€.\x1b[0m");
55
57
  return;
56
58
  } catch {}
@@ -81,13 +83,13 @@ program
81
83
  p.text({
82
84
  message: "λͺ¨λΈλͺ…",
83
85
  placeholder:
84
- results.provider === "gemini" ? "gemini-2.0-flash-lite" :
85
- results.provider === "azure-openai" ? "gpt-5-nano" :
86
- results.provider === "openai" ? "gpt-4o-mini" : "claude-sonnet-4-20250514",
86
+ results.provider === "gemini" ? "gemini-3.1-flash-lite-preview" :
87
+ results.provider === "azure-openai" ? "gpt-5.4-nano" :
88
+ results.provider === "openai" ? "gpt-5.4-nano" : "claude-sonnet-4-6",
87
89
  initialValue:
88
- results.provider === "gemini" ? "gemini-2.0-flash-lite" :
89
- results.provider === "azure-openai" ? "gpt-5-nano" :
90
- results.provider === "openai" ? "gpt-4o-mini" : "claude-sonnet-4-20250514",
90
+ results.provider === "gemini" ? "gemini-3.1-flash-lite-preview" :
91
+ results.provider === "azure-openai" ? "gpt-5.4-nano" :
92
+ results.provider === "openai" ? "gpt-5.4-nano" : "claude-sonnet-4-6",
91
93
  }),
92
94
  apiKey: () =>
93
95
  p.password({
@@ -122,13 +124,14 @@ program
122
124
  // --- add ---
123
125
  program
124
126
  .command("add <source>")
125
- .description("URL λ˜λŠ” νŒŒμΌμ„ μΆ”κ°€ν•©λ‹ˆλ‹€ (PDF, DOCX, PPTX, DOC, PPT, KEY, RTF)")
127
+ .description("URL, 파일, λ˜λŠ” 디렉토리λ₯Ό μΆ”κ°€ν•©λ‹ˆλ‹€ (PDF, DOCX, PPTX, DOC, PPT, KEY, RTF, MD)")
126
128
  .action(async (source: string) => {
127
129
  const root = findProjectRoot();
128
130
  const config = loadConfig(root);
129
131
  const persona = getActivePersona(config);
130
132
  const store = new Store(join(root, DB_FILE));
131
133
  try {
134
+ const schema = config.schema;
132
135
  const isUrl = source.startsWith("http://") || source.startsWith("https://");
133
136
 
134
137
  if (isUrl) {
@@ -136,23 +139,63 @@ program
136
139
  validateUrl(source);
137
140
  console.log(`\x1b[34mπŸ“₯ URL κ°€μ Έμ˜€λŠ” 쀑: ${source}\x1b[0m`);
138
141
  const { ingestUrl } = await import("./services/ingest");
139
- const result = await ingestUrl(root, store, source, config.llm, persona, (s) => console.log(` ${s}`));
142
+ const result = await ingestUrl(root, store, source, config.llm, persona, (s) => console.log(` ${s}`), schema);
140
143
  console.log(`\x1b[32mβœ… πŸ“– ${result.sourceCount}개 원본 + πŸ“ ${result.conceptCount}개 κ°œλ… λ¬Έμ„œ 생성\x1b[0m`);
141
144
  console.log(`\x1b[34mπŸ“Š LLM: ${result.usage.totalCalls}회 호좜, ~$${result.usage.estimatedCostUsd.toFixed(4)}\x1b[0m`);
142
145
  } else {
143
- const { resolve } = await import("path");
146
+ const { resolve, basename } = await import("path");
144
147
  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;
148
+ const { statSync, readdirSync } = await import("fs");
149
+
150
+ let stat;
151
+ try {
152
+ stat = statSync(absPath);
153
+ } catch {
154
+ console.error(`\x1b[31m❌ νŒŒμΌμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€: ${source}\x1b[0m`);
155
+ process.exit(1);
156
+ }
157
+
158
+ if (stat.isDirectory()) {
159
+ // Find all .md files in directory
160
+ const mdFiles = readdirSync(absPath)
161
+ .filter(f => f.endsWith('.md'))
162
+ .map(f => join(absPath, f));
163
+
164
+ if (mdFiles.length === 0) {
165
+ console.error("디렉토리에 .md 파일이 μ—†μŠ΅λ‹ˆλ‹€");
166
+ process.exit(1);
167
+ }
168
+
169
+ console.log(`πŸ“‚ ${mdFiles.length}개 λ§ˆν¬λ‹€μš΄ 파일 발견`);
170
+ const { ingestFile } = await import("./services/ingest");
171
+ for (const mdFile of mdFiles) {
172
+ console.log(`\x1b[34mπŸ“₯ 파일 처리 쀑: ${basename(mdFile)}\x1b[0m`);
173
+ const result = await ingestFile(root, store, mdFile, basename(mdFile), config.llm, persona, (s) => console.log(` ${s}`), schema);
174
+ console.log(`\x1b[32mβœ… πŸ“– ${result.sourceCount}개 원본 + πŸ“ ${result.conceptCount}개 κ°œλ…\x1b[0m`);
175
+ }
176
+ } else {
177
+ const file = Bun.file(absPath);
178
+ if (!(await file.exists())) {
179
+ console.error(`\x1b[31m❌ νŒŒμΌμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€: ${source}\x1b[0m`);
180
+ process.exit(1);
181
+ }
182
+ const ext = source.split(".").pop()?.toLowerCase() || "";
183
+ if (!SUPPORTED_EXTENSIONS.includes(ext)) {
184
+ console.error(`\x1b[31m❌ μ§€μ›ν•˜μ§€ μ•ŠλŠ” 파일 ν˜•μ‹μž…λ‹ˆλ‹€: .${ext}\x1b[0m`);
185
+ console.error(` 지원 ν˜•μ‹: ${SUPPORTED_EXTENSIONS.join(', ')}`);
186
+ process.exit(1);
187
+ }
188
+ console.log(`\x1b[34mπŸ“₯ 파일 처리 쀑: ${source}\x1b[0m`);
189
+ const { ingestFile } = await import("./services/ingest");
190
+ const result = await ingestFile(root, store, absPath, source, config.llm, persona, (s) => console.log(` ${s}`), schema);
191
+ console.log(`\x1b[32mβœ… πŸ“– ${result.sourceCount}개 원본 + πŸ“ ${result.conceptCount}개 κ°œλ… λ¬Έμ„œ 생성\x1b[0m`);
192
+ console.log(`\x1b[34mπŸ“Š LLM: ${result.usage.totalCalls}회 호좜, ~$${result.usage.estimatedCostUsd.toFixed(4)}\x1b[0m`);
149
193
  }
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
194
  }
195
+ } catch (e: unknown) {
196
+ const message = e instanceof Error ? e.message : String(e);
197
+ console.error(`\x1b[31m❌ ${message}\x1b[0m`);
198
+ process.exit(1);
156
199
  } finally {
157
200
  store.close();
158
201
  }
@@ -198,13 +241,17 @@ program
198
241
  store.updatePageContent(page.id, newContent);
199
242
  } catch (e: unknown) {
200
243
  const message = e instanceof Error ? e.message : String(e);
201
- console.log(` \x1b[31mμ‹€νŒ¨: ${message}\x1b[0m`);
244
+ console.error(` \x1b[31m❌ μ‹€νŒ¨: ${message}\x1b[0m`);
202
245
  }
203
246
  }
204
247
 
205
248
  const { autoLinkPages } = await import("./pipeline/linker");
206
249
  const linkCount = autoLinkPages(store);
207
250
  console.log(`\x1b[32mβœ… ν™•μž₯ μ™„λ£Œ! (${linkCount}개 링크 κ°±μ‹ )\x1b[0m`);
251
+ } catch (e: unknown) {
252
+ const message = e instanceof Error ? e.message : String(e);
253
+ console.error(`\x1b[31m❌ ${message}\x1b[0m`);
254
+ process.exit(1);
208
255
  } finally {
209
256
  store.close();
210
257
  }
@@ -224,6 +271,23 @@ program
224
271
  const count = await buildSite(store, config, root);
225
272
  console.log(`\x1b[32mβœ… ${count}개 νŽ˜μ΄μ§€κ°€ λΉŒλ“œλ˜μ—ˆμŠ΅λ‹ˆλ‹€!\x1b[0m`);
226
273
  console.log(` 좜λ ₯: ${join(root, config.build.output_dir)}/`);
274
+
275
+ // Generate embeddings (optional β€” uses [embedding] config or falls back to [llm])
276
+ try {
277
+ const embConfig = config.embedding
278
+ ? { ...config.llm, provider: config.embedding.provider, api_key: config.embedding.api_key }
279
+ : config.llm;
280
+ if (embConfig.api_key && embConfig.provider !== "demo") {
281
+ const { generateMissingEmbeddings } = await import("./services/embedding");
282
+ await generateMissingEmbeddings(store, embConfig, (msg) => console.log(msg));
283
+ }
284
+ } catch (e: unknown) {
285
+ console.log(` ⚠ μž„λ² λ”© 생성 κ±΄λ„ˆλœ€: ${e instanceof Error ? e.message : String(e)}`);
286
+ }
287
+ } catch (e: unknown) {
288
+ const message = e instanceof Error ? e.message : String(e);
289
+ console.error(`\x1b[31m❌ ${message}\x1b[0m`);
290
+ process.exit(1);
227
291
  } finally {
228
292
  store.close();
229
293
  }
@@ -246,31 +310,42 @@ program
246
310
  console.log("\x1b[34mπŸ”¨ λΉŒλ“œ 쀑...\x1b[0m");
247
311
  const count = await buildSite(store, config, root);
248
312
  console.log(`\x1b[32m ${count}개 νŽ˜μ΄μ§€ λΉŒλ“œ μ™„λ£Œ\x1b[0m`);
313
+ } catch (e: unknown) {
314
+ const message = e instanceof Error ? e.message : String(e);
315
+ console.error(`\x1b[31m❌ λΉŒλ“œ μ‹€νŒ¨: ${message}\x1b[0m`);
316
+ process.exit(1);
249
317
  } finally {
250
318
  store.close();
251
319
  }
252
320
 
253
- console.log(`\x1b[34mπŸš€ ${opts.target}에 배포 쀑...\x1b[0m`);
321
+ try {
322
+ console.log(`\x1b[34mπŸš€ ${opts.target}에 배포 쀑...\x1b[0m`);
254
323
 
255
- if (opts.target === "gh-pages") {
256
- const { deployGhPages } = await import("./deploy");
257
- await deployGhPages(siteDir, opts.message);
258
- console.log("\x1b[32mβœ… GitHub Pages에 λ°°ν¬λ˜μ—ˆμŠ΅λ‹ˆλ‹€!\x1b[0m");
259
- try {
260
- const proc = Bun.spawn(["gh", "repo", "view", "--json", "url", "-q", ".url"], { stdout: "pipe" });
261
- const repoUrl = (await new Response(proc.stdout).text()).trim();
262
- if (repoUrl) {
263
- const owner = repoUrl.split("/").slice(-2).join("/").replace("https://github.com/", "");
264
- const [user, repo] = owner.split("/");
265
- console.log(` https://${user}.github.io/${repo}/`);
266
- }
267
- } catch {}
268
- } else if (opts.target === "vercel") {
269
- const { deployVercel } = await import("./deploy");
270
- await deployVercel(siteDir);
271
- console.log("\x1b[32mβœ… Vercel에 λ°°ν¬λ˜μ—ˆμŠ΅λ‹ˆλ‹€!\x1b[0m");
272
- } else {
273
- console.log(`\x1b[31mμ§€μ›ν•˜μ§€ μ•ŠλŠ” 배포 λŒ€μƒ: ${opts.target}\x1b[0m`);
324
+ if (opts.target === "gh-pages") {
325
+ const { deployGhPages } = await import("./deploy");
326
+ await deployGhPages(siteDir, opts.message);
327
+ console.log("\x1b[32mβœ… GitHub Pages에 λ°°ν¬λ˜μ—ˆμŠ΅λ‹ˆλ‹€!\x1b[0m");
328
+ try {
329
+ const proc = Bun.spawn(["gh", "repo", "view", "--json", "url", "-q", ".url"], { stdout: "pipe" });
330
+ const repoUrl = (await new Response(proc.stdout).text()).trim();
331
+ if (repoUrl) {
332
+ const owner = repoUrl.split("/").slice(-2).join("/").replace("https://github.com/", "");
333
+ const [user, repo] = owner.split("/");
334
+ console.log(` https://${user}.github.io/${repo}/`);
335
+ }
336
+ } catch {}
337
+ } else if (opts.target === "vercel") {
338
+ const { deployVercel } = await import("./deploy");
339
+ await deployVercel(siteDir);
340
+ console.log("\x1b[32mβœ… Vercel에 λ°°ν¬λ˜μ—ˆμŠ΅λ‹ˆλ‹€!\x1b[0m");
341
+ } else {
342
+ console.error(`\x1b[31m❌ μ§€μ›ν•˜μ§€ μ•ŠλŠ” 배포 λŒ€μƒ: ${opts.target}\x1b[0m`);
343
+ process.exit(1);
344
+ }
345
+ } catch (e: unknown) {
346
+ const message = e instanceof Error ? e.message : String(e);
347
+ console.error(`\x1b[31m❌ 배포 μ‹€νŒ¨: ${message}\x1b[0m`);
348
+ process.exit(1);
274
349
  }
275
350
  });
276
351
 
@@ -281,20 +356,29 @@ program
281
356
  .option("-p, --port <port>", "포트 번호", "8000")
282
357
  .option("-H, --host <host>", "λ°”μΈλ“œ μ£Όμ†Œ", "localhost")
283
358
  .action(async (opts) => {
284
- const root = findProjectRoot();
285
- const config = loadConfig(root);
286
- const siteDir = join(root, config.build.output_dir);
359
+ try {
360
+ const root = findProjectRoot();
361
+ const config = loadConfig(root);
362
+ const siteDir = join(root, config.build.output_dir);
287
363
 
288
- const { existsSync } = await import("fs");
289
- if (!existsSync(siteDir)) {
290
- const store = new Store(join(root, DB_FILE));
291
- const { buildSite } = await import("./build/renderer");
292
- await buildSite(store, config, root);
293
- store.close();
294
- }
364
+ const { existsSync } = await import("fs");
365
+ if (!existsSync(siteDir)) {
366
+ const store = new Store(join(root, DB_FILE));
367
+ try {
368
+ const { buildSite } = await import("./build/renderer");
369
+ await buildSite(store, config, root);
370
+ } finally {
371
+ store.close();
372
+ }
373
+ }
295
374
 
296
- const { startServer } = await import("./server");
297
- startServer(root, parseInt(opts.port), opts.host);
375
+ const { startServer } = await import("./server");
376
+ startServer(root, parseInt(opts.port), opts.host);
377
+ } catch (e: unknown) {
378
+ const message = e instanceof Error ? e.message : String(e);
379
+ console.error(`\x1b[31m❌ ${message}\x1b[0m`);
380
+ process.exit(1);
381
+ }
298
382
  });
299
383
 
300
384
  // --- quiz ---
@@ -308,7 +392,7 @@ program
308
392
  try {
309
393
  store.initSchema(); // ensure quizzes table exists
310
394
  const count = parseInt(opts.count) || 5;
311
- const quizzes = store.getRandomQuizzes(count);
395
+ const quizzes = store.getSmartQuizzes(count);
312
396
  if (quizzes.length === 0) {
313
397
  console.log("\x1b[33mν€΄μ¦ˆκ°€ μ—†μŠ΅λ‹ˆλ‹€. λ¨Όμ € λ¬Έμ„œλ₯Ό μΆ”κ°€ν•˜μ„Έμš”.\x1b[0m");
314
398
  return;
@@ -350,16 +434,25 @@ program
350
434
  return;
351
435
  }
352
436
 
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);
437
+ const norm = (s: string) => s.trim().toLowerCase().replace(/\s+/g, ' ');
438
+ const isCorrect = norm(userAnswer as string) === norm(q.answer);
439
+
440
+ store.addQuizAttempt(q.id, isCorrect);
441
+
442
+ // SM-2 spaced repetition update
443
+ const quality = isCorrect ? 4 : 1; // 4=correct with hesitation, 1=wrong
444
+ store.updateQuizSRS(q.id, quality);
356
445
 
357
446
  if (isCorrect) {
358
447
  score++;
359
- console.log(` \x1b[32mβœ… μ •λ‹΅!\x1b[0m\n`);
448
+ console.log(` \x1b[32mβœ… μ •λ‹΅!\x1b[0m`);
360
449
  } else {
361
- console.log(` \x1b[31m❌ μ˜€λ‹΅! μ •λ‹΅: ${q.answer}\x1b[0m\n`);
450
+ console.log(` \x1b[31m❌ μ˜€λ‹΅! μ •λ‹΅: ${q.answer}\x1b[0m`);
451
+ }
452
+ if (q.explanation) {
453
+ console.log(` \x1b[36mπŸ’‘ ${q.explanation}\x1b[0m`);
362
454
  }
455
+ console.log();
363
456
  }
364
457
 
365
458
  const pct = Math.round((score / quizzes.length) * 100);
@@ -369,7 +462,176 @@ program
369
462
  else if (pct >= 50) console.log(" πŸ“š 쑰금 더 λ³΅μŠ΅ν•΄λ³΄μ„Έμš”!");
370
463
  else console.log(" πŸ’ͺ λ‹€μ‹œ λ„μ „ν•΄λ³΄μ„Έμš”!");
371
464
 
465
+ const stats = store.getQuizStats();
466
+ if (stats.total > 0) {
467
+ const overallPct = Math.round(stats.correct / stats.total * 100);
468
+ console.log(`\nπŸ“Š 전체 톡계: ${stats.correct}/${stats.total} μ •λ‹΅ (${overallPct}%)`);
469
+ if (stats.unattempted > 0) {
470
+ console.log(` πŸ“‹ λ―Έμ‹œλ„ ν€΄μ¦ˆ: ${stats.unattempted}개`);
471
+ }
472
+ }
473
+
372
474
  p.outro("ν•™μŠ΅μ„ κ³„μ†ν•˜μ„Έμš”! πŸ₯");
475
+ } catch (e: unknown) {
476
+ const message = e instanceof Error ? e.message : String(e);
477
+ console.error(`\x1b[31m❌ ${message}\x1b[0m`);
478
+ process.exit(1);
479
+ } finally {
480
+ store.close();
481
+ }
482
+ });
483
+
484
+ // --- lint ---
485
+ program
486
+ .command("lint")
487
+ .description("μœ„ν‚€ 건강 μƒνƒœλ₯Ό κ²€μ‚¬ν•©λ‹ˆλ‹€ (orphan pages, dead links, etc.)")
488
+ .action(async () => {
489
+ const root = findProjectRoot();
490
+ const store = new Store(join(root, DB_FILE));
491
+ try {
492
+ const { lintWiki } = await import("./services/lint");
493
+ const report = lintWiki(store);
494
+
495
+ const { summary, issues } = report;
496
+
497
+ console.log(`\n\x1b[1mπŸ” Wiki Lint Report\x1b[0m\n`);
498
+ console.log(` Pages: ${summary.total_pages} Links: ${summary.total_links}\n`);
499
+
500
+ if (issues.length === 0) {
501
+ console.log("\x1b[32m βœ… No issues found!\x1b[0m\n");
502
+ } else {
503
+ const errors = issues.filter(i => i.severity === 'error');
504
+ const warnings = issues.filter(i => i.severity === 'warning');
505
+ const infos = issues.filter(i => i.severity === 'info');
506
+
507
+ if (errors.length > 0) {
508
+ console.log(`\x1b[31m ❌ Errors (${errors.length})\x1b[0m`);
509
+ for (const issue of errors) {
510
+ console.log(` \x1b[31mβ€’ [${issue.type}] ${issue.message}\x1b[0m`);
511
+ if (issue.suggestion) console.log(` \x1b[2m→ ${issue.suggestion}\x1b[0m`);
512
+ }
513
+ console.log();
514
+ }
515
+
516
+ if (warnings.length > 0) {
517
+ console.log(`\x1b[33m ⚠ Warnings (${warnings.length})\x1b[0m`);
518
+ for (const issue of warnings) {
519
+ console.log(` \x1b[33mβ€’ [${issue.type}] ${issue.message}\x1b[0m`);
520
+ if (issue.suggestion) console.log(` \x1b[2m→ ${issue.suggestion}\x1b[0m`);
521
+ }
522
+ console.log();
523
+ }
524
+
525
+ if (infos.length > 0) {
526
+ console.log(`\x1b[36m β„Ή Info (${infos.length})\x1b[0m`);
527
+ for (const issue of infos) {
528
+ console.log(` \x1b[36mβ€’ [${issue.type}] ${issue.message}\x1b[0m`);
529
+ if (issue.suggestion) console.log(` \x1b[2m→ ${issue.suggestion}\x1b[0m`);
530
+ }
531
+ console.log();
532
+ }
533
+
534
+ console.log(`\x1b[1m Summary: \x1b[31m${summary.errors} errors\x1b[0m, \x1b[33m${summary.warnings} warnings\x1b[0m, \x1b[36m${summary.info} info\x1b[0m\n`);
535
+ }
536
+
537
+ if (summary.errors > 0) process.exit(1);
538
+ } catch (e: unknown) {
539
+ const message = e instanceof Error ? e.message : String(e);
540
+ console.error(`\x1b[31m❌ ${message}\x1b[0m`);
541
+ process.exit(1);
542
+ } finally {
543
+ store.close();
544
+ }
545
+ });
546
+
547
+ // --- cite (backfill citations) ---
548
+ program
549
+ .command("cite")
550
+ .description("κΈ°μ‘΄ κ°œλ… νŽ˜μ΄μ§€μ— λŒ€ν•΄ 인용 정보λ₯Ό μ—­μΆ”μ ν•©λ‹ˆλ‹€ (LLM 호좜 ν•„μš”)")
551
+ .option("--dry-run", "μ‹€μ œ DB에 μ €μž₯ν•˜μ§€ μ•Šκ³  결과만 ν‘œμ‹œ")
552
+ .action(async (opts: { dryRun?: boolean }) => {
553
+ const root = findProjectRoot();
554
+ const config = loadConfig(root);
555
+ const store = new Store(join(root, DB_FILE));
556
+ try {
557
+ const conceptPages = store.listConceptPages();
558
+ const sourcePages = store.listSourcePages();
559
+
560
+ if (conceptPages.length === 0) {
561
+ console.log("\x1b[33mκ°œλ… νŽ˜μ΄μ§€κ°€ μ—†μŠ΅λ‹ˆλ‹€.\x1b[0m");
562
+ return;
563
+ }
564
+ if (sourcePages.length === 0) {
565
+ console.log("\x1b[33m원본 νŽ˜μ΄μ§€κ°€ μ—†μŠ΅λ‹ˆλ‹€.\x1b[0m");
566
+ return;
567
+ }
568
+ if (!config.llm.api_key || config.llm.provider === "demo") {
569
+ console.error("\x1b[31m❌ LLM API ν‚€κ°€ ν•„μš”ν•©λ‹ˆλ‹€.\x1b[0m");
570
+ process.exit(1);
571
+ }
572
+
573
+ const { LLMClient } = await import("./llm-client");
574
+ const llmClient = new LLMClient(config.llm);
575
+
576
+ const sourcePageList = sourcePages.map(p => `- ${p.title} [slug: ${p.slug}]`).join("\n");
577
+
578
+ console.log(`\x1b[34mπŸ“š ${conceptPages.length}개 κ°œλ… νŽ˜μ΄μ§€μ— λŒ€ν•΄ 인용 역좔적 μ‹œμž‘...\x1b[0m`);
579
+ console.log(` 원본 νŽ˜μ΄μ§€: ${sourcePages.length}개\n`);
580
+
581
+ let totalCitations = 0;
582
+
583
+ for (let i = 0; i < conceptPages.length; i++) {
584
+ const page = conceptPages[i];
585
+ console.log(` [${i + 1}/${conceptPages.length}] ${page.title}...`);
586
+
587
+ const system = `You analyze wiki content and identify which source pages each claim comes from.
588
+ Return valid JSON only. No markdown fences.`;
589
+
590
+ const prompt = `Given this concept page content and a list of source pages, identify which source pages each major claim or fact comes from.
591
+
592
+ Concept page: "${page.title}"
593
+ Content:
594
+ ${page.content.slice(0, 3000)}
595
+
596
+ Available source pages:
597
+ ${sourcePageList}
598
+
599
+ Return a JSON array of citation matches:
600
+ [{"source_page_slug": "the-slug", "excerpt": "brief relevant quote or claim from the concept page (max 150 chars)"}]
601
+
602
+ Only include matches where you are confident the content derives from that source. Return an empty array [] if no clear matches.`;
603
+
604
+ try {
605
+ const raw = await llmClient.chatComplete(system, prompt, 2048);
606
+ let cleaned = raw.replace(/^```json?\n?/m, "").replace(/\n?```\s*$/m, "").trim();
607
+ const matches = JSON.parse(cleaned) as Array<{ source_page_slug: string; excerpt?: string }>;
608
+
609
+ for (const match of matches) {
610
+ const sourcePage = store.getPage(match.source_page_slug);
611
+ if (!sourcePage || !sourcePage.source_id) continue;
612
+
613
+ if (!opts.dryRun) {
614
+ store.addCitation(page.id, sourcePage.source_id, sourcePage.id, match.excerpt || null, null);
615
+ }
616
+ totalCitations++;
617
+ console.log(` β†’ ${sourcePage.title}${match.excerpt ? ': "' + match.excerpt.slice(0, 60) + '..."' : ''}`);
618
+ }
619
+ } catch (e: unknown) {
620
+ const message = e instanceof Error ? e.message : String(e);
621
+ console.log(` \x1b[33m⚠ μ‹€νŒ¨: ${message}\x1b[0m`);
622
+ }
623
+ }
624
+
625
+ if (opts.dryRun) {
626
+ console.log(`\n\x1b[33mπŸ” DRY RUN: ${totalCitations}개 인용 발견 (μ €μž₯ν•˜μ§€ μ•ŠμŒ)\x1b[0m`);
627
+ } else {
628
+ console.log(`\n\x1b[32mβœ… ${totalCitations}개 인용 정보가 μƒμ„±λ˜μ—ˆμŠ΅λ‹ˆλ‹€.\x1b[0m`);
629
+ console.log(` 인용 ν˜„ν™©: kiwimu serve ν›„ /provenance νŽ˜μ΄μ§€μ—μ„œ 확인`);
630
+ }
631
+ } catch (e: unknown) {
632
+ const message = e instanceof Error ? e.message : String(e);
633
+ console.error(`\x1b[31m❌ ${message}\x1b[0m`);
634
+ process.exit(1);
373
635
  } finally {
374
636
  store.close();
375
637
  }
@@ -387,13 +649,13 @@ program
387
649
  const sources = store.listSources();
388
650
  const sourcePages = store.listSourcePages();
389
651
  const conceptPages = store.listConceptPages();
390
- const links = store.getAllLinks();
652
+ const linkCount = store.countLinks();
391
653
 
392
654
  console.log(`\n\x1b[1mπŸ₯ ${config.project.name}\x1b[0m\n`);
393
655
  console.log(` μ†ŒμŠ€ ${sources.length}`);
394
656
  console.log(` πŸ“– 원본 ${sourcePages.length}`);
395
657
  console.log(` πŸ“ κ°œλ… ${conceptPages.length}`);
396
- console.log(` πŸ”— 링크 ${links.length}`);
658
+ console.log(` πŸ”— 링크 ${linkCount}`);
397
659
  console.log(` λΉŒλ“œ ${config.build.output_dir}`);
398
660
  console.log(` 배포 ${config.deploy.target}`);
399
661
 
@@ -410,9 +672,180 @@ program
410
672
  }
411
673
  }
412
674
  console.log();
675
+ } catch (e: unknown) {
676
+ const message = e instanceof Error ? e.message : String(e);
677
+ console.error(`\x1b[31m❌ ${message}\x1b[0m`);
678
+ process.exit(1);
679
+ } finally {
680
+ store.close();
681
+ }
682
+ });
683
+
684
+ // --- log ---
685
+ program
686
+ .command("log")
687
+ .description("ν™œλ™ 둜그λ₯Ό ν‘œμ‹œν•©λ‹ˆλ‹€")
688
+ .option("-n, --count <count>", "ν‘œμ‹œν•  ν•­λͺ© 수", "20")
689
+ .option("--action <action>", "μ•‘μ…˜μœΌλ‘œ 필터링 (ingest, page_created, quiz_attempted, query λ“±)")
690
+ .action((opts) => {
691
+ const root = findProjectRoot();
692
+ const store = new Store(join(root, DB_FILE));
693
+ try {
694
+ const limit = parseInt(opts.count) || 20;
695
+ const entries = store.getActivityLog(limit, 0, opts.action || undefined);
696
+ if (entries.length === 0) {
697
+ console.log("\x1b[33mν™œλ™ λ‘œκ·Έκ°€ μ—†μŠ΅λ‹ˆλ‹€.\x1b[0m");
698
+ return;
699
+ }
700
+ for (const e of entries) {
701
+ const action = e.action.toUpperCase().padEnd(15);
702
+ console.log(`\x1b[2m[${e.created_at}]\x1b[0m \x1b[36m[${action}]\x1b[0m ${e.title}`);
703
+ }
704
+ const stats = store.getActivityStats();
705
+ console.log(`\n\x1b[2m총 ${stats.total}건\x1b[0m`);
413
706
  } finally {
414
707
  store.close();
415
708
  }
416
709
  });
417
710
 
711
+ // --- schema ---
712
+ program
713
+ .command("schema")
714
+ .description("μŠ€ν‚€λ§ˆ 섀정을 κ΄€λ¦¬ν•©λ‹ˆλ‹€")
715
+ .option("--init", "κΈ°λ³Έ [schema] μ„Ήμ…˜μ„ kiwi.toml에 μΆ”κ°€ν•©λ‹ˆλ‹€")
716
+ .option("--validate", "κΈ°μ‘΄ νŽ˜μ΄μ§€κ°€ μŠ€ν‚€λ§ˆ κ·œμΉ™μ— λΆ€ν•©ν•˜λŠ”μ§€ ν™•μΈν•©λ‹ˆλ‹€")
717
+ .action(async (opts: { init?: boolean; validate?: boolean }) => {
718
+ if (opts.init) {
719
+ // Generate default schema section and append to kiwi.toml
720
+ const root = findProjectRoot();
721
+ const { readFileSync, writeFileSync } = await import("fs");
722
+ const configPath = join(root, CONFIG_FILE);
723
+ const existing = readFileSync(configPath, "utf-8");
724
+
725
+ if (existing.includes("[schema]")) {
726
+ console.log("\x1b[33m[schema] μ„Ήμ…˜μ΄ 이미 μ‘΄μž¬ν•©λ‹ˆλ‹€.\x1b[0m");
727
+ return;
728
+ }
729
+
730
+ const defaultSchema = `
731
+ [schema]
732
+ # Wiki structure rules
733
+ categories = ["Fundamentals", "Advanced Topics", "Applications", "History", "People"]
734
+ # Naming conventions: 'noun_phrase', 'question', 'topic'
735
+ naming_convention = "noun_phrase"
736
+ # Content length rules (characters)
737
+ min_page_length = 200
738
+ max_page_length = 3000
739
+
740
+ [schema.terms]
741
+ # Term standardization: abbreviation = "Standard Form"
742
+ # "ML" = "Machine Learning"
743
+ # "DL" = "Deep Learning"
744
+
745
+ [schema.page_template]
746
+ sections = ["Definition", "Explanation", "Examples", "Related Concepts"]
747
+ `;
748
+ writeFileSync(configPath, existing.trimEnd() + "\n" + defaultSchema);
749
+ console.log("\x1b[32m[schema] μ„Ήμ…˜μ΄ kiwi.toml에 μΆ”κ°€λ˜μ—ˆμŠ΅λ‹ˆλ‹€.\x1b[0m");
750
+ console.log(" ν•„μš”μ— 맞게 μˆ˜μ •ν•΄μ£Όμ„Έμš”.");
751
+ return;
752
+ }
753
+
754
+ if (opts.validate) {
755
+ const root = findProjectRoot();
756
+ const config = loadConfig(root);
757
+ const schema = config.schema;
758
+
759
+ if (!schema) {
760
+ console.log("\x1b[33mμŠ€ν‚€λ§ˆκ°€ μ •μ˜λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€. 'kiwimu schema --init'으둜 μƒμ„±ν•˜μ„Έμš”.\x1b[0m");
761
+ return;
762
+ }
763
+
764
+ const store = new Store(join(root, DB_FILE));
765
+ try {
766
+ const pages = store.listPages();
767
+ let issueCount = 0;
768
+
769
+ for (const page of pages) {
770
+ const issues: string[] = [];
771
+
772
+ // Check min length
773
+ if (schema.min_page_length && page.content.length < schema.min_page_length) {
774
+ issues.push(`길이 ${page.content.length}자 < μ΅œμ†Œ ${schema.min_page_length}자`);
775
+ }
776
+ // Check max length
777
+ if (schema.max_page_length && page.content.length > schema.max_page_length) {
778
+ issues.push(`길이 ${page.content.length}자 > μ΅œλŒ€ ${schema.max_page_length}자`);
779
+ }
780
+ // Check required sections
781
+ if (schema.page_template?.sections?.length && page.page_type === "concept") {
782
+ for (const section of schema.page_template.sections) {
783
+ const sectionPattern = new RegExp(`^##\\s+${section.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "mi");
784
+ if (!sectionPattern.test(page.content)) {
785
+ issues.push(`λˆ„λ½λœ μ„Ήμ…˜: "${section}"`);
786
+ }
787
+ }
788
+ }
789
+ // Check category assignment
790
+ if (schema.categories?.length && page.page_type === "concept" && !page.category) {
791
+ issues.push("μΉ΄ν…Œκ³ λ¦¬ λ―Έμ§€μ •");
792
+ }
793
+
794
+ if (issues.length > 0) {
795
+ issueCount += issues.length;
796
+ console.log(`\x1b[33m ${page.title}\x1b[0m (${page.slug})`);
797
+ for (const issue of issues) {
798
+ console.log(` - ${issue}`);
799
+ }
800
+ }
801
+ }
802
+
803
+ if (issueCount === 0) {
804
+ console.log("\x1b[32mλͺ¨λ“  νŽ˜μ΄μ§€κ°€ μŠ€ν‚€λ§ˆ κ·œμΉ™μ— λΆ€ν•©ν•©λ‹ˆλ‹€.\x1b[0m");
805
+ } else {
806
+ console.log(`\n\x1b[33m총 ${issueCount}개 이슈 발견 (${pages.length}개 νŽ˜μ΄μ§€ 검사)\x1b[0m`);
807
+ }
808
+ } finally {
809
+ store.close();
810
+ }
811
+ return;
812
+ }
813
+
814
+ // Default: display current schema settings
815
+ const root = findProjectRoot();
816
+ const config = loadConfig(root);
817
+ const schema = config.schema;
818
+
819
+ if (!schema) {
820
+ console.log("\x1b[33mμŠ€ν‚€λ§ˆκ°€ μ •μ˜λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.\x1b[0m");
821
+ console.log(" 'kiwimu schema --init'으둜 κΈ°λ³Έ μŠ€ν‚€λ§ˆλ₯Ό μƒμ„±ν•˜μ„Έμš”.");
822
+ return;
823
+ }
824
+
825
+ console.log("\n\x1b[1m[schema] μ„€μ •:\x1b[0m\n");
826
+
827
+ if (schema.categories?.length) {
828
+ console.log(` μΉ΄ν…Œκ³ λ¦¬: ${schema.categories.join(", ")}`);
829
+ }
830
+ if (schema.naming_convention) {
831
+ console.log(` λͺ…λͺ… κ·œμΉ™: ${schema.naming_convention}`);
832
+ }
833
+ if (schema.min_page_length != null) {
834
+ console.log(` μ΅œμ†Œ νŽ˜μ΄μ§€ 길이: ${schema.min_page_length}자`);
835
+ }
836
+ if (schema.max_page_length != null) {
837
+ console.log(` μ΅œλŒ€ νŽ˜μ΄μ§€ 길이: ${schema.max_page_length}자`);
838
+ }
839
+ if (schema.terms && Object.keys(schema.terms).length > 0) {
840
+ console.log(` μš©μ–΄ ν‘œμ€€ν™”:`);
841
+ for (const [abbrev, standard] of Object.entries(schema.terms)) {
842
+ console.log(` ${abbrev} -> ${standard}`);
843
+ }
844
+ }
845
+ if (schema.page_template?.sections?.length) {
846
+ console.log(` νŽ˜μ΄μ§€ ν…œν”Œλ¦Ώ μ„Ήμ…˜: ${schema.page_template.sections.join(", ")}`);
847
+ }
848
+ console.log();
849
+ });
850
+
418
851
  program.parse();