@open330/kiwimu 0.3.2 → 0.4.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.
@@ -422,6 +422,8 @@ export function renderAdmin(opts: {
422
422
  sources: Array<{ id: number; uri: string; type: string; title: string; fetched_at: string }>;
423
423
  usage: { totalCalls: number; promptTokens: number; completionTokens: number; totalTokens: number; totalCost: number };
424
424
  llmConfig: { provider: string; model: string; api_key: string; endpoint: string };
425
+ personas: Array<{ name: string; description: string; system_prompt: string; content_style: string }>;
426
+ activePersona: string;
425
427
  }): string {
426
428
  const maskedKey = opts.llmConfig.api_key ? "••••" + opts.llmConfig.api_key.slice(-4) : "(미설정)";
427
429
  const sourceRows = opts.sources
@@ -530,6 +532,62 @@ export function renderAdmin(opts: {
530
532
  </div>
531
533
  </div>
532
534
 
535
+ <div class="admin-section">
536
+ <h2>🎭 페르소나 설정</h2>
537
+ <div class="config-card" style="margin-bottom:12px">
538
+ <div class="config-row">
539
+ <span class="config-key">활성 페르소나</span>
540
+ <select id="active-persona" class="config-input" onchange="activatePersona(this.value)">
541
+ ${opts.personas.map(p => `<option value="${escapeHtml(p.name)}"${p.name === opts.activePersona ? " selected" : ""}>${escapeHtml(p.name)}</option>`).join("")}
542
+ <option value=""${!opts.activePersona ? " selected" : ""}>(없음 - 기본 스타일)</option>
543
+ </select>
544
+ <span id="persona-activate-status" style="font-size:13px;margin-left:8px;"></span>
545
+ </div>
546
+ </div>
547
+ <div id="persona-list">
548
+ ${opts.personas.map(p => `
549
+ <div class="config-card persona-card" style="margin-bottom:8px;" data-name="${escapeHtml(p.name)}">
550
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
551
+ <strong style="font-size:15px;">${escapeHtml(p.name)} ${p.name === opts.activePersona ? '<span style="color:#2e7d32;font-size:12px;">✅ 활성</span>' : ''}</strong>
552
+ <div style="display:flex;gap:6px;">
553
+ <button class="save-btn" style="font-size:12px;padding:4px 10px;" onclick="editPersona('${escapeHtml(p.name)}')">✏️ 편집</button>
554
+ <button class="save-btn" style="font-size:12px;padding:4px 10px;background:#e53935;" onclick="deletePersona('${escapeHtml(p.name)}')">🗑️ 삭제</button>
555
+ </div>
556
+ </div>
557
+ <div style="font-size:13px;color:var(--text-muted);">${escapeHtml(p.description)}</div>
558
+ </div>`).join("")}
559
+ </div>
560
+ <button class="save-btn" style="margin-top:8px;" onclick="showPersonaModal()">➕ 새 페르소나 추가</button>
561
+ </div>
562
+
563
+ <!-- Persona Modal -->
564
+ <div id="persona-modal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:1000;align-items:center;justify-content:center;">
565
+ <div style="background:white;border-radius:8px;padding:24px;max-width:700px;width:90%;max-height:85vh;overflow-y:auto;">
566
+ <h3 id="persona-modal-title" style="margin-bottom:16px;">새 페르소나 추가</h3>
567
+ <input type="hidden" id="persona-original-name" value="">
568
+ <div style="margin-bottom:12px;">
569
+ <label style="font-weight:600;font-size:13px;display:block;margin-bottom:4px;">이름</label>
570
+ <input id="persona-name" class="config-input" style="width:100%;" placeholder="예: 나무위키, 교과서, 유머러스">
571
+ </div>
572
+ <div style="margin-bottom:12px;">
573
+ <label style="font-weight:600;font-size:13px;display:block;margin-bottom:4px;">설명</label>
574
+ <input id="persona-desc" class="config-input" style="width:100%;" placeholder="이 페르소나의 간단한 설명">
575
+ </div>
576
+ <div style="margin-bottom:12px;">
577
+ <label style="font-weight:600;font-size:13px;display:block;margin-bottom:4px;">시스템 프롬프트</label>
578
+ <textarea id="persona-system" class="config-input" style="width:100%;height:180px;resize:vertical;font-size:13px;" placeholder="LLM에게 전달할 시스템 프롬프트. 문체, 톤, 규칙 등을 지정하세요."></textarea>
579
+ </div>
580
+ <div style="margin-bottom:16px;">
581
+ <label style="font-weight:600;font-size:13px;display:block;margin-bottom:4px;">콘텐츠 스타일 지시</label>
582
+ <textarea id="persona-style" class="config-input" style="width:100%;height:120px;resize:vertical;font-size:13px;" placeholder="콘텐츠 생성시 적용할 스타일 가이드"></textarea>
583
+ </div>
584
+ <div style="display:flex;gap:8px;justify-content:flex-end;">
585
+ <button class="save-btn" style="background:var(--text-muted);" onclick="closePersonaModal()">취소</button>
586
+ <button class="save-btn" onclick="savePersona()">💾 저장</button>
587
+ </div>
588
+ </div>
589
+ </div>
590
+
533
591
  <div class="admin-section">
534
592
  <h2>📚 등록된 소스 (${opts.sources.length})</h2>
535
593
  <table class="admin-table">
@@ -610,6 +668,71 @@ export function renderAdmin(opts: {
610
668
  else { status.textContent = '❌ 실패'; status.style.color = '#c62828'; }
611
669
  } catch { status.textContent = '❌ 연결 실패'; status.style.color = '#c62828'; }
612
670
  });
671
+
672
+ // ── Persona management ──
673
+ let personaData = ${JSON.stringify(opts.personas)};
674
+
675
+ async function activatePersona(name) {
676
+ const status = document.getElementById('persona-activate-status');
677
+ try {
678
+ const r = await fetch('/api/personas', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'activate', name }) });
679
+ if (r.ok) { status.textContent = '✅'; status.style.color = '#2e7d32'; setTimeout(() => location.reload(), 800); }
680
+ else { status.textContent = '❌'; status.style.color = '#c62828'; }
681
+ } catch { status.textContent = '❌'; status.style.color = '#c62828'; }
682
+ }
683
+
684
+ function showPersonaModal(existing) {
685
+ document.getElementById('persona-modal').style.display = 'flex';
686
+ if (existing) {
687
+ const p = personaData.find(x => x.name === existing);
688
+ if (!p) return;
689
+ document.getElementById('persona-modal-title').textContent = '페르소나 편집';
690
+ document.getElementById('persona-original-name').value = existing;
691
+ document.getElementById('persona-name').value = p.name;
692
+ document.getElementById('persona-desc').value = p.description;
693
+ document.getElementById('persona-system').value = p.system_prompt;
694
+ document.getElementById('persona-style').value = p.content_style;
695
+ } else {
696
+ document.getElementById('persona-modal-title').textContent = '새 페르소나 추가';
697
+ document.getElementById('persona-original-name').value = '';
698
+ document.getElementById('persona-name').value = '';
699
+ document.getElementById('persona-desc').value = '';
700
+ document.getElementById('persona-system').value = '';
701
+ document.getElementById('persona-style').value = '';
702
+ }
703
+ }
704
+
705
+ function editPersona(name) { showPersonaModal(name); }
706
+
707
+ function closePersonaModal() { document.getElementById('persona-modal').style.display = 'none'; }
708
+
709
+ async function savePersona() {
710
+ const originalName = document.getElementById('persona-original-name').value;
711
+ const persona = {
712
+ name: document.getElementById('persona-name').value.trim(),
713
+ description: document.getElementById('persona-desc').value.trim(),
714
+ system_prompt: document.getElementById('persona-system').value,
715
+ content_style: document.getElementById('persona-style').value,
716
+ };
717
+ if (!persona.name) { alert('이름을 입력해주세요'); return; }
718
+ const body = originalName
719
+ ? { action: 'update', original_name: originalName, persona }
720
+ : { action: 'add', persona };
721
+ try {
722
+ const r = await fetch('/api/personas', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
723
+ if (r.ok) { closePersonaModal(); location.reload(); }
724
+ else { const d = await r.json(); alert(d.error || '실패'); }
725
+ } catch { alert('연결 실패'); }
726
+ }
727
+
728
+ async function deletePersona(name) {
729
+ if (!confirm(name + ' 페르소나를 삭제하시겠습니까?')) return;
730
+ try {
731
+ const r = await fetch('/api/personas', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'delete', name }) });
732
+ if (r.ok) location.reload();
733
+ else { const d = await r.json(); alert(d.error || '실패'); }
734
+ } catch { alert('연결 실패'); }
735
+ }
613
736
  </script>
