@jungtz/wiki-router 1.0.4 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -95,12 +95,30 @@ const wikiB = wikiOf(7) // 旅館 B
95
95
 
96
96
  ✱ `source` 與 `knowledgeDir` 至少擇一;`store` 與 `outputDir` 至少擇一。
97
97
 
98
- ### `wiki.build()`
98
+ ### `wiki.build(options?)`
99
99
 
100
100
  建構或增量更新 Wiki 知識庫。回傳 `Promise<boolean>`。
101
101
 
102
+ | 選項 | 類型 | 預設 | 說明 |
103
+ |------|------|------|------|
104
+ | `force` | `boolean` | `false` | 為 `true` 時跳過 fingerprint 比對,無條件重新呼叫 LLM 生成 |
105
+
102
106
  - 首次(`store.list()` 為空):對 JSON 來源執行 split prompt,拆分成多個 `.md`
103
107
  - 後續:使用 merge prompt,將新來源合併到既有檔案
108
+ - **快取**:若 source 提供 `getFingerprint()` 且 store 提供 `readManifest()` / `writeManifest()`,build 會比對來源指紋,未變動時直接跳過 LLM(詳見「來源變更偵測」)
109
+
110
+ ### 來源變更偵測 (Fingerprint)
111
+
112
+ 當 source 與 store 都實作對應的選用方法時,`build()` 會自動進行內容指紋比對:
113
+
114
+ 1. 進入 build → 呼叫 `source.getFingerprint()` 取得當前來源指紋
115
+ 2. 呼叫 `store.readManifest()` 取得上次 build 留下的指紋
116
+ 3. 兩者相等且 store 已有檔案 → 跳過 LLM,回傳 `true`
117
+ 4. 不等 → 走原本流程,build 成功後呼叫 `store.writeManifest()` 寫入新指紋
118
+
119
+ `force: true` 跳過比對直接重建。內建 `fsSource` / `fsStore` 已實作這組方法(manifest 存在 store 目錄下的 `.manifest.json`)。
120
+
121
+ 自訂 adapter 沒實作這些方法時,build 仍會正常執行(每次都重跑 LLM),向下相容。
104
122
 
105
123
  ### `wiki.getContext(prompt)`
106
124
 
@@ -118,6 +136,7 @@ const wikiB = wikiOf(7) // 旅館 B
118
136
  interface Source {
119
137
  list(): Promise<string[]> // 來源 key 清單
120
138
  read(key: string): Promise<{ type: 'json' | 'markdown', content: string }>
139
+ getFingerprint?(): Promise<Record<string, string>> // 選用:來源指紋 { key: hash }
121
140
  }
122
141
  ```
123
142
 
@@ -128,9 +147,33 @@ interface Store {
128
147
  list(): Promise<string[]> // 已生成的 wiki 檔名(含 .md)
129
148
  read(filename: string): Promise<string | null> // 不存在回 null
130
149
  write(filename: string, content: string): Promise<void>
150
+ readManifest?(): Promise<Record<string, string> | null> // 選用:讀上次 build 指紋
151
+ writeManifest?(m: Record<string, string>): Promise<void> // 選用:寫本次 build 指紋
131
152
  }
132
153
  ```
133
154
 
155
+ `getFingerprint` / `readManifest` / `writeManifest` 是選用介面,用於來源變更偵測快取。**三者必須同時實作才會啟用快取**;缺其一則 build 仍會正常執行(每次都重跑 LLM)。
156
+
157
+ #### 自訂 adapter 範例(API + DB)
158
+
159
+ ```js
160
+ source: {
161
+ async list() { return ['base.json'] },
162
+ async read() { /* fetch from API */ },
163
+ async getFingerprint() {
164
+ const res = await fetch(`/api/hotels/${hotelId}/etag`)
165
+ return { 'base.json': await res.text() } // 用 ETag 當指紋
166
+ },
167
+ },
168
+ store: {
169
+ async list() { /* DB query */ },
170
+ async read(filename) { /* ... */ },
171
+ async write(filename, body) { /* ... */ },
172
+ async readManifest() { return await db.wikiManifest.get(hotelId) },
173
+ async writeManifest(manifest) { await db.wikiManifest.upsert(hotelId, manifest) },
174
+ },
175
+ ```
176
+
134
177
  ### 內建 adapter
