@jungtz/wiki-router 1.3.0 → 1.5.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/README.md CHANGED
@@ -1,28 +1,31 @@
1
1
  # @jungtz/wiki-router
2
2
 
3
- LLM Wiki 知識庫路由引擎 將結構化知識 (JSON/Markdown) 轉為 LLM 可查詢的 Markdown 維基
3
+ 把結構化知識(JSON / Markdown)丟給 LLM 拆成 wiki,再依使用者問題自動挑相關 .md 當上下文回傳。Build 完還會順便產一段「角色設定(persona)」給上層 chat 用。
4
4
 
5
5
  [![npm version](https://badge.fury.io/js/@jungtz%2Fwiki-router.svg)](https://badge.fury.io/js/@jungtz%2Fwiki-router)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
7
 
8
+ ```
9
+ ┌─→ getContext(prompt) → LLM 路由 → 相關 .md
10
+ 知識來源 → build() ─┤
11
+ └─→ getPersona() → 自動產出的角色設定字串
12
+ ```
13
+
8
14
  ## 安裝
9
15
 
10
16
  ```bash
11
- npm install @jungtz/wiki-router
17
+ npm install @jungtz/wiki-router @jungtz/ai-router
18
+ # 多租戶 + DB 儲存(範本 B)需要:
19
+ npm install better-sqlite3
12
20
  ```
13
21
 
14
- ## 運作流程
15
-
16
- ```
17
- 知識來源 -> build() -> LLM 生成 Wiki -> getContext(prompt) -> LLM 路由 -> 相關上下文
18
- ```
22
+ ---
19
23
 
20
- 1. **build()**: 將知識來源 (JSON/Markdown) 送交 LLM,拆分成結構化 `.md` 維基頁面
21
- 2. **getContext(prompt)**: 根據使用者問題,由 LLM 從 Index.md 中選擇相關檔案,回傳合併後的上下文
24
+ ## 整合範本(複製即用)
22
25
 
23
- 知識的「來源」與 wiki 的「儲存位置」皆透過 **adapter** 介面抽象,可使用內建檔案系統 adapter,亦可自訂 (例如 API + 資料庫),方便支援多租戶 / 多資料集場景。
26
+ ### A. 單一 wiki(檔案系統,最小整合)
24
27
 
25
- ## 快速開始(檔案系統)
28
+ `server.js`:
26
29
 
27
30
  ```js
28
31
  const { createWikiRouter } = require('@jungtz/wiki-router')
@@ -36,317 +39,349 @@ const router = createRouter({
36
39
 
37
40
  const wiki = createWikiRouter({
38
41
  router,
39
- knowledgeDir: './knowledge', // fs 來源(簡寫)
40
- outputDir: './wiki-output', // fs 儲存(簡寫)
42
+ knowledgeDir: './knowledge', // .json / .md 知識來源
43
+ outputDir: './wiki-output', // 生成的 .md 維基
41
44
  modelId: 'ollama-local/gemma4:31b',
42
45
  })
43
46
 
44
- await wiki.build()
45
- const ctx = await wiki.getContext('住宿有什麼規定?')
47
+ ;(async () => {
48
+ await wiki.build() // 啟動時建構(指紋未變動會自動跳過)
49
+ const ctx = await wiki.getContext('住宿規定?')
50
+ const persona = await wiki.getPersona() // build 完自動產出的角色設定
51
+ console.log(persona)
52
+ })()
46
53
  ```
47
54
 
48
- ## 進階:自訂 adapter(多租戶 / API + DB)
49
-
50
- 當知識來自 API、wiki 要存到資料庫時,傳入 `source` / `store` adapter:
51
-
52
- ```js
53
- const { createWikiRouter } = require('@jungtz/wiki-router')
54
-
55
- function wikiOf(hotelId) {
56
- return createWikiRouter({
57
- router,
58
- modelId: 'ollama-local/gemma4:31b',
59
- source: {
60
- async list() { return ['base.json'] },
61
- async read() {
62
- const res = await fetch(`/api/hotels/${hotelId}/knowledge`)
63
- return { type: 'json', content: await res.text() }
64
- },
65
- },
66
- store: {
67
- async list() { return await db.wiki.list(hotelId) },
68
- async read(filename) { return await db.wiki.read(hotelId, filename) },
69
- async write(name, body) { await db.wiki.upsert(hotelId, name, body) },
70
- },
71
- })
72
- }
73
-
74
- const wikiA = wikiOf(4) // 旅館 A
75
- const wikiB = wikiOf(7) // 旅館 B
55
+ 目錄結構:
56
+ ```
57
+ your-project/
58
+ ├── server.js
59
+ ├── knowledge/
60
+ │ └── base.json ← 知識來源
61
+ └── wiki-output/ ← 自動產生
62
+ ├── Index.md
63
+ ├── About.md
64
+ ├── _persona.md ← build 完自動產出(底線前綴排除於路由)
65
+ └── .manifest.json ← 來源指紋(自動寫入)
76
66
  ```
77
67
 
78
- ## API
68
+ ### B. 多租戶 + SQLite(推薦正式環境)
79
69
 
80
- ### `createWikiRouter(config)`
70
+ `server.js`:
81
71
 
82
- | 參數 | 類型 | 必要 | 說明 |
83
- |------|------|------|------|
84
- | `router` | `AIProviderRouter` | ✅ | AI Router 實例 |
85
- | `source` | `Source` | ✱ | Knowledge 來源 adapter(與 `knowledgeDir` 二選一) |
86
- | `store` | `Store` | ✱ | Wiki 儲存 adapter(與 `outputDir` 二選一) |
87
- | `knowledgeDir` | `string` | ✱ | 簡寫:等同 `source: fsSource(dir)` |
88
- | `outputDir` | `string` | ✱ | 簡寫:等同 `store: fsStore(dir)` |
89
- | `modelId` | `string` | | Wiki 生成模型,格式 `provider/model` |
90
- | `routerModelId` | `string` | | 路由選擇模型,預設同 `modelId` |
91
- | `timeout` | `number` | | LLM 對話逾時毫秒數,預設 `300000` (5 分鐘) |
92
- | `splitPrompt` | `string` | | 自訂 Split prompt |
93
- | `mergePrompt` | `string` | | 自訂 Merge prompt |
94
- | `routerPrompt` | `string` | | 自訂 Router prompt |
95
-
96
- ✱ `source` 與 `knowledgeDir` 至少擇一;`store` 與 `outputDir` 至少擇一。
97
-
98
- ### `wiki.build(options?)`
72
+ ```js
73
+ const fs = require('fs')
74
+ const Database = require('better-sqlite3')
75
+ const {
76
+ createTenantManager,
77
+ fsSource,
78
+ sqliteStore,
79
+ ensureWikiTables,
80
+ } = require('@jungtz/wiki-router')
81
+ const { createRouter } = require('@jungtz/ai-router')
99
82
 
100
- 建構或增量更新 Wiki 知識庫。回傳 `Promise<boolean>`。
83
+ const router = createRouter({
84
+ providers: {
85
+ 'ollama-local': { type: 'local', baseURL: 'http://localhost:11434' },
86
+ },
87
+ })
101
88
 
102
- | 選項 | 類型 | 預設 | 說明 |
103
- |------|------|------|------|
104
- | `force` | `boolean` | `false` | 為 `true` 時跳過 fingerprint 比對,無條件重新呼叫 LLM 生成 |
89
+ // DB(每個 tenant 透過 tenantId 隔離,全部存同一個 .db 檔)
90
+ const db = new Database('./data/wiki.db')
91
+ db.pragma('journal_mode = WAL')
92
+ ensureWikiTables(db)
105
93
 
106
- - 首次(`store.list()` 為空):對 JSON 來源執行 split prompt,拆分成多個 `.md`
107
- - 後續:使用 merge prompt,將新來源合併到既有檔案
108
- - **快取**:若 source 提供 `getFingerprint()` 且 store 提供 `readManifest()` / `writeManifest()`,build 會比對來源指紋,未變動時直接跳過 LLM(詳見「來源變更偵測」)
94
+ const KNOWLEDGE_DIR = './knowledge'
109
95
 
110
- ### 來源變更偵測 (Fingerprint)
96
+ const wiki = createTenantManager({
97
+ router,
98
+ modelId: 'ollama-local/gemma4:31b',
111
99
 
112
- source store 都實作對應的選用方法時,`build()` 會自動進行內容指紋比對:
100
+ // 每個 tenantId 各自建立 source / store
101
+ source: tid => fsSource(`${KNOWLEDGE_DIR}/${tid}`),
102
+ store: tid => sqliteStore({ db, tenantId: tid }),
113
103
 
114
- 1. 進入 build → 呼叫 `source.getFingerprint()` 取得當前來源指紋
115
- 2. 呼叫 `store.readManifest()` 取得上次 build 留下的指紋
116
- 3. 兩者相等且 store 已有檔案 → 跳過 LLM,回傳 `true`
117
- 4. 不等 → 走原本流程,build 成功後呼叫 `store.writeManifest()` 寫入新指紋
104
+ // buildAll() 才需要:列出所有 tenant
105
+ listTenants: () => fs.readdirSync(KNOWLEDGE_DIR),
106
+ })
118
107
 
119
- `force: true` 跳過比對直接重建。內建 `fsSource` / `fsStore` 已實作這組方法(manifest 存在 store 目錄下的 `.manifest.json`)。
108
+ ;(async () => {
109
+ await wiki.buildAll() // 並行 build 所有 tenant
110
+ const ctx = await wiki.getContext('問題', 'hotel-001') // 依 tenantId 取上下文
111
+ const persona = await wiki.getPersona('hotel-001') // 自動產出的角色設定
112
+ })()
113
+ ```
120
114
 
121
- 自訂 adapter 沒實作這些方法時,build 仍會正常執行(每次都重跑 LLM),向下相容。
115
+ 目錄結構:
116
+ ```
117
+ your-project/
118
+ ├── server.js
119
+ ├── data/
120
+ │ └── wiki.db ← 自動建立(含 wiki_files / wiki_manifests / wiki_personas 三張表)
121
+ └── knowledge/
122
+ ├── hotel-001/
123
+ │ └── base.json
124
+ └── hotel-002/
125
+ └── base.json
126
+ ```
122
127
 
123
- ### `wiki.getContext(prompt)`
128
+ ### C. 串進 Express server
124
129
 
125
- 根據使用者問題取得相關 Wiki 上下文。回傳 `Promise<string>`。
130
+ 接續範本 B `wiki`:
126
131
 
127
- - 若 store 為空,自動調用 `build()`
128
- - LLM 根據 `Index.md` 選擇最相關的檔案
129
- - 回傳合併後的 Markdown 內容;無相關檔案時回傳空字串
132
+ ```js
133
+ const express = require('express')
134
+ const app = express()
135
+ app.use(express.json())
130
136
 
131
- ## Adapter 介面
137
+ // 啟動時背景 build(不阻塞 listen)
138
+ app.listen(3000, () => {
139
+ wiki.buildAll().catch(err => console.error('[Wiki] init failed', err))
140
+ })
132
141
 
133
- ### `Source`
142
+ // 強制重建某 tenant
143
+ app.post('/api/wiki/rebuild', async (req, res) => {
144
+ const ok = await wiki.build(req.body.tenantId, { force: true })
145
+ res.json({ ok })
146
+ })
134
147
 
135
- ```ts
136
- interface Source {
137
- list(): Promise<string[]> // 來源 key 清單
138
- read(key: string): Promise<{ type: 'json' | 'markdown', content: string }>
139
- getFingerprint?(): Promise<Record<string, string>> // 選用:來源指紋 { key: hash }
140
- }
148
+ // 對話:用 wiki context 增強 prompt,並用 persona 當系統提示
149
+ app.post('/api/chat', async (req, res) => {
150
+ const { prompt, tenantId } = req.body
151
+ const [ctx, persona] = await Promise.all([
152
+ wiki.getContext(prompt, tenantId),
153
+ wiki.getPersona(tenantId),
154
+ ])
155
+ const system = persona || '你是一位專業的 AI 助理。'
156
+ const enhanced = ctx ? `${ctx}\n\n問題:${prompt}` : prompt
157
+ // ... 把 system + enhanced 餵給你的 LLM
158
+ })
141
159
  ```
142
160
 
143
- ### `Store`
161
+ ---
144
162
 
145
- ```ts
146
- interface Store {
147
- list(): Promise<string[]> // 已生成的 wiki 檔名(含 .md)
148
- read(filename: string): Promise<string | null> // 不存在回 null
149
- write(filename: string, content: string): Promise<void>
150
- readManifest?(): Promise<Record<string, string> | null> // 選用:讀上次 build 指紋
151
- writeManifest?(m: Record<string, string>): Promise<void> // 選用:寫本次 build 指紋
152
- }
153
- ```
163
+ ## 換成自訂 source / store
154
164
 
155
- `getFingerprint` / `readManifest` / `writeManifest` 是選用介面,用於來源變更偵測快取。**三者必須同時實作才會啟用快取**;缺其一則 build 仍會正常執行(每次都重跑 LLM)。
156
-
157
- #### 自訂 adapter 範例(API + DB)
165
+ 當知識不在檔案系統(例如打 API)或 wiki 不存 SQLite(例如改 PostgreSQL)時,自訂 adapter:
158
166
 
159
167
  ```js
160
- source: {
161
- async list() { return ['base.json'] },
162
- async read() { /* fetch from API */ },
168
+ const customSource = (tenantId) => ({
169
+ async list() { return ['base.json'] },
170
+ async read() {
171
+ const res = await fetch(`https://api.example.com/hotels/${tenantId}/knowledge`)
172
+ return { type: 'json', content: await res.text() }
173
+ },
174
+ // 選用:用 ETag / updated_at 當指紋,比 sha256 整檔輕量
163
175
  async getFingerprint() {
164
- const res = await fetch(`/api/hotels/${hotelId}/etag`)
165
- return { 'base.json': await res.text() } // 用 ETag 當指紋
176
+ const res = await fetch(`https://api.example.com/hotels/${tenantId}/etag`)
177
+ return { 'base.json': await res.text() }
166
178
  },
167
- },
168
- store: {
169
- async list() { /* DB query */ },
170
- async read(filename) { /* ... */ },
171
- async write(filename, body) { /* ... */ },
172
- async readManifest() { return await db.wikiManifest.get(hotelId) },
173
- async writeManifest(manifest) { await db.wikiManifest.upsert(hotelId, manifest) },
174
- },
175
- ```
176
-
177
- ### 內建 adapter
178
-
179
- ```js
180
- import { fsSource, fsStore, sqliteStore, ensureWikiTables } from '@jungtz/wiki-router'
181
-
182
- // 檔案系統
183
- createWikiRouter({
184
- router,
185
- source: fsSource('./knowledge'),
186
- store: fsStore('./wiki-output'),
187
179
  })