614
737
  </body>
615
738
  </html>`;
package/src/config.ts CHANGED
@@ -13,22 +13,62 @@ export interface LLMConfig {
13
13
  endpoint: string; // for Azure OpenAI
14
14
  }
15
15
 
16
+ export interface Persona {
17
+ name: string;
18
+ description: string;
19
+ system_prompt: string;
20
+ content_style: string; // injected into content generation prompts
21
+ }
22
+
16
23
  export interface KiwiConfig {
17
24
  project: { name: string; created: string };
18
25
  build: { output_dir: string };
19
26
  llm: LLMConfig;
20
27
  deploy: { target: string };
28
+ personas?: Persona[];
29
+ active_persona?: string; // name of the active persona
21
30
  }
22
31
 
32
+ export const NAMUWIKI_PERSONA: Persona = {
33
+ name: "나무위키",
34
+ description: "나무위키 특유의 문체와 스타일로 문서를 작성합니다",
35
+ system_prompt: `당신은 나무위키 스타일의 위키 편집자입니다. 다음 특징을 반드시 지켜주세요:
36
+
37
+ 1. **문체**: 해요체(~입니다/~합니다)를 기본으로 하되, 가끔 반말(~이다/~한다)을 섞어 사용
38
+ 2. **유머**: 적절한 곳에 ~~취소선 드립~~, (괄호 안의 부연설명), [1] 각주 스타일의 코멘트를 삽입
39
+ 3. **강조**: 중요한 키워드는 **굵게** 처리하고, 핵심 개념은 반복 강조
40
+ 4. **서술 톤**: 백과사전적이면서도 친근한 톤. "~라고 한다", "~라고 카더라" 등의 표현 활용
41
+ 5. **구조**: 목차가 잘 정리된 체계적 구조. 소제목을 적극 활용
42
+ 6. **부가 정보**: "여담으로~", "참고로~", "사실~" 등의 표현으로 부가 정보 추가
43
+ 7. **링크**: 관련 개념에 적극적으로 [[위키 링크]]를 사용
44
+
45
+ 절대 딱딱한 교과서 문체로 쓰지 마세요. 읽는 사람이 재미있게 학습할 수 있도록 작성해주세요.`,
46
+ content_style: `Write in Korean 나무위키 style:
47
+ - Use 해요체 with occasional 반말 mix
48
+ - Add ~~strikethrough humor~~ and (parenthetical asides)
49
+ - Bold **key terms** generously
50
+ - Use phrases like "~라고 한다", "여담으로~", "참고로~"
51
+ - Be encyclopedic yet friendly and entertaining
52
+ - Structure with clear subsections
53
+ - Use [[wiki links]] actively for related concepts`,
54
+ };
55
+
23
56
  export function defaultConfig(name: string): KiwiConfig {
24
57
  return {
25
58
  project: { name, created: new Date().toISOString().slice(0, 10) },
26
59
  build: { output_dir: SITE_DIR },
27
60
  llm: { provider: "gemini", model: "gemini-2.0-flash-lite", api_key: "", endpoint: "" },
28
61
  deploy: { target: "gh-pages" },
62
+ personas: [NAMUWIKI_PERSONA],
63
+ active_persona: "나무위키",
29
64
  };
30
65
  }
31
66
 
67
+ export function getActivePersona(config: KiwiConfig): Persona | null {
68
+ if (!config.active_persona || !config.personas?.length) return null;
69
+ return config.personas.find(p => p.name === config.active_persona) ?? null;
70
+ }
71
+
32
72
  export function saveConfig(root: string, config: KiwiConfig): void {
33
73
  Bun.write(join(root, CONFIG_FILE), stringify(config));
34
74
  }
@@ -40,6 +80,14 @@ export function loadConfig(root: string): KiwiConfig {
40
80
  if (!raw.llm) {
41
81
  raw.llm = { provider: "gemini", model: "gemini-2.0-flash-lite", api_key: "", endpoint: "" };
42
82
  }
83
+ // Migrate: add default persona if missing
84
+ if (!raw.personas || !raw.personas.length) {
85
+ raw.personas = [NAMUWIKI_PERSONA];
86
+ raw.active_persona = "나무위키";
87
+ }
88
+ if (!raw.active_persona) {
89
+ raw.active_persona = raw.personas[0]?.name || "나무위키";
90
+ }
43
91
  return raw as KiwiConfig;
44
92
  }
45
93
 
package/src/expand/llm.ts CHANGED
@@ -1,8 +1,14 @@
1
1
  import type { Page } from "../store";
2
+ import type { Persona } from "../config";
2
3
 
3
- const EXPAND_PROMPT = `You are a wiki editor for a learning platform. Given a wiki page about a topic,
4
+ function buildPrompt(page: Page, context: Page[], persona: Persona | null = null): string {
5
+ const styleInstruction = persona
6
+ ? `\n\nIMPORTANT STYLE GUIDE:\n${persona.system_prompt}\n\n${persona.content_style}`
7
+ : "";
8
+
9
+ const prompt = `You are a wiki editor for a learning platform. Given a wiki page about a topic,
4
10
  expand it with more detail, examples, and related concepts. Keep the markdown format.
5
- Add subsections where appropriate. Be accurate and educational.
11
+ Add subsections where appropriate. Be accurate and educational.${styleInstruction}
6
12
 
7
13
  Current page title: {title}
8
14
  Current content:
@@ -13,8 +19,7 @@ Related pages for context:
13
19
 
14
20
  Write an expanded version of this page in markdown:`;
15
21
 
16
- function buildPrompt(page: Page, context: Page[]): string {
17
- return EXPAND_PROMPT.replace("{title}", page.title)
22
+ return prompt.replace("{title}", page.title)
18
23
  .replace("{content}", page.content)
19
24
  .replace("{context}", context.slice(0, 10).map((p) => `- ${p.title}`).join("\n"));
20
25
  }
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { Command } from "commander";
4
4
  import { join } from "path";
5
- import { CONFIG_FILE, DB_FILE, defaultConfig, findProjectRoot, loadConfig, saveConfig } from "./config";
5
+ import { CONFIG_FILE, DB_FILE, defaultConfig, findProjectRoot, getActivePersona, loadConfig, saveConfig } from "./config";
6
6
  import { Store } from "./store";
7
7
 
8
8
  const program = new Command()
@@ -125,6 +125,8 @@ async function addUrl(store: Store, url: string) {
125
125
 
126
126
  const root = findProjectRoot();
127
127
  await initLLM(root);
128
+ const config = loadConfig(root);
129
+ const persona = getActivePersona(config);
128
130
 
129
131
  console.log(`\x1b[34m📥 URL 가져오는 중: ${url}\x1b[0m`);
130
132
  const { title, html } = await fetchPage(url);
@@ -134,7 +136,7 @@ async function addUrl(store: Store, url: string) {
134
136
  const rawText = htmlToRawText(html);
135
137
 
136
138
  console.log("\x1b[34m📄 LLM 기반 문서 분석 중...\x1b[0m");
137
- const { sourceCount, conceptCount } = await llmChunkDocument(rawText, title, source.id, store);
139
+ const { sourceCount, conceptCount } = await llmChunkDocument(rawText, title, source.id, store, 0, persona);
138
140
  console.log(`\x1b[32m✅ 📖 ${sourceCount}개 원본 + 📝 ${conceptCount}개 개념 문서 생성\x1b[0m`);
139
141
 
140
142
  const { getUsageStats, getEstimatedCost, printUsageSummary } = await import("./llm-client");
@@ -157,6 +159,8 @@ async function addPdf(store: Store, pdfPath: string) {
157
159
 
158
160
  const root = findProjectRoot();
159
161
  await initLLM(root);
162
+ const config = loadConfig(root);
163
+ const persona = getActivePersona(config);
160
164
 
161
165
  console.log(`\x1b[34m📥 PDF 처리 중: ${pdfPath}\x1b[0m`);
162
166
  const { title, text } = await extractTextFromPdf(absPath);
@@ -166,7 +170,7 @@ async function addPdf(store: Store, pdfPath: string) {
166
170
  const source = store.addSource(absPath, "pdf", title, "(PDF)");
167
171
 
168
172
  console.log("\x1b[34m📄 LLM 기반 문서 분석 중...\x1b[0m");
169
- const { sourceCount, conceptCount } = await llmChunkDocument(text, title, source.id, store);
173
+ const { sourceCount, conceptCount } = await llmChunkDocument(text, title, source.id, store, 0, persona);
170
174
  console.log(`\x1b[32m✅ 📖 ${sourceCount}개 원본 + 📝 ${conceptCount}개 개념 문서 생성\x1b[0m`);
171
175
 
172
176
  const { getUsageStats, getEstimatedCost, printUsageSummary } = await import("./llm-client");
@@ -388,7 +392,8 @@ program
388
392
  store.deletePagesBySource(src.id);
389
393
 
390
394
  processingStatus = "LLM 분석 중...";
391
- await llmChunkDocument(text, title, src.id, store);
395
+ const currentPersona = getActivePersona(loadConfig(root));
396
+ await llmChunkDocument(text, title, src.id, store, 0, currentPersona);
392
397
 
393
398
  const u = getUsageStats();
394
399
  store.addUsageLog(src.id, u.totalCalls, u.promptTokens, u.completionTokens, u.totalTokens, getEstimatedCost());
@@ -440,7 +445,8 @@ program
440
445
  const rawText = htmlToRawText(html);
441
446
 
442
447
  processingStatus = "LLM 분석 중...";
443
- await llmChunkDocument(rawText, title, src.id, store);
448
+ const currentPersona = getActivePersona(loadConfig(root));
449
+ await llmChunkDocument(rawText, title, src.id, store, 0, currentPersona);
444
450
 
445
451
  const u = getUsageStats();
446
452
  store.addUsageLog(src.id, u.totalCalls, u.promptTokens, u.completionTokens, u.totalTokens, getEstimatedCost());
@@ -497,6 +503,51 @@ program
497
503
  return Response.json(masked);
498
504
  }
499
505
 
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
+ });
513
+ }
514
+
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 });
549
+ }
550
+
500
551
  // Build API