135
178
 
136
179
  ```js
@@ -201,7 +244,21 @@ npm run preview -- --knowledge ./my-knowledge --output ./my-wiki
201
244
 
202
245
  支援指令:`:list` 查看維基頁面、`:build` 重建維基、`:files` 顯示檔案、`:quit` 離開。
203
246
 
204
- ## 發布腳本
247
+ ## 發布
248
+
249
+ ### 自動發布(推薦)
250
+
251
+ push 到 `master` 分支時,CI 會自動掃描自上個 tag 以來的 commit message,依 gitmoji 決定 bump 類型並 publish 到 npm:
252
+
253
+ | Commit message 包含 | Bump 類型 | 例 |
254
+ |--------------------|----------|----|
255
+ | `:boom:` 💥 / `BREAKING CHANGE` | major | 1.0.5 → 2.0.0 |
256
+ | `:sparkles:` ✨ | minor | 1.0.5 → 1.1.0 |
257
+ | 其他 (`:bug:` / `:hammer:` / ...) | patch | 1.0.5 → 1.0.6 |
258
+
259
+ 只要照常用 gitmoji 寫 commit,版號自己對齊語意。
260
+
261
+ ### 本地手動發布腳本(備用)
205
262
 
206
263
  ```bash
207
264
  npm run bump:patch # 版號 patch +1 並自動 git commit + tag
package/dist/index.cjs CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const crypto = require('crypto');
5
6
 
6
7
  /**
7
8
  * LLM Wiki 提示詞 — 從 src/prompts/*.md 檔案載入
@@ -20,9 +21,9 @@ function readPrompt(filename) {
20
21
  return ''
21
22
  }
22
23
 
23
- const SPLIT_PROMPT = readPrompt('split.md');
24
- const MERGE_PROMPT = readPrompt('merge.md');
25
- const ROUTER_PROMPT = readPrompt('router.md');
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
+ 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
+ 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
27
 
27
28
  /**
28
29
  * LLM 輸出解析器
@@ -97,6 +98,8 @@ function parseWikiOutput(output) {
97
98
  */
98
99
 
99
100
 
101
+ const MANIFEST_FILE = '.manifest.json';
102
+
100
103
  /**
101
104
  * 建立讀取本地檔案的 Source adapter
102
105
  * @param {string} dir - knowledge 來源目錄
@@ -104,19 +107,30 @@ function parseWikiOutput(output) {
104
107
  */
105
108
  function fsSource(dir) {
106
109
  const resolved = path.resolve(dir);
110
+ async function list() {
111
+ if (!fs.existsSync(resolved)) return []
112
+ return fs
113
+ .readdirSync(resolved)
114
+ .filter(f => f.endsWith('.json') || f.endsWith('.md'))
115
+ .sort()
116
+ }
117
+ async function read(key) {
118
+ const filePath = path.join(resolved, key);
119
+ const content = fs.readFileSync(filePath, 'utf-8');
120
+ const type = key.endsWith('.json') ? 'json' : 'markdown';
121
+ return { type, content }
122
+ }
107
123
  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 }
124
+ list,
125
+ read,
126
+ async getFingerprint() {
127
+ const keys = await list();
128
+ const fp = {};
129
+ for (const key of keys) {
130
+ const { content } = await read(key);
131
+ fp[key] = crypto.createHash('sha256').update(content).digest('hex');
132
+ }
133
+ return fp
120
134
  },
121
135
  }
122
136
  }
@@ -145,6 +159,23 @@ function fsStore(dir) {
145
159
  ensure();
146
160
  fs.writeFileSync(path.join(resolved, filename), content);
147
161
  },
