@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.
- package/bin/kiwimu +1 -1
- package/package.json +1 -1
- package/src/build/static/graph.js +2 -2
- package/src/build/static/style.css +237 -183
- package/src/build/templates.ts +123 -0
- package/src/config.ts +48 -0
- package/src/expand/llm.ts +9 -4
- package/src/index.ts +58 -5
- package/src/pipeline/llm-chunker.ts +40 -8
package/src/build/templates.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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) {
|