@jungtz/wiki-router 1.4.0 → 1.5.1

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,12 +1,14 @@
1
1
  # @jungtz/wiki-router
2
2
 
3
- 把結構化知識(JSON / Markdown)丟給 LLM 拆成 wiki,再依使用者問題自動挑相關 .md 當上下文回傳。
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
8
  ```
9
- 知識來源 → build() → LLM 拆成多個 .md → getContext(prompt) → LLM 路由 → 相關 .md
9
+ ┌─→ getContext(prompt) → LLM 路由 → 相關 .md
10
+ 知識來源 → build() ─┤
11
+ └─→ getPersona() → 自動產出的角色設定字串
10
12
  ```
11
13
 
12
14
  ## 安裝
@@ -44,8 +46,9 @@ const wiki = createWikiRouter({
44
46
 
45
47
  ;(async () => {
46
48
  await wiki.build() // 啟動時建構(指紋未變動會自動跳過)
47
- const ctx = await wiki.getContext('住宿規定?')
48
- console.log(ctx)
49
+ const ctx = await wiki.getContext('營業時間?')
50
+ const persona = await wiki.getPersona() // build 完自動產出的角色設定
51
+ console.log(persona)
49
52
  })()
50
53
  ```
51
54
 
@@ -58,6 +61,7 @@ your-project/
58
61
  └── wiki-output/ ← 自動產生
59
62
  ├── Index.md
60
63
  ├── About.md
64
+ ├── _persona.md ← build 完自動產出(底線前綴排除於路由)
61
65
  └── .manifest.json ← 來源指紋(自動寫入)
62
66
  ```
63
67
 
@@ -103,7 +107,8 @@ const wiki = createTenantManager({
103
107
 
104
108
  ;(async () => {
105
109
  await wiki.buildAll() // 並行 build 所有 tenant
106
- const ctx = await wiki.getContext('問題', 'hotel-001') // 依 tenantId 取上下文
110
+ const ctx = await wiki.getContext('問題', 'tenant-001') // 依 tenantId 取上下文
111
+ const persona = await wiki.getPersona('tenant-001') // 自動產出的角色設定
107
112
  })()
108
113
  ```
109
114
 
@@ -112,11 +117,11 @@ const wiki = createTenantManager({
112
117
  your-project/
113
118
  ├── server.js
114
119
  ├── data/
115
- │ └── wiki.db ← 自動建立(含 wiki_files / wiki_manifests 兩張表)
120
+ │ └── wiki.db ← 自動建立(含 wiki_files / wiki_manifests / wiki_personas 三張表)
116
121
  └── knowledge/
117
- ├── hotel-001/
122
+ ├── tenant-001/
118
123
  │ └── base.json
119
- └── hotel-002/
124
+ └── tenant-002/
120
125
  └── base.json
121
126
  ```
122
127
 
@@ -140,12 +145,16 @@ app.post('/api/wiki/rebuild', async (req, res) => {
140
145
  res.json({ ok })
141
146
  })
142
147
 
143
- // 對話:用 wiki context 增強 prompt
148
+ // 對話:用 wiki context 增強 prompt,並用 persona 當系統提示
144
149
  app.post('/api/chat', async (req, res) => {
145
150
  const { prompt, tenantId } = req.body
146
- const ctx = await wiki.getContext(prompt, tenantId)
151
+ const [ctx, persona] = await Promise.all([
152
+ wiki.getContext(prompt, tenantId),
153
+ wiki.getPersona(tenantId),
154
+ ])
155
+ const system = persona || '你是一位專業的 AI 助理。'
147
156
  const enhanced = ctx ? `${ctx}\n\n問題:${prompt}` : prompt
148
- // ... 把 enhanced 餵給你的 LLM
157
+ // ... 把 system + enhanced 餵給你的 LLM
149
158
  })
150
159
  ```
151
160
 
@@ -159,12 +168,12 @@ app.post('/api/chat', async (req, res) => {
159
168
  const customSource = (tenantId) => ({
160
169
  async list() { return ['base.json'] },
161
170
  async read() {
162
- const res = await fetch(`https://api.example.com/hotels/${tenantId}/knowledge`)
171
+ const res = await fetch(`https://api.example.com/tenants/${tenantId}/knowledge`)
163
172
  return { type: 'json', content: await res.text() }
164
173
  },
165
174
  // 選用:用 ETag / updated_at 當指紋,比 sha256 整檔輕量
166
175
  async getFingerprint() {
167
- const res = await fetch(`https://api.example.com/hotels/${tenantId}/etag`)
176
+ const res = await fetch(`https://api.example.com/tenants/${tenantId}/etag`)
168
177
  return { 'base.json': await res.text() }
169
178
  },
170
179
  })
@@ -174,7 +183,7 @@ createTenantManager({
174
183
  source: customSource,
175
184
  store: tid => sqliteStore({ db, tenantId: tid }), // 仍用 SQLite 儲 wiki
176
185
  listTenants: async () => {
177
- const res = await fetch('https://api.example.com/hotels')
186
+ const res = await fetch('https://api.example.com/tenants')
178
187
  return (await res.json()).map(h => String(h.id))
179
188
  },
180
189
  })
@@ -196,7 +205,8 @@ createTenantManager({
196
205
  | `modelId` | `string` | | Wiki 生成模型,格式 `provider/model` |
197
206
  | `routerModelId` | `string` | | 路由選擇模型,預設同 `modelId` |
198
207
  | `timeout` | `number` | | LLM 對話逾時毫秒數,預設 `300000` (5 分鐘) |
199
- | `splitPrompt` / `mergePrompt` / `routerPrompt` | `string` | | 自訂三種內建 prompt |
208
+ | `splitPrompt` / `mergePrompt` / `routerPrompt` / `personaPrompt` | `string` | | 自訂四種內建 prompt |
209
+ | `personaSampleSize` | `number` | | 產 persona 時抽取的內容樣本字元上限,預設 `4000` |
200
210
 
201
211
  ✱ `source`/`knowledgeDir` 至少擇一;`store`/`outputDir` 至少擇一。
202
212
 
@@ -204,8 +214,10 @@ createTenantManager({
204
214
 
205
215
  | 方法 | 說明 |
206
216
  |------|------|
207
- | `build({ force })` | 建構或增量更新;`force: true` 跳過指紋比對 |
217
+ | `build({ force, skipPersona })` | 建構或增量更新;`force: true` 跳過指紋比對;`skipPersona: true` 跳過自動產 persona |
208
218
  | `getContext(prompt)` | 依問題回傳相關 .md 合併內容;無相關回空字串 |
219
+ | `buildPersona({ force })` | 單獨重建 persona(不重建 wiki);store 不支援時 noop |
220
+ | `getPersona()` | 取出目前儲存的 persona;store 不支援或尚未產出回 `null` |
209
221
 
210
222
  ### `createTenantManager(config)` — 多租戶協調器
211
223
 
@@ -220,15 +232,17 @@ createTenantManager({
220
232
  | `autoIndex` | `boolean` | | 預設 `true`;build 後若 store 沒 Index.md,從現有 .md 合成 |
221
233
  | `autoIndexHeader` | `string` | | 預設 `# 知識庫目錄` |
222
234
  | `logger` | `{ log, warn, error }` | | 預設 `console` |
223
- | `modelId` / `routerModelId` / `timeout` / `*Prompt` | | | 同 `createWikiRouter`,傳給每個 wiki |
235
+ | `modelId` / `routerModelId` / `timeout` / `*Prompt` / `personaSampleSize` | | | 同 `createWikiRouter`,傳給每個 wiki |
224
236
 
225
237
  回傳:
226
238
 
227
239
  | 方法 | 說明 |
228
240
  |------|------|
229
- | `build(tenantId, { force })` | 為單一 tenant build;同 tenant 並行呼叫共用 promise |
241
+ | `build(tenantId, { force, skipPersona })` | 為單一 tenant build;同 tenant 並行呼叫共用 promise |
230
242
  | `buildAll()` | 並行 build 所有 tenant,使用 `allSettled` |
231
243
  | `getContext(prompt, tenantId)` | 取得指定 tenant 的 wiki 上下文;尚未 build 完成時回空字串 |
244
+ | `getPersona(tenantId)` | 取出指定 tenant 的 persona;尚未產出回 `null` |
245
+ | `buildPersona(tenantId, { force })` | 為指定 tenant 單獨重建 persona |
232
246
  | `isBuilt(tenantId)` / `listBuilt()` | build 狀態查詢 |
233
247
 
234
248
  ### 內建 adapter
@@ -237,13 +251,15 @@ createTenantManager({
237
251
  const { fsSource, fsStore, sqliteStore, ensureWikiTables } = require('@jungtz/wiki-router')
238
252
 
239
253
  fsSource('./knowledge') // 讀 .json/.md
240
- fsStore('./wiki-output') // 寫 .md + .manifest.json
254
+ fsStore('./wiki-output') // 寫 .md + .manifest.json + _persona.md
241
255
  sqliteStore({ db, tenantId: 'x' }) // SQLite Store(多租戶用同一 db)
242
- ensureWikiTables(db, { filesTable, manifestsTable }) // 一次性建表(idempotent)
256
+ ensureWikiTables(db, { filesTable, manifestsTable, personasTable }) // 一次性建表(idempotent)
243
257
  ```
244
258
 
245
259
  `sqliteStore` 內部會自動 `ensureWikiTables`,但建議啟動時先呼叫一次以避免每個 tenant 都跑 CREATE TABLE。
246
260
 
261
+ `fsStore.list()` 會自動排除以底線開頭的檔案(如 `_persona.md`),避免污染路由與 Index 自動生成。
262
+
247
263
  ### Adapter 介面
248
264
 
249
265
  ```ts
@@ -259,10 +275,17 @@ interface Store {
259
275
  write(filename: string, content: string): Promise<void>
260
276
  readManifest?(): Promise<Record<string, string> | null> // 選用:啟用快取
261
277
  writeManifest?(m: Record<string, string>): Promise<void>// 選用:啟用快取
278
+ readPersona?(): Promise<string | null> // 選用:啟用 persona 自動生成
279
+ writePersona?(persona: string): Promise<void> // 選用:啟用 persona 自動生成
262
280
  }
263
281
  ```
264
282
 
265
- `getFingerprint` + `readManifest` + `writeManifest` **三者同時實作才會啟用快取**;缺其一則每次 build 都重跑 LLM(向下相容)。
283
+ 選用方法成對啟用各自的功能;缺其一則該功能 noop,主流程不受影響:
284
+
285
+ | 功能 | 必要的選用方法 |
286
+ |---|---|
287
+ | Fingerprint 快取 | `getFingerprint` + `readManifest` + `writeManifest` |
288
+ | Persona 自動產出 | `readPersona` + `writePersona` |
266
289
 
267
290
  ---
268
291
 
@@ -280,6 +303,35 @@ interface Store {
280
303
 
281
304
  ---
282
305
 
306
+ ## Persona 自動生成
307
+
308
+ `build()` 成功後若 store 同時實作 `readPersona` / `writePersona`,會自動跑一輪 LLM 產出一段角色設定(給上層 chat 當系統提示用)。流程:
309
+
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
+ 讀取與覆蓋:
321
+
322
+ ```js
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
327
+ ```
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
+
283
335
  ## 範例
284
336
 
285
337
  - [`examples/basic.js`](./examples/basic.js) — 範本 A 完整可跑檔
@@ -307,7 +359,7 @@ npm run preview:multi -- --knowledge ./knowledge --db ./data/wiki-preview.db
307
359
 
308
360
  ```js
309
361
  const { parseWikiOutput } = require('@jungtz/wiki-router/parser')
310
- const { SPLIT_PROMPT, MERGE_PROMPT, ROUTER_PROMPT } = require('@jungtz/wiki-router/prompts')
362
+ const { SPLIT_PROMPT, MERGE_PROMPT, ROUTER_PROMPT, PERSONA_PROMPT } = require('@jungtz/wiki-router/prompts')
311
363
  ```
312
364
 
313
365
  ## 發布(自動)
@@ -322,7 +374,7 @@ push 到 master 後 CI 掃 commit message 決定 bump:
322
374
 
323
375
  ## 錯誤處理
324
376
 
325
- 採靜默失敗:模型連不上時只 warn 不 throw,`getContext` 回空字串、`build` 回 `false`,主流程不中斷。
377
+ 採靜默失敗:模型連不上時只 warn 不 throw,`getContext` / `getPersona` 回 `null` 或空字串、`build` 回 `false`,主流程不中斷。Persona 生成失敗不影響 `build` 主結果(仍回 `true`)。
326
378
 
327
379
  ## 相依
328
380
 
package/dist/index.cjs CHANGED
@@ -24,6 +24,7 @@ function readPrompt(filename) {
24
24
  const SPLIT_PROMPT = "# 角色:專業文件架構師 (Documentation Architect)\n\n## 任務目標\n將提供的 JSON 資料完整解析並轉換為多個獨立的 Markdown `.md` 檔案。\nJSON 可能來自 CMS、API 回應、設定檔或任何結構化資料來源。\n\n## 原始 JSON 資料\n```json\n{{context}}\n```\n\n## 處理規則:\n\n### 1. 結構識別(自動語義分析)\n- 分析 JSON 的頂層結構(object / array / 巢狀)\n- 若為巢狀物件,以**第一層 key** 作為主題分群依據(如 `data.rooms` → 房間主題、`data.location` → 地點主題)\n- 若為頂層陣列,每個元素視為獨立條目,依內容主題分組\n\n### 2. 完整性強制規則(最高優先)\n- **逐項清點**:陣列型資料必須逐一列出所有項目,先數原始 JSON 中有幾筆,輸出後再數確認數量一致\n- **巢狀陣列全收**:若某欄位本身是陣列(如設施清單、標籤列表),必須列出該陣列中的**每一項**\n- **不因長度省略**:資料再多也不能用「等」、「...」、「其他」概括\n\n### 3. 資訊提取(語義識別)\n- **標題欄位**:自動識別代表名稱/標題的欄位(`title`、`name`、`label`、`subject`)\n- **內文欄位**:自動識別長文字欄位(`content`、`description`、`body`、`intro`、`text`)\n- **時間欄位**:自動識別時間相關欄位(`time`、`date`、`checkin`、`checkout`、`created_at`、`published_at`)\n- **數值欄位**:自動識別價格/數量欄位(`price`、`qty`、`user`、`capacity`、`size`)\n- **布林欄位**:自動識別開關欄位(`enable`、`display`、`is_*`),轉為「啟用/停用」\n- **巢狀物件**:遞迴展開,保留層級結構(如 `social.{instagram_url, instagram_display}`)\n- **多語言欄位**:若欄位為 `{zh_tw, en, ja, ...}` 結構,以 `zh_tw` 為主要輸出語言,不存在時依序 fallback\n\n### 4. 格式還原\n- 若內容為 JSON 字串(Quill Delta 等 RTF 格式),解析後還原為純文字\n- 轉義換行 `\\n` → 真實換行、`\\t` → 縮排\n- 內嵌 HTML(`<a href>`, `<img>`, `<br>`)還原為對應 Markdown 語法\n- 內嵌 URL 保留為可點擊連結\n\n### 5. 檔案拆分原則\n- 依照 JSON 的**第一層 key** 或**語義主題**拆分成獨立 `.md` 檔案\n- 每個檔案涵蓋一個獨立主題區塊\n- 檔名使用英文,反映主題(如 `Rooms.md`、`Policies.md`、`Facilities.md`)\n\n### 6. 索引生成 (Index.md)\n- 彙整所有產出的檔案,生成 `Index.md`\n- 格式:`- [顯示名稱](檔名.md):一句話摘要涵蓋的關鍵主題與具體資訊`\n- 摘要必須具體,禁止只重複檔名\n\n### 7. 輸出約束\n- **輸出順序**:先 `Index.md`,再依序輸出其他檔案\n- **必須標示檔名**:每個程式碼區塊正上方必須有 `### 檔名.md` 標題,不可省略\n- 每個檔案以獨立 ` ```markdown ` 程式碼區塊呈現\n- 所有 JSON 內容一律轉為人類可讀的 Markdown,不可保留原始 JSON 字串\n\n## 輸出格式規範:\n\n### Index.md\n```markdown\n# 知識庫目錄\n- [顯示名稱 A](A.md):涵蓋的關鍵主題與具體資訊摘要\n- [顯示名稱 B](B.md):涵蓋的關鍵主題與具體資訊摘要\n```\n\n---\n\n### 檔名.md\n```markdown\n[還原後的 Markdown 內容]\n```\n\n---\n\n(重複上述格式直到所有檔案輸出完畢)\n";
25
25
  const MERGE_PROMPT = "# 角色:知識庫合併架構師 (Knowledge Merge Architect)\n\n## 任務目標\n將新的內容合併到既有的知識庫檔案結構中,根據內容類型與主題智能決定「新增頁面」、「合併到既有頁面」或「更新既有頁面」。\n\n## 既有知識庫目錄 (Index.md)\n```markdown\n{{indexContent}}\n```\n\n## 新內容類型:{{fileType}}\n## 新內容\n```{{contentBlock}}\n{{newContent}}\n```\n\n## 處理規則:\n\n### 1. 結構識別(依 fileType)\n\n**json**:\n- 分析 JSON 頂層結構,以第一層 key 或語義主題分群\n- 若為巢狀物件,每個第一層 key 視為一個主題區塊\n- 若為頂層陣列,每個元素視為獨立條目\n\n**markdown**:\n- 依照 `##` 或 `###` 標題拆分成獨立區塊\n- 每個區塊視為一個新知識條目\n\n### 2. 合併策略判斷\n\n對每個條目,檢查是否與既有 Index.md 中的頁面相關:\n\n- **主題全新**(目錄中無相關頁面)→ 建立**新檔案**,Index.md 追加新條目\n- **主題已存在**(標題或關鍵詞與既有頁面重疊)→ 將新內容**合併**到既有檔案,保留既有結構,新增內容作為補充。合併後輸出該檔案的**完整內容**\n- **既有檔案缺少條目**(例如列表型資料漏了某筆)→ 必須**補齊**,將缺漏條目加入對應區塊\n- **內容衝突**(同一主題但資訊不一致,如價格變動、規則修改)→ 以**新內容為準**更新,標記 `> 更新於 YYYY-MM-DD`\n- **內容重複**(完全相同)→ 跳過,不輸出\n\n### 3. 完整性驗證\n- 陣列型資料:比對新內容的項目數與既有檔案收錄數,缺漏必須補齊\n- 巢狀陣列(如設施清單、標籤列表):逐一檢查是否全數收錄\n- 多語言欄位:以 `zh_tw` 為主要輸出語言,不存在時 fallback 到 `en`\n- JSON 字串內容(Quill Delta 等):`\\n` → 換行、`\\t` → 縮排、HTML → Markdown\n\n### 4. 輸出約束\n- **輸出順序**:先輸出更新後的 `Index.md`,再依序輸出所有新增或更新過的檔案\n- 每個檔案前必須有 `### 檔名.md` 作為標題,絕對不可省略\n- 每個檔案以獨立 ` ```markdown ` 程式碼區塊呈現\n- **只輸出有變動的檔案**,未修改的既有檔案不需重複輸出\n- 合併的檔案必須包含**完整內容**(既有 + 新增),不可只輸出差異\n\n## 輸出格式規範:\n\n### Index.md\n```markdown\n# 知識庫目錄\n- [顯示名稱 A](A.md):一句話摘要涵蓋的關鍵主題\n- [顯示名稱 B](B.md):一句話摘要涵蓋的關鍵主題\n```\n\n---\n\n### 檔名.md\n```markdown\n[完整內容]\n```\n\n---\n\n(重複上述格式直到所有變動檔案輸出完畢)\n";
26
26
  const ROUTER_PROMPT = "# 角色設定\n你是一個精準的「知識庫路由助手 (Router)」。\n你的唯一任務是分析使用者的問題,並從提供的目錄清單中,挑選出最可能包含解答的檔案名稱。\n\n# 使用者問題\n{{prompt}}\n\n# 知識庫目錄\n{{indexContent}}\n\n# 挑選原則\n- 從目錄中每個檔案的**摘要**判斷相關性,不只比對檔名\n- 考慮同義詞與相關概念(例如:「運動」「健身」→ Facilities.md;「怎麼去」「交通」→ Location_and_Transport.md)\n- 若問題涉及多個主題(如「房價和退房時間」),選取所有相關檔案\n\n# 輸出嚴格規範\n1. **僅輸出檔名**:從「知識庫目錄」中挑選最相關的完整檔案名稱(必須包含 `.md` 後綴)。\n2. **多檔處理**:若有多個檔案相關,請以半形逗號 `,` 分隔。\n3. **無相關時**:若判斷目錄中的檔案皆不相關,請直接回覆 `NONE`。\n4. **禁止任何廢話**:**絕對禁止**輸出任何解釋文字、問候語、符號,也**禁止**使用 Markdown 標記(如代碼塊 ` ``` ` 或清單 `-`)。\n\n**正確輸出範例:**\nIntroduction.md,Policy_Info.md\n";
27
+ const PERSONA_PROMPT = "你正在依據以下知識庫,為這個業務單位產出一段給 AI 模型使用的「智慧管家/服務窗口」角色設定。\n\n【輸出規則】\n- 120 字以內\n- 使用第二人稱開頭:「你是 …」\n- **必須**使用知識庫中實際出現的單位名稱(含中英文/品牌名/所在地),不得改寫、不得概括成「店家」「飯店」「公司」等通稱\n- 必須點出業種與服務範圍(依資料推斷,例如:精品民宿、商務飯店、餐廳、停車場、診所…)\n- 必須描述語氣(專業、親切、簡明等),可參考品牌調性\n- **嚴禁**編造未在資料中出現的細節\n- **嚴禁**寫成介紹文、宣傳文,必須是給 AI 模型看的角色指令\n- 直接輸出純文字角色描述,**不要**任何前後綴、不要 markdown、不要程式碼框\n\n【知識庫目錄】\n{{indexContent}}\n\n【主要內容摘要】\n{{contentSample}}\n\n【角色設定】\n";
27
28
 
28
29
  /**
29
30
  * LLM 輸出解析器
@@ -95,10 +96,14 @@ function parseWikiOutput(output) {
95
96
  * createWikiRouter({ router, source: fsSource('./knowledge'), store: fsStore('./wiki') })
96
97
  *
97
98
  * 也可直接傳 knowledgeDir / outputDir 給 createWikiRouter,內部會自動包成這兩個 adapter。
99
+ *
100
+ * 註:fsStore.list() 會排除以底線開頭的檔案(如 `_persona.md`),
101
+ * 因為這類檔案被視為「非路由用」的衍生資料(例如自動產生的角色設定)。
98
102
  */
99
103
 
100
104
 
101
105
  const MANIFEST_FILE = '.manifest.json';
106
+ const PERSONA_FILE = '_persona.md';
102
107
 
103
108
  /**
104
109
  * 建立讀取本地檔案的 Source adapter
@@ -148,7 +153,8 @@ function fsStore(dir) {
148
153
  return {
149
154
  async list() {
150
155
  ensure();
151
- return fs.readdirSync(resolved).filter(f => f.endsWith('.md'))
156
+ // 排除以底線開頭的檔案(例如 _persona.md),避免污染路由與 Index 自動生成
157
+ return fs.readdirSync(resolved).filter(f => f.endsWith('.md') && !f.startsWith('_'))
152
158
  },
153
159
  async read(filename) {
154
160
  const filePath = path.join(resolved, filename);
@@ -176,6 +182,15 @@ function fsStore(dir) {
176
182
  'utf-8'
177
183
  );
178
184
  },
185
+ async readPersona() {
186
+ const filePath = path.join(resolved, PERSONA_FILE);
187
+ if (!fs.existsSync(filePath)) return null
188
+ return fs.readFileSync(filePath, 'utf-8')
189
+ },
190
+ async writePersona(persona) {
191
+ ensure();
192
+ fs.writeFileSync(path.join(resolved, PERSONA_FILE), persona, 'utf-8');
193
+ },
179
194
  }
180
195
  }
181
196
 
@@ -206,11 +221,14 @@ function createWikiRouter(config) {
206
221
  splitPrompt,
207
222
  mergePrompt,
208
223
  routerPrompt,
224
+ personaPrompt,
225
+ personaSampleSize = 4000,
209
226
  } = config;
210
227
 
211
228
  const activeSplitPrompt = splitPrompt || SPLIT_PROMPT;
212
229
  const activeMergePrompt = mergePrompt || MERGE_PROMPT;
213
230
  const activeRouterPrompt = routerPrompt || ROUTER_PROMPT;
231
+ const activePersonaPrompt = personaPrompt || PERSONA_PROMPT;
214
232
 
215
233
  if (!router) throw new Error('[wiki-router] router is required')
216
234
 
@@ -380,6 +398,15 @@ function createWikiRouter(config) {
380
398
  }
381
399
 
382
400
  console.log(`[Wiki] Build complete — ${totalFiles} file(s), ${formatElapsed(buildStart)}`);
401
+
402
+ if (!options.skipPersona && typeof activeStore.writePersona === 'function') {
403
+ try {
404
+ await buildPersona({ force: !!force });
405
+ } catch (err) {
406
+ console.warn('[Wiki] Persona build failed (non-fatal):', err.message);
407
+ }
408
+ }
409
+
383
410
  return true
384
411
  } catch (err) {
385
412
  console.error(`[Wiki Generation Error] (after ${formatElapsed(buildStart)})`, err);
@@ -387,6 +414,67 @@ function createWikiRouter(config) {
387
414
  }
388
415
  }
389
416
 
417
+ /**
418
+ * 依據 Index.md + 其他 .md 樣本,呼叫 LLM 產出一段角色設定,寫入 store.writePersona()
419
+ * 必須 store 同時實作 readPersona/writePersona 才會有效;否則 noop。
420
+ * @param {{ force?: boolean }} [options]
421
+ * @returns {Promise<boolean>}
422
+ */
423
+ async function buildPersona(options = {}) {
424
+ const { force = false } = options;
425
+ if (typeof activeStore.writePersona !== 'function') {
426
+ return false
427
+ }
428
+
429
+ if (!force && typeof activeStore.readPersona === 'function') {
430
+ const existing = await activeStore.readPersona();
431
+ if (existing && existing.trim().length > 0) return true
432
+ }
433
+
434
+ const indexContent = await activeStore.read('Index.md');
435
+ if (!indexContent) {
436
+ console.warn('[Wiki] No Index.md, skip persona generation');
437
+ return false
438
+ }
439
+
440
+ const allFiles = await activeStore.list();
441
+ const samples = [];
442
+ let totalLen = 0;
443
+ for (const f of allFiles) {
444
+ if (!f.endsWith('.md') || f === 'Index.md' || f.startsWith('_')) continue
445
+ if (totalLen >= personaSampleSize) break
446
+ const c = await activeStore.read(f);
447
+ if (!c) continue
448
+ const remaining = personaSampleSize - totalLen;
449
+ const slice = c.slice(0, remaining);
450
+ samples.push(`### ${f}\n${slice}`);
451
+ totalLen += slice.length;
452
+ }
453
+
454
+ const prompt = activePersonaPrompt
455
+ .replace('{{indexContent}}', indexContent)
456
+ .replace('{{contentSample}}', samples.join('\n\n') || '(無其他內容)');
457
+
458
+ const persona = await chat([{ role: 'user', content: prompt }]);
459
+ if (!persona || persona.trim().length < 10) {
460
+ console.warn('[Wiki] Persona LLM returned empty/too-short, skip write');
461
+ return false
462
+ }
463
+
464
+ await activeStore.writePersona(persona.trim());
465
+ console.log(`[Wiki] Persona generated (${persona.trim().length} chars)`);
466
+ return true
467
+ }
468
+
469
+ /**
470
+ * 取得目前儲存的 persona;若 store 不支援或尚未生成回 null
471
+ * @returns {Promise<string|null>}
472
+ */
473
+ async function getPersona() {
474
+ if (typeof activeStore.readPersona !== 'function') return null
475
+ return activeStore.readPersona()
476
+ }
477
+
390
478
  async function getContext(prompt) {
391
479
  try {
392
480
  let wikiFiles = await activeStore.list();
@@ -445,7 +533,7 @@ function createWikiRouter(config) {
445
533
  return ''
446
534
  }
447
535
 
448
- return { build, getContext }
536
+ return { build, getContext, buildPersona, getPersona }
449
537
  }
450
538
 
451
539
  /**
@@ -453,22 +541,24 @@ function createWikiRouter(config) {
453
541
  *
454
542
  * 用法:
455
543
  * import { sqliteStore } from '@jungtz/wiki-router'
456
- * const store = sqliteStore({ db, tenantId: 'hotel-001' })
544
+ * const store = sqliteStore({ db, tenantId: 'tenant-001' })
457
545
  *
458
546
  * 不直接相依 better-sqlite3;只要傳入的 db 物件支援 prepare(sql) 與 exec(sql) 即可。
459
547
  */
460
548
 
461
549
  const DEFAULT_FILES_TABLE = 'wiki_files';
462
550
  const DEFAULT_MANIFESTS_TABLE = 'wiki_manifests';
551
+ const DEFAULT_PERSONAS_TABLE = 'wiki_personas';
463
552
 
464
553
  /**
465
- * 建立 wiki_files / wiki_manifests 兩個表(CREATE TABLE IF NOT EXISTS,可重複呼叫)
554
+ * 建立 wiki_files / wiki_manifests / wiki_personas 三個表(CREATE TABLE IF NOT EXISTS,可重複呼叫)
466
555
  * @param {*} db - better-sqlite3 相容實例
467
- * @param {{ filesTable?: string, manifestsTable?: string }} [opts]
556
+ * @param {{ filesTable?: string, manifestsTable?: string, personasTable?: string }} [opts]
468
557
  */
469
558
  function ensureWikiTables(db, opts = {}) {
470
559
  const filesTable = opts.filesTable || DEFAULT_FILES_TABLE;
471
560
  const manifestsTable = opts.manifestsTable || DEFAULT_MANIFESTS_TABLE;
561
+ const personasTable = opts.personasTable || DEFAULT_PERSONAS_TABLE;
472
562
  db.exec(`
473
563
  CREATE TABLE IF NOT EXISTS ${filesTable} (
474
564
  tenant_id TEXT NOT NULL,
@@ -482,12 +572,17 @@ function ensureWikiTables(db, opts = {}) {
482
572
  manifest TEXT NOT NULL,
483
573
  updated_at TEXT NOT NULL
484
574
  );
575
+ CREATE TABLE IF NOT EXISTS ${personasTable} (
576
+ tenant_id TEXT PRIMARY KEY,
577
+ persona TEXT NOT NULL,
578
+ updated_at TEXT NOT NULL
579
+ );
485
580
  `);
486
581
  }
487
582
 
488
583
  /**
489
584
  * 建立 SQLite-backed Store adapter
490
- * @param {{ db: *, tenantId: string, filesTable?: string, manifestsTable?: string }} config
585
+ * @param {{ db: *, tenantId: string, filesTable?: string, manifestsTable?: string, personasTable?: string }} config
491
586
  * @returns {import('../types').Store}
492
587
  */
493
588
  function sqliteStore(config) {
@@ -500,8 +595,9 @@ function sqliteStore(config) {
500
595
  const { db, tenantId } = config;
501
596
  const filesTable = config.filesTable || DEFAULT_FILES_TABLE;
502
597
  const manifestsTable = config.manifestsTable || DEFAULT_MANIFESTS_TABLE;
598
+ const personasTable = config.personasTable || DEFAULT_PERSONAS_TABLE;
503
599
 
504
- ensureWikiTables(db, { filesTable, manifestsTable });
600
+ ensureWikiTables(db, { filesTable, manifestsTable, personasTable });
505
601
 
506
602
  const stmts = {
507
603
  list: db.prepare(`SELECT filename FROM ${filesTable} WHERE tenant_id = ? ORDER BY filename`),
@@ -517,6 +613,12 @@ function sqliteStore(config) {
517
613
  VALUES (?, ?, ?)
518
614
  ON CONFLICT(tenant_id) DO UPDATE SET manifest = excluded.manifest, updated_at = excluded.updated_at`
519
615
  ),
616
+ readPersona: db.prepare(`SELECT persona FROM ${personasTable} WHERE tenant_id = ?`),
617
+ writePersona: db.prepare(
618
+ `INSERT INTO ${personasTable} (tenant_id, persona, updated_at)
619
+ VALUES (?, ?, ?)
620
+ ON CONFLICT(tenant_id) DO UPDATE SET persona = excluded.persona, updated_at = excluded.updated_at`
621
+ ),
520
622
  };
521
623
 
522
624
  return {
@@ -542,6 +644,13 @@ function sqliteStore(config) {
542
644
  async writeManifest(manifest) {
543
645
  stmts.writeManifest.run(tenantId, JSON.stringify(manifest), new Date().toISOString());
544
646
  },
647
+ async readPersona() {
648
+ const row = stmts.readPersona.get(tenantId);
649
+ return row ? row.persona : null
650
+ },
651
+ async writePersona(persona) {
652
+ stmts.writePersona.run(tenantId, persona, new Date().toISOString());
653
+ },
545
654
  }
546
655
  }
547
656
 
@@ -705,10 +814,20 @@ function createTenantManager(config) {
705
814
  return getWiki(tenantId).wiki.getContext(prompt)
706
815
  }
707
816
 
817
+ async function getPersona(tenantId) {
818
+ return getWiki(tenantId).wiki.getPersona()
819
+ }
820
+
821
+ async function buildPersona(tenantId, options = {}) {
822
+ return getWiki(tenantId).wiki.buildPersona(options)
823
+ }
824
+
708
825
  return {
709
826
  build,
710
827
  buildAll,
711
828
  getContext,
829
+ getPersona,
830
+ buildPersona,
712
831
  isBuilt: tenantId => builtTenants.has(tenantId),
713
832
  listBuilt: () => Array.from(builtTenants),
714
833
  }
@@ -728,6 +847,7 @@ function createTenantManager(config) {
728
847
  */
729
848
 
730
849
  exports.MERGE_PROMPT = MERGE_PROMPT;
850
+ exports.PERSONA_PROMPT = PERSONA_PROMPT;
731
851
  exports.ROUTER_PROMPT = ROUTER_PROMPT;
732
852
  exports.SPLIT_PROMPT = SPLIT_PROMPT;
733
853
  exports.createTenantManager = createTenantManager;
package/dist/index.d.ts CHANGED
@@ -17,11 +17,13 @@
17
17
 
18
18
  /**
19
19
  * @typedef {Object} Store
20
- * @property {() => Promise<string[]>} list - 列出已生成的 wiki 檔名(含副檔名)
20
+ * @property {() => Promise<string[]>} list - 列出已生成的 wiki 檔名(含副檔名);應排除非路由用的衍生檔(例如以底線開頭者)
21
21
  * @property {(filename: string) => Promise<string|null>} read - 讀取 wiki 檔;不存在回 null
22
22
  * @property {(filename: string, content: string) => Promise<void>} write - 寫入 wiki 檔
23
23
  * @property {() => Promise<Record<string, string>|null>} [readManifest] - 選用;讀取上次 build 的來源指紋;不存在回 null
24
24
  * @property {(manifest: Record<string, string>) => Promise<void>} [writeManifest] - 選用;寫入本次 build 的來源指紋
25
+ * @property {() => Promise<string|null>} [readPersona] - 選用;讀取自動產生的角色設定;不存在回 null
26
+ * @property {(persona: string) => Promise<void>} [writePersona] - 選用;寫入自動產生的角色設定
25
27
  */
26
28
 
27
29
  /**
@@ -37,17 +39,22 @@
37
39
  * @property {string} [splitPrompt] - 自訂 Split prompt 字串,未提供時使用內建預設
38
40
  * @property {string} [mergePrompt] - 自訂 Merge prompt 字串,未提供時使用內建預設
39
41
  * @property {string} [routerPrompt] - 自訂 Router prompt 字串,未提供時使用內建預設
42
+ * @property {string} [personaPrompt] - 自訂 Persona prompt 字串,未提供時使用內建預設
43
+ * @property {number} [personaSampleSize=4000] - 產 persona 時,從非 Index 檔抽取的內容總長度上限(字元)
40
44
  */
41
45
 
42
46
  /**
43
47
  * @typedef {Object} BuildOptions
44
48
  * @property {boolean} [force=false] - 為 true 時跳過 fingerprint 比對,無條件重建
49
+ * @property {boolean} [skipPersona=false] - 為 true 時 build 完不順帶產 persona
45
50
  */
46
51
 
47
52
  /**
48
53
  * @typedef {Object} WikiRouterInstance
49
54
  * @property {(options?: BuildOptions) => Promise<boolean>} build - 建構或更新 Wiki 知識庫
50
55
  * @property {(prompt: string) => Promise<string>} getContext - 根據問題取得相關 Wiki 上下文
56
+ * @property {(options?: { force?: boolean }) => Promise<boolean>} buildPersona - 依 Index.md + 內容樣本產出角色設定,寫入 store.writePersona()
57
+ * @property {() => Promise<string|null>} getPersona - 取得目前儲存的角色設定;store 不支援或尚未生成則回 null
51
58
  */
52
59
 
53
60
  /**
@@ -62,6 +69,7 @@
62
69
  * @property {string} tenantId - 多租戶識別字串
63
70
  * @property {string} [filesTable='wiki_files'] - 自訂 files 表名
64
71
  * @property {string} [manifestsTable='wiki_manifests'] - 自訂 manifests 表名
72
+ * @property {string} [personasTable='wiki_personas'] - 自訂 personas 表名
65
73
  */
66
74
 
67
75
  /**
@@ -79,6 +87,8 @@
79
87
  * @property {string} [splitPrompt] - 同 WikiRouterConfig.splitPrompt
80
88
  * @property {string} [mergePrompt] - 同 WikiRouterConfig.mergePrompt
81
89
  * @property {string} [routerPrompt] - 同 WikiRouterConfig.routerPrompt
90
+ * @property {string} [personaPrompt] - 同 WikiRouterConfig.personaPrompt
91
+ * @property {number} [personaSampleSize] - 同 WikiRouterConfig.personaSampleSize
82
92
  */
83
93
 
84
94
  /**
@@ -86,6 +96,8 @@
86
96
  * @property {(tenantId: string, options?: BuildOptions) => Promise<boolean>} build - 為單一租戶建構 wiki
87
97
  * @property {() => Promise<PromiseSettledResult<boolean>[]>} buildAll - 並行建構所有租戶(需設定 listTenants)
88
98
  * @property {(prompt: string, tenantId: string) => Promise<string>} getContext - 取得指定租戶的 wiki 上下文
99
+ * @property {(tenantId: string) => Promise<string|null>} getPersona - 取得指定租戶的 persona
100
+ * @property {(tenantId: string, options?: { force?: boolean }) => Promise<boolean>} buildPersona - 為指定租戶單獨重建 persona
89
101
  * @property {(tenantId: string) => boolean} isBuilt - 該租戶是否已成功 build 過
90
102
  * @property {() => string[]} listBuilt - 已成功 build 過的租戶清單
91
103
  */
package/dist/index.mjs CHANGED
@@ -22,6 +22,7 @@ function readPrompt(filename) {
22
22
  const SPLIT_PROMPT = "# 角色:專業文件架構師 (Documentation Architect)\n\n## 任務目標\n將提供的 JSON 資料完整解析並轉換為多個獨立的 Markdown `.md` 檔案。\nJSON 可能來自 CMS、API 回應、設定檔或任何結構化資料來源。\n\n## 原始 JSON 資料\n```json\n{{context}}\n```\n\n## 處理規則:\n\n### 1. 結構識別(自動語義分析)\n- 分析 JSON 的頂層結構(object / array / 巢狀)\n- 若為巢狀物件,以**第一層 key** 作為主題分群依據(如 `data.rooms` → 房間主題、`data.location` → 地點主題)\n- 若為頂層陣列,每個元素視為獨立條目,依內容主題分組\n\n### 2. 完整性強制規則(最高優先)\n- **逐項清點**:陣列型資料必須逐一列出所有項目,先數原始 JSON 中有幾筆,輸出後再數確認數量一致\n- **巢狀陣列全收**:若某欄位本身是陣列(如設施清單、標籤列表),必須列出該陣列中的**每一項**\n- **不因長度省略**:資料再多也不能用「等」、「...」、「其他」概括\n\n### 3. 資訊提取(語義識別)\n- **標題欄位**:自動識別代表名稱/標題的欄位(`title`、`name`、`label`、`subject`)\n- **內文欄位**:自動識別長文字欄位(`content`、`description`、`body`、`intro`、`text`)\n- **時間欄位**:自動識別時間相關欄位(`time`、`date`、`checkin`、`checkout`、`created_at`、`published_at`)\n- **數值欄位**:自動識別價格/數量欄位(`price`、`qty`、`user`、`capacity`、`size`)\n- **布林欄位**:自動識別開關欄位(`enable`、`display`、`is_*`),轉為「啟用/停用」\n- **巢狀物件**:遞迴展開,保留層級結構(如 `social.{instagram_url, instagram_display}`)\n- **多語言欄位**:若欄位為 `{zh_tw, en, ja, ...}` 結構,以 `zh_tw` 為主要輸出語言,不存在時依序 fallback\n\n### 4. 格式還原\n- 若內容為 JSON 字串(Quill Delta 等 RTF 格式),解析後還原為純文字\n- 轉義換行 `\\n` → 真實換行、`\\t` → 縮排\n- 內嵌 HTML(`<a href>`, `<img>`, `<br>`)還原為對應 Markdown 語法\n- 內嵌 URL 保留為可點擊連結\n\n### 5. 檔案拆分原則\n- 依照 JSON 的**第一層 key** 或**語義主題**拆分成獨立 `.md` 檔案\n- 每個檔案涵蓋一個獨立主題區塊\n- 檔名使用英文,反映主題(如 `Rooms.md`、`Policies.md`、`Facilities.md`)\n\n### 6. 索引生成 (Index.md)\n- 彙整所有產出的檔案,生成 `Index.md`\n- 格式:`- [顯示名稱](檔名.md):一句話摘要涵蓋的關鍵主題與具體資訊`\n- 摘要必須具體,禁止只重複檔名\n\n### 7. 輸出約束\n- **輸出順序**:先 `Index.md`,再依序輸出其他檔案\n- **必須標示檔名**:每個程式碼區塊正上方必須有 `### 檔名.md` 標題,不可省略\n- 每個檔案以獨立 ` ```markdown ` 程式碼區塊呈現\n- 所有 JSON 內容一律轉為人類可讀的 Markdown,不可保留原始 JSON 字串\n\n## 輸出格式規範:\n\n### Index.md\n```markdown\n# 知識庫目錄\n- [顯示名稱 A](A.md):涵蓋的關鍵主題與具體資訊摘要\n- [顯示名稱 B](B.md):涵蓋的關鍵主題與具體資訊摘要\n```\n\n---\n\n### 檔名.md\n```markdown\n[還原後的 Markdown 內容]\n```\n\n---\n\n(重複上述格式直到所有檔案輸出完畢)\n";
23
23
  const MERGE_PROMPT = "# 角色:知識庫合併架構師 (Knowledge Merge Architect)\n\n## 任務目標\n將新的內容合併到既有的知識庫檔案結構中,根據內容類型與主題智能決定「新增頁面」、「合併到既有頁面」或「更新既有頁面」。\n\n## 既有知識庫目錄 (Index.md)\n```markdown\n{{indexContent}}\n```\n\n## 新內容類型:{{fileType}}\n## 新內容\n```{{contentBlock}}\n{{newContent}}\n```\n\n## 處理規則:\n\n### 1. 結構識別(依 fileType)\n\n**json**:\n- 分析 JSON 頂層結構,以第一層 key 或語義主題分群\n- 若為巢狀物件,每個第一層 key 視為一個主題區塊\n- 若為頂層陣列,每個元素視為獨立條目\n\n**markdown**:\n- 依照 `##` 或 `###` 標題拆分成獨立區塊\n- 每個區塊視為一個新知識條目\n\n### 2. 合併策略判斷\n\n對每個條目,檢查是否與既有 Index.md 中的頁面相關:\n\n- **主題全新**(目錄中無相關頁面)→ 建立**新檔案**,Index.md 追加新條目\n- **主題已存在**(標題或關鍵詞與既有頁面重疊)→ 將新內容**合併**到既有檔案,保留既有結構,新增內容作為補充。合併後輸出該檔案的**完整內容**\n- **既有檔案缺少條目**(例如列表型資料漏了某筆)→ 必須**補齊**,將缺漏條目加入對應區塊\n- **內容衝突**(同一主題但資訊不一致,如價格變動、規則修改)→ 以**新內容為準**更新,標記 `> 更新於 YYYY-MM-DD`\n- **內容重複**(完全相同)→ 跳過,不輸出\n\n### 3. 完整性驗證\n- 陣列型資料:比對新內容的項目數與既有檔案收錄數,缺漏必須補齊\n- 巢狀陣列(如設施清單、標籤列表):逐一檢查是否全數收錄\n- 多語言欄位:以 `zh_tw` 為主要輸出語言,不存在時 fallback 到 `en`\n- JSON 字串內容(Quill Delta 等):`\\n` → 換行、`\\t` → 縮排、HTML → Markdown\n\n### 4. 輸出約束\n- **輸出順序**:先輸出更新後的 `Index.md`,再依序輸出所有新增或更新過的檔案\n- 每個檔案前必須有 `### 檔名.md` 作為標題,絕對不可省略\n- 每個檔案以獨立 ` ```markdown ` 程式碼區塊呈現\n- **只輸出有變動的檔案**,未修改的既有檔案不需重複輸出\n- 合併的檔案必須包含**完整內容**(既有 + 新增),不可只輸出差異\n\n## 輸出格式規範:\n\n### Index.md\n```markdown\n# 知識庫目錄\n- [顯示名稱 A](A.md):一句話摘要涵蓋的關鍵主題\n- [顯示名稱 B](B.md):一句話摘要涵蓋的關鍵主題\n```\n\n---\n\n### 檔名.md\n```markdown\n[完整內容]\n```\n\n---\n\n(重複上述格式直到所有變動檔案輸出完畢)\n";
24
24
  const ROUTER_PROMPT = "# 角色設定\n你是一個精準的「知識庫路由助手 (Router)」。\n你的唯一任務是分析使用者的問題,並從提供的目錄清單中,挑選出最可能包含解答的檔案名稱。\n\n# 使用者問題\n{{prompt}}\n\n# 知識庫目錄\n{{indexContent}}\n\n# 挑選原則\n- 從目錄中每個檔案的**摘要**判斷相關性,不只比對檔名\n- 考慮同義詞與相關概念(例如:「運動」「健身」→ Facilities.md;「怎麼去」「交通」→ Location_and_Transport.md)\n- 若問題涉及多個主題(如「房價和退房時間」),選取所有相關檔案\n\n# 輸出嚴格規範\n1. **僅輸出檔名**:從「知識庫目錄」中挑選最相關的完整檔案名稱(必須包含 `.md` 後綴)。\n2. **多檔處理**:若有多個檔案相關,請以半形逗號 `,` 分隔。\n3. **無相關時**:若判斷目錄中的檔案皆不相關,請直接回覆 `NONE`。\n4. **禁止任何廢話**:**絕對禁止**輸出任何解釋文字、問候語、符號,也**禁止**使用 Markdown 標記(如代碼塊 ` ``` ` 或清單 `-`)。\n\n**正確輸出範例:**\nIntroduction.md,Policy_Info.md\n";
25
+ const PERSONA_PROMPT = "你正在依據以下知識庫,為這個業務單位產出一段給 AI 模型使用的「智慧管家/服務窗口」角色設定。\n\n【輸出規則】\n- 120 字以內\n- 使用第二人稱開頭:「你是 …」\n- **必須**使用知識庫中實際出現的單位名稱(含中英文/品牌名/所在地),不得改寫、不得概括成「店家」「飯店」「公司」等通稱\n- 必須點出業種與服務範圍(依資料推斷,例如:精品民宿、商務飯店、餐廳、停車場、診所…)\n- 必須描述語氣(專業、親切、簡明等),可參考品牌調性\n- **嚴禁**編造未在資料中出現的細節\n- **嚴禁**寫成介紹文、宣傳文,必須是給 AI 模型看的角色指令\n- 直接輸出純文字角色描述,**不要**任何前後綴、不要 markdown、不要程式碼框\n\n【知識庫目錄】\n{{indexContent}}\n\n【主要內容摘要】\n{{contentSample}}\n\n【角色設定】\n";
25
26
 
26
27
  /**
27
28
  * LLM 輸出解析器
@@ -93,10 +94,14 @@ function parseWikiOutput(output) {
93
94
  * createWikiRouter({ router, source: fsSource('./knowledge'), store: fsStore('./wiki') })
94
95
  *
95
96
  * 也可直接傳 knowledgeDir / outputDir 給 createWikiRouter,內部會自動包成這兩個 adapter。
97
+ *
98
+ * 註:fsStore.list() 會排除以底線開頭的檔案(如 `_persona.md`),
99
+ * 因為這類檔案被視為「非路由用」的衍生資料(例如自動產生的角色設定)。
96
100
  */
97
101
 
98
102
 
99
103
  const MANIFEST_FILE = '.manifest.json';
104
+ const PERSONA_FILE = '_persona.md';
100
105
 
101
106
  /**
102
107
  * 建立讀取本地檔案的 Source adapter
@@ -146,7 +151,8 @@ function fsStore(dir) {
146
151
  return {
147
152
  async list() {
148
153
  ensure();
149
- return fs.readdirSync(resolved).filter(f => f.endsWith('.md'))
154
+ // 排除以底線開頭的檔案(例如 _persona.md),避免污染路由與 Index 自動生成
155
+ return fs.readdirSync(resolved).filter(f => f.endsWith('.md') && !f.startsWith('_'))
150
156
  },
151
157
  async read(filename) {
152
158
  const filePath = path.join(resolved, filename);
@@ -174,6 +180,15 @@ function fsStore(dir) {
174
180
  'utf-8'
175
181
  );
176
182
  },
183
+ async readPersona() {
184
+ const filePath = path.join(resolved, PERSONA_FILE);
185
+ if (!fs.existsSync(filePath)) return null
186
+ return fs.readFileSync(filePath, 'utf-8')
187
+ },
188
+ async writePersona(persona) {
189
+ ensure();
190
+ fs.writeFileSync(path.join(resolved, PERSONA_FILE), persona, 'utf-8');
191
+ },
177
192
  }
178
193
  }
179
194
 
@@ -204,11 +219,14 @@ function createWikiRouter(config) {
204
219
  splitPrompt,
205
220
  mergePrompt,
206
221
  routerPrompt,
222
+ personaPrompt,
223
+ personaSampleSize = 4000,
207
224
  } = config;
208
225
 
209
226
  const activeSplitPrompt = splitPrompt || SPLIT_PROMPT;
210
227
  const activeMergePrompt = mergePrompt || MERGE_PROMPT;
211
228
  const activeRouterPrompt = routerPrompt || ROUTER_PROMPT;
229
+ const activePersonaPrompt = personaPrompt || PERSONA_PROMPT;
212
230
 
213
231
  if (!router) throw new Error('[wiki-router] router is required')
214
232
 
@@ -378,6 +396,15 @@ function createWikiRouter(config) {
378
396
  }
379
397
 
380
398
  console.log(`[Wiki] Build complete — ${totalFiles} file(s), ${formatElapsed(buildStart)}`);
399
+
400
+ if (!options.skipPersona && typeof activeStore.writePersona === 'function') {
401
+ try {
402
+ await buildPersona({ force: !!force });
403
+ } catch (err) {
404
+ console.warn('[Wiki] Persona build failed (non-fatal):', err.message);
405
+ }
406
+ }
407
+
381
408
  return true
382
409
  } catch (err) {
383
410
  console.error(`[Wiki Generation Error] (after ${formatElapsed(buildStart)})`, err);
@@ -385,6 +412,67 @@ function createWikiRouter(config) {
385
412
  }
386
413
  }
387
414
 
415
+ /**
416
+ * 依據 Index.md + 其他 .md 樣本,呼叫 LLM 產出一段角色設定,寫入 store.writePersona()
417
+ * 必須 store 同時實作 readPersona/writePersona 才會有效;否則 noop。
418
+ * @param {{ force?: boolean }} [options]
419
+ * @returns {Promise<boolean>}
420
+ */
421
+ async function buildPersona(options = {}) {
422
+ const { force = false } = options;
423
+ if (typeof activeStore.writePersona !== 'function') {
424
+ return false
425
+ }
426
+
427
+ if (!force && typeof activeStore.readPersona === 'function') {
428
+ const existing = await activeStore.readPersona();
429
+ if (existing && existing.trim().length > 0) return true
430
+ }
431
+
432
+ const indexContent = await activeStore.read('Index.md');
433
+ if (!indexContent) {
434
+ console.warn('[Wiki] No Index.md, skip persona generation');
435
+ return false
436
+ }
437
+
438
+ const allFiles = await activeStore.list();
439
+ const samples = [];
440
+ let totalLen = 0;
441
+ for (const f of allFiles) {
442
+ if (!f.endsWith('.md') || f === 'Index.md' || f.startsWith('_')) continue
443
+ if (totalLen >= personaSampleSize) break
444
+ const c = await activeStore.read(f);
445
+ if (!c) continue
446
+ const remaining = personaSampleSize - totalLen;
447
+ const slice = c.slice(0, remaining);
448
+ samples.push(`### ${f}\n${slice}`);
449
+ totalLen += slice.length;
450
+ }
451
+
452
+ const prompt = activePersonaPrompt
453
+ .replace('{{indexContent}}', indexContent)
454
+ .replace('{{contentSample}}', samples.join('\n\n') || '(無其他內容)');
455
+
456
+ const persona = await chat([{ role: 'user', content: prompt }]);
457
+ if (!persona || persona.trim().length < 10) {
458
+ console.warn('[Wiki] Persona LLM returned empty/too-short, skip write');
459
+ return false
460
+ }
461
+
462
+ await activeStore.writePersona(persona.trim());
463
+ console.log(`[Wiki] Persona generated (${persona.trim().length} chars)`);
464
+ return true
465
+ }
466
+
467
+ /**
468
+ * 取得目前儲存的 persona;若 store 不支援或尚未生成回 null
469
+ * @returns {Promise<string|null>}
470
+ */
471
+ async function getPersona() {
472
+ if (typeof activeStore.readPersona !== 'function') return null
473
+ return activeStore.readPersona()
474
+ }
475
+
388
476
  async function getContext(prompt) {
389
477
  try {
390
478
  let wikiFiles = await activeStore.list();
@@ -443,7 +531,7 @@ function createWikiRouter(config) {
443
531
  return ''
444
532
  }
445
533
 
446
- return { build, getContext }
534
+ return { build, getContext, buildPersona, getPersona }
447
535
  }
448
536
 
449
537
  /**
@@ -451,22 +539,24 @@ function createWikiRouter(config) {
451
539
  *
452
540
  * 用法:
453
541
  * import { sqliteStore } from '@jungtz/wiki-router'
454
- * const store = sqliteStore({ db, tenantId: 'hotel-001' })
542
+ * const store = sqliteStore({ db, tenantId: 'tenant-001' })
455
543
  *
456
544
  * 不直接相依 better-sqlite3;只要傳入的 db 物件支援 prepare(sql) 與 exec(sql) 即可。
457
545
  */
458
546
 
459
547
  const DEFAULT_FILES_TABLE = 'wiki_files';
460
548
  const DEFAULT_MANIFESTS_TABLE = 'wiki_manifests';
549
+ const DEFAULT_PERSONAS_TABLE = 'wiki_personas';
461
550
 
462
551
  /**
463
- * 建立 wiki_files / wiki_manifests 兩個表(CREATE TABLE IF NOT EXISTS,可重複呼叫)
552
+ * 建立 wiki_files / wiki_manifests / wiki_personas 三個表(CREATE TABLE IF NOT EXISTS,可重複呼叫)
464
553
  * @param {*} db - better-sqlite3 相容實例
465
- * @param {{ filesTable?: string, manifestsTable?: string }} [opts]
554
+ * @param {{ filesTable?: string, manifestsTable?: string, personasTable?: string }} [opts]
466
555
  */
467
556
  function ensureWikiTables(db, opts = {}) {
468
557
  const filesTable = opts.filesTable || DEFAULT_FILES_TABLE;
469
558
  const manifestsTable = opts.manifestsTable || DEFAULT_MANIFESTS_TABLE;
559
+ const personasTable = opts.personasTable || DEFAULT_PERSONAS_TABLE;
470
560
  db.exec(`
471
561
  CREATE TABLE IF NOT EXISTS ${filesTable} (
472
562
  tenant_id TEXT NOT NULL,
@@ -480,12 +570,17 @@ function ensureWikiTables(db, opts = {}) {
480
570
  manifest TEXT NOT NULL,
481
571
  updated_at TEXT NOT NULL
482
572
  );
573
+ CREATE TABLE IF NOT EXISTS ${personasTable} (
574
+ tenant_id TEXT PRIMARY KEY,
575
+ persona TEXT NOT NULL,
576
+ updated_at TEXT NOT NULL
577
+ );
483
578
  `);
484
579
  }
485
580
 
486
581
  /**
487
582
  * 建立 SQLite-backed Store adapter
488
- * @param {{ db: *, tenantId: string, filesTable?: string, manifestsTable?: string }} config
583
+ * @param {{ db: *, tenantId: string, filesTable?: string, manifestsTable?: string, personasTable?: string }} config
489
584
  * @returns {import('../types').Store}
490
585
  */
491
586
  function sqliteStore(config) {
@@ -498,8 +593,9 @@ function sqliteStore(config) {
498
593
  const { db, tenantId } = config;
499
594
  const filesTable = config.filesTable || DEFAULT_FILES_TABLE;
500
595
  const manifestsTable = config.manifestsTable || DEFAULT_MANIFESTS_TABLE;
596
+ const personasTable = config.personasTable || DEFAULT_PERSONAS_TABLE;
501
597
 
502
- ensureWikiTables(db, { filesTable, manifestsTable });
598
+ ensureWikiTables(db, { filesTable, manifestsTable, personasTable });
503
599
 
504
600
  const stmts = {
505
601
  list: db.prepare(`SELECT filename FROM ${filesTable} WHERE tenant_id = ? ORDER BY filename`),
@@ -515,6 +611,12 @@ function sqliteStore(config) {
515
611
  VALUES (?, ?, ?)
516
612
  ON CONFLICT(tenant_id) DO UPDATE SET manifest = excluded.manifest, updated_at = excluded.updated_at`
517
613
  ),
614
+ readPersona: db.prepare(`SELECT persona FROM ${personasTable} WHERE tenant_id = ?`),
615
+ writePersona: db.prepare(
616
+ `INSERT INTO ${personasTable} (tenant_id, persona, updated_at)
617
+ VALUES (?, ?, ?)
618
+ ON CONFLICT(tenant_id) DO UPDATE SET persona = excluded.persona, updated_at = excluded.updated_at`
619
+ ),
518
620
  };
519
621
 
520
622
  return {
@@ -540,6 +642,13 @@ function sqliteStore(config) {
540
642
  async writeManifest(manifest) {
541
643
  stmts.writeManifest.run(tenantId, JSON.stringify(manifest), new Date().toISOString());
542
644
  },
645
+ async readPersona() {
646
+ const row = stmts.readPersona.get(tenantId);
647
+ return row ? row.persona : null
648
+ },
649
+ async writePersona(persona) {
650
+ stmts.writePersona.run(tenantId, persona, new Date().toISOString());
651
+ },
543
652
  }
544
653
  }
545
654
 
@@ -703,10 +812,20 @@ function createTenantManager(config) {
703
812
  return getWiki(tenantId).wiki.getContext(prompt)
704
813
  }
705
814
 
815
+ async function getPersona(tenantId) {
816
+ return getWiki(tenantId).wiki.getPersona()
817
+ }
818
+
819
+ async function buildPersona(tenantId, options = {}) {
820
+ return getWiki(tenantId).wiki.buildPersona(options)
821
+ }
822
+
706
823
  return {
707
824
  build,
708
825
  buildAll,
709
826
  getContext,
827
+ getPersona,
828
+ buildPersona,
710
829
  isBuilt: tenantId => builtTenants.has(tenantId),
711
830
  listBuilt: () => Array.from(builtTenants),
712
831
  }
@@ -725,4 +844,4 @@ function createTenantManager(config) {
725
844
  * const manager = createTenantManager({ router, source, store, listTenants })
726
845
  */
727
846
 
728
- export { MERGE_PROMPT, ROUTER_PROMPT, SPLIT_PROMPT, createTenantManager, createWikiRouter, ensureWikiTables, fsSource, fsStore, parseWikiOutput, sqliteStore };
847
+ export { MERGE_PROMPT, PERSONA_PROMPT, ROUTER_PROMPT, SPLIT_PROMPT, createTenantManager, createWikiRouter, ensureWikiTables, fsSource, fsStore, parseWikiOutput, sqliteStore };
package/dist/prompts.cjs CHANGED
@@ -23,7 +23,9 @@ function readPrompt(filename) {
23
23
  const SPLIT_PROMPT = "# 角色:專業文件架構師 (Documentation Architect)\n\n## 任務目標\n將提供的 JSON 資料完整解析並轉換為多個獨立的 Markdown `.md` 檔案。\nJSON 可能來自 CMS、API 回應、設定檔或任何結構化資料來源。\n\n## 原始 JSON 資料\n```json\n{{context}}\n```\n\n## 處理規則:\n\n### 1. 結構識別(自動語義分析)\n- 分析 JSON 的頂層結構(object / array / 巢狀)\n- 若為巢狀物件,以**第一層 key** 作為主題分群依據(如 `data.rooms` → 房間主題、`data.location` → 地點主題)\n- 若為頂層陣列,每個元素視為獨立條目,依內容主題分組\n\n### 2. 完整性強制規則(最高優先)\n- **逐項清點**:陣列型資料必須逐一列出所有項目,先數原始 JSON 中有幾筆,輸出後再數確認數量一致\n- **巢狀陣列全收**:若某欄位本身是陣列(如設施清單、標籤列表),必須列出該陣列中的**每一項**\n- **不因長度省略**:資料再多也不能用「等」、「...」、「其他」概括\n\n### 3. 資訊提取(語義識別)\n- **標題欄位**:自動識別代表名稱/標題的欄位(`title`、`name`、`label`、`subject`)\n- **內文欄位**:自動識別長文字欄位(`content`、`description`、`body`、`intro`、`text`)\n- **時間欄位**:自動識別時間相關欄位(`time`、`date`、`checkin`、`checkout`、`created_at`、`published_at`)\n- **數值欄位**:自動識別價格/數量欄位(`price`、`qty`、`user`、`capacity`、`size`)\n- **布林欄位**:自動識別開關欄位(`enable`、`display`、`is_*`),轉為「啟用/停用」\n- **巢狀物件**:遞迴展開,保留層級結構(如 `social.{instagram_url, instagram_display}`)\n- **多語言欄位**:若欄位為 `{zh_tw, en, ja, ...}` 結構,以 `zh_tw` 為主要輸出語言,不存在時依序 fallback\n\n### 4. 格式還原\n- 若內容為 JSON 字串(Quill Delta 等 RTF 格式),解析後還原為純文字\n- 轉義換行 `\\n` → 真實換行、`\\t` → 縮排\n- 內嵌 HTML(`<a href>`, `<img>`, `<br>`)還原為對應 Markdown 語法\n- 內嵌 URL 保留為可點擊連結\n\n### 5. 檔案拆分原則\n- 依照 JSON 的**第一層 key** 或**語義主題**拆分成獨立 `.md` 檔案\n- 每個檔案涵蓋一個獨立主題區塊\n- 檔名使用英文,反映主題(如 `Rooms.md`、`Policies.md`、`Facilities.md`)\n\n### 6. 索引生成 (Index.md)\n- 彙整所有產出的檔案,生成 `Index.md`\n- 格式:`- [顯示名稱](檔名.md):一句話摘要涵蓋的關鍵主題與具體資訊`\n- 摘要必須具體,禁止只重複檔名\n\n### 7. 輸出約束\n- **輸出順序**:先 `Index.md`,再依序輸出其他檔案\n- **必須標示檔名**:每個程式碼區塊正上方必須有 `### 檔名.md` 標題,不可省略\n- 每個檔案以獨立 ` ```markdown ` 程式碼區塊呈現\n- 所有 JSON 內容一律轉為人類可讀的 Markdown,不可保留原始 JSON 字串\n\n## 輸出格式規範:\n\n### Index.md\n```markdown\n# 知識庫目錄\n- [顯示名稱 A](A.md):涵蓋的關鍵主題與具體資訊摘要\n- [顯示名稱 B](B.md):涵蓋的關鍵主題與具體資訊摘要\n```\n\n---\n\n### 檔名.md\n```markdown\n[還原後的 Markdown 內容]\n```\n\n---\n\n(重複上述格式直到所有檔案輸出完畢)\n";
24
24
  const MERGE_PROMPT = "# 角色:知識庫合併架構師 (Knowledge Merge Architect)\n\n## 任務目標\n將新的內容合併到既有的知識庫檔案結構中,根據內容類型與主題智能決定「新增頁面」、「合併到既有頁面」或「更新既有頁面」。\n\n## 既有知識庫目錄 (Index.md)\n```markdown\n{{indexContent}}\n```\n\n## 新內容類型:{{fileType}}\n## 新內容\n```{{contentBlock}}\n{{newContent}}\n```\n\n## 處理規則:\n\n### 1. 結構識別(依 fileType)\n\n**json**:\n- 分析 JSON 頂層結構,以第一層 key 或語義主題分群\n- 若為巢狀物件,每個第一層 key 視為一個主題區塊\n- 若為頂層陣列,每個元素視為獨立條目\n\n**markdown**:\n- 依照 `##` 或 `###` 標題拆分成獨立區塊\n- 每個區塊視為一個新知識條目\n\n### 2. 合併策略判斷\n\n對每個條目,檢查是否與既有 Index.md 中的頁面相關:\n\n- **主題全新**(目錄中無相關頁面)→ 建立**新檔案**,Index.md 追加新條目\n- **主題已存在**(標題或關鍵詞與既有頁面重疊)→ 將新內容**合併**到既有檔案,保留既有結構,新增內容作為補充。合併後輸出該檔案的**完整內容**\n- **既有檔案缺少條目**(例如列表型資料漏了某筆)→ 必須**補齊**,將缺漏條目加入對應區塊\n- **內容衝突**(同一主題但資訊不一致,如價格變動、規則修改)→ 以**新內容為準**更新,標記 `> 更新於 YYYY-MM-DD`\n- **內容重複**(完全相同)→ 跳過,不輸出\n\n### 3. 完整性驗證\n- 陣列型資料:比對新內容的項目數與既有檔案收錄數,缺漏必須補齊\n- 巢狀陣列(如設施清單、標籤列表):逐一檢查是否全數收錄\n- 多語言欄位:以 `zh_tw` 為主要輸出語言,不存在時 fallback 到 `en`\n- JSON 字串內容(Quill Delta 等):`\\n` → 換行、`\\t` → 縮排、HTML → Markdown\n\n### 4. 輸出約束\n- **輸出順序**:先輸出更新後的 `Index.md`,再依序輸出所有新增或更新過的檔案\n- 每個檔案前必須有 `### 檔名.md` 作為標題,絕對不可省略\n- 每個檔案以獨立 ` ```markdown ` 程式碼區塊呈現\n- **只輸出有變動的檔案**,未修改的既有檔案不需重複輸出\n- 合併的檔案必須包含**完整內容**(既有 + 新增),不可只輸出差異\n\n## 輸出格式規範:\n\n### Index.md\n```markdown\n# 知識庫目錄\n- [顯示名稱 A](A.md):一句話摘要涵蓋的關鍵主題\n- [顯示名稱 B](B.md):一句話摘要涵蓋的關鍵主題\n```\n\n---\n\n### 檔名.md\n```markdown\n[完整內容]\n```\n\n---\n\n(重複上述格式直到所有變動檔案輸出完畢)\n";
25
25
  const ROUTER_PROMPT = "# 角色設定\n你是一個精準的「知識庫路由助手 (Router)」。\n你的唯一任務是分析使用者的問題,並從提供的目錄清單中,挑選出最可能包含解答的檔案名稱。\n\n# 使用者問題\n{{prompt}}\n\n# 知識庫目錄\n{{indexContent}}\n\n# 挑選原則\n- 從目錄中每個檔案的**摘要**判斷相關性,不只比對檔名\n- 考慮同義詞與相關概念(例如:「運動」「健身」→ Facilities.md;「怎麼去」「交通」→ Location_and_Transport.md)\n- 若問題涉及多個主題(如「房價和退房時間」),選取所有相關檔案\n\n# 輸出嚴格規範\n1. **僅輸出檔名**:從「知識庫目錄」中挑選最相關的完整檔案名稱(必須包含 `.md` 後綴)。\n2. **多檔處理**:若有多個檔案相關,請以半形逗號 `,` 分隔。\n3. **無相關時**:若判斷目錄中的檔案皆不相關,請直接回覆 `NONE`。\n4. **禁止任何廢話**:**絕對禁止**輸出任何解釋文字、問候語、符號,也**禁止**使用 Markdown 標記(如代碼塊 ` ``` ` 或清單 `-`)。\n\n**正確輸出範例:**\nIntroduction.md,Policy_Info.md\n";
26
+ const PERSONA_PROMPT = "你正在依據以下知識庫,為這個業務單位產出一段給 AI 模型使用的「智慧管家/服務窗口」角色設定。\n\n【輸出規則】\n- 120 字以內\n- 使用第二人稱開頭:「你是 …」\n- **必須**使用知識庫中實際出現的單位名稱(含中英文/品牌名/所在地),不得改寫、不得概括成「店家」「飯店」「公司」等通稱\n- 必須點出業種與服務範圍(依資料推斷,例如:精品民宿、商務飯店、餐廳、停車場、診所…)\n- 必須描述語氣(專業、親切、簡明等),可參考品牌調性\n- **嚴禁**編造未在資料中出現的細節\n- **嚴禁**寫成介紹文、宣傳文,必須是給 AI 模型看的角色指令\n- 直接輸出純文字角色描述,**不要**任何前後綴、不要 markdown、不要程式碼框\n\n【知識庫目錄】\n{{indexContent}}\n\n【主要內容摘要】\n{{contentSample}}\n\n【角色設定】\n";
26
27
 
27
28
  exports.MERGE_PROMPT = MERGE_PROMPT;
29
+ exports.PERSONA_PROMPT = PERSONA_PROMPT;
28
30
  exports.ROUTER_PROMPT = ROUTER_PROMPT;
29
31
  exports.SPLIT_PROMPT = SPLIT_PROMPT;
package/dist/prompts.mjs CHANGED
@@ -21,5 +21,6 @@ function readPrompt(filename) {
21
21
  const SPLIT_PROMPT = "# 角色:專業文件架構師 (Documentation Architect)\n\n## 任務目標\n將提供的 JSON 資料完整解析並轉換為多個獨立的 Markdown `.md` 檔案。\nJSON 可能來自 CMS、API 回應、設定檔或任何結構化資料來源。\n\n## 原始 JSON 資料\n```json\n{{context}}\n```\n\n## 處理規則:\n\n### 1. 結構識別(自動語義分析)\n- 分析 JSON 的頂層結構(object / array / 巢狀)\n- 若為巢狀物件,以**第一層 key** 作為主題分群依據(如 `data.rooms` → 房間主題、`data.location` → 地點主題)\n- 若為頂層陣列,每個元素視為獨立條目,依內容主題分組\n\n### 2. 完整性強制規則(最高優先)\n- **逐項清點**:陣列型資料必須逐一列出所有項目,先數原始 JSON 中有幾筆,輸出後再數確認數量一致\n- **巢狀陣列全收**:若某欄位本身是陣列(如設施清單、標籤列表),必須列出該陣列中的**每一項**\n- **不因長度省略**:資料再多也不能用「等」、「...」、「其他」概括\n\n### 3. 資訊提取(語義識別)\n- **標題欄位**:自動識別代表名稱/標題的欄位(`title`、`name`、`label`、`subject`)\n- **內文欄位**:自動識別長文字欄位(`content`、`description`、`body`、`intro`、`text`)\n- **時間欄位**:自動識別時間相關欄位(`time`、`date`、`checkin`、`checkout`、`created_at`、`published_at`)\n- **數值欄位**:自動識別價格/數量欄位(`price`、`qty`、`user`、`capacity`、`size`)\n- **布林欄位**:自動識別開關欄位(`enable`、`display`、`is_*`),轉為「啟用/停用」\n- **巢狀物件**:遞迴展開,保留層級結構(如 `social.{instagram_url, instagram_display}`)\n- **多語言欄位**:若欄位為 `{zh_tw, en, ja, ...}` 結構,以 `zh_tw` 為主要輸出語言,不存在時依序 fallback\n\n### 4. 格式還原\n- 若內容為 JSON 字串(Quill Delta 等 RTF 格式),解析後還原為純文字\n- 轉義換行 `\\n` → 真實換行、`\\t` → 縮排\n- 內嵌 HTML(`<a href>`, `<img>`, `<br>`)還原為對應 Markdown 語法\n- 內嵌 URL 保留為可點擊連結\n\n### 5. 檔案拆分原則\n- 依照 JSON 的**第一層 key** 或**語義主題**拆分成獨立 `.md` 檔案\n- 每個檔案涵蓋一個獨立主題區塊\n- 檔名使用英文,反映主題(如 `Rooms.md`、`Policies.md`、`Facilities.md`)\n\n### 6. 索引生成 (Index.md)\n- 彙整所有產出的檔案,生成 `Index.md`\n- 格式:`- [顯示名稱](檔名.md):一句話摘要涵蓋的關鍵主題與具體資訊`\n- 摘要必須具體,禁止只重複檔名\n\n### 7. 輸出約束\n- **輸出順序**:先 `Index.md`,再依序輸出其他檔案\n- **必須標示檔名**:每個程式碼區塊正上方必須有 `### 檔名.md` 標題,不可省略\n- 每個檔案以獨立 ` ```markdown ` 程式碼區塊呈現\n- 所有 JSON 內容一律轉為人類可讀的 Markdown,不可保留原始 JSON 字串\n\n## 輸出格式規範:\n\n### Index.md\n```markdown\n# 知識庫目錄\n- [顯示名稱 A](A.md):涵蓋的關鍵主題與具體資訊摘要\n- [顯示名稱 B](B.md):涵蓋的關鍵主題與具體資訊摘要\n```\n\n---\n\n### 檔名.md\n```markdown\n[還原後的 Markdown 內容]\n```\n\n---\n\n(重複上述格式直到所有檔案輸出完畢)\n";
22
22
  const MERGE_PROMPT = "# 角色:知識庫合併架構師 (Knowledge Merge Architect)\n\n## 任務目標\n將新的內容合併到既有的知識庫檔案結構中,根據內容類型與主題智能決定「新增頁面」、「合併到既有頁面」或「更新既有頁面」。\n\n## 既有知識庫目錄 (Index.md)\n```markdown\n{{indexContent}}\n```\n\n## 新內容類型:{{fileType}}\n## 新內容\n```{{contentBlock}}\n{{newContent}}\n```\n\n## 處理規則:\n\n### 1. 結構識別(依 fileType)\n\n**json**:\n- 分析 JSON 頂層結構,以第一層 key 或語義主題分群\n- 若為巢狀物件,每個第一層 key 視為一個主題區塊\n- 若為頂層陣列,每個元素視為獨立條目\n\n**markdown**:\n- 依照 `##` 或 `###` 標題拆分成獨立區塊\n- 每個區塊視為一個新知識條目\n\n### 2. 合併策略判斷\n\n對每個條目,檢查是否與既有 Index.md 中的頁面相關:\n\n- **主題全新**(目錄中無相關頁面)→ 建立**新檔案**,Index.md 追加新條目\n- **主題已存在**(標題或關鍵詞與既有頁面重疊)→ 將新內容**合併**到既有檔案,保留既有結構,新增內容作為補充。合併後輸出該檔案的**完整內容**\n- **既有檔案缺少條目**(例如列表型資料漏了某筆)→ 必須**補齊**,將缺漏條目加入對應區塊\n- **內容衝突**(同一主題但資訊不一致,如價格變動、規則修改)→ 以**新內容為準**更新,標記 `> 更新於 YYYY-MM-DD`\n- **內容重複**(完全相同)→ 跳過,不輸出\n\n### 3. 完整性驗證\n- 陣列型資料:比對新內容的項目數與既有檔案收錄數,缺漏必須補齊\n- 巢狀陣列(如設施清單、標籤列表):逐一檢查是否全數收錄\n- 多語言欄位:以 `zh_tw` 為主要輸出語言,不存在時 fallback 到 `en`\n- JSON 字串內容(Quill Delta 等):`\\n` → 換行、`\\t` → 縮排、HTML → Markdown\n\n### 4. 輸出約束\n- **輸出順序**:先輸出更新後的 `Index.md`,再依序輸出所有新增或更新過的檔案\n- 每個檔案前必須有 `### 檔名.md` 作為標題,絕對不可省略\n- 每個檔案以獨立 ` ```markdown ` 程式碼區塊呈現\n- **只輸出有變動的檔案**,未修改的既有檔案不需重複輸出\n- 合併的檔案必須包含**完整內容**(既有 + 新增),不可只輸出差異\n\n## 輸出格式規範:\n\n### Index.md\n```markdown\n# 知識庫目錄\n- [顯示名稱 A](A.md):一句話摘要涵蓋的關鍵主題\n- [顯示名稱 B](B.md):一句話摘要涵蓋的關鍵主題\n```\n\n---\n\n### 檔名.md\n```markdown\n[完整內容]\n```\n\n---\n\n(重複上述格式直到所有變動檔案輸出完畢)\n";
23
23
  const ROUTER_PROMPT = "# 角色設定\n你是一個精準的「知識庫路由助手 (Router)」。\n你的唯一任務是分析使用者的問題,並從提供的目錄清單中,挑選出最可能包含解答的檔案名稱。\n\n# 使用者問題\n{{prompt}}\n\n# 知識庫目錄\n{{indexContent}}\n\n# 挑選原則\n- 從目錄中每個檔案的**摘要**判斷相關性,不只比對檔名\n- 考慮同義詞與相關概念(例如:「運動」「健身」→ Facilities.md;「怎麼去」「交通」→ Location_and_Transport.md)\n- 若問題涉及多個主題(如「房價和退房時間」),選取所有相關檔案\n\n# 輸出嚴格規範\n1. **僅輸出檔名**:從「知識庫目錄」中挑選最相關的完整檔案名稱(必須包含 `.md` 後綴)。\n2. **多檔處理**:若有多個檔案相關,請以半形逗號 `,` 分隔。\n3. **無相關時**:若判斷目錄中的檔案皆不相關,請直接回覆 `NONE`。\n4. **禁止任何廢話**:**絕對禁止**輸出任何解釋文字、問候語、符號,也**禁止**使用 Markdown 標記(如代碼塊 ` ``` ` 或清單 `-`)。\n\n**正確輸出範例:**\nIntroduction.md,Policy_Info.md\n";
24
+ const PERSONA_PROMPT = "你正在依據以下知識庫,為這個業務單位產出一段給 AI 模型使用的「智慧管家/服務窗口」角色設定。\n\n【輸出規則】\n- 120 字以內\n- 使用第二人稱開頭:「你是 …」\n- **必須**使用知識庫中實際出現的單位名稱(含中英文/品牌名/所在地),不得改寫、不得概括成「店家」「飯店」「公司」等通稱\n- 必須點出業種與服務範圍(依資料推斷,例如:精品民宿、商務飯店、餐廳、停車場、診所…)\n- 必須描述語氣(專業、親切、簡明等),可參考品牌調性\n- **嚴禁**編造未在資料中出現的細節\n- **嚴禁**寫成介紹文、宣傳文,必須是給 AI 模型看的角色指令\n- 直接輸出純文字角色描述,**不要**任何前後綴、不要 markdown、不要程式碼框\n\n【知識庫目錄】\n{{indexContent}}\n\n【主要內容摘要】\n{{contentSample}}\n\n【角色設定】\n";
24
25
 
25
- export { MERGE_PROMPT, ROUTER_PROMPT, SPLIT_PROMPT };
26
+ export { MERGE_PROMPT, PERSONA_PROMPT, ROUTER_PROMPT, SPLIT_PROMPT };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jungtz/wiki-router",
3
- "version": "1.4.0",
3
+ "version": "1.5.1",
4
4
  "description": "LLM Wiki 知識庫路由引擎 - 將結構化知識轉為 Markdown 維基,智慧路由查詢",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.mjs",
@@ -39,6 +39,7 @@
39
39
  "lint:fix": "eslint src/ --fix",
40
40
  "format": "prettier --write src/",
41
41
  "typecheck": "tsc --noEmit --skipLibCheck",
42
+ "prepare": "git config core.hooksPath .githooks 2>/dev/null || true",
42
43
  "prepublishOnly": "npm run build:prod",
43
44
  "release": "npm version patch && git push origin master",
44
45
  "bump:patch": "node scripts/bump-version.js patch",
package/src/types.d.ts CHANGED
@@ -17,11 +17,13 @@
17
17
 
18
18
  /**
19
19
  * @typedef {Object} Store
20
- * @property {() => Promise<string[]>} list - 列出已生成的 wiki 檔名(含副檔名)
20
+ * @property {() => Promise<string[]>} list - 列出已生成的 wiki 檔名(含副檔名);應排除非路由用的衍生檔(例如以底線開頭者)
21
21
  * @property {(filename: string) => Promise<string|null>} read - 讀取 wiki 檔;不存在回 null
22
22
  * @property {(filename: string, content: string) => Promise<void>} write - 寫入 wiki 檔
23
23
  * @property {() => Promise<Record<string, string>|null>} [readManifest] - 選用;讀取上次 build 的來源指紋;不存在回 null
24
24
  * @property {(manifest: Record<string, string>) => Promise<void>} [writeManifest] - 選用;寫入本次 build 的來源指紋
25
+ * @property {() => Promise<string|null>} [readPersona] - 選用;讀取自動產生的角色設定;不存在回 null
26
+ * @property {(persona: string) => Promise<void>} [writePersona] - 選用;寫入自動產生的角色設定
25
27
  */
26
28
 
27
29
  /**
@@ -37,17 +39,22 @@
37
39
  * @property {string} [splitPrompt] - 自訂 Split prompt 字串,未提供時使用內建預設
38
40
  * @property {string} [mergePrompt] - 自訂 Merge prompt 字串,未提供時使用內建預設
39
41
  * @property {string} [routerPrompt] - 自訂 Router prompt 字串,未提供時使用內建預設
42
+ * @property {string} [personaPrompt] - 自訂 Persona prompt 字串,未提供時使用內建預設
43
+ * @property {number} [personaSampleSize=4000] - 產 persona 時,從非 Index 檔抽取的內容總長度上限(字元)
40
44
  */
41
45
 
42
46
  /**
43
47
  * @typedef {Object} BuildOptions
44
48
  * @property {boolean} [force=false] - 為 true 時跳過 fingerprint 比對,無條件重建
49
+ * @property {boolean} [skipPersona=false] - 為 true 時 build 完不順帶產 persona
45
50
  */
46
51
 
47
52
  /**
48
53
  * @typedef {Object} WikiRouterInstance
49
54
  * @property {(options?: BuildOptions) => Promise<boolean>} build - 建構或更新 Wiki 知識庫
50
55
  * @property {(prompt: string) => Promise<string>} getContext - 根據問題取得相關 Wiki 上下文
56
+ * @property {(options?: { force?: boolean }) => Promise<boolean>} buildPersona - 依 Index.md + 內容樣本產出角色設定,寫入 store.writePersona()
57
+ * @property {() => Promise<string|null>} getPersona - 取得目前儲存的角色設定;store 不支援或尚未生成則回 null
51
58
  */
52
59
 
53
60
  /**
@@ -62,6 +69,7 @@
62
69
  * @property {string} tenantId - 多租戶識別字串
63
70
  * @property {string} [filesTable='wiki_files'] - 自訂 files 表名
64
71
  * @property {string} [manifestsTable='wiki_manifests'] - 自訂 manifests 表名
72
+ * @property {string} [personasTable='wiki_personas'] - 自訂 personas 表名
65
73
  */
66
74
 
67
75
  /**
@@ -79,6 +87,8 @@
79
87
  * @property {string} [splitPrompt] - 同 WikiRouterConfig.splitPrompt
80
88
  * @property {string} [mergePrompt] - 同 WikiRouterConfig.mergePrompt
81
89
  * @property {string} [routerPrompt] - 同 WikiRouterConfig.routerPrompt
90
+ * @property {string} [personaPrompt] - 同 WikiRouterConfig.personaPrompt
91
+ * @property {number} [personaSampleSize] - 同 WikiRouterConfig.personaSampleSize
82
92
  */
83
93
 
84
94
  /**
@@ -86,6 +96,8 @@
86
96
  * @property {(tenantId: string, options?: BuildOptions) => Promise<boolean>} build - 為單一租戶建構 wiki
87
97
  * @property {() => Promise<PromiseSettledResult<boolean>[]>} buildAll - 並行建構所有租戶(需設定 listTenants)
88
98
  * @property {(prompt: string, tenantId: string) => Promise<string>} getContext - 取得指定租戶的 wiki 上下文
99
+ * @property {(tenantId: string) => Promise<string|null>} getPersona - 取得指定租戶的 persona
100
+ * @property {(tenantId: string, options?: { force?: boolean }) => Promise<boolean>} buildPersona - 為指定租戶單獨重建 persona
89
101
  * @property {(tenantId: string) => boolean} isBuilt - 該租戶是否已成功 build 過
90
102
  * @property {() => string[]} listBuilt - 已成功 build 過的租戶清單
91
103
  */