162
+ async readManifest() {
163
+ const filePath = path.join(resolved, MANIFEST_FILE);
164
+ if (!fs.existsSync(filePath)) return null
165
+ try {
166
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
167
+ } catch {
168
+ return null
169
+ }
170
+ },
171
+ async writeManifest(manifest) {
172
+ ensure();
173
+ fs.writeFileSync(
174
+ path.join(resolved, MANIFEST_FILE),
175
+ JSON.stringify(manifest, null, 2),
176
+ 'utf-8'
177
+ );
178
+ },
148
179
  }
149
180
  }
150
181
 
@@ -268,11 +299,28 @@ function createWikiRouter(config) {
268
299
  }
269
300
  }
270
301
 
302
+ /**
303
+ * 比對兩個 fingerprint 字典是否完全一致
304
+ * @param {Record<string,string>|null|undefined} a
305
+ * @param {Record<string,string>|null|undefined} b
306
+ * @returns {boolean}
307
+ */
308
+ function fingerprintsEqual(a, b) {
309
+ if (!a || !b) return false
310
+ const ak = Object.keys(a);
311
+ const bk = Object.keys(b);
312
+ if (ak.length !== bk.length) return false
313
+ return ak.every(k => a[k] === b[k])
314
+ }
315
+
271
316
  /**
272
317
  * 建構或更新 LLM Wiki 知識庫
318
+ * @param {{ force?: boolean }} [options]
319
+ * force=true 時跳過 fingerprint 比對,無條件重建
273
320
  * @returns {Promise<boolean>}
274
321
  */
