@jungtz/wiki-router 1.0.5 → 1.2.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,23 +147,117 @@ 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
137
- import { fsSource, fsStore } from '@jungtz/wiki-router'
180
+ import { fsSource, fsStore, sqliteStore, ensureWikiTables } from '@jungtz/wiki-router'
138
181
 
182
+ // 檔案系統
139
183
  createWikiRouter({
140
184
  router,
141
185
  source: fsSource('./knowledge'),
142
186
  store: fsStore('./wiki-output'),
143
187
  })
188
+
189
+ // SQLite (better-sqlite3 相容 db 物件)
190
+ ensureWikiTables(db) // 一次性建表(idempotent)
191
+ createWikiRouter({
192
+ router,
193
+ source: fsSource('./knowledge'),
194
+ store: sqliteStore({ db, tenantId: 'hotel-001' }), // 多租戶用同一個 db、不同 tenantId
195
+ })
144
196
  ```
145
197
 
146
198
  `knowledgeDir` / `outputDir` 簡寫即在內部分別包成 `fsSource` / `fsStore`。
147
199
 
200
+ #### `sqliteStore({ db, tenantId, ... })`
201
+
202
+ | 參數 | 類型 | 必要 | 說明 |
203
+ |------|------|------|------|
204
+ | `db` | better-sqlite3 instance | ✅ | 須有 `prepare(sql)` 與 `exec(sql)` 方法(不直接相依 better-sqlite3 套件) |
205
+ | `tenantId` | `string` | ✅ | 多租戶識別字串,不同 tenant 完全隔離 |
206
+ | `filesTable` | `string` | | 預設 `wiki_files` |
207
+ | `manifestsTable` | `string` | | 預設 `wiki_manifests` |
208
+
209
+ `sqliteStore` 內部會自動呼叫 `ensureWikiTables(db)`,但若想預先建表也可以單獨匯入使用。
210
+
211
+ ## 多租戶管理 — `createTenantManager`
212
+
213
+ 把「快取 wiki 實例 / 並行 build dedup / buildAll / 已 build 狀態追蹤 / Index.md fallback」統一封裝。其他專案串多 wiki 時只要設定 config 即可。
214
+
215
+ ```js
216
+ import { createTenantManager, fsSource, sqliteStore } from '@jungtz/wiki-router'
217
+ import Database from 'better-sqlite3'
218
+
219
+ const db = new Database('./wiki.db')
220
+
221
+ const manager = createTenantManager({
222
+ router,
223
+ modelId: 'ollama-local/gemma4:31b',
224
+
225
+ // adapter factory:每個 tenantId 各自建立 source / store
226
+ source: tid => fsSource(`./knowledge/${tid}`),
227
+ store: tid => sqliteStore({ db, tenantId: tid }),
228
+
229
+ // 選用:buildAll() 才需要
230
+ listTenants: () => fs.readdirSync('./knowledge'),
231
+ })
232
+
233
+ await manager.buildAll() // 啟動時並行 build 所有租戶
234
+ await manager.build('hotel-001', { force: true }) // 單一租戶強制重建
235
+ const ctx = await manager.getContext('問題', 'hotel-001')
236
+ ```
237
+
238
+ ### Config
239
+
240
+ | 參數 | 類型 | 必要 | 說明 |
241
+ |------|------|------|------|
242
+ | `router` | `AIProviderRouter` | ✅ | AI Router 實例 |
243
+ | `source` | `(tenantId) => Source` | ✅ | Source factory |
244
+ | `store` | `(tenantId) => Store` | ✅ | Store factory |
245
+ | `listTenants` | `() => string[] \| Promise<string[]>` | | `buildAll()` 才需要 |
246
+ | `autoIndex` | `boolean` | | 預設 `true`;build 後 store 沒 Index.md 時,從現有 .md 合成一份目錄 |
247
+ | `autoIndexHeader` | `string` | | 預設 `# 知識庫目錄` |
248
+ | `logger` | `{ log, warn, error }` | | 預設 `console` |
249
+ | `modelId` / `routerModelId` / `timeout` / `*Prompt` | | | 同 `createWikiRouter` config,傳給每個 tenant 的 wiki 實例 |
250
+
251
+ ### 回傳 API
252
+
253
+ | 方法 | 說明 |
254
+ |------|------|
255
+ | `build(tenantId, { force })` | 為單一租戶 build;同租戶並行呼叫共用同一個 promise |
256
+ | `buildAll()` | 並行 build 所有租戶,使用 `Promise.allSettled` 避免單租戶失敗影響其他 |
257
+ | `getContext(prompt, tenantId)` | 取得該租戶的 wiki 上下文;尚未 build 完成時回空字串(不阻塞) |
258
+ | `isBuilt(tenantId)` | 該租戶是否已成功 build 過 |
259
+ | `listBuilt()` | 已 build 過的租戶清單 |
260
+
148
261
  ## 子模組
149
262
 
150
263
  ### 解析器
@@ -201,7 +314,21 @@ npm run preview -- --knowledge ./my-knowledge --output ./my-wiki
201
314
 
202
315
  支援指令:`:list` 查看維基頁面、`:build` 重建維基、`:files` 顯示檔案、`:quit` 離開。
203
316
 
204
- ## 發布腳本
317
+ ## 發布
318
+
319
+ ### 自動發布(推薦)
320
+
321
+ push 到 `master` 分支時,CI 會自動掃描自上個 tag 以來的 commit message,依 gitmoji 決定 bump 類型並 publish 到 npm:
322
+
323
+ | Commit message 包含 | Bump 類型 | 例 |
324
+ |--------------------|----------|----|
325
+ | `:boom:` 💥 / `BREAKING CHANGE` | major | 1.0.5 → 2.0.0 |
326
+ | `:sparkles:` ✨ | minor | 1.0.5 → 1.1.0 |
327
+ | 其他 (`:bug:` / `:hammer:` / ...) | patch | 1.0.5 → 1.0.6 |
328
+
329
+ 只要照常用 gitmoji 寫 commit,版號自己對齊語意。
330
+
331
+ ### 本地手動發布腳本(備用)
205
332
 
