@jungtz/wiki-router 1.0.2 → 1.0.5
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 +111 -41
- package/dist/index.cjs +116 -208
- package/dist/index.d.ts +26 -2
- package/dist/index.mjs +115 -209
- package/dist/prompts.cjs +17 -168
- package/dist/prompts.mjs +17 -168
- package/package.json +2 -2
- package/src/types.d.ts +26 -2
package/README.md
CHANGED
|
@@ -14,84 +14,142 @@ npm install @jungtz/wiki-router
|
|
|
14
14
|
## 運作流程
|
|
15
15
|
|
|
16
16
|
```
|
|
17
|
-
|
|
17
|
+
知識來源 -> build() -> LLM 生成 Wiki -> getContext(prompt) -> LLM 路由 -> 相關上下文
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
1. **build()**:
|
|
20
|
+
1. **build()**: 將知識來源 (JSON/Markdown) 送交 LLM,拆分成結構化 `.md` 維基頁面
|
|
21
21
|
2. **getContext(prompt)**: 根據使用者問題,由 LLM 從 Index.md 中選擇相關檔案,回傳合併後的上下文
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
知識的「來源」與 wiki 的「儲存位置」皆透過 **adapter** 介面抽象,可使用內建檔案系統 adapter,亦可自訂 (例如 API + 資料庫),方便支援多租戶 / 多資料集場景。
|
|
24
|
+
|
|
25
|
+
## 快速開始(檔案系統)
|
|
24
26
|
|
|
25
27
|
```js
|
|
26
28
|
const { createWikiRouter } = require('@jungtz/wiki-router')
|
|
27
29
|
const { createRouter } = require('@jungtz/ai-router')
|
|
28
30
|
|
|
29
|
-
// 1. 準備 AI Router
|
|
30
31
|
const router = createRouter({
|
|
31
32
|
providers: {
|
|
32
|
-
'ollama-local': {
|
|
33
|
-
type: 'local',
|
|
34
|
-
baseURL: 'http://localhost:11434',
|
|
35
|
-
},
|
|
33
|
+
'ollama-local': { type: 'local', baseURL: 'http://localhost:11434' },
|
|
36
34
|
},
|
|
37
35
|
})
|
|
38
36
|
|
|
39
|
-
// 2. 建立 WikiRouter
|
|
40
37
|
const wiki = createWikiRouter({
|
|
41
|
-
router,
|
|
42
|
-
knowledgeDir: './knowledge',
|
|
43
|
-
outputDir:
|
|
44
|
-
modelId:
|
|
45
|
-
routerModelId: 'ollama-local/gemma4:31b', // 路由選擇模型 (可選)
|
|
46
|
-
|
|
38
|
+
router,
|
|
39
|
+
knowledgeDir: './knowledge', // fs 來源(簡寫)
|
|
40
|
+
outputDir: './wiki-output', // fs 儲存(簡寫)
|
|
41
|
+
modelId: 'ollama-local/gemma4:31b',
|
|
47
42
|
})
|
|
48
43
|
|
|
49
|
-
// 3. 建構/更新 Wiki
|
|
50
44
|
await wiki.build()
|
|
51
|
-
|
|
52
|
-
// 4. 查詢相關上下文
|
|
53
45
|
const ctx = await wiki.getContext('住宿有什麼規定?')
|
|
54
|
-
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## 進階:自訂 adapter(多租戶 / API + DB)
|
|
49
|
+
|
|
50
|
+
當知識來自 API、wiki 要存到資料庫時,傳入 `source` / `store` adapter:
|
|
51
|
+
|
|
52
|
+
```js
|
|
53
|
+
const { createWikiRouter } = require('@jungtz/wiki-router')
|
|
54
|
+
|
|
55
|
+
function wikiOf(hotelId) {
|
|
56
|
+
return createWikiRouter({
|
|
57
|
+
router,
|
|
58
|
+
modelId: 'ollama-local/gemma4:31b',
|
|
59
|
+
source: {
|
|
60
|
+
async list() { return ['base.json'] },
|
|
61
|
+
async read() {
|
|
62
|
+
const res = await fetch(`/api/hotels/${hotelId}/knowledge`)
|
|
63
|
+
return { type: 'json', content: await res.text() }
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
store: {
|
|
67
|
+
async list() { return await db.wiki.list(hotelId) },
|
|
68
|
+
async read(filename) { return await db.wiki.read(hotelId, filename) },
|
|
69
|
+
async write(name, body) { await db.wiki.upsert(hotelId, name, body) },
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const wikiA = wikiOf(4) // 旅館 A
|
|
75
|
+
const wikiB = wikiOf(7) // 旅館 B
|
|
55
76
|
```
|
|
56
77
|
|
|
57
78
|
## API
|
|
58
79
|
|
|
59
80
|
### `createWikiRouter(config)`
|
|
60
81
|
|
|
61
|
-
建立 WikiRouter 實例。
|
|
62
|
-
|
|
63
82
|
| 參數 | 類型 | 必要 | 說明 |
|
|
64
83
|
|------|------|------|------|
|
|
65
84
|
| `router` | `AIProviderRouter` | ✅ | AI Router 實例 |
|
|
66
|
-
| `
|
|
67
|
-
| `
|
|
85
|
+
| `source` | `Source` | ✱ | Knowledge 來源 adapter(與 `knowledgeDir` 二選一) |
|
|
86
|
+
| `store` | `Store` | ✱ | Wiki 儲存 adapter(與 `outputDir` 二選一) |
|
|
87
|
+
| `knowledgeDir` | `string` | ✱ | 簡寫:等同 `source: fsSource(dir)` |
|
|
88
|
+
| `outputDir` | `string` | ✱ | 簡寫:等同 `store: fsStore(dir)` |
|
|
68
89
|
| `modelId` | `string` | | Wiki 生成模型,格式 `provider/model` |
|
|
69
90
|
| `routerModelId` | `string` | | 路由選擇模型,預設同 `modelId` |
|
|
70
91
|
| `timeout` | `number` | | LLM 對話逾時毫秒數,預設 `300000` (5 分鐘) |
|
|
92
|
+
| `splitPrompt` | `string` | | 自訂 Split prompt |
|
|
93
|
+
| `mergePrompt` | `string` | | 自訂 Merge prompt |
|
|
94
|
+
| `routerPrompt` | `string` | | 自訂 Router prompt |
|
|
71
95
|
|
|
96
|
+
✱ `source` 與 `knowledgeDir` 至少擇一;`store` 與 `outputDir` 至少擇一。
|
|
72
97
|
|
|
73
98
|
### `wiki.build()`
|
|
74
99
|
|
|
75
100
|
建構或增量更新 Wiki 知識庫。回傳 `Promise<boolean>`。
|
|
76
101
|
|
|
77
|
-
-
|
|
78
|
-
-
|
|
102
|
+
- 首次(`store.list()` 為空):對 JSON 來源執行 split prompt,拆分成多個 `.md`
|
|
103
|
+
- 後續:使用 merge prompt,將新來源合併到既有檔案
|
|
79
104
|
|
|
80
105
|
### `wiki.getContext(prompt)`
|
|
81
106
|
|
|
82
107
|
根據使用者問題取得相關 Wiki 上下文。回傳 `Promise<string>`。
|
|
83
108
|
|
|
84
|
-
- 若
|
|
85
|
-
- 由 LLM 根據 Index.md 選擇最相關的檔案
|
|
86
|
-
- 回傳合併後的 Markdown
|
|
109
|
+
- 若 store 為空,自動調用 `build()`
|
|
110
|
+
- 由 LLM 根據 `Index.md` 選擇最相關的檔案
|
|
111
|
+
- 回傳合併後的 Markdown 內容;無相關檔案時回傳空字串
|
|
112
|
+
|
|
113
|
+
## Adapter 介面
|
|
114
|
+
|
|
115
|
+
### `Source`
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
interface Source {
|
|
119
|
+
list(): Promise<string[]> // 來源 key 清單
|
|
120
|
+
read(key: string): Promise<{ type: 'json' | 'markdown', content: string }>
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### `Store`
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
interface Store {
|
|
128
|
+
list(): Promise<string[]> // 已生成的 wiki 檔名(含 .md)
|
|
129
|
+
read(filename: string): Promise<string | null> // 不存在回 null
|
|
130
|
+
write(filename: string, content: string): Promise<void>
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### 內建 adapter
|
|
135
|
+
|
|
136
|
+
```js
|
|
137
|
+
import { fsSource, fsStore } from '@jungtz/wiki-router'
|
|
138
|
+
|
|
139
|
+
createWikiRouter({
|
|
140
|
+
router,
|
|
141
|
+
source: fsSource('./knowledge'),
|
|
142
|
+
store: fsStore('./wiki-output'),
|
|
143
|
+
})
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
`knowledgeDir` / `outputDir` 簡寫即在內部分別包成 `fsSource` / `fsStore`。
|
|
87
147
|
|
|
88
148
|
## 子模組
|
|
89
149
|
|
|
90
150
|
### 解析器
|
|
91
151
|
|
|
92
152
|
```js
|
|
93
|
-
const { parseWikiOutput } = require('@jungtz/wiki-router')
|
|
94
|
-
// 或
|
|
95
153
|
import { parseWikiOutput } from '@jungtz/wiki-router/parser'
|
|
96
154
|
|
|
97
155
|
const files = parseWikiOutput(llmResponse)
|
|
@@ -100,23 +158,35 @@ const files = parseWikiOutput(llmResponse)
|
|
|
100
158
|
|
|
101
159
|
### 提示詞模板
|
|
102
160
|
|
|
161
|
+
三個內建 prompt 定義於 `src/prompts/*.md`,build 時自動 inline 進 dist:
|
|
162
|
+
|
|
163
|
+
| 檔案 | 匯出名稱 | 用途 |
|
|
164
|
+
|------|----------|------|
|
|
165
|
+
| `src/prompts/split.md` | `SPLIT_PROMPT` | 將 JSON 資料拆分為多個 `.md` 檔案 |
|
|
166
|
+
| `src/prompts/merge.md` | `MERGE_PROMPT` | 將新內容合併到既有 Wiki 檔案 |
|
|
167
|
+
| `src/prompts/router.md` | `ROUTER_PROMPT` | 根據使用者問題選擇相關檔案 |
|
|
168
|
+
|
|
103
169
|
```js
|
|
104
170
|
import { SPLIT_PROMPT, MERGE_PROMPT, ROUTER_PROMPT } from '@jungtz/wiki-router/prompts'
|
|
105
171
|
```
|
|
106
172
|
|
|
107
|
-
|
|
173
|
+
#### 自訂 Prompt
|
|
108
174
|
|
|
175
|
+
於 `createWikiRouter` 設定中傳入 `splitPrompt` / `mergePrompt` / `routerPrompt` 字串可在 runtime 動態覆蓋;不傳則使用內建預設。
|
|
176
|
+
|
|
177
|
+
```js
|
|
178
|
+
const wiki = createWikiRouter({
|
|
179
|
+
router,
|
|
180
|
+
knowledgeDir: './knowledge',
|
|
181
|
+
outputDir: './wiki-output',
|
|
182
|
+
mergePrompt: readFileSync('./my-custom-merge.md', 'utf-8'),
|
|
183
|
+
})
|
|
109
184
|
```
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
│ ├── Index.md
|
|
116
|
-
│ ├── Rooms.md
|
|
117
|
-
│ ├── Facilities.md
|
|
118
|
-
│ └── ...
|
|
119
|
-
```
|
|
185
|
+
|
|
186
|
+
## 範例
|
|
187
|
+
|
|
188
|
+
- `examples/basic.js` — 檔案系統用法
|
|
189
|
+
- `examples/multi-tenant.js` — 多租戶 (API + DB) 用法
|
|
120
190
|
|
|
121
191
|
## 互動預覽
|
|
122
192
|
|
package/dist/index.cjs
CHANGED
|
@@ -4,179 +4,25 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* LLM Wiki 提示詞 — 從 src/prompts/*.md 檔案載入
|
|
8
|
+
* 欲修改提示詞內容,請直接編輯對應的 .md 檔案
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
const SPLIT_PROMPT = `# 角色:專業文件架構師 (Documentation Architect)
|
|
12
11
|
|
|
13
|
-
|
|
14
|
-
將提供的 JSON 資料完整解析並轉換為多個獨立的 Markdown \`.md\` 檔案。
|
|
15
|
-
JSON 可能來自 CMS、API 回應、設定檔或任何結構化資料來源。
|
|
12
|
+
const PROMPTS_DIR = path.resolve(undefined || __dirname, 'prompts');
|
|
16
13
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
{
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
- 分析 JSON 的頂層結構(object / array / 巢狀)
|
|
26
|
-
- 若為巢狀物件,以**第一層 key** 作為主題分群依據(如 \`data.rooms\` → 房間主題、\`data.location\` → 地點主題)
|
|
27
|
-
- 若為頂層陣列,每個元素視為獨立條目,依內容主題分組
|
|
28
|
-
|
|
29
|
-
### 2. 完整性強制規則(最高優先)
|
|
30
|
-
- **逐項清點**:陣列型資料必須逐一列出所有項目,先數原始 JSON 中有幾筆,輸出後再數確認數量一致
|
|
31
|
-
- **巢狀陣列全收**:若某欄位本身是陣列(如設施清單、標籤列表),必須列出該陣列中的**每一項**
|
|
32
|
-
- **不因長度省略**:資料再多也不能用「等」、「...」、「其他」概括
|
|
33
|
-
|
|
34
|
-
### 3. 資訊提取(語義識別)
|
|
35
|
-
- **標題欄位**:自動識別代表名稱/標題的欄位(\`title\`、\`name\`、\`label\`、\`subject\`)
|
|
36
|
-
- **內文欄位**:自動識別長文字欄位(\`content\`、\`description\`、\`body\`、\`intro\`、\`text\`)
|
|
37
|
-
- **時間欄位**:自動識別時間相關欄位(\`time\`、\`date\`、\`checkin\`、\`checkout\`、\`created_at\`、\`published_at\`)
|
|
38
|
-
- **數值欄位**:自動識別價格/數量欄位(\`price\`、\`qty\`、\`user\`、\`capacity\`、\`size\`)
|
|
39
|
-
- **布林欄位**:自動識別開關欄位(\`enable\`、\`display\`、\`is_*\`),轉為「啟用/停用」
|
|
40
|
-
- **巢狀物件**:遞迴展開,保留層級結構(如 \`social.{instagram_url, instagram_display}\`)
|
|
41
|
-
- **多語言欄位**:若欄位為 \`{zh_tw, en, ja, ...}\` 結構,以 \`zh_tw\` 為主要輸出語言,不存在時依序 fallback
|
|
42
|
-
|
|
43
|
-
### 4. 格式還原
|
|
44
|
-
- 若內容為 JSON 字串(Quill Delta 等 RTF 格式),解析後還原為純文字
|
|
45
|
-
- 轉義換行 \`\\n\` → 真實換行、\`\\t\` → 縮排
|
|
46
|
-
- 內嵌 HTML(\`<a href>\`, \`<img>\`, \`<br>\`)還原為對應 Markdown 語法
|
|
47
|
-
- 內嵌 URL 保留為可點擊連結
|
|
48
|
-
|
|
49
|
-
### 5. 檔案拆分原則
|
|
50
|
-
- 依照 JSON 的**第一層 key** 或**語義主題**拆分成獨立 \`.md\` 檔案
|
|
51
|
-
- 每個檔案涵蓋一個獨立主題區塊
|
|
52
|
-
- 檔名使用英文,反映主題(如 \`Rooms.md\`、\`Policies.md\`、\`Facilities.md\`)
|
|
53
|
-
|
|
54
|
-
### 6. 索引生成 (Index.md)
|
|
55
|
-
- 彙整所有產出的檔案,生成 \`Index.md\`
|
|
56
|
-
- 格式:\`- [顯示名稱](檔名.md):一句話摘要涵蓋的關鍵主題與具體資訊\`
|
|
57
|
-
- 摘要必須具體,禁止只重複檔名
|
|
58
|
-
|
|
59
|
-
### 7. 輸出約束
|
|
60
|
-
- **輸出順序**:先 \`Index.md\`,再依序輸出其他檔案
|
|
61
|
-
- **必須標示檔名**:每個程式碼區塊正上方必須有 \`### 檔名.md\` 標題,不可省略
|
|
62
|
-
- 每個檔案以獨立 \` \`\`\`markdown \` 程式碼區塊呈現
|
|
63
|
-
- 所有 JSON 內容一律轉為人類可讀的 Markdown,不可保留原始 JSON 字串
|
|
64
|
-
|
|
65
|
-
## 輸出格式規範:
|
|
66
|
-
|
|
67
|
-
### Index.md
|
|
68
|
-
\`\`\`markdown
|
|
69
|
-
# 知識庫目錄
|
|
70
|
-
- [顯示名稱 A](A.md):涵蓋的關鍵主題與具體資訊摘要
|
|
71
|
-
- [顯示名稱 B](B.md):涵蓋的關鍵主題與具體資訊摘要
|
|
72
|
-
\`\`\`
|
|
73
|
-
|
|
74
|
-
---
|
|
75
|
-
|
|
76
|
-
### 檔名.md
|
|
77
|
-
\`\`\`markdown
|
|
78
|
-
[還原後的 Markdown 內容]
|
|
79
|
-
\`\`\`
|
|
80
|
-
|
|
81
|
-
---
|
|
82
|
-
|
|
83
|
-
(重複上述格式直到所有檔案輸出完畢)`;
|
|
84
|
-
|
|
85
|
-
const MERGE_PROMPT = `# 角色:知識庫合併架構師 (Knowledge Merge Architect)
|
|
86
|
-
|
|
87
|
-
## 任務目標
|
|
88
|
-
將新的內容合併到既有的知識庫檔案結構中,根據內容類型與主題智能決定「新增頁面」、「合併到既有頁面」或「更新既有頁面」。
|
|
89
|
-
|
|
90
|
-
## 既有知識庫目錄 (Index.md)
|
|
91
|
-
\`\`\`markdown
|
|
92
|
-
{{indexContent}}
|
|
93
|
-
\`\`\`
|
|
94
|
-
|
|
95
|
-
## 新內容類型:{{fileType}}
|
|
96
|
-
## 新內容
|
|
97
|
-
\`\`\`{{contentBlock}}
|
|
98
|
-
{{newContent}}
|
|
99
|
-
\`\`\`
|
|
100
|
-
|
|
101
|
-
## 處理規則:
|
|
102
|
-
|
|
103
|
-
### 1. 結構識別(依 fileType)
|
|
104
|
-
|
|
105
|
-
**json**:
|
|
106
|
-
- 分析 JSON 頂層結構,以第一層 key 或語義主題分群
|
|
107
|
-
- 若為巢狀物件,每個第一層 key 視為一個主題區塊
|
|
108
|
-
- 若為頂層陣列,每個元素視為獨立條目
|
|
109
|
-
|
|
110
|
-
**markdown**:
|
|
111
|
-
- 依照 \`##\` 或 \`###\` 標題拆分成獨立區塊
|
|
112
|
-
- 每個區塊視為一個新知識條目
|
|
113
|
-
|
|
114
|
-
### 2. 合併策略判斷
|
|
115
|
-
|
|
116
|
-
對每個條目,檢查是否與既有 Index.md 中的頁面相關:
|
|
117
|
-
|
|
118
|
-
- **主題全新**(目錄中無相關頁面)→ 建立**新檔案**,Index.md 追加新條目
|
|
119
|
-
- **主題已存在**(標題或關鍵詞與既有頁面重疊)→ 將新內容**合併**到既有檔案,保留既有結構,新增內容作為補充。合併後輸出該檔案的**完整內容**
|
|
120
|
-
- **既有檔案缺少條目**(例如列表型資料漏了某筆)→ 必須**補齊**,將缺漏條目加入對應區塊
|
|
121
|
-
- **內容衝突**(同一主題但資訊不一致,如價格變動、規則修改)→ 以**新內容為準**更新,標記 \`> 更新於 YYYY-MM-DD\`
|
|
122
|
-
- **內容重複**(完全相同)→ 跳過,不輸出
|
|
123
|
-
|
|
124
|
-
### 3. 完整性驗證
|
|
125
|
-
- 陣列型資料:比對新內容的項目數與既有檔案收錄數,缺漏必須補齊
|
|
126
|
-
- 巢狀陣列(如設施清單、標籤列表):逐一檢查是否全數收錄
|
|
127
|
-
- 多語言欄位:以 \`zh_tw\` 為主要輸出語言,不存在時 fallback 到 \`en\`
|
|
128
|
-
- JSON 字串內容(Quill Delta 等):\`\\n\` → 換行、\`\\t\` → 縮排、HTML → Markdown
|
|
129
|
-
|
|
130
|
-
### 4. 輸出約束
|
|
131
|
-
- **輸出順序**:先輸出更新後的 \`Index.md\`,再依序輸出所有新增或更新過的檔案
|
|
132
|
-
- 每個檔案前必須有 \`### 檔名.md\` 作為標題,絕對不可省略
|
|
133
|
-
- 每個檔案以獨立 \` \`\`\`markdown \` 程式碼區塊呈現
|
|
134
|
-
- **只輸出有變動的檔案**,未修改的既有檔案不需重複輸出
|
|
135
|
-
- 合併的檔案必須包含**完整內容**(既有 + 新增),不可只輸出差異
|
|
136
|
-
|
|
137
|
-
## 輸出格式規範:
|
|
138
|
-
|
|
139
|
-
### Index.md
|
|
140
|
-
\`\`\`markdown
|
|
141
|
-
# 知識庫目錄
|
|
142
|
-
- [顯示名稱 A](A.md):一句話摘要涵蓋的關鍵主題
|
|
143
|
-
- [顯示名稱 B](B.md):一句話摘要涵蓋的關鍵主題
|
|
144
|
-
\`\`\`
|
|
145
|
-
|
|
146
|
-
---
|
|
147
|
-
|
|
148
|
-
### 檔名.md
|
|
149
|
-
\`\`\`markdown
|
|
150
|
-
[完整內容]
|
|
151
|
-
\`\`\`
|
|
152
|
-
|
|
153
|
-
---
|
|
154
|
-
|
|
155
|
-
(重複上述格式直到所有變動檔案輸出完畢)`;
|
|
156
|
-
|
|
157
|
-
const ROUTER_PROMPT = `# 角色設定
|
|
158
|
-
你是一個精準的「知識庫路由助手 (Router)」。
|
|
159
|
-
你的唯一任務是分析使用者的問題,並從提供的目錄清單中,挑選出最可能包含解答的檔案名稱。
|
|
160
|
-
|
|
161
|
-
# 使用者問題
|
|
162
|
-
{{prompt}}
|
|
163
|
-
|
|
164
|
-
# 知識庫目錄
|
|
165
|
-
{{indexContent}}
|
|
166
|
-
|
|
167
|
-
# 挑選原則
|
|
168
|
-
- 從目錄中每個檔案的**摘要**判斷相關性,不只比對檔名
|
|
169
|
-
- 考慮同義詞與相關概念(例如:「運動」「健身」→ Facilities.md;「怎麼去」「交通」→ Location_and_Transport.md)
|
|
170
|
-
- 若問題涉及多個主題(如「房價和退房時間」),選取所有相關檔案
|
|
171
|
-
|
|
172
|
-
# 輸出嚴格規範
|
|
173
|
-
1. **僅輸出檔名**:從「知識庫目錄」中挑選最相關的完整檔案名稱(必須包含 \`.md\` 後綴)。
|
|
174
|
-
2. **多檔處理**:若有多個檔案相關,請以半形逗號 \`,\` 分隔。
|
|
175
|
-
3. **無相關時**:若判斷目錄中的檔案皆不相關,請直接回覆 \`NONE\`。
|
|
176
|
-
4. **禁止任何廢話**:**絕對禁止**輸出任何解釋文字、問候語、符號,也**禁止**使用 Markdown 標記(如代碼塊 \` \`\`\` \` \`\`\` 或清單 \`-\`)。
|
|
14
|
+
function readPrompt(filename) {
|
|
15
|
+
const filePath = path.join(PROMPTS_DIR, filename);
|
|
16
|
+
if (fs.existsSync(filePath)) {
|
|
17
|
+
return fs.readFileSync(filePath, 'utf-8')
|
|
18
|
+
}
|
|
19
|
+
console.warn(`[wiki-router] Prompt file not found: ${filePath}, using empty string`);
|
|
20
|
+
return ''
|
|
21
|
+
}
|
|
177
22
|
|
|
178
|
-
|
|
179
|
-
|
|
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
|
+
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
|
+
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";
|
|
180
26
|
|
|
181
27
|
/**
|
|
182
28
|
* LLM 輸出解析器
|
|
@@ -240,14 +86,76 @@ function parseWikiOutput(output) {
|
|
|
240
86
|
return results
|
|
241
87
|
}
|
|
242
88
|
|
|
89
|
+
/**
|
|
90
|
+
* 檔案系統 adapter — 將 fs 操作包裝成 Source / Store 介面
|
|
91
|
+
*
|
|
92
|
+
* 用法:
|
|
93
|
+
* import { fsSource, fsStore } from '@jungtz/wiki-router'
|
|
94
|
+
* createWikiRouter({ router, source: fsSource('./knowledge'), store: fsStore('./wiki') })
|
|
95
|
+
*
|
|
96
|
+
* 也可直接傳 knowledgeDir / outputDir 給 createWikiRouter,內部會自動包成這兩個 adapter。
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 建立讀取本地檔案的 Source adapter
|
|
102
|
+
* @param {string} dir - knowledge 來源目錄
|
|
103
|
+
* @returns {import('../types').Source}
|
|
104
|
+
*/
|
|
105
|
+
function fsSource(dir) {
|
|
106
|
+
const resolved = path.resolve(dir);
|
|
107
|
+
return {
|
|
108
|
+
async list() {
|
|
109
|
+
if (!fs.existsSync(resolved)) return []
|
|
110
|
+
return fs
|
|
111
|
+
.readdirSync(resolved)
|
|
112
|
+
.filter(f => f.endsWith('.json') || f.endsWith('.md'))
|
|
113
|
+
.sort()
|
|
114
|
+
},
|
|
115
|
+
async read(key) {
|
|
116
|
+
const filePath = path.join(resolved, key);
|
|
117
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
118
|
+
const type = key.endsWith('.json') ? 'json' : 'markdown';
|
|
119
|
+
return { type, content }
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 建立讀寫本地檔案的 Store adapter
|
|
126
|
+
* @param {string} dir - wiki 輸出目錄
|
|
127
|
+
* @returns {import('../types').Store}
|
|
128
|
+
*/
|
|
129
|
+
function fsStore(dir) {
|
|
130
|
+
const resolved = path.resolve(dir);
|
|
131
|
+
function ensure() {
|
|
132
|
+
if (!fs.existsSync(resolved)) fs.mkdirSync(resolved, { recursive: true });
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
async list() {
|
|
136
|
+
ensure();
|
|
137
|
+
return fs.readdirSync(resolved).filter(f => f.endsWith('.md'))
|
|
138
|
+
},
|
|
139
|
+
async read(filename) {
|
|
140
|
+
const filePath = path.join(resolved, filename);
|
|
141
|
+
if (!fs.existsSync(filePath)) return null
|
|
142
|
+
return fs.readFileSync(filePath, 'utf-8')
|
|
143
|
+
},
|
|
144
|
+
async write(filename, content) {
|
|
145
|
+
ensure();
|
|
146
|
+
fs.writeFileSync(path.join(resolved, filename), content);
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
243
151
|
/**
|
|
244
152
|
* wiki-router - LLM Wiki 知識庫路由引擎
|
|
245
153
|
*
|
|
246
|
-
*
|
|
247
|
-
* const { createWikiRouter } = require('@jungtz/wiki-router')
|
|
154
|
+
* 使用方式 (檔案系統):
|
|
248
155
|
* const wiki = createWikiRouter({ router, knowledgeDir, outputDir })
|
|
249
|
-
*
|
|
250
|
-
*
|
|
156
|
+
*
|
|
157
|
+
* 使用方式 (自訂 adapter,例如 API + DB):
|
|
158
|
+
* const wiki = createWikiRouter({ router, source, store })
|
|
251
159
|
*/
|
|
252
160
|
|
|
253
161
|
|
|
@@ -262,19 +170,29 @@ function parseWikiOutput(output) {
|
|
|
262
170
|
function createWikiRouter(config) {
|
|
263
171
|
const {
|
|
264
172
|
router,
|
|
173
|
+
source,
|
|
174
|
+
store,
|
|
265
175
|
knowledgeDir,
|
|
266
176
|
outputDir,
|
|
267
177
|
modelId,
|
|
268
178
|
routerModelId,
|
|
269
179
|
timeout = 300000,
|
|
180
|
+
splitPrompt,
|
|
181
|
+
mergePrompt,
|
|
182
|
+
routerPrompt,
|
|
270
183
|
} = config;
|
|
271
184
|
|
|
185
|
+
const activeSplitPrompt = splitPrompt || SPLIT_PROMPT;
|
|
186
|
+
const activeMergePrompt = mergePrompt || MERGE_PROMPT;
|
|
187
|
+
const activeRouterPrompt = routerPrompt || ROUTER_PROMPT;
|
|
188
|
+
|
|
272
189
|
if (!router) throw new Error('[wiki-router] router is required')
|
|
273
|
-
if (!knowledgeDir) throw new Error('[wiki-router] knowledgeDir is required')
|
|
274
|
-
if (!outputDir) throw new Error('[wiki-router] outputDir is required')
|
|
275
190
|
|
|
276
|
-
const
|
|
277
|
-
const
|
|
191
|
+
const activeSource = source || (knowledgeDir ? fsSource(knowledgeDir) : null);
|
|
192
|
+
const activeStore = store || (outputDir ? fsStore(outputDir) : null);
|
|
193
|
+
|
|
194
|
+
if (!activeSource) throw new Error('[wiki-router] source or knowledgeDir is required')
|
|
195
|
+
if (!activeStore) throw new Error('[wiki-router] store or outputDir is required')
|
|
278
196
|
|
|
279
197
|
let modelsFetched = false;
|
|
280
198
|
|
|
@@ -356,37 +274,28 @@ function createWikiRouter(config) {
|
|
|
356
274
|
*/
|
|
357
275
|
async function build() {
|
|
358
276
|
try {
|
|
359
|
-
|
|
277
|
+
const knowledgeKeys = await activeSource.list();
|
|
360
278
|
|
|
361
|
-
|
|
362
|
-
.
|
|
363
|
-
.sort();
|
|
364
|
-
|
|
365
|
-
if (knowledgeFiles.length === 0) {
|
|
366
|
-
console.warn(`[Wiki] No .json or .md files found in: ${resolvedKnowledgeDir}`);
|
|
279
|
+
if (knowledgeKeys.length === 0) {
|
|
280
|
+
console.warn('[Wiki] No knowledge entries found from source.');
|
|
367
281
|
return false
|
|
368
282
|
}
|
|
369
283
|
|
|
370
284
|
let totalFiles = 0;
|
|
371
285
|
|
|
372
|
-
for (const
|
|
373
|
-
const existingFiles =
|
|
286
|
+
for (const key of knowledgeKeys) {
|
|
287
|
+
const existingFiles = await activeStore.list();
|
|
374
288
|
const isFirstTime = existingFiles.length === 0;
|
|
375
|
-
const
|
|
376
|
-
const fileContent = fs.readFileSync(filePath, 'utf-8');
|
|
377
|
-
const fileType = knowledgeFile.endsWith('.json') ? 'json' : 'markdown';
|
|
289
|
+
const { type: fileType, content: fileContent } = await activeSource.read(key);
|
|
378
290
|
|
|
379
|
-
console.log(`[Wiki] Processing: ${
|
|
291
|
+
console.log(`[Wiki] Processing: ${key} (type: ${fileType}, mode: ${isFirstTime ? 'first' : 'merge'})`);
|
|
380
292
|
|
|
381
293
|
let finalPrompt;
|
|
382
294
|
if (isFirstTime && fileType === 'json') {
|
|
383
|
-
finalPrompt =
|
|
295
|
+
finalPrompt = activeSplitPrompt.replace('{{context}}', fileContent);
|
|
384
296
|
} else {
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
? fs.readFileSync(indexPath, 'utf-8')
|
|
388
|
-
: '';
|
|
389
|
-
finalPrompt = MERGE_PROMPT
|
|
297
|
+
const indexContent = (await activeStore.read('Index.md')) || '';
|
|
298
|
+
finalPrompt = activeMergePrompt
|
|
390
299
|
.replace('{{indexContent}}', indexContent)
|
|
391
300
|
.replace('{{fileType}}', fileType)
|
|
392
301
|
.replace('{{contentBlock}}', fileType === 'json' ? 'json' : 'markdown')
|
|
@@ -395,18 +304,18 @@ function createWikiRouter(config) {
|
|
|
395
304
|
|
|
396
305
|
const output = await chat([{ role: 'user', content: finalPrompt }]);
|
|
397
306
|
if (!output) {
|
|
398
|
-
console.warn(`[Wiki] No output for: ${
|
|
307
|
+
console.warn(`[Wiki] No output for: ${key}`);
|
|
399
308
|
continue
|
|
400
309
|
}
|
|
401
310
|
|
|
402
311
|
const parsed = parseWikiOutput(output);
|
|
403
312
|
if (parsed.length === 0) {
|
|
404
|
-
console.error(`[Wiki] Failed to parse output for: ${
|
|
313
|
+
console.error(`[Wiki] Failed to parse output for: ${key}. Raw:`, output);
|
|
405
314
|
continue
|
|
406
315
|
}
|
|
407
316
|
|
|
408
317
|
for (const { filename, content } of parsed) {
|
|
409
|
-
|
|
318
|
+
await activeStore.write(filename, content);
|
|
410
319
|
console.log(`[Wiki] ${isFirstTime ? 'Generated' : 'Updated'}: ${filename}`);
|
|
411
320
|
}
|
|
412
321
|
totalFiles += parsed.length;
|
|
@@ -427,23 +336,20 @@ function createWikiRouter(config) {
|
|
|
427
336
|
*/
|
|
428
337
|
async function getContext(prompt) {
|
|
429
338
|
try {
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
let wikiFiles = fs.readdirSync(resolvedOutputDir).filter(f => f.endsWith('.md'));
|
|
339
|
+
let wikiFiles = await activeStore.list();
|
|
433
340
|
|
|
434
341
|
if (wikiFiles.length === 0) {
|
|
435
342
|
const success = await build();
|
|
436
343
|
if (!success) return ''
|
|
437
344
|
}
|
|
438
345
|
|
|
439
|
-
const
|
|
440
|
-
if (!
|
|
346
|
+
const indexContent = await activeStore.read('Index.md');
|
|
347
|
+
if (!indexContent) {
|
|
441
348
|
console.warn('[Wiki] Index.md not found.');
|
|
442
349
|
return ''
|
|
443
350
|
}
|
|
444
351
|
|
|
445
|
-
const
|
|
446
|
-
const selectionPrompt = ROUTER_PROMPT
|
|
352
|
+
const selectionPrompt = activeRouterPrompt
|
|
447
353
|
.replace('{{prompt}}', prompt)
|
|
448
354
|
.replace('{{indexContent}}', indexContent);
|
|
449
355
|
|
|
@@ -465,9 +371,9 @@ function createWikiRouter(config) {
|
|
|
465
371
|
let loadedCount = 0;
|
|
466
372
|
|
|
467
373
|
for (const file of selectedFiles) {
|
|
468
|
-
const
|
|
469
|
-
if (
|
|
470
|
-
relevantContext += `\n--- 檔案: ${file} ---\n${
|
|
374
|
+
const content = await activeStore.read(file);
|
|
375
|
+
if (content !== null && content !== undefined) {
|
|
376
|
+
relevantContext += `\n--- 檔案: ${file} ---\n${content}\n`;
|
|
471
377
|
loadedCount++;
|
|
472
378
|
}
|
|
473
379
|
}
|
|
@@ -490,4 +396,6 @@ exports.MERGE_PROMPT = MERGE_PROMPT;
|
|
|
490
396
|
exports.ROUTER_PROMPT = ROUTER_PROMPT;
|
|
491
397
|
exports.SPLIT_PROMPT = SPLIT_PROMPT;
|
|
492
398
|
exports.createWikiRouter = createWikiRouter;
|
|
399
|
+
exports.fsSource = fsSource;
|
|
400
|
+
exports.fsStore = fsStore;
|
|
493
401
|
exports.parseWikiOutput = parseWikiOutput;
|