188
180
 
189
- // SQLite (better-sqlite3 相容 db 物件)
190
- ensureWikiTables(db) // 一次性建表(idempotent)
191
- createWikiRouter({
181
+ createTenantManager({
192
182
  router,
193
- source: fsSource('./knowledge'),
194
- store: sqliteStore({ db, tenantId: 'hotel-001' }), // 多租戶用同一個 db、不同 tenantId
183
+ source: customSource,
184
+ store: tid => sqliteStore({ db, tenantId: tid }), // 仍用 SQLite 儲 wiki
185
+ listTenants: async () => {
186
+ const res = await fetch('https://api.example.com/hotels')
187
+ return (await res.json()).map(h => String(h.id))
188
+ },
195
189
  })
196
190
  ```
197
191
 
198
- `knowledgeDir` / `outputDir` 簡寫即在內部分別包成 `fsSource` / `fsStore`。
192
+ ---
193
+
194
+ ## API 參考
199
195
 
200
- #### `sqliteStore({ db, tenantId, ... })`
196
+ ### `createWikiRouter(config)` 單一 wiki 工廠
201
197
 
202
198
  | 參數 | 類型 | 必要 | 說明 |
203
199
  |------|------|------|------|
204
- | `db` | better-sqlite3 instance | ✅ | 須有 `prepare(sql)` `exec(sql)` 方法(不直接相依 better-sqlite3 套件) |
205
- | `tenantId` | `string` | | 多租戶識別字串,不同 tenant 完全隔離 |
206
- | `filesTable` | `string` | | 預設 `wiki_files` |
207
- | `manifestsTable` | `string` | | 預設 `wiki_manifests` |
208
-
209
- `sqliteStore` 內部會自動呼叫 `ensureWikiTables(db)`,但若想預先建表也可以單獨匯入使用。
210
-
211
- ## 多租戶管理 `createTenantManager`
212
-
213
- 把「快取 wiki 實例 / 並行 build dedup / buildAll / 已 build 狀態追蹤 / Index.md fallback」統一封裝。其他專案串多 wiki 時只要設定 config 即可。
214
-
215
- ```js
216
- import { createTenantManager, fsSource, sqliteStore } from '@jungtz/wiki-router'
217
- import Database from 'better-sqlite3'
218
-
219
- const db = new Database('./wiki.db')
200
+ | `router` | `AIProviderRouter` | ✅ | AI Router 實例 |
201
+ | `source` | `Source` | | Knowledge 來源 adapter(與 `knowledgeDir` 二選一) |
202
+ | `store` | `Store` | | Wiki 儲存 adapter(與 `outputDir` 二選一) |
203
+ | `knowledgeDir` | `string` | | 簡寫:等同 `source: fsSource(dir)` |
204
+ | `outputDir` | `string` | ✱ | 簡寫:等同 `store: fsStore(dir)` |
205
+ | `modelId` | `string` | | Wiki 生成模型,格式 `provider/model` |
206
+ | `routerModelId` | `string` | | 路由選擇模型,預設同 `modelId` |
207
+ | `timeout` | `number` | | LLM 對話逾時毫秒數,預設 `300000` (5 分鐘) |
208
+ | `splitPrompt` / `mergePrompt` / `routerPrompt` / `personaPrompt` | `string` | | 自訂四種內建 prompt |
209
+ | `personaSampleSize` | `number` | | persona 時抽取的內容樣本字元上限,預設 `4000` |
220
210
 
221
- const manager = createTenantManager({
222
- router,
223
- modelId: 'ollama-local/gemma4:31b',
211
+ `source`/`knowledgeDir` 至少擇一;`store`/`outputDir` 至少擇一。
224
212
 
225
- // adapter factory:每個 tenantId 各自建立 source / store
226
- source: tid => fsSource(`./knowledge/${tid}`),
227
- store: tid => sqliteStore({ db, tenantId: tid }),
213
+ 回傳:
228
214
 
229
- // 選用:buildAll() 才需要
230
- listTenants: () => fs.readdirSync('./knowledge'),
231
- })
215
+ | 方法 | 說明 |
216
+ |------|------|
217
+ | `build({ force, skipPersona })` | 建構或增量更新;`force: true` 跳過指紋比對;`skipPersona: true` 跳過自動產 persona |
218
+ | `getContext(prompt)` | 依問題回傳相關 .md 合併內容;無相關回空字串 |
219
+ | `buildPersona({ force })` | 單獨重建 persona(不重建 wiki);store 不支援時 noop |
220
+ | `getPersona()` | 取出目前儲存的 persona;store 不支援或尚未產出回 `null` |
232
221
 
233
- await manager.buildAll() // 啟動時並行 build 所有租戶
234
- await manager.build('hotel-001', { force: true }) // 單一租戶強制重建
235
- const ctx = await manager.getContext('問題', 'hotel-001')
236
- ```
222
+ ### `createTenantManager(config)` 多租戶協調器
237
223
 
238
- ### Config
224
+ 封裝多 wiki 的快取、並行 build dedup、`buildAll`、Index.md fallback 等樣板。
239
225
 
240
226
  | 參數 | 類型 | 必要 | 說明 |
241
227
  |------|------|------|------|
242
- | `router` | `AIProviderRouter` | ✅ | AI Router 實例 |
228
+ | `router` | `AIProviderRouter` | ✅ | 同上 |
243
229
  | `source` | `(tenantId) => Source` | ✅ | Source factory |
244
- | `store` | `(tenantId) => Store` | ✅ | Store factory |
230
+ | `store` | `(tenantId) => Store` | ✅ | Store factory |
245
231
  | `listTenants` | `() => string[] \| Promise<string[]>` | | `buildAll()` 才需要 |
246
- | `autoIndex` | `boolean` | | 預設 `true`;build store 沒 Index.md 時,從現有 .md 合成一份目錄 |
232
+ | `autoIndex` | `boolean` | | 預設 `true`;build 後若 store 沒 Index.md,從現有 .md 合成 |
247
233
  | `autoIndexHeader` | `string` | | 預設 `# 知識庫目錄` |
248
234
  | `logger` | `{ log, warn, error }` | | 預設 `console` |
249
- | `modelId` / `routerModelId` / `timeout` / `*Prompt` | | | 同 `createWikiRouter` config,傳給每個 tenant 的 wiki 實例 |
235
+ | `modelId` / `routerModelId` / `timeout` / `*Prompt` / `personaSampleSize` | | | 同 `createWikiRouter`,傳給每個 wiki |
250
236
 
251
- ### 回傳 API
237
+ 回傳:
252
238
 
253
239
  | 方法 | 說明 |
254
240
  |------|------|
255
- | `build(tenantId, { force })` | 為單一租戶 build;同租戶並行呼叫共用同一個 promise |
256
- | `buildAll()` | 並行 build 所有租戶,使用 `Promise.allSettled` 避免單租戶失敗影響其他 |
257
- | `getContext(prompt, tenantId)` | 取得該租戶的 wiki 上下文;尚未 build 完成時回空字串(不阻塞) |
258
- | `isBuilt(tenantId)` | 該租戶是否已成功 build |
259
- | `listBuilt()` | build 過的租戶清單 |
241
+ | `build(tenantId, { force, skipPersona })` | 為單一 tenant build;同 tenant 並行呼叫共用 promise |
242
+ | `buildAll()` | 並行 build 所有 tenant,使用 `allSettled` |
243
+ | `getContext(prompt, tenantId)` | 取得指定 tenant 的 wiki 上下文;尚未 build 完成時回空字串 |
244
+ | `getPersona(tenantId)` | 取出指定 tenant persona;尚未產出回 `null` |
245
+ | `buildPersona(tenantId, { force })` | 為指定 tenant 單獨重建 persona |
246
+ | `isBuilt(tenantId)` / `listBuilt()` | build 狀態查詢 |
260
247
 
261
- ## 子模組
262
-
263
- ### 解析器
248
+ ### 內建 adapter
264
249
 
265
250
  ```js
266
- import { parseWikiOutput } from '@jungtz/wiki-router/parser'
251
+ const { fsSource, fsStore, sqliteStore, ensureWikiTables } = require('@jungtz/wiki-router')
267
252
 
268
- const files = parseWikiOutput(llmResponse)
269
- // [{ filename: 'Index.md', content: '...' }, ...]
253
+ fsSource('./knowledge') // .json/.md
254
+ fsStore('./wiki-output') // .md + .manifest.json + _persona.md
255
+ sqliteStore({ db, tenantId: 'x' }) // SQLite Store(多租戶用同一 db)
256
+ ensureWikiTables(db, { filesTable, manifestsTable, personasTable }) // 一次性建表(idempotent)
270
257
  ```
271
258
 
272
- ### 提示詞模板
259
+ `sqliteStore` 內部會自動 `ensureWikiTables`,但建議啟動時先呼叫一次以避免每個 tenant 都跑 CREATE TABLE。
273
260
 
274
- 三個內建 prompt 定義於 `src/prompts/*.md`,build 時自動 inline 進 dist:
261
+ `fsStore.list()` 會自動排除以底線開頭的檔案(如 `_persona.md`),避免污染路由與 Index 自動生成。
275
262
 
276
- | 檔案 | 匯出名稱 | 用途 |
277
- |------|----------|------|
278
- | `src/prompts/split.md` | `SPLIT_PROMPT` | 將 JSON 資料拆分為多個 `.md` 檔案 |
279
- | `src/prompts/merge.md` | `MERGE_PROMPT` | 將新內容合併到既有 Wiki 檔案 |
280
- | `src/prompts/router.md` | `ROUTER_PROMPT` | 根據使用者問題選擇相關檔案 |
263
+ ### Adapter 介面
281
264
 
282
- ```js
283
- import { SPLIT_PROMPT, MERGE_PROMPT, ROUTER_PROMPT } from '@jungtz/wiki-router/prompts'
265
+ ```ts
266
+ interface Source {
267
+ list(): Promise<string[]>
268
+ read(key: string): Promise<{ type: 'json' | 'markdown', content: string }>
269
+ getFingerprint?(): Promise<Record<string, string>> // 選用:啟用快取
270
+ }
271
+
272
+ interface Store {
273
+ list(): Promise<string[]>
274
+ read(filename: string): Promise<string | null>
275
+ write(filename: string, content: string): Promise<void>
276
+ readManifest?(): Promise<Record<string, string> | null> // 選用:啟用快取
277
+ writeManifest?(m: Record<string, string>): Promise<void>// 選用:啟用快取
278
+ readPersona?(): Promise<string | null> // 選用:啟用 persona 自動生成
279
+ writePersona?(persona: string): Promise<void> // 選用:啟用 persona 自動生成
280
+ }
284
281
  ```
285
282
 
286
- #### 自訂 Prompt
283
+ 選用方法成對啟用各自的功能;缺其一則該功能 noop,主流程不受影響:
284
+
285
+ | 功能 | 必要的選用方法 |
286
+ |---|---|
287
+ | Fingerprint 快取 | `getFingerprint` + `readManifest` + `writeManifest` |
288
+ | Persona 自動產出 | `readPersona` + `writePersona` |
289
+
290
+ ---
291
+
292
+ ## 來源變更偵測(Fingerprint Cache)
293
+
294
+ `build()` 流程:
295
+ 1. 從 `source.getFingerprint()` 算當前指紋
296
+ 2. 從 `store.readManifest()` 讀上次指紋
297
+ 3. 兩者相等且 store 已有檔案 → 跳過 LLM,回傳 `true`
298
+ 4. 不等 → 走原本流程;成功後 `store.writeManifest()` 更新指紋
299
+
300
+ `build({ force: true })` 跳過比對直接重建。
301
+
302
+ `fsStore` 把 manifest 存於 store 目錄下的 `.manifest.json`;`sqliteStore` 存於 `wiki_manifests` 表。
303
+
304
+ ---
305
+
306
+ ## Persona 自動生成
307
+
308
+ `build()` 成功後若 store 同時實作 `readPersona` / `writePersona`,會自動跑一輪 LLM 產出一段角色設定(給上層 chat 當系統提示用)。流程:
287
309
 
288
- `createWikiRouter` 設定中傳入 `splitPrompt` / `mergePrompt` / `routerPrompt` 字串可在 runtime 動態覆蓋;不傳則使用內建預設。
310
+ 1. store 讀取 `Index.md`(沒有就跳過)
311
+ 2. 從非 Index、非底線前綴的 .md 抽樣(合計上限 `personaSampleSize`,預設 4000 字元)
312
+ 3. 把目錄 + 樣本餵給 `PERSONA_PROMPT` → LLM 輸出純文字角色描述
313
+ 4. 透過 `store.writePersona()` 落地
314
+
315
+ 預設 prompt 要求模型:
316
+ - 120 字內、第二人稱「你是 …」開頭
317
+ - 必須使用知識庫實際出現的單位名稱、業種、語氣
318
+ - 嚴禁編造、嚴禁寫成介紹文/宣傳文
319
+
320
+ 讀取與覆蓋:
289
321
 
290
322
  ```js
291
- const wiki = createWikiRouter({
292
- router,
293
- knowledgeDir: './knowledge',
294
- outputDir: './wiki-output',
295
- mergePrompt: readFileSync('./my-custom-merge.md', 'utf-8'),
296
- })
323
+ const persona = await wiki.getPersona() // 讀目前的
324
+ await wiki.buildPersona({ force: true }) // 單獨重建(不重建 wiki)
325
+ await wiki.build({ skipPersona: true }) // build 時不自動產 persona
326
+ const wiki2 = createWikiRouter({ ..., personaPrompt: '...' }) // 換成自己的 prompt
297
327
  ```
298
328
 
329
+ 儲存位置:`fsStore` 寫 `_persona.md`(底線前綴自動排除於 `list()` 與路由);`sqliteStore` 寫 `wiki_personas` 表(`tenant_id` PRIMARY KEY)。
330
+
331
+ 不需要 persona 時:自訂 store 不實作 `readPersona/writePersona`,或每次 `build({ skipPersona: true })`。
332
+
333
+ ---
334
+
299
335
  ## 範例
300
336
 
301
- - `examples/basic.js` — 檔案系統用法
302
- - `examples/multi-tenant.js` — 多租戶 (API + DB) 用法
337
+ - [`examples/basic.js`](./examples/basic.js)範本 A 完整可跑檔
338
+ - [`examples/multi-tenant.js`](./examples/multi-tenant.js)範本 B 完整可跑檔
303
339
 
304
340
  ## 互動預覽
305
341
 
306
- 內建互動式 TUI,可在終端直接問答測試 Wiki 路由效果:
342
+ 單一 wiki:
307
343
 
308
344
  ```bash
309
345
  npm run preview
310
-
311
- # 自訂路徑
312
346
  npm run preview -- --knowledge ./my-knowledge --output ./my-wiki
313
347
  ```
314
348
 
315
- 支援指令:`:list` 查看維基頁面、`:build` 重建維基、`:files` 顯示檔案、`:quit` 離開。
349
+ 多租戶(createTenantManager + sqliteStore):
316
350
 
317
- ## 發布
351
+ ```bash
352
+ npm run preview:multi
353
+ npm run preview:multi -- --knowledge ./knowledge --db ./data/wiki-preview.db
354
+ ```
318
355
 
319
- ### 自動發布(推薦)
356
+ `preview:multi` 額外支援 `:tenants`、`:tenant <id>` 切換、`:buildall` 全租戶重建。其餘指令:`:list / :build / :files / :help / :quit`。
320
357
 
321
- push 到 `master` 分支時,CI 會自動掃描自上個 tag 以來的 commit message,依 gitmoji 決定 bump 類型並 publish 到 npm:
358
+ ## 子模組匯出
322
359
 
323
- | Commit message 包含 | Bump 類型 | 例 |
324
- |--------------------|----------|----|
325
- | `:boom:` 💥 / `BREAKING CHANGE` | major | 1.0.5 → 2.0.0 |
326
- | `:sparkles:` ✨ | minor | 1.0.5 → 1.1.0 |
327
- | 其他 (`:bug:` / `:hammer:` / ...) | patch | 1.0.5 → 1.0.6 |
360
+ ```js
361
+ const { parseWikiOutput } = require('@jungtz/wiki-router/parser')
362
+ const { SPLIT_PROMPT, MERGE_PROMPT, ROUTER_PROMPT, PERSONA_PROMPT } = require('@jungtz/wiki-router/prompts')
363
+ ```
328
364
 
329
- 只要照常用 gitmoji 寫 commit,版號自己對齊語意。
365
+ ## 發布(自動)
330
366
 
331
- ### 本地手動發布腳本(備用)
367
+ push 到 master 後 CI 掃 commit message 決定 bump:
332
368
 
333
- ```bash
334
- npm run bump:patch # 版號 patch +1 並自動 git commit + tag
335
- npm run bump:minor # 版號 minor +1
336
- npm run bump:major # 版號 major +1
337
- npm run push:version # 推送 commit tag remote
338
- npm run release # bump:patch + push:version
339
- ```
369
+ | Commit 訊息 | Bump |
370
+ |---|---|
371
+ | `:boom:` 💥 / `BREAKING CHANGE` | major |
372
+ | `:sparkles:` | minor |
373
+ | 其他 (`:bug:` / `:hammer:` / ...) | patch |
340
374
 
341
375
  ## 錯誤處理
342
376
 
343
- Wiki Router 採用靜默失敗策略:若模型設定錯誤或 provider 無法連線,只會輸出警告並回傳空內容,不會中斷主流程。
377
+ 採靜默失敗:模型連不上時只 warn throw,`getContext` / `getPersona` 回 `null` 或空字串、`build` 回 `false`,主流程不中斷。Persona 生成失敗不影響 `build` 主結果(仍回 `true`)。
344
378
 
345
- ## 相依關係
379
+ ## 相依
346
380
 
347
381
  | 套件 | 關係 |
348
382
  |------|------|
349
383
  | `@jungtz/ai-router` | Peer (optional) — LLM 調度 |
384
+ | `better-sqlite3` 等 SQLite 驅動 | 由使用者提供(傳入 `sqliteStore`),套件不直接相依 |
350
385
 
351
386
  ## 授權
352
387