w-dispatch-ai 1.0.2 → 1.0.3

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.
Files changed (44) hide show
  1. package/README.md +60 -5
  2. package/dist/w-dispatch-ai.umd.js +2 -2
  3. package/dist/w-dispatch-ai.umd.js.map +1 -1
  4. package/docs/WDispatchAi.mjs.html +8 -4
  5. package/docs/adapters.mjs.html +9 -7
  6. package/docs/dispatchAi.mjs.html +2 -2
  7. package/docs/dispatchAiFallback.mjs.html +2 -2
  8. package/docs/dispatchAiWkf.mjs.html +172 -0
  9. package/docs/dispatchAntigravity.mjs.html +2 -2
  10. package/docs/dispatchApiOpenaiCompat.mjs.html +483 -0
  11. package/docs/dispatchClaude.mjs.html +2 -2
  12. package/docs/dispatchCodex.mjs.html +2 -2
  13. package/docs/dispatchOpencode.mjs.html +2 -2
  14. package/docs/getCliArgs.mjs.html +2 -2
  15. package/docs/getErrorResult.mjs.html +2 -2
  16. package/docs/global.html +5747 -1467
  17. package/docs/index.html +2 -2
  18. package/docs/wkf_callAiWithFallback.mjs.html +281 -0
  19. package/docs/wkf_extractJsonLoose.mjs.html +180 -0
  20. package/docs/wkf_runFanout.mjs.html +227 -0
  21. package/docs/wkf_runFanoutPipeline.mjs.html +178 -0
  22. package/docs/wkf_runRolePipeline.mjs.html +195 -0
  23. package/g.mjs +11 -2
  24. package/package.json +1 -1
  25. package/src/WDispatchAi.mjs +6 -2
  26. package/src/adapters.mjs +7 -5
  27. package/src/dispatchAiWkf.mjs +100 -0
  28. package/src/dispatchApiOpenaiCompat.mjs +411 -0
  29. package/src/wkf/callAiWithFallback.mjs +209 -0
  30. package/src/wkf/extractJsonLoose.mjs +108 -0
  31. package/src/wkf/runFanout.mjs +155 -0
  32. package/src/wkf/runFanoutPipeline.mjs +106 -0
  33. package/src/wkf/runRolePipeline.mjs +123 -0
  34. package/test/tools/fakeServerForApiTest.mjs +137 -0
  35. package/test/unit-WDispatchAi.test.mjs +13 -6
  36. package/test/unit-adapters.test.mjs +5 -3
  37. package/test/unit-callAiWithFallback.test.mjs +146 -0
  38. package/test/unit-dispatchAi.test.mjs +1 -1
  39. package/test/unit-dispatchAiWkf.test.mjs +118 -0
  40. package/test/unit-dispatchApiOpenaiCompat.test.mjs +233 -0
  41. package/test/unit-extractJsonLoose.test.mjs +78 -0
  42. package/test/unit-runFanout.test.mjs +166 -0
  43. package/test/unit-runFanoutPipeline.test.mjs +86 -0
  44. package/test/unit-runRolePipeline.test.mjs +163 -0