501
552
  if (url.pathname === "/api/build" && req.method === "POST") {
502
553
  if (isProcessing) {
@@ -535,6 +586,8 @@ program
535
586
  sources,
536
587
  usage,
537
588
  llmConfig: configData.llm,
589
+ personas: configData.personas || [],
590
+ activePersona: configData.active_persona || "",
538
591
  }), { headers: { "Content-Type": "text/html" } });
539
592
  }
540
593
 
@@ -1,6 +1,7 @@
1
1
  import { chatComplete } from "../llm-client";
2
2
  import type { Store } from "../store";
3
3
  import { slugify } from "./chunker";
4
+ import type { Persona } from "../config";
4
5
 
5
6
  // ── Phase 1: Extract original document structure ──
6
7
 
@@ -27,7 +28,8 @@ Return at most 8 sections per response to keep output manageable.`;
27
28
 
28
29
  // ── Phase 2: Extract concepts for separate pages ──
29
30
 
30
- const CONCEPT_SYSTEM = `You are a study wiki editor. Given source material pages, identify important concepts, terms, and definitions that deserve their own dedicated wiki pages.
31
+ function getConceptSystem(persona: Persona | null): string {
32
+ const base = `You are a study wiki editor. Given source material pages, identify important concepts, terms, and definitions that deserve their own dedicated wiki pages.
31
33
 
32
34
  Rules:
33
35
  - Pick terms that appear across multiple sections OR are fundamental domain concepts
@@ -38,19 +40,42 @@ Rules:
38
40
 
39
41
  Return valid JSON only. No markdown fences.`;
40
42
 
41
- const CONCEPT_PROMPT = `Based on these source pages, create concept/glossary wiki pages for important terms.
43
+ if (persona) {
44
+ return `${persona.system_prompt}\n\n${base}\n\nIMPORTANT: ${persona.content_style}`;
45
+ }
46
+ return base;
47
+ }
48
+
49
+ function getConceptPrompt(persona: Persona | null): string {
50
+ const styleNote = persona
51
+ ? `\n\nWrite content in the following style:\n${persona.content_style}`
52
+ : "";
53
+
54
+ return `Based on these source pages, create concept/glossary wiki pages for important terms.
42
55
 
43
56
  Source pages already created:
44
57
  {sourcePages}
45
58
 
46
59
  Create 3-6 concept pages for the most important terms, definitions, laws, and equations found in these pages.
47
60
  Do NOT duplicate the source pages — instead, create focused concept pages that the source pages can link to.
48
- Keep each page concise (2-3 paragraphs).
61
+ Keep each page concise (2-3 paragraphs).${styleNote}
49
62
 
50
63
  Return a JSON array where each element has:
51
64
  - "title": string — Short concept name, 1-3 words (e.g., "Synchrotron Radiation", "Flux Density", "Angular Resolution"). Keep titles short so they match naturally in text.
52
65
  - "content": string — Educational markdown content with [[wiki links]] to other concepts and source pages
53
66
  - "suggested_links": Array<{text: string, url: string}> — Wikipedia/external reference links`;
67
+ }
68
+
69
+ function getStructureSystem(persona: Persona | null): string {
70
+ const base = `You are a document analyzer. Extract the chapter/section structure from this textbook content, preserving the original order and hierarchy.
71
+
72
+ Return valid JSON only. No markdown fences.`;
73
+
74
+ if (persona) {
75
+ return `${persona.system_prompt}\n\n${base}\n\nWhen converting content to markdown, apply this writing style:\n${persona.content_style}`;
76
+ }
77
+ return base;
78
+ }
54
79
 
55
80
  interface StructurePage {
56
81
  title: string;
@@ -173,13 +198,17 @@ export async function llmChunkDocument(
173
198
  sourceTitle: string,
174
199
  sourceId: number,
175
200
  store: Store,
176
- maxChunks: number = 0 // 0 = unlimited
201
+ maxChunks: number = 0, // 0 = unlimited
202
+ persona: Persona | null = null
177
203
  ): Promise<{ sourceCount: number; conceptCount: number }> {
178
204
  let chunks = splitByChapters(rawText);
179
205
  if (maxChunks > 0 && chunks.length > maxChunks) {
180
206
  console.log(`\x1b[33m⚠ ${chunks.length}개 청크 중 ${maxChunks}개만 처리합니다\x1b[0m`);
181
207
  chunks = chunks.slice(0, maxChunks);
182
208
  }
209
+ if (persona) {
210
+ console.log(`\x1b[35m🎭 페르소나: ${persona.name}\x1b[0m`);
211
+ }
183
212
  console.log(`\x1b[34m🧠 Phase 1: 원본 구조 추출 (${chunks.length}개 청크)...\x1b[0m`);
184
213
 
185
214
  // ── Phase 1: Extract source pages ──
@@ -194,11 +223,12 @@ export async function llmChunkDocument(
194
223
  .replace("{sourceTitle}", sourceTitle)
195
224
  .replace("{text}", chunk.text.slice(0, 80000));
196
225
 
226
+ const structureSystem = getStructureSystem(persona);
197
227
  try {
198
- const raw = await chatComplete(STRUCTURE_SYSTEM, prompt, 16384);
228
+ const raw = await chatComplete(structureSystem, prompt, 16384);
199
229
  if (!raw || raw.trim().length < 10) {
200
230
  console.log(` \x1b[33m⚠ 빈 응답, 재시도...\x1b[0m`);
201
- const retry = await chatComplete(STRUCTURE_SYSTEM, prompt, 16384);
231
+ const retry = await chatComplete(structureSystem, prompt, 16384);
202
232
  if (!retry || retry.trim().length < 10) {
203
233
  console.log(` \x1b[31m✗ 재시도도 빈 응답\x1b[0m`);
204
234
  continue;
@@ -257,10 +287,12 @@ export async function llmChunkDocument(
257
287
  const existingConcepts = conceptCount > 0
258
288
  ? `\n\nAlready created concept pages (do not duplicate): ${store.listConceptPages().map(p => p.title).join(", ")}`
259
289
  : "";
260
- const prompt = CONCEPT_PROMPT.replace("{sourcePages}", batch.join("\n")) + existingConcepts;
290
+ const conceptPrompt = getConceptPrompt(persona);
291
+ const prompt = conceptPrompt.replace("{sourcePages}", batch.join("\n")) + existingConcepts;
292
+ const conceptSystem = getConceptSystem(persona);
261
293
 
262
294
  try {
263
- const raw = await chatComplete(CONCEPT_SYSTEM, prompt, 16384);
295
+ const raw = await chatComplete(conceptSystem, prompt, 16384);
264
296
  const concepts = parseJSON<ConceptPage[]>(raw).filter(c => c.title && c.content && c.content.length > 50);
265
297
 
266
298
  for (const concept of concepts) {