206
333
  ```bash
207
334
  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 檔案載入
@@ -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,17 +159,29 @@ 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
 
151
182
  /**
152
- * wiki-router - LLM Wiki 知識庫路由引擎
153
- *
154
- * 使用方式 (檔案系統):
155
- * const wiki = createWikiRouter({ router, knowledgeDir, outputDir })
156
- *
157
- * 使用方式 (自訂 adapter,例如 API + DB):
158
- * const wiki = createWikiRouter({ router, source, store })
183
+ * createWikiRouter 單一 wiki 實例工廠
184
+ * 已從 index.js 拆出,避免 tenant.js → index.js 的循環依賴。
159
185
  */
160
186
 
161
187
 
@@ -196,10 +222,6 @@ function createWikiRouter(config) {
196
222
 
197
223
  let modelsFetched = false;
198
224
 
199
- /**
200
- * 預先獲取本地模型清單(仿照 models.js 的 getModelsList 邏輯)
201
- * 確保 switchModel 能識別動態發現的 local 模型
202
- */
203
225
  async function ensureModels() {
204
226
  if (modelsFetched) return
205
227
  try {
@@ -212,7 +234,7 @@ function createWikiRouter(config) {
212
234
  if (pModels.length > 0) {
213
235
  router.config.providers[pName].models = pModels.map(m => ({
214
236
  model: m.model,
215
- description: m.description
237
+ description: m.description,
216
238
  }));
217
239
  }
218
240
  });
@@ -222,13 +244,6 @@ function createWikiRouter(config) {
222
244
  }
223
245
  }
224
246
 
225
- /**
226
- * 向 LLM 發送對話並取得完整回應
227
- * @param {{ role: string, content: string }[]} messages
228
- * @param {string} [overrideModelId]
229
- * @param {number} [overrideTimeout]
230
- * @returns {Promise<string|null>}
231
- */
232
247
  async function chat(messages, overrideModelId, overrideTimeout) {
233
248
  const wikiModelId = overrideModelId || modelId || process.env.WIKI_MODEL || 'ollama-local/gemma4:31b';
234
249
  const effectiveTimeout = overrideTimeout || timeout;
@@ -243,7 +258,6 @@ function createWikiRouter(config) {
243
258
  modelName = parts.slice(1).join('/');
244
259
  }
245
260
 
246
- // 使用 AI Router 進行對話
247
261
  if (router && provider) {
248
262
  await ensureModels();
249
263
  await router.switchModel(provider, modelName);
@@ -268,11 +282,16 @@ function createWikiRouter(config) {
268
282
  }
269
283
  }
270
284
 
271
- /**
272
- * 建構或更新 LLM Wiki 知識庫
273
- * @returns {Promise<boolean>}
274
- */
275
- async function build() {
285
+ function fingerprintsEqual(a, b) {
286
+ if (!a || !b) return false
287
+ const ak = Object.keys(a);
288
+ const bk = Object.keys(b);
289
+ if (ak.length !== bk.length) return false
290
+ return ak.every(k => a[k] === b[k])
291
+ }
292
+
293
+ async function build(options = {}) {
294
+ const { force = false } = options;
276
295
  try {
277
296
  const knowledgeKeys = await activeSource.list();
278
297
 
@@ -281,6 +300,24 @@ function createWikiRouter(config) {
281
300
  return false
282
301
  }
283
302
 
303
+ let currentFp = null;
304
+ const sourceSupportsFp = typeof activeSource.getFingerprint === 'function';
305
+ const storeSupportsManifest =
306
+ typeof activeStore.readManifest === 'function' &&
307
+ typeof activeStore.writeManifest === 'function';
308
+
309
+ if (sourceSupportsFp && storeSupportsManifest) {
310
+ currentFp = await activeSource.getFingerprint();
311
+ if (!force) {
312
+ const storedFp = await activeStore.readManifest();
313
+ const existingFiles = await activeStore.list();
314
+ if (existingFiles.length > 0 && fingerprintsEqual(currentFp, storedFp)) {
315
+ console.log('[Wiki] Source unchanged, skipping build (fingerprint match)');
316
+ return true
317
+ }
318
+ }
319
+ }
320
+
284
321
  let totalFiles = 0;
285
322
 
286
323
  for (const key of knowledgeKeys) {
@@ -322,6 +359,11 @@ function createWikiRouter(config) {
322
359
  }
323
360
 
324
361
  if (totalFiles === 0) return false
362
+
363
+ if (currentFp && storeSupportsManifest) {
364
+ await activeStore.writeManifest(currentFp);
365
+ }
366
+
325
367
  return true
326
368
  } catch (err) {
327
369
  console.error('[Wiki Generation Error]', err);
@@ -329,11 +371,6 @@ function createWikiRouter(config) {
329
371
  }
330
372
  }
331
373
 
332
- /**
333
- * 根據使用者問題取得相關的 Wiki 上下文
334
- * @param {string} prompt - 使用者問題
335
- * @returns {Promise<string>}
336
- */
337
374
  async function getContext(prompt) {
338
375
  try {
339
376
  let wikiFiles = await activeStore.list();
@@ -354,11 +391,15 @@ function createWikiRouter(config) {
354
391
  .replace('{{indexContent}}', indexContent);
355
392
 
356
393
  const routeModel = routerModelId || process.env.WIKI_ROUTER_MODEL || modelId || process.env.WIKI_MODEL;
357
- const output = await chat(
358
- [{ role: 'user', content: selectionPrompt }],
359
- routeModel
360
- );
361
- if (!output) return ''
394
+ let output = await chat([{ role: 'user', content: selectionPrompt }], routeModel);
395
+ if (!output || !output.trim()) {
396
+ console.warn('[Wiki] Router LLM returned empty, retrying once...');
397
+ output = await chat([{ role: 'user', content: selectionPrompt }], routeModel);
398
+ }
399
+ if (!output) {
400
+ console.warn('[Wiki] Router LLM returned empty after retry.');
401
+ return ''
402
+ }
362
403
 
363
404
  const selectedFilesStr = output.trim();
364
405
  if (selectedFilesStr === 'NONE' || !selectedFilesStr) {
@@ -382,7 +423,6 @@ function createWikiRouter(config) {
382
423
  console.log(`[Wiki] LLM correctly routed to ${loadedCount} file(s): ${selectedFiles.join(', ')}`);
383
424
  return relevantContext
384
425
  }
385
-
386
426
  } catch (err) {
387
427
  console.error('[Wiki Context Error]', err);
388
428
  }
@@ -392,10 +432,275 @@ function createWikiRouter(config) {
392
432
  return { build, getContext }
393
433
  }
394
434
 
435
+ /**
436
+ * SQLite adapter — 將 better-sqlite3(或相容 API)包裝成 Store 介面
437
+ *
438
+ * 用法:
439
+ * import { sqliteStore } from '@jungtz/wiki-router'
440
+ * const store = sqliteStore({ db, tenantId: 'hotel-001' })
441
+ *
442
+ * 不直接相依 better-sqlite3;只要傳入的 db 物件支援 prepare(sql) 與 exec(sql) 即可。
443
+ */
444
+
445
+ const DEFAULT_FILES_TABLE = 'wiki_files';
446
+ const DEFAULT_MANIFESTS_TABLE = 'wiki_manifests';
447
+
448
+ /**
449
+ * 建立 wiki_files / wiki_manifests 兩個表(CREATE TABLE IF NOT EXISTS,可重複呼叫)
450
+ * @param {*} db - better-sqlite3 相容實例
451
+ * @param {{ filesTable?: string, manifestsTable?: string }} [opts]
452
+ */
453
+ function ensureWikiTables(db, opts = {}) {
454
+ const filesTable = opts.filesTable || DEFAULT_FILES_TABLE;
455
+ const manifestsTable = opts.manifestsTable || DEFAULT_MANIFESTS_TABLE;
456
+ db.exec(`
457
+ CREATE TABLE IF NOT EXISTS ${filesTable} (
458
+ tenant_id TEXT NOT NULL,
459
+ filename TEXT NOT NULL,
460
+ content TEXT NOT NULL,
461
+ updated_at TEXT NOT NULL,
462
+ PRIMARY KEY (tenant_id, filename)
463
+ );
464
+ CREATE TABLE IF NOT EXISTS ${manifestsTable} (
465
+ tenant_id TEXT PRIMARY KEY,
466
+ manifest TEXT NOT NULL,
467
+ updated_at TEXT NOT NULL
468
+ );
469
+ `);
470
+ }
471
+
472
+ /**
473
+ * 建立 SQLite-backed Store adapter
474
+ * @param {{ db: *, tenantId: string, filesTable?: string, manifestsTable?: string }} config
475
+ * @returns {import('../types').Store}
476
+ */
477
+ function sqliteStore(config) {
478
+ if (!config || !config.db) {
479
+ throw new Error('[wiki-router] sqliteStore: db is required')
480
+ }
481
+ if (!config.tenantId) {
482
+ throw new Error('[wiki-router] sqliteStore: tenantId is required')
483
+ }
484
+ const { db, tenantId } = config;
485
+ const filesTable = config.filesTable || DEFAULT_FILES_TABLE;
486
+ const manifestsTable = config.manifestsTable || DEFAULT_MANIFESTS_TABLE;
487
+
488
+ ensureWikiTables(db, { filesTable, manifestsTable });
489
+
490
+ const stmts = {
491
+ list: db.prepare(`SELECT filename FROM ${filesTable} WHERE tenant_id = ? ORDER BY filename`),
492
+ read: db.prepare(`SELECT content FROM ${filesTable} WHERE tenant_id = ? AND filename = ?`),
493
+ upsert: db.prepare(
494
+ `INSERT INTO ${filesTable} (tenant_id, filename, content, updated_at)
495
+ VALUES (?, ?, ?, ?)
496
+ ON CONFLICT(tenant_id, filename) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at`
497
+ ),
498
+ readManifest: db.prepare(`SELECT manifest FROM ${manifestsTable} WHERE tenant_id = ?`),
499
+ writeManifest: db.prepare(
500
+ `INSERT INTO ${manifestsTable} (tenant_id, manifest, updated_at)
501
+ VALUES (?, ?, ?)
502
+ ON CONFLICT(tenant_id) DO UPDATE SET manifest = excluded.manifest, updated_at = excluded.updated_at`
503
+ ),
504
+ };
505
+
506
+ return {
507
+ async list() {
508
+ return stmts.list.all(tenantId).map(r => r.filename)
509
+ },
510
+ async read(filename) {
511
+ const row = stmts.read.get(tenantId, filename);
512
+ return row ? row.content : null
513
+ },
514
+ async write(filename, content) {
515
+ stmts.upsert.run(tenantId, filename, content, new Date().toISOString());
516
+ },
517
+ async readManifest() {
518
+ const row = stmts.readManifest.get(tenantId);
519
+ if (!row) return null
520
+ try {
521
+ return JSON.parse(row.manifest)
522
+ } catch {
523
+ return null
524
+ }
525
+ },
526
+ async writeManifest(manifest) {
527
+ stmts.writeManifest.run(tenantId, JSON.stringify(manifest), new Date().toISOString());
528
+ },
529
+ }
530
+ }
531
+
532
+ /**
533
+ * Multi-tenant orchestrator — 把多租戶 wiki 的常見管理邏輯封裝成單一 API
534
+ *
535
+ * 用法:
536
+ * import { createTenantManager, fsSource, sqliteStore } from '@jungtz/wiki-router'
537
+ *
538
+ * const manager = createTenantManager({
539
+ * router,
540
+ * source: tid => fsSource(`./knowledge/${tid}`),
541
+ * store: tid => sqliteStore({ db, tenantId: tid }),
542
+ * listTenants: () => fs.readdirSync('./knowledge'), // 選用:buildAll 才需要
543
+ * })
544
+ *
545
+ * await manager.buildAll()
546
+ * const ctx = await manager.getContext('問題', 'tenant-id')
547
+ */
548
+
549
+
550
+ const DEFAULT_AUTO_INDEX_HEADER = '# 知識庫目錄';
551
+
552
+ /**
553
+ * @param {import('./types').TenantManagerConfig} config
554
+ * @returns {import('./types').TenantManager}
555
+ */
556
+ function createTenantManager(config) {
557
+ if (!config || !config.router) {
558
+ throw new Error('[wiki-router] createTenantManager: router is required')
559
+ }
560
+ if (typeof config.source !== 'function') {
561
+ throw new Error('[wiki-router] createTenantManager: source must be a factory function (tenantId) => Source')
562
+ }
563
+ if (typeof config.store !== 'function') {
564
+ throw new Error('[wiki-router] createTenantManager: store must be a factory function (tenantId) => Store')
565
+ }
566
+
567
+ const {
568
+ router,
569
+ source: sourceFactory,
570
+ store: storeFactory,
571
+ listTenants,
572
+ autoIndex = true,
573
+ autoIndexHeader = DEFAULT_AUTO_INDEX_HEADER,
574
+ logger = console,
575
+ ...wikiConfig
576
+ } = config;
577
+
578
+ const wikiCache = new Map(); // tenantId → { wiki, source, store }
579
+ const buildPromises = new Map(); // tenantId → Promise<boolean>
580
+ const builtTenants = new Set(); // 已成功 build 過的租戶
581
+
582
+ function getWiki(tenantId) {
583
+ if (!wikiCache.has(tenantId)) {
584
+ const source = sourceFactory(tenantId);
585
+ const store = storeFactory(tenantId);
586
+ const wiki = createWikiRouter({ router, source, store, ...wikiConfig });
587
+ wikiCache.set(tenantId, { wiki, source, store });
588
+ }
589
+ return wikiCache.get(tenantId)
590
+ }
591
+
592
+ /** 從 store 已有 .md 檔合成一份簡單 Index.md(LLM 沒生成時的 fallback) */
593
+ async function synthesizeIndex(store) {
594
+ const filenames = (await store.list()).filter(f => f.endsWith('.md')).sort();
595
+ const lines = [autoIndexHeader];
596
+ for (const f of filenames) {
597
+ const content = await store.read(f);
598
+ if (!content) continue
599
+ const titleMatch = content.match(/^#\s+(.+)/m);
600
+ const title = titleMatch ? titleMatch[1].trim() : f.replace('.md', '');
601
+ const bodyStart = content.indexOf('\n\n');
602
+ const snippet =
603
+ bodyStart > 0 ? content.slice(bodyStart).trim().replace(/\n/g, ' ').slice(0, 80) : '';
604
+ lines.push(`- [${title}](${f}):${snippet}`);
605
+ }
606
+ await store.write('Index.md', lines.join('\n'));
607
+ }
608
+
609
+ async function build(tenantId, options = {}) {
610
+ const { force = false } = options;
611
+ if (buildPromises.has(tenantId)) return buildPromises.get(tenantId)
612
+
613
+ const promise = (async () => {
614
+ const { wiki, store } = getWiki(tenantId);
615
+ const ok = await wiki.build({ force });
616
+ if (!ok) return false
617
+
618
+ if (autoIndex) {
619
+ const hasIndex = await store.read('Index.md');
620
+ if (!hasIndex) {
621
+ await synthesizeIndex(store);
622
+ logger.log && logger.log(`[Wiki] Auto-generated Index.md for tenant: ${tenantId}`);
623
+ }
624
+ }
625
+
626
+ builtTenants.add(tenantId);
627
+ return true
628
+ })().finally(() => {
629
+ buildPromises.delete(tenantId);
630
+ });
631
+
632
+ buildPromises.set(tenantId, promise);
633
+ return promise
634
+ }
635
+
636
+ async function buildAll() {
637
+ if (typeof listTenants !== 'function') {
638
+ throw new Error('[wiki-router] buildAll: listTenants config is required')
639
+ }
640
+ const tenants = await listTenants();
641
+ if (!tenants || tenants.length === 0) {
642
+ logger.log && logger.log('[Wiki] No tenants to build');
643
+ return []
644
+ }
645
+ logger.log && logger.log(`[Wiki] Pre-building ${tenants.length} tenant(s): ${tenants.join(', ')}`);
646
+ const results = await Promise.allSettled(tenants.map(t => build(t)));
647
+ results.forEach((r, i) => {
648
+ if (r.status === 'rejected') {
649
+ logger.error && logger.error(
650
+ `[Wiki] Build failed for "${tenants[i]}":`,
651
+ r.reason && r.reason.message ? r.reason.message : r.reason
652
+ );
653
+ } else if (r.value === false) {
654
+ logger.warn && logger.warn(`[Wiki] Build returned false for "${tenants[i]}"`);
655
+ }
656
+ });
657
+ return results
658
+ }
659
+
660
+ /**
661
+ * 取得指定租戶的 wiki 上下文;尚未 build 完成時回傳空字串(不阻塞主流程)
662
+ */
663
+ async function getContext(prompt, tenantId) {
664
+ if (!builtTenants.has(tenantId)) {
665
+ if (buildPromises.has(tenantId)) {
666
+ logger.log && logger.log(`[Wiki] Build in progress for "${tenantId}", returning empty context`);
667
+ } else {
668
+ logger.log && logger.log(`[Wiki] No wiki built for "${tenantId}", returning empty context`);
669
+ }
670
+ return ''
671
+ }
672
+ return getWiki(tenantId).wiki.getContext(prompt)
673
+ }
674
+
675
+ return {
676
+ build,
677
+ buildAll,
678
+ getContext,
679
+ isBuilt: tenantId => builtTenants.has(tenantId),
680
+ listBuilt: () => Array.from(builtTenants),
681
+ }
682
+ }
683
+
684
+ /**
685
+ * wiki-router - LLM Wiki 知識庫路由引擎
686
+ *
687
+ * 使用方式 (檔案系統):
688
+ * const wiki = createWikiRouter({ router, knowledgeDir, outputDir })
689
+ *
690
+ * 使用方式 (自訂 adapter,例如 API + DB):
691
+ * const wiki = createWikiRouter({ router, source, store })
692
+ *
693
+ * 多租戶 (推薦):
694
+ * const manager = createTenantManager({ router, source, store, listTenants })
695
+ */
696
+
395
697
  exports.MERGE_PROMPT = MERGE_PROMPT;
396
698
  exports.ROUTER_PROMPT = ROUTER_PROMPT;
397
699
  exports.SPLIT_PROMPT = SPLIT_PROMPT;
700
+ exports.createTenantManager = createTenantManager;
398
701
  exports.createWikiRouter = createWikiRouter;
702
+ exports.ensureWikiTables = ensureWikiTables;
399
703
  exports.fsSource = fsSource;
400
704
  exports.fsStore = fsStore;
401
705
  exports.parseWikiOutput = parseWikiOutput;
706
+ exports.sqliteStore = sqliteStore;
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
 
@@ -48,4 +56,38 @@
48
56
  * @property {string} content - 檔案內容
49
57
  */
50
58
 
59
+ /**
60
+ * @typedef {Object} SqliteStoreConfig
61
+ * @property {*} db - better-sqlite3 相容實例(需有 prepare/exec 方法)
62
+ * @property {string} tenantId - 多租戶識別字串
63
+ * @property {string} [filesTable='wiki_files'] - 自訂 files 表名
64
+ * @property {string} [manifestsTable='wiki_manifests'] - 自訂 manifests 表名
65
+ */
66
+
67
+ /**
68
+ * @typedef {Object} TenantManagerConfig
69
+ * @property {import('@jungtz/ai-router').AIProviderRouter} router - AI Router 實例
70
+ * @property {(tenantId: string) => Source} source - Source factory(依 tenantId 建立)
71
+ * @property {(tenantId: string) => Store} store - Store factory(依 tenantId 建立)
72
+ * @property {() => string[] | Promise<string[]>} [listTenants] - 列出所有租戶;buildAll() 才需要
73
+ * @property {boolean} [autoIndex=true] - build 後若 store 沒有 Index.md,自動從現有 .md 合成一份目錄
74
+ * @property {string} [autoIndexHeader='# 知識庫目錄'] - autoIndex 的 header
75
+ * @property {{log?: Function, warn?: Function, error?: Function}} [logger=console] - 自訂 logger
76
+ * @property {string} [modelId] - 同 WikiRouterConfig.modelId
77
+ * @property {string} [routerModelId] - 同 WikiRouterConfig.routerModelId
78
+ * @property {number} [timeout] - 同 WikiRouterConfig.timeout
79
+ * @property {string} [splitPrompt] - 同 WikiRouterConfig.splitPrompt
80
+ * @property {string} [mergePrompt] - 同 WikiRouterConfig.mergePrompt
81
+ * @property {string} [routerPrompt] - 同 WikiRouterConfig.routerPrompt
82
+ */
83
+
84
+ /**
85
+ * @typedef {Object} TenantManager
86
+ * @property {(tenantId: string, options?: BuildOptions) => Promise<boolean>} build - 為單一租戶建構 wiki
87
+ * @property {() => Promise<PromiseSettledResult<boolean>[]>} buildAll - 並行建構所有租戶(需設定 listTenants)
88
+ * @property {(prompt: string, tenantId: string) => Promise<string>} getContext - 取得指定租戶的 wiki 上下文
89
+ * @property {(tenantId: string) => boolean} isBuilt - 該租戶是否已成功 build 過
90
+ * @property {() => string[]} listBuilt - 已成功 build 過的租戶清單
91
+ */
92
+
51
93
  export {}
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 檔案載入
@@ -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,17 +157,29 @@ 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
 
149
180
  /**
150
- * wiki-router - LLM Wiki 知識庫路由引擎
151
- *
152
- * 使用方式 (檔案系統):
153
- * const wiki = createWikiRouter({ router, knowledgeDir, outputDir })
154
- *
155
- * 使用方式 (自訂 adapter,例如 API + DB):
156
- * const wiki = createWikiRouter({ router, source, store })
181
+ * createWikiRouter 單一 wiki 實例工廠
182
+ * 已從 index.js 拆出,避免 tenant.js → index.js 的循環依賴。
157
183
  */
158
184
 
159
185
 
@@ -194,10 +220,6 @@ function createWikiRouter(config) {
194
220
 
195
221
  let modelsFetched = false;
196
222
 
197
- /**
198
- * 預先獲取本地模型清單(仿照 models.js 的 getModelsList 邏輯)
199
- * 確保 switchModel 能識別動態發現的 local 模型
200
- */
201
223
  async function ensureModels() {
202
224
  if (modelsFetched) return
203
225
  try {
@@ -210,7 +232,7 @@ function createWikiRouter(config) {
210
232
  if (pModels.length > 0) {
211
233
  router.config.providers[pName].models = pModels.map(m => ({
212
234
  model: m.model,
213
- description: m.description
235
+ description: m.description,
214
236
  }));
215
237
  }
216
238
  });
@@ -220,13 +242,6 @@ function createWikiRouter(config) {
220
242
  }
221
243
  }
222
244
 
223
- /**
224
- * 向 LLM 發送對話並取得完整回應
225
- * @param {{ role: string, content: string }[]} messages
226
- * @param {string} [overrideModelId]
227
- * @param {number} [overrideTimeout]
228
- * @returns {Promise<string|null>}
229
- */
230
245
  async function chat(messages, overrideModelId, overrideTimeout) {
231
246
  const wikiModelId = overrideModelId || modelId || process.env.WIKI_MODEL || 'ollama-local/gemma4:31b';
232
247
  const effectiveTimeout = overrideTimeout || timeout;
@@ -241,7 +256,6 @@ function createWikiRouter(config) {
241
256
  modelName = parts.slice(1).join('/');
242
257
  }
243
258
 
244
- // 使用 AI Router 進行對話
245
259
  if (router && provider) {
246
260
  await ensureModels();
247
261
  await router.switchModel(provider, modelName);
@@ -266,11 +280,16 @@ function createWikiRouter(config) {
266
280
  }
267
281
  }
268
282
 
269
- /**
270
- * 建構或更新 LLM Wiki 知識庫
271
- * @returns {Promise<boolean>}
272
- */
273
- async function build() {
283
+ function fingerprintsEqual(a, b) {
284
+ if (!a || !b) return false
285
+ const ak = Object.keys(a);
286
+ const bk = Object.keys(b);
287
+ if (ak.length !== bk.length) return false
288
+ return ak.every(k => a[k] === b[k])
289
+ }
290
+
291
+ async function build(options = {}) {
292
+ const { force = false } = options;
274
293
  try {
275
294
  const knowledgeKeys = await activeSource.list();
276
295
 
@@ -279,6 +298,24 @@ function createWikiRouter(config) {
279
298
  return false
280
299
  }
281
300
 
301
+ let currentFp = null;
302
+ const sourceSupportsFp = typeof activeSource.getFingerprint === 'function';
303
+ const storeSupportsManifest =
304
+ typeof activeStore.readManifest === 'function' &&
305
+ typeof activeStore.writeManifest === 'function';
306
+
307
+ if (sourceSupportsFp && storeSupportsManifest) {
308
+ currentFp = await activeSource.getFingerprint();
309
+ if (!force) {
310
+ const storedFp = await activeStore.readManifest();
311
+ const existingFiles = await activeStore.list();
312
+ if (existingFiles.length > 0 && fingerprintsEqual(currentFp, storedFp)) {
313
+ console.log('[Wiki] Source unchanged, skipping build (fingerprint match)');
314
+ return true
315
+ }
316
+ }
317
+ }
318
+
282
319
  let totalFiles = 0;
283
320
 
284
321
  for (const key of knowledgeKeys) {
@@ -320,6 +357,11 @@ function createWikiRouter(config) {
320
357
  }
321
358
 
322
359
  if (totalFiles === 0) return false
360
+
361
+ if (currentFp && storeSupportsManifest) {
362
+ await activeStore.writeManifest(currentFp);
363
+ }
364
+
323
365
  return true
324
366
  } catch (err) {
325
367
  console.error('[Wiki Generation Error]', err);
@@ -327,11 +369,6 @@ function createWikiRouter(config) {
327
369
  }
328
370
  }
329
371
 
330
- /**
331
- * 根據使用者問題取得相關的 Wiki 上下文
332
- * @param {string} prompt - 使用者問題
333
- * @returns {Promise<string>}
334
- */
335
372
  async function getContext(prompt) {
336
373
  try {
337
374
  let wikiFiles = await activeStore.list();
@@ -352,11 +389,15 @@ function createWikiRouter(config) {
352
389
  .replace('{{indexContent}}', indexContent);
353
390
 
354
391
  const routeModel = routerModelId || process.env.WIKI_ROUTER_MODEL || modelId || process.env.WIKI_MODEL;
355
- const output = await chat(
356
- [{ role: 'user', content: selectionPrompt }],
357
- routeModel
358
- );
359
- if (!output) return ''
392
+ let output = await chat([{ role: 'user', content: selectionPrompt }], routeModel);
393
+ if (!output || !output.trim()) {
394
+ console.warn('[Wiki] Router LLM returned empty, retrying once...');
395
+ output = await chat([{ role: 'user', content: selectionPrompt }], routeModel);
396
+ }
397
+ if (!output) {
398
+ console.warn('[Wiki] Router LLM returned empty after retry.');
399
+ return ''
400
+ }
360
401
 
361
402
  const selectedFilesStr = output.trim();
362
403
  if (selectedFilesStr === 'NONE' || !selectedFilesStr) {
@@ -380,7 +421,6 @@ function createWikiRouter(config) {
380
421
  console.log(`[Wiki] LLM correctly routed to ${loadedCount} file(s): ${selectedFiles.join(', ')}`);
381
422
  return relevantContext
382
423
  }
383
-
384
424
  } catch (err) {
385
425
  console.error('[Wiki Context Error]', err);
386
426
  }
@@ -390,4 +430,266 @@ function createWikiRouter(config) {
390
430
  return { build, getContext }
391
431
  }
392
432
 
393
- export { MERGE_PROMPT, ROUTER_PROMPT, SPLIT_PROMPT, createWikiRouter, fsSource, fsStore, parseWikiOutput };
433
+ /**
434
+ * SQLite adapter — 將 better-sqlite3(或相容 API)包裝成 Store 介面
435
+ *
436
+ * 用法:
437
+ * import { sqliteStore } from '@jungtz/wiki-router'
438
+ * const store = sqliteStore({ db, tenantId: 'hotel-001' })
439
+ *
440
+ * 不直接相依 better-sqlite3;只要傳入的 db 物件支援 prepare(sql) 與 exec(sql) 即可。
441
+ */
442
+
443
+ const DEFAULT_FILES_TABLE = 'wiki_files';
444
+ const DEFAULT_MANIFESTS_TABLE = 'wiki_manifests';
445
+
446
+ /**
447
+ * 建立 wiki_files / wiki_manifests 兩個表(CREATE TABLE IF NOT EXISTS,可重複呼叫)
448
+ * @param {*} db - better-sqlite3 相容實例
449
+ * @param {{ filesTable?: string, manifestsTable?: string }} [opts]
450
+ */
451
+ function ensureWikiTables(db, opts = {}) {
452
+ const filesTable = opts.filesTable || DEFAULT_FILES_TABLE;
453
+ const manifestsTable = opts.manifestsTable || DEFAULT_MANIFESTS_TABLE;
454
+ db.exec(`
455
+ CREATE TABLE IF NOT EXISTS ${filesTable} (
456
+ tenant_id TEXT NOT NULL,
457
+ filename TEXT NOT NULL,
458
+ content TEXT NOT NULL,
459
+ updated_at TEXT NOT NULL,
460
+ PRIMARY KEY (tenant_id, filename)
461
+ );
462
+ CREATE TABLE IF NOT EXISTS ${manifestsTable} (
463
+ tenant_id TEXT PRIMARY KEY,
464
+ manifest TEXT NOT NULL,
465
+ updated_at TEXT NOT NULL
466
+ );
467
+ `);
468
+ }
469
+
470
+ /**
471
+ * 建立 SQLite-backed Store adapter
472
+ * @param {{ db: *, tenantId: string, filesTable?: string, manifestsTable?: string }} config
473
+ * @returns {import('../types').Store}
474
+ */
475
+ function sqliteStore(config) {
476
+ if (!config || !config.db) {
477
+ throw new Error('[wiki-router] sqliteStore: db is required')
478
+ }
479
+ if (!config.tenantId) {
480
+ throw new Error('[wiki-router] sqliteStore: tenantId is required')
481
+ }
482
+ const { db, tenantId } = config;
483
+ const filesTable = config.filesTable || DEFAULT_FILES_TABLE;
484
+ const manifestsTable = config.manifestsTable || DEFAULT_MANIFESTS_TABLE;
485
+
486
+ ensureWikiTables(db, { filesTable, manifestsTable });
487
+
488
+ const stmts = {
489
+ list: db.prepare(`SELECT filename FROM ${filesTable} WHERE tenant_id = ? ORDER BY filename`),
490
+ read: db.prepare(`SELECT content FROM ${filesTable} WHERE tenant_id = ? AND filename = ?`),
491
+ upsert: db.prepare(
492
+ `INSERT INTO ${filesTable} (tenant_id, filename, content, updated_at)
493
+ VALUES (?, ?, ?, ?)
494
+ ON CONFLICT(tenant_id, filename) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at`
495
+ ),
496
+ readManifest: db.prepare(`SELECT manifest FROM ${manifestsTable} WHERE tenant_id = ?`),
497
+ writeManifest: db.prepare(
498
+ `INSERT INTO ${manifestsTable} (tenant_id, manifest, updated_at)
499
+ VALUES (?, ?, ?)
500
+ ON CONFLICT(tenant_id) DO UPDATE SET manifest = excluded.manifest, updated_at = excluded.updated_at`
501
+ ),
502
+ };
503
+
504
+ return {
505
+ async list() {
506
+ return stmts.list.all(tenantId).map(r => r.filename)
507
+ },
508
+ async read(filename) {
509
+ const row = stmts.read.get(tenantId, filename);
510
+ return row ? row.content : null
511
+ },
512
+ async write(filename, content) {
513
+ stmts.upsert.run(tenantId, filename, content, new Date().toISOString());
514
+ },
515
+ async readManifest() {
516
+ const row = stmts.readManifest.get(tenantId);
517
+ if (!row) return null
518
+ try {
519
+ return JSON.parse(row.manifest)
520
+ } catch {
521
+ return null
522
+ }
523
+ },
524
+ async writeManifest(manifest) {
525
+ stmts.writeManifest.run(tenantId, JSON.stringify(manifest), new Date().toISOString());
526
+ },
527
+ }
528
+ }
529
+
530
+ /**
531
+ * Multi-tenant orchestrator — 把多租戶 wiki 的常見管理邏輯封裝成單一 API
532
+ *
533
+ * 用法:
534
+ * import { createTenantManager, fsSource, sqliteStore } from '@jungtz/wiki-router'
535
+ *
536
+ * const manager = createTenantManager({
537
+ * router,
538
+ * source: tid => fsSource(`./knowledge/${tid}`),
539
+ * store: tid => sqliteStore({ db, tenantId: tid }),
540
+ * listTenants: () => fs.readdirSync('./knowledge'), // 選用:buildAll 才需要
541
+ * })
542
+ *
543
+ * await manager.buildAll()
544
+ * const ctx = await manager.getContext('問題', 'tenant-id')
545
+ */
546
+
547
+
548
+ const DEFAULT_AUTO_INDEX_HEADER = '# 知識庫目錄';
549
+
550
+ /**
551
+ * @param {import('./types').TenantManagerConfig} config
552
+ * @returns {import('./types').TenantManager}
553
+ */
554
+ function createTenantManager(config) {
555
+ if (!config || !config.router) {
556
+ throw new Error('[wiki-router] createTenantManager: router is required')
557
+ }
558
+ if (typeof config.source !== 'function') {
559
+ throw new Error('[wiki-router] createTenantManager: source must be a factory function (tenantId) => Source')
560
+ }
561
+ if (typeof config.store !== 'function') {
562
+ throw new Error('[wiki-router] createTenantManager: store must be a factory function (tenantId) => Store')
563
+ }
564
+
565
+ const {
566
+ router,
567
+ source: sourceFactory,
568
+ store: storeFactory,
569
+ listTenants,
570
+ autoIndex = true,
571
+ autoIndexHeader = DEFAULT_AUTO_INDEX_HEADER,
572
+ logger = console,
573
+ ...wikiConfig
574
+ } = config;
575
+
576
+ const wikiCache = new Map(); // tenantId → { wiki, source, store }
577
+ const buildPromises = new Map(); // tenantId → Promise<boolean>
578
+ const builtTenants = new Set(); // 已成功 build 過的租戶
579
+
580
+ function getWiki(tenantId) {
581
+ if (!wikiCache.has(tenantId)) {
582
+ const source = sourceFactory(tenantId);
583
+ const store = storeFactory(tenantId);
584
+ const wiki = createWikiRouter({ router, source, store, ...wikiConfig });
585
+ wikiCache.set(tenantId, { wiki, source, store });
586
+ }
587
+ return wikiCache.get(tenantId)
588
+ }
589
+
590
+ /** 從 store 已有 .md 檔合成一份簡單 Index.md(LLM 沒生成時的 fallback) */
591
+ async function synthesizeIndex(store) {
592
+ const filenames = (await store.list()).filter(f => f.endsWith('.md')).sort();
593
+ const lines = [autoIndexHeader];
594
+ for (const f of filenames) {
595
+ const content = await store.read(f);
596
+ if (!content) continue
597
+ const titleMatch = content.match(/^#\s+(.+)/m);
598
+ const title = titleMatch ? titleMatch[1].trim() : f.replace('.md', '');
599
+ const bodyStart = content.indexOf('\n\n');
600
+ const snippet =
601
+ bodyStart > 0 ? content.slice(bodyStart).trim().replace(/\n/g, ' ').slice(0, 80) : '';
602
+ lines.push(`- [${title}](${f}):${snippet}`);
603
+ }
604
+ await store.write('Index.md', lines.join('\n'));
605
+ }
606
+
607
+ async function build(tenantId, options = {}) {
608
+ const { force = false } = options;
609
+ if (buildPromises.has(tenantId)) return buildPromises.get(tenantId)
610
+
611
+ const promise = (async () => {
612
+ const { wiki, store } = getWiki(tenantId);
613
+ const ok = await wiki.build({ force });
614
+ if (!ok) return false
615
+
616
+ if (autoIndex) {
617
+ const hasIndex = await store.read('Index.md');
618
+ if (!hasIndex) {
619
+ await synthesizeIndex(store);
620
+ logger.log && logger.log(`[Wiki] Auto-generated Index.md for tenant: ${tenantId}`);
621
+ }
622
+ }
623
+
624
+ builtTenants.add(tenantId);
625
+ return true
626
+ })().finally(() => {
627
+ buildPromises.delete(tenantId);
628
+ });
629
+
630
+ buildPromises.set(tenantId, promise);
631
+ return promise
632
+ }
633
+
634
+ async function buildAll() {
635
+ if (typeof listTenants !== 'function') {
636
+ throw new Error('[wiki-router] buildAll: listTenants config is required')
637
+ }
638
+ const tenants = await listTenants();
639
+ if (!tenants || tenants.length === 0) {
640
+ logger.log && logger.log('[Wiki] No tenants to build');
641
+ return []
642
+ }
643
+ logger.log && logger.log(`[Wiki] Pre-building ${tenants.length} tenant(s): ${tenants.join(', ')}`);
644
+ const results = await Promise.allSettled(tenants.map(t => build(t)));
645
+ results.forEach((r, i) => {
646
+ if (r.status === 'rejected') {
647
+ logger.error && logger.error(
648
+ `[Wiki] Build failed for "${tenants[i]}":`,
649
+ r.reason && r.reason.message ? r.reason.message : r.reason
650
+ );
651
+ } else if (r.value === false) {
652
+ logger.warn && logger.warn(`[Wiki] Build returned false for "${tenants[i]}"`);
653
+ }
654
+ });
655
+ return results
656
+ }
657
+
658
+ /**
659
+ * 取得指定租戶的 wiki 上下文;尚未 build 完成時回傳空字串(不阻塞主流程)
660
+ */
661
+ async function getContext(prompt, tenantId) {
662
+ if (!builtTenants.has(tenantId)) {
663
+ if (buildPromises.has(tenantId)) {
664
+ logger.log && logger.log(`[Wiki] Build in progress for "${tenantId}", returning empty context`);
665
+ } else {
666
+ logger.log && logger.log(`[Wiki] No wiki built for "${tenantId}", returning empty context`);
667
+ }
668
+ return ''
669
+ }
670
+ return getWiki(tenantId).wiki.getContext(prompt)
671
+ }
672
+
673
+ return {
674
+ build,
675
+ buildAll,
676
+ getContext,
677
+ isBuilt: tenantId => builtTenants.has(tenantId),
678
+ listBuilt: () => Array.from(builtTenants),
679
+ }
680
+ }
681
+
682
+ /**
683
+ * wiki-router - LLM Wiki 知識庫路由引擎
684
+ *
685
+ * 使用方式 (檔案系統):
686
+ * const wiki = createWikiRouter({ router, knowledgeDir, outputDir })
687
+ *
688
+ * 使用方式 (自訂 adapter,例如 API + DB):
689
+ * const wiki = createWikiRouter({ router, source, store })
690
+ *
691
+ * 多租戶 (推薦):
692
+ * const manager = createTenantManager({ router, source, store, listTenants })
693
+ */
694
+
695
+ export { MERGE_PROMPT, ROUTER_PROMPT, SPLIT_PROMPT, createTenantManager, createWikiRouter, ensureWikiTables, fsSource, fsStore, parseWikiOutput, sqliteStore };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jungtz/wiki-router",
3
- "version": "1.0.5",
3
+ "version": "1.2.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
 
@@ -48,4 +56,38 @@
48
56
  * @property {string} content - 檔案內容
49
57
  */
50
58
 
59
+ /**
60
+ * @typedef {Object} SqliteStoreConfig
61
+ * @property {*} db - better-sqlite3 相容實例(需有 prepare/exec 方法)
62
+ * @property {string} tenantId - 多租戶識別字串
63
+ * @property {string} [filesTable='wiki_files'] - 自訂 files 表名
64
+ * @property {string} [manifestsTable='wiki_manifests'] - 自訂 manifests 表名
65
+ */
66
+
67
+ /**
68
+ * @typedef {Object} TenantManagerConfig
69
+ * @property {import('@jungtz/ai-router').AIProviderRouter} router - AI Router 實例
70
+ * @property {(tenantId: string) => Source} source - Source factory(依 tenantId 建立)
71
+ * @property {(tenantId: string) => Store} store - Store factory(依 tenantId 建立)
72
+ * @property {() => string[] | Promise<string[]>} [listTenants] - 列出所有租戶;buildAll() 才需要
73
+ * @property {boolean} [autoIndex=true] - build 後若 store 沒有 Index.md,自動從現有 .md 合成一份目錄
74
+ * @property {string} [autoIndexHeader='# 知識庫目錄'] - autoIndex 的 header
75
+ * @property {{log?: Function, warn?: Function, error?: Function}} [logger=console] - 自訂 logger
76
+ * @property {string} [modelId] - 同 WikiRouterConfig.modelId
77
+ * @property {string} [routerModelId] - 同 WikiRouterConfig.routerModelId
78
+ * @property {number} [timeout] - 同 WikiRouterConfig.timeout
79
+ * @property {string} [splitPrompt] - 同 WikiRouterConfig.splitPrompt
80
+ * @property {string} [mergePrompt] - 同 WikiRouterConfig.mergePrompt
81
+ * @property {string} [routerPrompt] - 同 WikiRouterConfig.routerPrompt
82
+ */
83
+
84
+ /**
85
+ * @typedef {Object} TenantManager
86
+ * @property {(tenantId: string, options?: BuildOptions) => Promise<boolean>} build - 為單一租戶建構 wiki
87
+ * @property {() => Promise<PromiseSettledResult<boolean>[]>} buildAll - 並行建構所有租戶(需設定 listTenants)
88
+ * @property {(prompt: string, tenantId: string) => Promise<string>} getContext - 取得指定租戶的 wiki 上下文
89
+ * @property {(tenantId: string) => boolean} isBuilt - 該租戶是否已成功 build 過
90
+ * @property {() => string[]} listBuilt - 已成功 build 過的租戶清單
91
+ */
92
+
51
93
  export {}