@@ -0,0 +1,209 @@
1
+ import get from 'lodash-es/get.js'
2
+ import isearr from 'wsemi/src/isearr.mjs'
3
+ import isestr from 'wsemi/src/isestr.mjs'
4
+ import isfun from 'wsemi/src/isfun.mjs'
5
+ import isobj from 'wsemi/src/isobj.mjs'
6
+ import dispatchAiFallback from '../dispatchAiFallback.mjs'
7
+ import extractJsonLoose from './extractJsonLoose.mjs'
8
+
9
+
10
+ // callAiWithFallback.mjs — 工作流的最小呼叫單元: 一個「AI名額」=主模型+自帶遞補鏈
11
+ //
12
+ // 【設計】呼叫端以名稱宣告主模型與遞補(use:'deepseek', fallback:['agnes-ai','sonnet']),
13
+ // 本函數依providers定義表把名稱展開成dispatchAiFallback的providers陣列
14
+ // (順序即優先序), 故「各AI名額可各自指定fallback」天然成立。
15
+ // 名稱查無定義時視為設定錯誤直接回報(fail fast), 不靜默略過——
16
+ // 否則fallback名稱打錯字只會讓遞補鏈無感知地短一截, 事後無從察覺。
17
+ //
18
+ // 【JSON驗證接進遞補層】parse+check包成dispatchAiFallback的validate:
19
+ // 回覆非法(空回、截斷、缺欄位)時為OUTPUT_VALIDATION_FAILED, 遞補層視為
20
+ // 與金鑰無關之失敗而「整組跳過換下一家」(不換組內金鑰——同模型換金鑰仍是
21
+ // 同樣的產出習慣); 端點不穩而偶發空回的模型, 以maxRetries調高令同鍵重試。
22
+ //
23
+ // 【防寫檔前綴】agentic CLI對cwd隔離免疫(會自行解析專案根目錄寫檔),
24
+ // 故預設在prompt前掛「禁止建檔」約束(實測有效); 不需要時傳promptPrefix:''關閉。
25
+ // 殷鑑: 2026-08-10執行任務歷史.md遭AI覆寫、評比腳本繞過前綴又產生根目錄孤兒檔。
26
+
27
+
28
+ //預設防寫檔前綴
29
+ let NO_SIDE_EFFECT = [
30
+ '【執行約束】你只需把結果輸出在回覆內容中。',
31
+ '禁止建立、修改或刪除任何檔案,禁止執行任何指令——呼叫端只讀取你的回覆文字,',
32
+ '任何寫入磁碟的動作都不會被採用,只會製造無人讀取的垃圾檔。',
33
+ '', '',
34
+ ].join('\n')
35
+
36
+
37
+ /**
38
+ * 依providers定義表把「名稱規格」展開成dispatchAiFallback的providers陣列
39
+ *
40
+ * @param {Object} providers 輸入定義表物件(名稱 → 條目)
41
+ * @param {Object} spec 輸入名額規格物件{ use, fallback }
42
+ * @returns {Object} 回傳物件,內含chain(條目物件陣列,id一律用名稱)與missing(查無定義之名稱字串陣列)
43
+ * @example
44
+ *
45
+ * import { buildChain } from './src/wkf/callAiWithFallback.mjs'
46
+ *
47
+ * let providers = { a: { kind: 'claude' }, b: { kind: 'codex' } }
48
+ * console.log(buildChain(providers, { use: 'a', fallback: ['b', 'c'] }))
49
+ * // => { chain: [ { id: 'a', kind: 'claude' }, { id: 'b', kind: 'codex' } ], missing: [ 'c' ] }
50
+ *
51
+ */
52
+ function buildChain(providers, spec) {
53
+ let names = [get(spec, 'use', '')]
54
+ let fallback = get(spec, 'fallback', null)
55
+ if (isearr(fallback)) {
56
+ names = [...names, ...fallback]
57
+ }
58
+ let chain = []
59
+ let missing = []
60
+ for (let name of names) {
61
+ let entry = get(providers, name, null)
62
+ if (isobj(entry)) {
63
+ chain.push({ id: name, ...entry }) //id一律用名稱, 令游標與事件可讀
64
+ }
65
+ else {
66
+ missing.push(String(name))
67
+ }
68
+ }
69
+ return { chain, missing }
70
+ }
71
+
72
+
73
+ /**
74
+ * 呼叫一個AI名額(主模型+自帶遞補鏈),回覆經parse+check驗證後回傳結構化結果
75
+ *
76
+ * 特點:
77
+ * spec.use為主模型名稱、spec.fallback為遞補名稱陣列,依序展開為遞補鏈,名稱查無定義即回報錯誤(fail fast);
78
+ * parse+check接進遞補層之validate——非法回覆視為該家失敗而自動換下一家,不把壞結果帶回來;
79
+ * 預設掛防寫檔前綴(promptPrefix傳空字串可關閉);
80
+ * 本函數不會reject,一律以結果物件之ok與error欄位回報成敗
81
+ *
82
+ * @param {String} prompt 輸入提示詞字串
83
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
84
+ * @param {Object} opt.providers 輸入provider定義表物件(名稱 → dispatchAiFallback條目,條目內含kind、model、keys、exe、provider、config等)
85
+ * @param {Object} opt.spec 輸入名額規格物件{ use:'主模型名稱', fallback:['遞補名稱', ...] }
86
+ * @param {Function} [opt.check=null] 輸入結果檢核函數(json)=>Boolean,預設null代表只要能解析出JSON即通過
87
+ * @param {Function} [opt.parse=extractJsonLoose] 輸入回覆解析函數(stdout)=>Object|null,預設寬鬆JSON抽取
88
+ * @param {Boolean} [opt.rawText=false] 輸入是否以純文字模式運作布林值,true代表不解析JSON(json欄位為修剪後文字、check收文字),預設false
89
+ * @param {String} [opt.promptPrefix=防寫檔約束] 輸入prompt前綴字串,預設為防寫檔約束,傳''關閉
90
+ * @param {Number} [opt.timeoutMs=300000] 輸入單次嘗試逾時毫秒正整數,預設300000
91
+ * @param {Number} [opt.budgetMs=null] 輸入整條遞補鏈之時間預算毫秒正整數,預設null代表不限
92
+ * @param {Number} [opt.maxRetries=0] 輸入同家重試次數非負整數,預設0(韌性交給遞補;端點不穩偶發空回之模型可調高令同鍵重試)
93
+ * @param {String} [opt.cwd=process.cwd()] 輸入子進程工作目錄字串,預設process.cwd()
94
+ * @param {Object} [opt.store=null] 輸入游標持久化物件{get,set},預設null代表用行程內記憶體
95
+ * @param {Function} [opt.onEvent=null] 輸入遞補層事件回調函數,預設null
96
+ * @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(是否取得可用結果布林值)、json(解析後物件,rawText模式下為文字)、providerId(實際使用之名稱)、keyIndex、keyId、ms(總耗時毫秒)、tried(遞補嘗試歷程陣列)、error(錯誤訊息字串),本函數不會reject
97
+ * @example
98
+ * //need cli in system PATH
99
+ *
100
+ * import callAiWithFallback from './src/wkf/callAiWithFallback.mjs'
101
+ *
102
+ * let providers = {
103
+ * 'deepseek': { kind: 'opencode', model: 'opencode/deepseek-v4-flash-free', provider: 'opencode', keys: ['sk-xxx'] },
104
+ * 'sonnet': { kind: 'claude', model: 'sonnet' },
105
+ * }
106
+ *
107
+ * let test = async () => {
108
+ *
109
+ * let r = await callAiWithFallback('只回覆JSON: {"a":1}', {
110
+ * providers,
111
+ * spec: { use: 'deepseek', fallback: ['sonnet'] },
112
+ * check: (j) => j.a === 1,
113
+ * })
114
+ * console.log(r.ok, r.json, r.providerId)
115
+ * // => true { a: 1 } 'deepseek'
116
+ *
117
+ * }
118
+ * await test()
119
+ * .catch((err) => {
120
+ * console.log(err)
121
+ * })
122
+ *
123
+ */
124
+ async function callAiWithFallback(prompt, opt = {}) {
125
+ let t0 = Date.now()
126
+
127
+ if (!isestr(prompt)) {
128
+ return { ok: false, json: null, error: 'prompt must be a non-empty string', ms: 0, tried: [] }
129
+ }
130
+
131
+ let providers = get(opt, 'providers', null)
132
+ let spec = get(opt, 'spec', null)
133
+ if (!isobj(providers) || !isobj(spec)) {
134
+ return { ok: false, json: null, error: `no valid provider for spec: ${JSON.stringify(spec)}`, ms: 0, tried: [] }
135
+ }
136
+
137
+ //buildChain, 名稱查無定義即回報(fail fast), 不讓打錯字的fallback靜默消失
138
+ let { chain, missing } = buildChain(providers, spec)
139
+ if (missing.length > 0) {
140
+ return { ok: false, json: null, error: `unknown provider name(s): ${missing.join(', ')}`, ms: 0, tried: [] }
141
+ }
142
+ if (chain.length === 0) {
143
+ return { ok: false, json: null, error: `no valid provider for spec: ${JSON.stringify(spec)}`, ms: 0, tried: [] }
144
+ }
145
+
146
+ let rawText = get(opt, 'rawText', false) === true
147
+ let parse = get(opt, 'parse', null)
148
+ if (!isfun(parse)) {
149
+ parse = extractJsonLoose
150
+ }
151
+ let check = get(opt, 'check', null)
152
+ if (!isfun(check)) {
153
+ check = null
154
+ }
155
+
156
+ let promptPrefix = get(opt, 'promptPrefix', null)
157
+ if (!isestr(promptPrefix)) {
158
+ promptPrefix = (promptPrefix === '') ? '' : NO_SIDE_EFFECT
159
+ }
160
+
161
+ //validate接進遞補層: 非法回覆=這一家失敗, 遞補層換下一家
162
+ let validate = (stdout) => {
163
+ if (rawText) {
164
+ let s = String(stdout || '').trim()
165
+ if (s === '') {
166
+ return false
167
+ }
168
+ return check ? check(s) === true : true
169
+ }
170
+ let j = parse(stdout)
171
+ if (j === null) {
172
+ return false
173
+ }
174
+ return check ? check(j) === true : true
175
+ }
176
+
177
+ let r = await dispatchAiFallback(promptPrefix + prompt, {
178
+ providers: chain,
179
+ validate,
180
+ timeoutMs: get(opt, 'timeoutMs', null) || 300000,
181
+ budgetMs: get(opt, 'budgetMs', null) || undefined,
182
+ maxRetries: get(opt, 'maxRetries', null) || 0,
183
+ cwd: get(opt, 'cwd', null) || process.cwd(),
184
+ store: get(opt, 'store', null) || undefined,
185
+ onEvent: get(opt, 'onEvent', null) || undefined,
186
+ })
187
+
188
+ //result, 已過validate故此處parse必然成功(同一解析器), 重解析僅為取出物件
189
+ let result = null
190
+ if (r.ok) {
191
+ result = rawText ? String(r.stdout || '').trim() : parse(r.stdout)
192
+ }
193
+ let providerId = get(r, 'providerId', null)
194
+ let keyIndex = get(r, 'keyIndex', null)
195
+ return {
196
+ ok: r.ok && result !== null,
197
+ json: result, //rawText模式下此欄為文字
198
+ providerId,
199
+ keyIndex,
200
+ keyId: (keyIndex === null) ? providerId : `${providerId}#${keyIndex}`,
201
+ ms: Date.now() - t0,
202
+ tried: get(r, 'tried', []),
203
+ error: r.ok ? '' : get(r, 'error', 'unknown error'),
204
+ }
205
+ }
206
+
207
+
208
+ export default callAiWithFallback
209
+ export { buildChain, NO_SIDE_EFFECT }
@@ -0,0 +1,108 @@
1
+ // extractJsonLoose.mjs — 從AI回覆文字中寬鬆抽取JSON(工作流層預設解析器, 可被注入覆寫)
2
+ //
3
+ // 【為何需要】各家CLI的回覆常帶code fence、前後說明文字、ANSI色碼;
4
+ // 直接JSON.parse必炸。本函數做清理後以「括號配對」找出第一個完整
5
+ // 物件或陣列再解析。呼叫端若有更強的解析器(如含截斷搶救), 以opt.parse注入即可。
6
+
7
+
8
+ /**
9
+ * 從文字中抽取第一個完整的JSON物件或陣列
10
+ *
11
+ * 特點:
12
+ * 先去除ANSI色碼與code fence標記後嘗試整段解析(最常見情境之最快路徑);
13
+ * 整段非法時自第一個`{`或`[`起以括號配對(跳過字串與跳脫)取得第一個完整片段再解析;
14
+ * 僅接受物件與陣列,純量(字串/數字/布林)回傳null;
15
+ * 括號未閉合(輸出被截斷)或片段非法一律回傳null,不throw
16
+ *
17
+ * @param {String} text 輸入AI回覆文字字串
18
+ * @returns {Object|Array|null} 回傳解析成功之物件或陣列,失敗回傳null
19
+ * @example
20
+ *
21
+ * import extractJsonLoose from './src/wkf/extractJsonLoose.mjs'
22
+ *
23
+ * console.log(extractJsonLoose('{"a":1}'))
24
+ * // => { a: 1 }
25
+ *
26
+ * console.log(extractJsonLoose('說明文字\n```json\n{"a":1}\n```\n後記'))
27
+ * // => { a: 1 }
28
+ *
29
+ * console.log(extractJsonLoose('{"a":1')) //截斷
30
+ * // => null
31
+ *
32
+ * console.log(extractJsonLoose('純文字回覆'))
33
+ * // => null
34
+ *
35
+ */
36
+ function extractJsonLoose(text) {
37
+ let s = String(text || '')
38
+
39
+ //去ANSI色碼與code fence標記
40
+ s = s.replace(new RegExp(String.fromCharCode(27) + '\\[[0-9;]*m', 'g'), '')
41
+ s = s.replace(/```(?:json)?/g, '')
42
+ s = s.trim()
43
+ if (s === '') {
44
+ return null
45
+ }
46
+
47
+ //整段直接解析(最常見情境, 最快路徑)
48
+ try {
49
+ let j = JSON.parse(s)
50
+ if (j !== null && typeof j === 'object') {
51
+ return j
52
+ }
53
+ }
54
+ catch (e) { /* 進入括號配對路徑 */ }
55
+
56
+ //括號配對: 自第一個{或[起, 逐字元追蹤深度(跳過字串與跳脫), 取得第一個完整片段
57
+ let start = -1
58
+ for (let i = 0; i < s.length; i++) {
59
+ if (s[i] === '{' || s[i] === '[') {
60
+ start = i
61
+ break
62
+ }
63
+ }
64
+ if (start < 0) {
65
+ return null
66
+ }
67
+ let depth = 0
68
+ let inStr = false
69
+ let esc = false
70
+ for (let i = start; i < s.length; i++) {
71
+ let c = s[i]
72
+ if (inStr) {
73
+ if (esc) {
74
+ esc = false
75
+ }
76
+ else if (c === '\\') {
77
+ esc = true
78
+ }
79
+ else if (c === '"') {
80
+ inStr = false
81
+ }
82
+ continue
83
+ }
84
+ if (c === '"') {
85
+ inStr = true
86
+ }
87
+ else if (c === '{' || c === '[') {
88
+ depth++
89
+ }
90
+ else if (c === '}' || c === ']') {
91
+ depth--
92
+ if (depth === 0) {
93
+ try {
94
+ let j = JSON.parse(s.slice(start, i + 1))
95
+ if (j !== null && typeof j === 'object') {
96
+ return j
97
+ }
98
+ }
99
+ catch (e) { /* 片段仍非法, 視為失敗 */ }
100
+ return null
101
+ }
102
+ }
103
+ }
104
+ return null //括號未閉合(輸出被截斷)
105
+ }
106
+
107
+
108
+ export default extractJsonLoose
@@ -0,0 +1,155 @@
1
+ import get from 'lodash-es/get.js'
2
+ import isearr from 'wsemi/src/isearr.mjs'
3
+ import isestr from 'wsemi/src/isestr.mjs'
4
+ import isfun from 'wsemi/src/isfun.mjs'
5
+ import ispint from 'wsemi/src/ispint.mjs'
6
+ import cint from 'wsemi/src/cint.mjs'
7
+ import callAiWithFallback from './callAiWithFallback.mjs'
8
+
9
+
10
+ // runFanout.mjs — Fanout工作流: 多開執行+單點整合收斂
11
+ //
12
+ // 【結構】前段(fanout)並行開N個AI名額執行同一任務, 各名額可指定主模型與自帶fallback;
13
+ // 後段開單一AI名額把成功候選整合成最終版; 整合成果即工作流成果。
14
+ //
15
+ // 【部分接受】個別名額失敗不炸整輪: 成功候選達minCandidates才進整合;
16
+ // 未達門檻(含恰為1份)時不硬整合, 直接以首位成功候選為成果(integrated:false)——
17
+ // 單稿無從「整合」, 硬呼叫整合者只是空耗一次額度。
18
+ // 全部失敗才回ok:false, 且已成功候選仍完整回傳(便於接續重試)。
19
+ //
20
+ // 【實測依據(2026-08-10評比)】整合者是本流程的單點故障——端點不穩的模型
21
+ // (如偶發靜默空回者)當整合者時, 靠spec.fallback遞補或maxRetries調高才能保住整條鏈。
22
+
23
+
24
+ /**
25
+ * 預設整合提示詞模板:把成功候選JSON併入整合任務
26
+ *
27
+ * @param {Array} candidates 輸入成功候選物件陣列
28
+ * @param {Object} [opt={}] 輸入設定物件(取schema作為輸出格式示意),預設{}
29
+ * @returns {String} 回傳整合提示詞字串
30
+ */
31
+ function defaultIntegratePrompt(candidates, opt = {}) {
32
+ let schema = get(opt, 'schema', '')
33
+ let schemaLine = isestr(schema) ? `\n只回覆 JSON 物件,不要任何其他說明文字,格式與候選相同:\n${schema}\n` : '\n只回覆 JSON 物件,不要任何其他說明文字,格式與候選相同。\n'
34
+ return `你是整合者。以下是同一任務由 ${candidates.length} 個獨立執行產生的候選結果(JSON),請整合成單一最佳版本:擇優合併、去重、保留最完整的證據標注與爭議呈現,不可加入候選中沒有的數字或結論。
35
+ ${schemaLine}
36
+ ${candidates.map((c, i) => `【候選 ${i + 1}】\n${JSON.stringify(c)}`).join('\n\n')}`
37
+ }
38
+
39
+
40
+ /**
41
+ * 執行Fanout工作流:多開執行與單點整合
42
+ *
43
+ * 特點:
44
+ * 前段各名額並行執行同一任務,各名額可指定主模型(use)與自帶遞補鏈(fallback);
45
+ * 後段為單一整合名額,同樣可帶遞補鏈;
46
+ * 個別名額失敗不中斷整輪,成功候選未達minCandidates時以首位候選為成果(integrated:false)不硬整合;
47
+ * 成功候選完整保留於回傳(部分接受、便於接續重試整合);
48
+ * 本函數不會reject
49
+ *
50
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
51
+ * @param {Object} opt.providers 輸入provider定義表物件(名稱 → 條目),透傳callAiWithFallback
52
+ * @param {String} opt.task 輸入前段各名額共用之任務提示詞字串
53
+ * @param {Array} opt.agents 輸入前段名額規格陣列,各元素{ use, fallback, maxRetries?, timeoutMs? }等(除use/fallback外之鍵覆寫該名額呼叫設定)
54
+ * @param {Object} opt.integrate 輸入整合名額規格物件{ use, fallback, prompt?, ... },prompt可為(candidates)=>String自訂整合提示詞,省略用預設模板
55
+ * @param {Function} [opt.check=null] 輸入候選與終稿共用之檢核函數(json)=>Boolean,預設null
56
+ * @param {String} [opt.schema=''] 輸入輸出格式示意字串,供預設整合模板嵌入,預設''
57
+ * @param {Number} [opt.minCandidates=2] 輸入進入整合所需之最少成功候選數正整數,未達門檻以首位候選為成果,預設2
58
+ * @param {Object} [opt.callOpt={}] 輸入透傳callAiWithFallback之共用設定(cwd、store、onEvent、timeoutMs、promptPrefix等),預設{}
59
+ * @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(布林值)、result(工作流成果)、integrated(是否經過整合布林值)、agents(各名額完整結果陣列)、candidates(成功候選陣列)、integrateDetail(整合呼叫完整結果)、totalMs(總耗時毫秒)、error(錯誤訊息字串),本函數不會reject
60
+ * @example
61
+ * //need cli in system PATH
62
+ *
63
+ * import runFanout from './src/wkf/runFanout.mjs'
64
+ *
65
+ * let providers = {
66
+ * 'deepseek': { kind: 'opencode', model: 'opencode/deepseek-v4-flash-free', provider: 'opencode', keys: ['sk-xxx'] },
67
+ * 'sonnet': { kind: 'claude', model: 'sonnet' },
68
+ * }
69
+ *
70
+ * let test = async () => {
71
+ *
72
+ * let r = await runFanout({
73
+ * providers,
74
+ * task: '分析並只回覆JSON: {"essence":"..."}',
75
+ * agents: [
76
+ * { use: 'deepseek', fallback: ['sonnet'] },
77
+ * { use: 'sonnet' },
78
+ * ],
79
+ * integrate: { use: 'sonnet' },
80
+ * check: (j) => !!j.essence,
81
+ * })
82
+ * console.log(r.ok, r.integrated, r.candidates.length)
83
+ * // => true true 2
84
+ *
85
+ * }
86
+ * await test()
87
+ * .catch((err) => {
88
+ * console.log(err)
89
+ * })
90
+ *
91
+ */
92
+ async function runFanout(opt = {}) {
93
+ let t0 = Date.now()
94
+ let providers = get(opt, 'providers', null)
95
+ let task = get(opt, 'task', '')
96
+ let agents = get(opt, 'agents', null)
97
+ let integrate = get(opt, 'integrate', null)
98
+ let check = get(opt, 'check', null)
99
+ let callOpt = get(opt, 'callOpt', {})
100
+
101
+ if (!isestr(task)) {
102
+ return { ok: false, result: null, integrated: false, agents: [], candidates: [], totalMs: 0, error: 'task must be a non-empty string' }
103
+ }
104
+ if (!isearr(agents)) {
105
+ return { ok: false, result: null, integrated: false, agents: [], candidates: [], totalMs: 0, error: 'agents must be a non-empty array' }
106
+ }
107
+
108
+ let minCandidates = get(opt, 'minCandidates', null)
109
+ if (!ispint(minCandidates)) {
110
+ minCandidates = 2
111
+ }
112
+ else {
113
+ minCandidates = cint(minCandidates)
114
+ }
115
+
116
+ //前段: 並行多開, 個別失敗不炸整輪
117
+ let rsAgents = await Promise.all(agents.map((spec) => {
118
+ let { use, fallback, ...overrides } = spec
119
+ return callAiWithFallback(task, { ...callOpt, ...overrides, providers, spec: { use, fallback }, check })
120
+ }))
121
+ let candidates = rsAgents.filter((r) => r.ok).map((r) => r.json)
122
+
123
+ //全部失敗
124
+ if (candidates.length === 0) {
125
+ return { ok: false, result: null, integrated: false, agents: rsAgents, candidates, totalMs: Date.now() - t0, error: 'all agents failed' }
126
+ }
127
+
128
+ //未達整合門檻(含恰為1份): 不硬整合, 以首位成功候選為成果
129
+ if (candidates.length === 1 || candidates.length < minCandidates) {
130
+ return { ok: true, result: candidates[0], integrated: false, agents: rsAgents, candidates, totalMs: Date.now() - t0, error: '' }
131
+ }
132
+
133
+ //後段: 整合
134
+ if (!integrate || !isestr(get(integrate, 'use', ''))) {
135
+ return { ok: false, result: null, integrated: false, agents: rsAgents, candidates, totalMs: Date.now() - t0, error: 'integrate spec (with use) is required' }
136
+ }
137
+ let { use, fallback, prompt: intPromptFn, ...intOverrides } = integrate
138
+ let intPrompt = isfun(intPromptFn) ? intPromptFn(candidates) : defaultIntegratePrompt(candidates, opt)
139
+ let rInt = await callAiWithFallback(intPrompt, { ...callOpt, ...intOverrides, providers, spec: { use, fallback }, check })
140
+
141
+ return {
142
+ ok: rInt.ok,
143
+ result: rInt.ok ? rInt.json : null,
144
+ integrated: rInt.ok,
145
+ agents: rsAgents,
146
+ candidates, //即使整合失敗, 成功候選仍完整回傳, 供接續重試整合(只重跑整合段)
147
+ integrateDetail: rInt,
148
+ totalMs: Date.now() - t0,
149
+ error: rInt.ok ? '' : `integrate failed: ${rInt.error}`,
150
+ }
151
+ }
152
+
153
+
154
+ export default runFanout
155
+ export { defaultIntegratePrompt }
@@ -0,0 +1,106 @@
1
+ import get from 'lodash-es/get.js'
2
+ import runFanout from './runFanout.mjs'
3
+ import runRolePipeline from './runRolePipeline.mjs'
4
+
5
+
6
+ // runFanoutPipeline.mjs — FanoutPipeline工作流(Fanout+RolePipeline): 多開收斂成果接串行角色鏈
7
+ //
8
+ // 【結構】先跑runFanout(多開 → 整合), 其成果作為runRolePipeline的input
9
+ // (各階段以ctx.input取用), 最末階段回傳即工作流成果。
10
+ //
11
+ // 【實測依據(2026-08-10評比)】Fanout+RolePipeline是品質天花板: 前段的多樣性擇優給出最豐底稿、
12
+ // 審計鏈再修幻覺與證據標注; 六模型的歷史最高品質全部出現在此組合(或與純RolePipeline並列)。
13
+ //
14
+ // 【部分接受】前段失敗即回(附前段完整明細, 含已成功候選);
15
+ // 後段失敗回傳前段成果與後段已完成階段——呼叫端可只重跑失敗段。
16
+
17
+
18
+ /**
19
+ * 執行FanoutPipeline工作流(Fanout+RolePipeline):多開+整合+串行角色鏈
20
+ *
21
+ * 特點:
22
+ * 前段同runFanout(agents各名額可自帶fallback、integrate單點整合);
23
+ * 後段同runRolePipeline(stages各階段可自帶AI/fallback/提示詞),其input即前段成果;
24
+ * 本函數不會reject
25
+ *
26
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
27
+ * @param {Object} opt.providers 輸入provider定義表物件(名稱 → 條目)
28
+ * @param {String} opt.task 輸入前段各名額共用之任務提示詞字串
29
+ * @param {Array} opt.agents 輸入前段名額規格陣列(同runFanout)
30
+ * @param {Object} opt.integrate 輸入前段整合名額規格物件(同runFanout)
31
+ * @param {Array} opt.stages 輸入後段階段規格陣列(同runRolePipeline),各階段以ctx.input取得前段成果
32
+ * @param {Function} [opt.check=null] 輸入前段共用檢核函數,後段各階段自帶check,預設null
33
+ * @param {String} [opt.schema=''] 輸入輸出格式示意字串(供前段預設整合模板),預設''
34
+ * @param {Number} [opt.minCandidates=2] 輸入前段整合門檻正整數,預設2
35
+ * @param {Object} [opt.callOpt={}] 輸入透傳兩段之共用呼叫設定,預設{}
36
+ * @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(布林值)、result(工作流成果)、A(前段runFanout完整結果)、B(後段runRolePipeline完整結果)、totalMs(總耗時毫秒)、error(錯誤訊息字串),本函數不會reject
37
+ * @example
38
+ * //need cli in system PATH
39
+ *
40
+ * import runFanoutPipeline from './src/wkf/runFanoutPipeline.mjs'
41
+ *
42
+ * let providers = {
43
+ * 'sonnet': { kind: 'claude', model: 'sonnet' },
44
+ * 'luna': { kind: 'codex', model: 'gpt-5.6-luna' },
45
+ * }
46
+ *
47
+ * let test = async () => {
48
+ *
49
+ * let r = await runFanoutPipeline({
50
+ * providers,
51
+ * task: '分析並只回覆JSON: {"essence":"..."}',
52
+ * agents: [{ use: 'sonnet' }, { use: 'luna' }],
53
+ * integrate: { use: 'sonnet' },
54
+ * stages: [
55
+ * { id: 'audit', use: 'luna', prompt: (ctx) => `審計此稿並修訂, 只回覆同格式JSON: ${JSON.stringify(ctx.input)}` },
56
+ * ],
57
+ * check: (j) => !!j.essence,
58
+ * })
59
+ * console.log(r.ok, r.A.integrated, r.B.order)
60
+ * // => true true [ 'audit' ]
61
+ *
62
+ * }
63
+ * await test()
64
+ * .catch((err) => {
65
+ * console.log(err)
66
+ * })
67
+ *
68
+ */
69
+ async function runFanoutPipeline(opt = {}) {
70
+ let t0 = Date.now()
71
+
72
+ //前段: Fanout(多開 → 整合)
73
+ let rA = await runFanout({
74
+ providers: get(opt, 'providers', null),
75
+ task: get(opt, 'task', ''),
76
+ agents: get(opt, 'agents', null),
77
+ integrate: get(opt, 'integrate', null),
78
+ check: get(opt, 'check', null),
79
+ schema: get(opt, 'schema', ''),
80
+ minCandidates: get(opt, 'minCandidates', null),
81
+ callOpt: get(opt, 'callOpt', {}),
82
+ })
83
+ if (!rA.ok) {
84
+ return { ok: false, result: null, A: rA, B: null, totalMs: Date.now() - t0, error: `A failed: ${rA.error}` }
85
+ }
86
+
87
+ //後段: RolePipeline, input即前段成果
88
+ let rB = await runRolePipeline({
89
+ providers: get(opt, 'providers', null),
90
+ input: rA.result,
91
+ stages: get(opt, 'stages', null),
92
+ callOpt: get(opt, 'callOpt', {}),
93
+ })
94
+
95
+ return {
96
+ ok: rB.ok,
97
+ result: rB.ok ? rB.result : null,
98
+ A: rA, //前段成果與明細一律回傳——後段失敗時可據此只重跑後段
99
+ B: rB,
100
+ totalMs: Date.now() - t0,
101
+ error: rB.ok ? '' : `B failed: ${rB.error}`,
102
+ }
103
+ }
104
+
105
+
106
+ export default runFanoutPipeline