w-dispatch-ai 1.0.2 → 1.0.4
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 +123 -35
- package/dist/w-dispatch-ai.umd.js +2 -2
- package/dist/w-dispatch-ai.umd.js.map +1 -1
- package/docs/WDispatchAi.mjs.html +8 -4
- package/docs/adapters.mjs.html +43 -7
- package/docs/dispatchAi.mjs.html +2 -2
- package/docs/dispatchAiFallback.mjs.html +42 -8
- package/docs/dispatchAiWkf.mjs.html +197 -0
- package/docs/dispatchAntigravity.mjs.html +2 -2
- package/docs/dispatchApiOpenaiCompat.mjs.html +516 -0
- package/docs/dispatchClaude.mjs.html +2 -2
- package/docs/dispatchCodex.mjs.html +2 -2
- package/docs/dispatchOpencode.mjs.html +2 -2
- package/docs/getCliArgs.mjs.html +2 -2
- package/docs/getErrorResult.mjs.html +2 -2
- package/docs/global.html +5768 -1469
- package/docs/index.html +2 -2
- package/docs/wkf_callAiWithFallback.mjs.html +282 -0
- package/docs/wkf_extractJsonLoose.mjs.html +180 -0
- package/docs/wkf_runFanout.mjs.html +227 -0
- package/docs/wkf_runFanoutPipeline.mjs.html +178 -0
- package/docs/wkf_runRolePipeline.mjs.html +195 -0
- package/g.mjs +41 -25
- package/package.json +1 -1
- package/src/WDispatchAi.mjs +6 -2
- package/src/adapters.mjs +41 -5
- package/src/dispatchAiFallback.mjs +40 -6
- package/src/dispatchAiWkf.mjs +125 -0
- package/src/dispatchApiOpenaiCompat.mjs +444 -0
- package/src/wkf/callAiWithFallback.mjs +210 -0
- package/src/wkf/extractJsonLoose.mjs +108 -0
- package/src/wkf/runFanout.mjs +155 -0
- package/src/wkf/runFanoutPipeline.mjs +106 -0
- package/src/wkf/runRolePipeline.mjs +123 -0
- package/test/tools/fakeServerForApiTest.mjs +151 -0
- package/test/unit-WDispatchAi.test.mjs +13 -6
- package/test/unit-adapters.test.mjs +5 -3
- package/test/unit-callAiWithFallback.test.mjs +146 -0
- package/test/unit-dispatchAi.test.mjs +1 -1
- package/test/unit-dispatchAiWkf.test.mjs +118 -0
- package/test/unit-dispatchApiOpenaiCompat.test.mjs +264 -0
- package/test/unit-extractJsonLoose.test.mjs +78 -0
- package/test/unit-runFanout.test.mjs +166 -0
- package/test/unit-runFanoutPipeline.test.mjs +86 -0
- package/test/unit-runRolePipeline.test.mjs +163 -0
|
@@ -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
|
+
* 'zen:deepseek-v4-flash-free': { kind: 'api-openai-compat', baseURL: 'https://opencode.ai/zen/v1', model: 'deepseek-v4-flash-free', keys: ['sk-xxx'] },
|
|
67
|
+
* 'claude: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: 'zen:deepseek-v4-flash-free', fallback: ['claude:sonnet'] },
|
|
77
|
+
* { use: 'claude:sonnet' },
|
|
78
|
+
* ],
|
|
79
|
+
* integrate: { use: 'claude: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
|
+
* 'claude:sonnet': { kind: 'claude', model: 'sonnet' },
|
|
44
|
+
* 'codex:gpt-5.6-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: 'claude:sonnet' }, { use: 'codex:gpt-5.6-luna' }],
|
|
53
|
+
* integrate: { use: 'claude:sonnet' },
|
|
54
|
+
* stages: [
|
|
55
|
+
* { id: 'audit', use: 'codex:gpt-5.6-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
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import get from 'lodash-es/get.js'
|
|
2
|
+
import omit from 'lodash-es/omit.js'
|
|
3
|
+
import isearr from 'wsemi/src/isearr.mjs'
|
|
4
|
+
import isestr from 'wsemi/src/isestr.mjs'
|
|
5
|
+
import isfun from 'wsemi/src/isfun.mjs'
|
|
6
|
+
import callAiWithFallback from './callAiWithFallback.mjs'
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
// runRolePipeline.mjs — RolePipeline工作流: 多角色串行鏈(stage1 → … → stageN)
|
|
10
|
+
//
|
|
11
|
+
// 【結構】各階段可各自指定AI(use)、遞補鏈(fallback)、提示詞(角色與任務)與檢核;
|
|
12
|
+
// 前一階段的成果傳給下一階段、一路傳到最末階段, 其成果即工作流成果。
|
|
13
|
+
//
|
|
14
|
+
// 【提示詞為函數】stage.prompt收ctx = { input, prev, results, index }:
|
|
15
|
+
// input=工作流輸入(如原始任務或Fanout的整合稿)、prev=上一階段成果、
|
|
16
|
+
// results=已完成各階段成果(依id查詢, 例如修訂階段要同時引用初稿與審計意見)。
|
|
17
|
+
//
|
|
18
|
+
// 【部分接受】某階段(含遞補全敗)失敗即中止後續, 但已完成階段之成果完整回傳
|
|
19
|
+
// (failedStage標明斷點), 呼叫端可據此只重跑失敗段而非整條鏈。
|
|
20
|
+
//
|
|
21
|
+
// 【實測依據(2026-08-10評比)】審計類角色鏈曾出現「審計刪過頭」——
|
|
22
|
+
// 修訂/終審提示詞應包含護欄「意見未涉及的內容不可刪除」; 此屬提示詞設計,
|
|
23
|
+
// 本函數不代寫角色提示詞, 由呼叫端(或上層預設模板)自理。
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
//各階段規格自用之鍵, 其餘鍵覆寫該階段之呼叫設定
|
|
27
|
+
let STAGE_KEYS = ['id', 'use', 'fallback', 'prompt', 'check']
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 執行RolePipeline工作流:多角色串行鏈
|
|
32
|
+
*
|
|
33
|
+
* 特點:
|
|
34
|
+
* 各階段可各自指定use/fallback/prompt/check(含rawText純文字階段);
|
|
35
|
+
* 階段成果依序傳遞,最末階段成果即工作流成果;
|
|
36
|
+
* 失敗即止但已完成成果完整回傳(部分接受、便於接續重跑失敗段);
|
|
37
|
+
* 本函數不會reject
|
|
38
|
+
*
|
|
39
|
+
* @param {Object} [opt={}] 輸入設定物件,預設{}
|
|
40
|
+
* @param {Object} opt.providers 輸入provider定義表物件(名稱 → 條目)
|
|
41
|
+
* @param {*} [opt.input=null] 輸入工作流輸入(原始任務字串或前一工作流之成果物件),提供給各階段ctx.input,預設null
|
|
42
|
+
* @param {Array} opt.stages 輸入階段規格陣列,各元素{ id, use, fallback, prompt:(ctx)=>String, check?, rawText?, maxRetries?, timeoutMs? }等
|
|
43
|
+
* @param {Object} [opt.callOpt={}] 輸入透傳callAiWithFallback之共用設定,預設{}
|
|
44
|
+
* @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(布林值)、result(最末階段成果)、stages(id對階段完整呼叫結果之物件)、results(id對階段成果之物件)、order(階段id順序陣列)、failedStage(失敗階段id,無失敗為null)、totalMs(總耗時毫秒)、error(錯誤訊息字串),本函數不會reject
|
|
45
|
+
* @example
|
|
46
|
+
* //need cli in system PATH
|
|
47
|
+
*
|
|
48
|
+
* import runRolePipeline from './src/wkf/runRolePipeline.mjs'
|
|
49
|
+
*
|
|
50
|
+
* let providers = {
|
|
51
|
+
* 'claude:sonnet': { kind: 'claude', model: 'sonnet' },
|
|
52
|
+
* 'codex:gpt-5.6-luna': { kind: 'codex', model: 'gpt-5.6-luna' },
|
|
53
|
+
* }
|
|
54
|
+
*
|
|
55
|
+
* let test = async () => {
|
|
56
|
+
*
|
|
57
|
+
* let r = await runRolePipeline({
|
|
58
|
+
* providers,
|
|
59
|
+
* input: '原始任務',
|
|
60
|
+
* stages: [
|
|
61
|
+
* { id: 'draft', use: 'claude:sonnet', prompt: (ctx) => `就「${ctx.input}」寫初稿, 只回覆JSON: {"text":"..."}` },
|
|
62
|
+
* { id: 'review', use: 'codex:gpt-5.6-luna', prompt: (ctx) => `審閱並修訂, 只回覆同格式JSON: ${JSON.stringify(ctx.prev)}` },
|
|
63
|
+
* ],
|
|
64
|
+
* })
|
|
65
|
+
* console.log(r.ok, r.order, r.failedStage)
|
|
66
|
+
* // => true [ 'draft', 'review' ] null
|
|
67
|
+
*
|
|
68
|
+
* }
|
|
69
|
+
* await test()
|
|
70
|
+
* .catch((err) => {
|
|
71
|
+
* console.log(err)
|
|
72
|
+
* })
|
|
73
|
+
*
|
|
74
|
+
*/
|
|
75
|
+
async function runRolePipeline(opt = {}) {
|
|
76
|
+
let t0 = Date.now()
|
|
77
|
+
let providers = get(opt, 'providers', null)
|
|
78
|
+
let input = get(opt, 'input', null)
|
|
79
|
+
let stages = get(opt, 'stages', null)
|
|
80
|
+
let callOpt = get(opt, 'callOpt', {})
|
|
81
|
+
|
|
82
|
+
if (!isearr(stages)) {
|
|
83
|
+
return { ok: false, result: null, stages: {}, results: {}, order: [], failedStage: null, totalMs: 0, error: 'stages must be a non-empty array' }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let results = {} //id → 成果(json或文字)
|
|
87
|
+
let details = {} //id → 完整呼叫結果
|
|
88
|
+
let order = []
|
|
89
|
+
let prev = null
|
|
90
|
+
|
|
91
|
+
for (let i = 0; i < stages.length; i++) {
|
|
92
|
+
let stage = stages[i]
|
|
93
|
+
let id = isestr(get(stage, 'id', '')) ? stage.id : `stage${i + 1}`
|
|
94
|
+
order.push(id)
|
|
95
|
+
|
|
96
|
+
let promptFn = get(stage, 'prompt', null)
|
|
97
|
+
if (!isfun(promptFn)) {
|
|
98
|
+
details[id] = { ok: false, error: `stage[${id}].prompt must be a function` }
|
|
99
|
+
return { ok: false, result: null, stages: details, results, order, failedStage: id, totalMs: Date.now() - t0, error: details[id].error }
|
|
100
|
+
}
|
|
101
|
+
let prompt = promptFn({ input, prev, results, index: i })
|
|
102
|
+
if (!isestr(prompt)) {
|
|
103
|
+
details[id] = { ok: false, error: `stage[${id}].prompt returned empty` }
|
|
104
|
+
return { ok: false, result: null, stages: details, results, order, failedStage: id, totalMs: Date.now() - t0, error: details[id].error }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
//overrides, 剔除階段自用鍵後其餘鍵覆寫該階段呼叫設定(rawText、maxRetries、timeoutMs等)
|
|
108
|
+
let overrides = omit(stage, STAGE_KEYS)
|
|
109
|
+
let r = await callAiWithFallback(prompt, { ...callOpt, ...overrides, providers, spec: { use: get(stage, 'use', ''), fallback: get(stage, 'fallback', null) }, check: get(stage, 'check', null) })
|
|
110
|
+
details[id] = r
|
|
111
|
+
if (!r.ok) {
|
|
112
|
+
//失敗即止: 已完成階段成果保留於results, 供呼叫端接續重跑
|
|
113
|
+
return { ok: false, result: null, stages: details, results, order, failedStage: id, totalMs: Date.now() - t0, error: `stage[${id}] failed: ${r.error}` }
|
|
114
|
+
}
|
|
115
|
+
results[id] = r.json
|
|
116
|
+
prev = r.json
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { ok: true, result: prev, stages: details, results, order, failedStage: null, totalMs: Date.now() - t0, error: '' }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
export default runRolePipeline
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import http from 'http'
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
// fakeServerForApiTest.mjs — 測試用的OpenAI相容假伺服器
|
|
5
|
+
//
|
|
6
|
+
// dispatchApiOpenaiCompat之可觀察行為為「送出什麼HTTP請求、如何處理各種回應」,
|
|
7
|
+
// 故起一個本機http伺服器, 依請求之model與Authorization決定回應行為,
|
|
8
|
+
// 即可對成功/401/429/500/逾時/畸形回應逐條斷言, 而無須真的呼叫外部API。
|
|
9
|
+
//
|
|
10
|
+
// 【行為路由(依body.model)】
|
|
11
|
+
// echo — 200, content為JSON字串{ auth, body }, 供斷言請求組成
|
|
12
|
+
// empty-content — 200, content為空字串(驗證路徑用)
|
|
13
|
+
// slow — 延遲10秒才回應(逾時路徑用)
|
|
14
|
+
// err-500 — 500
|
|
15
|
+
// flaky-429 — 同一Authorization首次429, 之後200(重試路徑用)
|
|
16
|
+
// no-choices — 200但無choices(畸形回應路徑用)
|
|
17
|
+
// not-json — 200但本體非JSON(畸形回應路徑用)
|
|
18
|
+
// tool-calls — 200但finish_reason為tool_calls(工具不支援路徑用)
|
|
19
|
+
// 其他 — 404
|
|
20
|
+
// 【金鑰規則】Authorization含'sk-bad'一律401(優先於model路由), 模擬無效金鑰。
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 啟動測試用OpenAI相容假伺服器
|
|
25
|
+
*
|
|
26
|
+
* @returns {Promise} 回傳Promise,resolve回傳物件,內含port(埠號)、url(基底網址字串,等同baseURL)、close(關閉伺服器之async函數)
|
|
27
|
+
*/
|
|
28
|
+
async function fakeServerForApiTest() {
|
|
29
|
+
|
|
30
|
+
//flakyCount, 記錄flaky-429各金鑰之呼叫次數
|
|
31
|
+
let flakyCount = {}
|
|
32
|
+
|
|
33
|
+
//sockets, 追蹤連線供close時強制斷開(避免keep-alive令close懸置)
|
|
34
|
+
let sockets = new Set()
|
|
35
|
+
|
|
36
|
+
let server = http.createServer((req, res) => {
|
|
37
|
+
|
|
38
|
+
//僅受理POST /v1/chat/completions
|
|
39
|
+
if (req.method !== 'POST' || !req.url.endsWith('/chat/completions')) {
|
|
40
|
+
res.writeHead(404, { 'Content-Type': 'application/json' })
|
|
41
|
+
res.end(JSON.stringify({ error: { message: 'not found' } }))
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let chunks = []
|
|
46
|
+
req.on('data', (c) => chunks.push(c))
|
|
47
|
+
req.on('end', () => {
|
|
48
|
+
|
|
49
|
+
let auth = req.headers['authorization'] || ''
|
|
50
|
+
|
|
51
|
+
//body非JSON → 400
|
|
52
|
+
let body = null
|
|
53
|
+
try {
|
|
54
|
+
body = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
58
|
+
res.end(JSON.stringify({ error: { message: 'invalid json body' } }))
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
//無效金鑰, 模擬Zen之401形態
|
|
63
|
+
if (auth.includes('sk-bad')) {
|
|
64
|
+
res.writeHead(401, { 'Content-Type': 'application/json' })
|
|
65
|
+
res.end(JSON.stringify({ type: 'error', error: { type: 'AuthError', message: 'Invalid API key.' } }))
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let model = body.model || ''
|
|
70
|
+
let ok = (content) => {
|
|
71
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
72
|
+
res.end(JSON.stringify({ choices: [{ message: { role: 'assistant', content } }] }))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (model === 'echo') {
|
|
76
|
+
ok(JSON.stringify({ auth, body }))
|
|
77
|
+
}
|
|
78
|
+
else if (model === 'empty-content') {
|
|
79
|
+
ok('')
|
|
80
|
+
}
|
|
81
|
+
else if (model === 'slow') {
|
|
82
|
+
setTimeout(() => ok('too late'), 10000)
|
|
83
|
+
}
|
|
84
|
+
else if (model === 'err-500') {
|
|
85
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
86
|
+
res.end(JSON.stringify({ error: { message: 'internal error' } }))
|
|
87
|
+
}
|
|
88
|
+
else if (model === 'flaky-429') {
|
|
89
|
+
flakyCount[auth] = (flakyCount[auth] || 0) + 1
|
|
90
|
+
if (flakyCount[auth] === 1) {
|
|
91
|
+
res.writeHead(429, { 'Content-Type': 'application/json' })
|
|
92
|
+
res.end(JSON.stringify({ error: { message: 'rate limited' } }))
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
ok(JSON.stringify({ attempt: flakyCount[auth] }))
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else if (model === 'tool-calls') {
|
|
99
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
100
|
+
res.end(JSON.stringify({
|
|
101
|
+
choices: [{
|
|
102
|
+
finish_reason: 'tool_calls',
|
|
103
|
+
message: {
|
|
104
|
+
role: 'assistant',
|
|
105
|
+
content: '\n\n', //Agnes實測形態: 非null而是空白, 不攔截會靜默成功
|
|
106
|
+
tool_calls: [{ id: 'call-1', type: 'function', function: { name: 'get_weather', arguments: '{"city":"台北"}' } }],
|
|
107
|
+
},
|
|
108
|
+
}],
|
|
109
|
+
}))
|
|
110
|
+
}
|
|
111
|
+
else if (model === 'no-choices') {
|
|
112
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
113
|
+
res.end(JSON.stringify({ id: 'x', object: 'chat.completion' }))
|
|
114
|
+
}
|
|
115
|
+
else if (model === 'not-json') {
|
|
116
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' })
|
|
117
|
+
res.end('plain text body')
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
res.writeHead(404, { 'Content-Type': 'application/json' })
|
|
121
|
+
res.end(JSON.stringify({ error: { message: `model ${model} not found` } }))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
server.on('connection', (s) => {
|
|
128
|
+
sockets.add(s)
|
|
129
|
+
s.on('close', () => sockets.delete(s))
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
//listen於127.0.0.1動態埠
|
|
133
|
+
await new Promise((resolve) => {
|
|
134
|
+
server.listen(0, '127.0.0.1', resolve)
|
|
135
|
+
})
|
|
136
|
+
let port = server.address().port
|
|
137
|
+
|
|
138
|
+
let close = async () => {
|
|
139
|
+
for (let s of sockets) {
|
|
140
|
+
s.destroy()
|
|
141
|
+
}
|
|
142
|
+
await new Promise((resolve) => {
|
|
143
|
+
server.close(resolve)
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { port, url: `http://127.0.0.1:${port}/v1`, close }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
export default fakeServerForApiTest
|