275
- async function build() {
322
+ async function build(options = {}) {
323
+ const { force = false } = options;
276
324
  try {
277
325
  const knowledgeKeys = await activeSource.list();
278
326
 
@@ -281,6 +329,25 @@ function createWikiRouter(config) {
281
329
  return false
282
330
  }
283
331
 
332
+ // Fingerprint cache: 若來源未變動且既有 wiki 存在,直接跳過 LLM
333
+ let currentFp = null;
334
+ const sourceSupportsFp = typeof activeSource.getFingerprint === 'function';
335
+ const storeSupportsManifest =
336
+ typeof activeStore.readManifest === 'function' &&
337
+ typeof activeStore.writeManifest === 'function';
338
+
339
+ if (sourceSupportsFp && storeSupportsManifest) {
340
+ currentFp = await activeSource.getFingerprint();
341
+ if (!force) {
342
+ const storedFp = await activeStore.readManifest();
343
+ const existingFiles = await activeStore.list();
344
+ if (existingFiles.length > 0 && fingerprintsEqual(currentFp, storedFp)) {
345
+ console.log('[Wiki] Source unchanged, skipping build (fingerprint match)');
346
+ return true
347
+ }
348
+ }
349
+ }
350
+
284
351
  let totalFiles = 0;
285
352
 
286
353
  for (const key of knowledgeKeys) {
@@ -322,6 +389,12 @@ function createWikiRouter(config) {
322
389
  }
323
390
 
324
391
  if (totalFiles === 0) return false
392
+
393
+ // build 成功 → 寫入 manifest 供下次比對
394
+ if (currentFp && storeSupportsManifest) {
395
+ await activeStore.writeManifest(currentFp);
396
+ }
397
+
325
398
  return true
326
399
  } catch (err) {
327
400
  console.error('[Wiki Generation Error]', err);
@@ -354,11 +427,22 @@ function createWikiRouter(config) {
354
427
  .replace('{{indexContent}}', indexContent);
355
428
 
356
429
  const routeModel = routerModelId || process.env.WIKI_ROUTER_MODEL || modelId || process.env.WIKI_MODEL;
357
- const output = await chat(
430
+ let output = await chat(
358
431
  [{ role: 'user', content: selectionPrompt }],
359
432
  routeModel
360
433
  );
361
- if (!output) return ''
434
+ // Cloud LLM (e.g. Gemini) 偶爾會回空 stream,retry 一次
435
+ if (!output || !output.trim()) {
436
+ console.warn('[Wiki] Router LLM returned empty, retrying once...');
437
+ output = await chat(
438
+ [{ role: 'user', content: selectionPrompt }],
439
+ routeModel
440
+ );
441
+ }
442
+ if (!output) {
443
+ console.warn('[Wiki] Router LLM returned empty after retry.');
444
+ return ''
445
+ }
362
446
 
363
447
  const selectedFilesStr = output.trim();
364
448
  if (selectedFilesStr === 'NONE' || !selectedFilesStr) {
package/dist/index.d.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  * @typedef {Object} Source
13
13
  * @property {() => Promise<string[]>} list - 列出所有 knowledge 來源 key(檔名 / DB id)
14
14
  * @property {(key: string) => Promise<SourceEntry>} read - 讀取單一來源內容
15
+ * @property {() => Promise<Record<string, string>>} [getFingerprint] - 選用;回傳來源指紋(如 { key: hash })供 build 判斷是否需要重新生成
15
16
  */
16
17
 
17
18
  /**
@@ -19,6 +20,8 @@
19
20
  * @property {() => Promise<string[]>} list - 列出已生成的 wiki 檔名(含副檔名)
20
21
  * @property {(filename: string) => Promise<string|null>} read - 讀取 wiki 檔;不存在回 null
21
22
  * @property {(filename: string, content: string) => Promise<void>} write - 寫入 wiki 檔
23
+ * @property {() => Promise<Record<string, string>|null>} [readManifest] - 選用;讀取上次 build 的來源指紋;不存在回 null
24
+ * @property {(manifest: Record<string, string>) => Promise<void>} [writeManifest] - 選用;寫入本次 build 的來源指紋
22
25
  */
23
26
 
24
27
  /**
@@ -36,9 +39,14 @@
36
39
  * @property {string} [routerPrompt] - 自訂 Router prompt 字串,未提供時使用內建預設
37
40
  */
38
41
 
42
+ /**
43
+ * @typedef {Object} BuildOptions
44
+ * @property {boolean} [force=false] - 為 true 時跳過 fingerprint 比對,無條件重建
45
+ */
46
+
39
47
  /**
40
48
  * @typedef {Object} WikiRouterInstance
41
- * @property {() => Promise<boolean>} build - 建構或更新 Wiki 知識庫
49
+ * @property {(options?: BuildOptions) => Promise<boolean>} build - 建構或更新 Wiki 知識庫
42
50
  * @property {(prompt: string) => Promise<string>} getContext - 根據問題取得相關 Wiki 上下文
43
51
  */
44
52
 
package/dist/index.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
+ import crypto from 'crypto';
3
4
 
4
5
  /**
5
6
  * LLM Wiki 提示詞 — 從 src/prompts/*.md 檔案載入
@@ -18,9 +19,9 @@ function readPrompt(filename) {
18
19
  return ''
19
20
  }
20
21
 
21
- const SPLIT_PROMPT = readPrompt('split.md');
22
- const MERGE_PROMPT = readPrompt('merge.md');
23
- const ROUTER_PROMPT = readPrompt('router.md');
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
+ 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
+ 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
25
 
25
26
  /**
26
27
  * LLM 輸出解析器
@@ -95,6 +96,8 @@ function parseWikiOutput(output) {
95
96
  */
96
97
 
97
98
 
99
+ const MANIFEST_FILE = '.manifest.json';
100
+
98
101
  /**
99
102
  * 建立讀取本地檔案的 Source adapter
100
103
  * @param {string} dir - knowledge 來源目錄
@@ -102,19 +105,30 @@ function parseWikiOutput(output) {
102
105
  */
103
106
  function fsSource(dir) {
104
107
  const resolved = path.resolve(dir);
108
+ async function 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 function 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
+ }
105
121
  return {
106
- async list() {
107
- if (!fs.existsSync(resolved)) return []
108
- return fs
109
- .readdirSync(resolved)
110
- .filter(f => f.endsWith('.json') || f.endsWith('.md'))
111
- .sort()
112
- },
113
- async read(key) {
114
- const filePath = path.join(resolved, key);
115
- const content = fs.readFileSync(filePath, 'utf-8');
116
- const type = key.endsWith('.json') ? 'json' : 'markdown';
117
- return { type, content }
122
+ list,
123
+ read,
124
+ async getFingerprint() {
125
+ const keys = await list();
126
+ const fp = {};
127
+ for (const key of keys) {
128
+ const { content } = await read(key);
129
+ fp[key] = crypto.createHash('sha256').update(content).digest('hex');
130
+ }
131
+ return fp
118
132
  },
119
133
  }
120
134
  }
@@ -143,6 +157,23 @@ function fsStore(dir) {
143
157
  ensure();
144
158
  fs.writeFileSync(path.join(resolved, filename), content);
145
159
  },
160
+ async readManifest() {
161
+ const filePath = path.join(resolved, MANIFEST_FILE);
162
+ if (!fs.existsSync(filePath)) return null
163
+ try {
164
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
165
+ } catch {
166
+ return null
167
+ }
168
+ },
169
+ async writeManifest(manifest) {
170
+ ensure();
171
+ fs.writeFileSync(
172
+ path.join(resolved, MANIFEST_FILE),
173
+ JSON.stringify(manifest, null, 2),
174
+ 'utf-8'
175
+ );
176
+ },
146
177
  }
147
178
  }
148
179
 
@@ -266,11 +297,28 @@ function createWikiRouter(config) {
266
297
  }
267
298
  }
268
299
 
300
+ /**
301
+ * 比對兩個 fingerprint 字典是否完全一致
302
+ * @param {Record<string,string>|null|undefined} a
303
+ * @param {Record<string,string>|null|undefined} b
304
+ * @returns {boolean}
305
+ */
306
+ function fingerprintsEqual(a, b) {
307
+ if (!a || !b) return false
308
+ const ak = Object.keys(a);
309
+ const bk = Object.keys(b);
310
+ if (ak.length !== bk.length) return false
311
+ return ak.every(k => a[k] === b[k])
312
+ }
313
+
269
314
  /**
270
315
  * 建構或更新 LLM Wiki 知識庫
316
+ * @param {{ force?: boolean }} [options]
317
+ * force=true 時跳過 fingerprint 比對,無條件重建
271
318
  * @returns {Promise<boolean>}
272
319
  */
273
- async function build() {
320
+ async function build(options = {}) {
321
+ const { force = false } = options;
274
322
  try {
275
323
  const knowledgeKeys = await activeSource.list();
276
324
 
@@ -279,6 +327,25 @@ function createWikiRouter(config) {
279
327
  return false
280
328
  }
281
329
 
330
+ // Fingerprint cache: 若來源未變動且既有 wiki 存在,直接跳過 LLM
331
+ let currentFp = null;
332
+ const sourceSupportsFp = typeof activeSource.getFingerprint === 'function';
333
+ const storeSupportsManifest =
334
+ typeof activeStore.readManifest === 'function' &&
335
+ typeof activeStore.writeManifest === 'function';
336
+
337
+ if (sourceSupportsFp && storeSupportsManifest) {
338
+ currentFp = await activeSource.getFingerprint();
339
+ if (!force) {
340
+ const storedFp = await activeStore.readManifest();
341
+ const existingFiles = await activeStore.list();
342
+ if (existingFiles.length > 0 && fingerprintsEqual(currentFp, storedFp)) {
343
+ console.log('[Wiki] Source unchanged, skipping build (fingerprint match)');
344
+ return true
345
+ }
346
+ }
347
+ }
348
+
282
349
  let totalFiles = 0;
283
350
 
284
351
  for (const key of knowledgeKeys) {
@@ -320,6 +387,12 @@ function createWikiRouter(config) {
320
387
  }
321
388
 
322
389
  if (totalFiles === 0) return false
390
+
391
+ // build 成功 → 寫入 manifest 供下次比對
392
+ if (currentFp && storeSupportsManifest) {
393
+ await activeStore.writeManifest(currentFp);
394
+ }
395
+
323
396
  return true
324
397
  } catch (err) {
325
398
  console.error('[Wiki Generation Error]', err);
@@ -352,11 +425,22 @@ function createWikiRouter(config) {
352
425
  .replace('{{indexContent}}', indexContent);
353
426
 
354
427
  const routeModel = routerModelId || process.env.WIKI_ROUTER_MODEL || modelId || process.env.WIKI_MODEL;
355
- const output = await chat(
428
+ let output = await chat(
356
429
  [{ role: 'user', content: selectionPrompt }],
357
430
  routeModel
358
431
  );
359
- if (!output) return ''
432
+ // Cloud LLM (e.g. Gemini) 偶爾會回空 stream,retry 一次
433
+ if (!output || !output.trim()) {
434
+ console.warn('[Wiki] Router LLM returned empty, retrying once...');
435
+ output = await chat(
436
+ [{ role: 'user', content: selectionPrompt }],
437
+ routeModel
438
+ );
439
+ }
440
+ if (!output) {
441
+ console.warn('[Wiki] Router LLM returned empty after retry.');
442
+ return ''
443
+ }
360
444
 
361
445
  const selectedFilesStr = output.trim();
362
446
  if (selectedFilesStr === 'NONE' || !selectedFilesStr) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jungtz/wiki-router",
3
- "version": "1.0.4",
3
+ "version": "1.1.0",
4
4
  "description": "LLM Wiki 知識庫路由引擎 - 將結構化知識轉為 Markdown 維基,智慧路由查詢",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.mjs",
package/src/types.d.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  * @typedef {Object} Source
13
13
  * @property {() => Promise<string[]>} list - 列出所有 knowledge 來源 key(檔名 / DB id)
14
14
  * @property {(key: string) => Promise<SourceEntry>} read - 讀取單一來源內容
15
+ * @property {() => Promise<Record<string, string>>} [getFingerprint] - 選用;回傳來源指紋(如 { key: hash })供 build 判斷是否需要重新生成
15
16
  */
16
17
 
17
18
  /**
@@ -19,6 +20,8 @@
19
20
  * @property {() => Promise<string[]>} list - 列出已生成的 wiki 檔名(含副檔名)
20
21
  * @property {(filename: string) => Promise<string|null>} read - 讀取 wiki 檔;不存在回 null
21
22
  * @property {(filename: string, content: string) => Promise<void>} write - 寫入 wiki 檔
23
+ * @property {() => Promise<Record<string, string>|null>} [readManifest] - 選用;讀取上次 build 的來源指紋;不存在回 null
24
+ * @property {(manifest: Record<string, string>) => Promise<void>} [writeManifest] - 選用;寫入本次 build 的來源指紋
22
25
  */
23
26
 
24
27
  /**
@@ -36,9 +39,14 @@
36
39
  * @property {string} [routerPrompt] - 自訂 Router prompt 字串,未提供時使用內建預設
37
40
  */
38
41
 
42
+ /**
43
+ * @typedef {Object} BuildOptions
44
+ * @property {boolean} [force=false] - 為 true 時跳過 fingerprint 比對,無條件重建
45
+ */
46
+
39
47
  /**
40
48
  * @typedef {Object} WikiRouterInstance
41
- * @property {() => Promise<boolean>} build - 建構或更新 Wiki 知識庫
49
+ * @property {(options?: BuildOptions) => Promise<boolean>} build - 建構或更新 Wiki 知識庫
42
50
  * @property {(prompt: string) => Promise<string>} getContext - 根據問題取得相關 Wiki 上下文
43
51
  */
44
52