@jungtz/wiki-router 1.4.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 +68 -16
- package/dist/index.cjs +126 -6
- package/dist/index.d.ts +13 -1
- package/dist/index.mjs +126 -7
- package/dist/prompts.cjs +2 -0
- package/dist/prompts.mjs +2 -1
- package/package.json +1 -1
- package/src/types.d.ts +13 -1
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
|
[](https://badge.fury.io/js/@jungtz%2Fwiki-router)
|
|
6
6
|
[](https://opensource.org/licenses/MIT)
|
|
7
7
|
|
|
8
8
|
```
|
|
9
|
-
|
|
9
|
+
┌─→ getContext(prompt) → LLM 路由 → 相關 .md
|
|
10
|
+
知識來源 → build() ─┤
|
|
11
|
+
└─→ getPersona() → 自動產出的角色設定字串
|
|
10
12
|
```
|
|
11
13
|
|
|
12
14
|
## 安裝
|
|
@@ -45,7 +47,8 @@ const wiki = createWikiRouter({
|
|
|
45
47
|
;(async () => {
|
|
46
48
|
await wiki.build() // 啟動時建構(指紋未變動會自動跳過)
|
|
47
49
|
const ctx = await wiki.getContext('住宿規定?')
|
|
48
|
-
|
|
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
|
|
|
@@ -104,6 +108,7 @@ const wiki = createTenantManager({
|
|
|
104
108
|
;(async () => {
|
|
105
109
|
await wiki.buildAll() // 並行 build 所有 tenant
|
|
106
110
|
const ctx = await wiki.getContext('問題', 'hotel-001') // 依 tenantId 取上下文
|
|
111
|
+
const persona = await wiki.getPersona('hotel-001') // 自動產出的角色設定
|
|
107
112
|
})()
|
|
108
113
|
```
|
|
109
114
|
|
|
@@ -112,7 +117,7 @@ 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
122
|
├── hotel-001/
|
|
118
123
|
│ └── base.json
|
|
@@ -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
|
|
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
|
|
|
@@ -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` | |
|
|
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
|
-
|
|
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`
|
|
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
|
-
|
|
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
|
/**
|
|
@@ -460,15 +548,17 @@ function createWikiRouter(config) {
|
|
|
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
|
|
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
|
-
|
|
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
|
/**
|
|
@@ -458,15 +546,17 @@ function createWikiRouter(config) {
|
|
|
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
|
|
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
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
|
*/
|