dsh-plan-completion 0.0.0-stage → 0.1.1
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/CHANGELOG.md +71 -0
- package/LICENSE +21 -0
- package/README.md +271 -3
- package/client.js +2041 -0
- package/cordis.patch.yml +13 -0
- package/docs/delivery-notes.md +106 -0
- package/docs/design-v1.2.md +387 -0
- package/docs/experiment-archive.md +36 -0
- package/docs/phase-2-plan.md +73 -0
- package/docs/publish-assessment.md +101 -0
- package/docs/screenshots/always-on-hint.png +0 -0
- package/docs/screenshots/dark-theme.png +0 -0
- package/docs/screenshots/light-theme.png +0 -0
- package/docs/screenshots/plan-panel.png +0 -0
- package/docs/security.md +57 -0
- package/host/README.md +443 -0
- package/host/config.js +87 -0
- package/host/evidence.js +392 -0
- package/host/experiment.js +261 -0
- package/host/heuristics.js +160 -0
- package/host/job.js +905 -0
- package/host/plan.js +613 -0
- package/host/prompt.js +386 -0
- package/host/security.js +66 -0
- package/host/store.js +312 -0
- package/host/text.js +168 -0
- package/icon.svg +12 -0
- package/index.js +1282 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +58 -6
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* host/experiment.js — isolated prompt-budget experiment (round 2).
|
|
3
|
+
*
|
|
4
|
+
* Fixed session snapshot + fixed plan; variables:
|
|
5
|
+
* - evidenceChars: explicit character budget for the serialized evidence object
|
|
6
|
+
* (truncated from the least important fields first), and
|
|
7
|
+
* - maxTokens: the output budget for the plan/suggestion JSON.
|
|
8
|
+
*
|
|
9
|
+
* Each variant runs at temperature 0 and is scored by a second (judge) model call.
|
|
10
|
+
* Exposed only through the temporary `/plan-completion/experiment` route.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
function parseLooseJson(text) {
|
|
14
|
+
try {
|
|
15
|
+
const cleaned = String(text ?? '').replace(/```(?:json)?/gi, '').trim();
|
|
16
|
+
const match = cleaned.match(/\{[\s\S]*\}/);
|
|
17
|
+
if (!match) return null;
|
|
18
|
+
return JSON.parse(match[0]);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const JUDGE_SYSTEM = [
|
|
25
|
+
'你是严格但务实的评分员。只输出一个 JSON 对象,不要输出任何解释、markdown 或代码块。',
|
|
26
|
+
'评分维度(1-5 分,5 最好):',
|
|
27
|
+
' coherence:计划步骤的状态与实际证据是否一致(无凭空完成、无遗漏明显进度)。',
|
|
28
|
+
' specificity:下一步建议是否具体可执行(有明确动作和对象),而不是空话。',
|
|
29
|
+
' consistency:建议是否与计划不矛盾、是否指向计划的下一步。',
|
|
30
|
+
' usable:该建议是否是一句人可以直接发送的话(<=60 字,单行,无解释)。',
|
|
31
|
+
'重要:若候选输出声称某个步骤完成、而基准计划尚未体现,但候选原始输出给出了具体证据',
|
|
32
|
+
'(工具名/文件/命令结果),视为合理进度更新,不算 coherence 扣分;只有“无证据的完成声明”才扣分。',
|
|
33
|
+
'输出格式:{"coherence":4,"specificity":5,"consistency":4,"usable":true,"issue":"<=20字"}',
|
|
34
|
+
].join('\n');
|
|
35
|
+
|
|
36
|
+
function judgeMessage(basePlan, rawOutput, suggestion) {
|
|
37
|
+
return [
|
|
38
|
+
'【评估对象】一次「维护会话计划 + 生成下一条用户建议」的模型输出。',
|
|
39
|
+
'【基准计划(当前真实状态)】',
|
|
40
|
+
JSON.stringify(
|
|
41
|
+
basePlan
|
|
42
|
+
? {
|
|
43
|
+
version: basePlan.version,
|
|
44
|
+
intent: basePlan.intent,
|
|
45
|
+
steps: (basePlan.steps || []).map(step => ({ id: step.id, title: step.title, status: step.status })),
|
|
46
|
+
}
|
|
47
|
+
: null,
|
|
48
|
+
null,
|
|
49
|
+
2,
|
|
50
|
+
),
|
|
51
|
+
'【候选建议】',
|
|
52
|
+
suggestion || '',
|
|
53
|
+
'【候选原始输出前 900 字】',
|
|
54
|
+
String(rawOutput || '').slice(0, 900),
|
|
55
|
+
'【评分要求】对照基准计划评估候选输出,按系统提示的 JSON 格式输出。',
|
|
56
|
+
].join('\n');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Trim the serialized evidence down to `targetChars`, dropping the least important parts first. */
|
|
60
|
+
export function sliceEvidence(evidence, targetChars) {
|
|
61
|
+
const copy = (() => {
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(JSON.stringify(evidence ?? {}));
|
|
64
|
+
} catch (error) {
|
|
65
|
+
return { ok: false };
|
|
66
|
+
}
|
|
67
|
+
})();
|
|
68
|
+
const size = () => JSON.stringify(copy).length;
|
|
69
|
+
if (size() <= targetChars) return copy;
|
|
70
|
+
|
|
71
|
+
// 1. Trim the long assistant tail.
|
|
72
|
+
if (typeof copy.lastAssistantText === 'string') {
|
|
73
|
+
const overflow = size() - targetChars;
|
|
74
|
+
copy.lastAssistantText = copy.lastAssistantText.slice(0, Math.max(0, copy.lastAssistantText.length - overflow - 40));
|
|
75
|
+
if (!copy.lastAssistantText) delete copy.lastAssistantText;
|
|
76
|
+
}
|
|
77
|
+
// 2. Drop recent messages from the oldest end.
|
|
78
|
+
while (size() > targetChars && Array.isArray(copy.recentMessages) && copy.recentMessages.length > 1) {
|
|
79
|
+
copy.recentMessages.shift();
|
|
80
|
+
}
|
|
81
|
+
// 3. Drop tool calls from the oldest end.
|
|
82
|
+
while (size() > targetChars && Array.isArray(copy.toolCalls) && copy.toolCalls.length > 1) {
|
|
83
|
+
copy.toolCalls.shift();
|
|
84
|
+
}
|
|
85
|
+
// 4. Drop secondary fields entirely.
|
|
86
|
+
for (const field of ['failedTools', 'files', 'todoSteps', 'counts', 'recentMessages', 'toolCalls', 'evidence', 'lastUserMessages']) {
|
|
87
|
+
if (size() <= targetChars) break;
|
|
88
|
+
if (field in copy) delete copy[field];
|
|
89
|
+
}
|
|
90
|
+
// 5. Hard cap as a last resort.
|
|
91
|
+
if (size() > targetChars && typeof copy.lastAssistantText === 'string') {
|
|
92
|
+
copy.lastAssistantText = copy.lastAssistantText.slice(0, Math.max(0, targetChars - 160));
|
|
93
|
+
}
|
|
94
|
+
return copy;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function runVariant({ deps, evidenceChars, maxTokens, rep, index, total }) {
|
|
98
|
+
const {
|
|
99
|
+
session,
|
|
100
|
+
sessionId,
|
|
101
|
+
basePlan,
|
|
102
|
+
collectFromEvents,
|
|
103
|
+
buildPatchPrompt,
|
|
104
|
+
parseModelOutput,
|
|
105
|
+
callModel,
|
|
106
|
+
route,
|
|
107
|
+
judgeRoute,
|
|
108
|
+
debugLog,
|
|
109
|
+
} = deps;
|
|
110
|
+
const startedAt = Date.now();
|
|
111
|
+
let seqNow = 0;
|
|
112
|
+
try {
|
|
113
|
+
seqNow = Number(session.seq ?? 0);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
seqNow = 0;
|
|
116
|
+
}
|
|
117
|
+
let events = [];
|
|
118
|
+
try {
|
|
119
|
+
events = Array.from(session.snapshotEvents(0) ?? []);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
events = [];
|
|
122
|
+
}
|
|
123
|
+
const fullEvidence = (() => {
|
|
124
|
+
try {
|
|
125
|
+
return collectFromEvents({ events, plan: basePlan, turn: Number(basePlan?.watermark?.turn ?? 0), sessionId });
|
|
126
|
+
} catch (error) {
|
|
127
|
+
return { ok: false };
|
|
128
|
+
}
|
|
129
|
+
})();
|
|
130
|
+
const evidence = deps.evidenceChars
|
|
131
|
+
? sliceEvidence(fullEvidence, evidenceChars)
|
|
132
|
+
: fullEvidence;
|
|
133
|
+
const evidenceSize = JSON.stringify(evidence).length;
|
|
134
|
+
const prompt = (() => {
|
|
135
|
+
try {
|
|
136
|
+
return buildPatchPrompt(evidence);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
})();
|
|
141
|
+
|
|
142
|
+
const result = {
|
|
143
|
+
index,
|
|
144
|
+
total,
|
|
145
|
+
evidenceChars,
|
|
146
|
+
fullEvidenceChars: JSON.stringify(fullEvidence).length,
|
|
147
|
+
slicedEvidenceChars: evidenceSize,
|
|
148
|
+
maxTokens,
|
|
149
|
+
rep,
|
|
150
|
+
parseOk: false,
|
|
151
|
+
suggestion: '',
|
|
152
|
+
suggestionChars: 0,
|
|
153
|
+
changeCount: 0,
|
|
154
|
+
inputTokens: 0,
|
|
155
|
+
outputTokens: 0,
|
|
156
|
+
latencyMs: 0,
|
|
157
|
+
judge: null,
|
|
158
|
+
judgeRaw: '',
|
|
159
|
+
error: '',
|
|
160
|
+
};
|
|
161
|
+
if (!prompt) {
|
|
162
|
+
result.error = 'no-prompt';
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
prompt.maxTokens = maxTokens;
|
|
166
|
+
prompt.temperature = 0;
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
const response = await callModel(deps.services, route, prompt, undefined);
|
|
170
|
+
result.inputTokens = response.usage?.inputTokens ?? 0;
|
|
171
|
+
result.outputTokens = response.usage?.outputTokens ?? 0;
|
|
172
|
+
const parsed = parseModelOutput(response.text);
|
|
173
|
+
result.parseOk = parsed?.ok === true;
|
|
174
|
+
if (parsed?.ok) {
|
|
175
|
+
result.suggestion = parsed.output.suggestion || '';
|
|
176
|
+
result.suggestionChars = result.suggestion.length;
|
|
177
|
+
result.changeCount = (parsed.output.changes || []).length + (parsed.output.steps || []).length;
|
|
178
|
+
}
|
|
179
|
+
result.latencyMs = Date.now() - startedAt;
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const judgePrompt = {
|
|
183
|
+
system: JUDGE_SYSTEM,
|
|
184
|
+
messages: [{ role: 'user', content: judgeMessage(basePlan, response.text, result.suggestion) }],
|
|
185
|
+
maxTokens: 500,
|
|
186
|
+
temperature: 0,
|
|
187
|
+
};
|
|
188
|
+
const judged = await callModel(deps.services, judgeRoute || route, judgePrompt, undefined);
|
|
189
|
+
result.judgeRaw = String(judged.text || '').slice(0, 400);
|
|
190
|
+
result.judgeModel = judgeRoute ? judgeRoute.model : route.model;
|
|
191
|
+
const score = parseLooseJson(judged.text);
|
|
192
|
+
if (score && typeof score === 'object') {
|
|
193
|
+
const clamp = value => (typeof value === 'number' ? Math.max(1, Math.min(5, Math.round(value))) : null);
|
|
194
|
+
result.judge = {
|
|
195
|
+
coherence: clamp(score.coherence),
|
|
196
|
+
specificity: clamp(score.specificity),
|
|
197
|
+
consistency: clamp(score.consistency),
|
|
198
|
+
usable: score.usable === true,
|
|
199
|
+
issue: typeof score.issue === 'string' ? score.issue.slice(0, 40) : '',
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
} catch (error) {
|
|
203
|
+
result.error = `judge:${String(error)}`;
|
|
204
|
+
}
|
|
205
|
+
} catch (error) {
|
|
206
|
+
result.error = String(error);
|
|
207
|
+
result.latencyMs = Date.now() - startedAt;
|
|
208
|
+
}
|
|
209
|
+
debugLog({
|
|
210
|
+
phase: 'experiment-variant',
|
|
211
|
+
sessionId,
|
|
212
|
+
evidenceChars,
|
|
213
|
+
maxTokens,
|
|
214
|
+
rep,
|
|
215
|
+
parseOk: result.parseOk,
|
|
216
|
+
suggestionChars: result.suggestionChars,
|
|
217
|
+
inputTokens: result.inputTokens,
|
|
218
|
+
outputTokens: result.outputTokens,
|
|
219
|
+
judge: result.judge,
|
|
220
|
+
});
|
|
221
|
+
return result;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function runExperiment(deps) {
|
|
225
|
+
const budgets = Array.isArray(deps.maxTokens) && deps.maxTokens.length
|
|
226
|
+
? deps.maxTokens.map(Number).filter(value => Number.isFinite(value) && value > 0)
|
|
227
|
+
: [200, 300, 500];
|
|
228
|
+
const slices = Array.isArray(deps.evidenceChars) && deps.evidenceChars.length
|
|
229
|
+
? deps.evidenceChars.map(Number).filter(value => Number.isFinite(value) && value > 0)
|
|
230
|
+
: [800, 2000, 4000];
|
|
231
|
+
const reps = Math.max(1, Math.min(3, Number(deps.reps) || 3));
|
|
232
|
+
const total = slices.length * budgets.length * reps;
|
|
233
|
+
const results = [];
|
|
234
|
+
let index = 0;
|
|
235
|
+
for (const evidenceChars of slices) {
|
|
236
|
+
for (const maxTokens of budgets) {
|
|
237
|
+
for (let rep = 0; rep < reps; rep += 1) {
|
|
238
|
+
index += 1;
|
|
239
|
+
try {
|
|
240
|
+
const result = await runVariant({ deps, evidenceChars, maxTokens, rep, index, total });
|
|
241
|
+
results.push(result);
|
|
242
|
+
if (deps.onProgress) deps.onProgress(index, total, result);
|
|
243
|
+
} catch (error) {
|
|
244
|
+
results.push({ index, evidenceChars, maxTokens, rep, error: String(error) });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
finishedAt: new Date().toISOString(),
|
|
251
|
+
mode: 'explicit-evidence-slice',
|
|
252
|
+
temperature: 0,
|
|
253
|
+
model: deps.route ? { provider: deps.route.provider, model: deps.route.model } : null,
|
|
254
|
+
judgeModel: deps.judgeRoute ? { provider: deps.judgeRoute.provider, model: deps.judgeRoute.model } : null,
|
|
255
|
+
slices,
|
|
256
|
+
budgets,
|
|
257
|
+
reps,
|
|
258
|
+
total,
|
|
259
|
+
results,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* host/heuristics.js — 启发式建议与降级计划骨架(纯函数,零依赖)。
|
|
3
|
+
*
|
|
4
|
+
* 设计依据:v1.2 §3.5.4(模型不可用 → 启发式)、§3.6(降级链:
|
|
5
|
+
* 「计划下一步 / 失败工具 / 未完成 todo」;无模型 → 首条消息骨架 + todo)。
|
|
6
|
+
*
|
|
7
|
+
* 优先级(与 task 验收一致):
|
|
8
|
+
* 1. 失败工具 → 修报错
|
|
9
|
+
* 2. 有未完成 todo/doing → 下一个
|
|
10
|
+
* 3. 最后一条 assistant 是提问 → 给倾向性决策
|
|
11
|
+
* 4. 全部 done/dropped → 验收
|
|
12
|
+
* 5. 否则 → 计划下一步 title / 按 intent 起步
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { DEFAULTS } from './config.js';
|
|
16
|
+
import { statusFor } from './plan.js';
|
|
17
|
+
import { clampChars, looksLikeQuestion, oneLine, sanitizeSuggestion } from './text.js';
|
|
18
|
+
|
|
19
|
+
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
20
|
+
|
|
21
|
+
function make(text, kind) {
|
|
22
|
+
const clean = sanitizeSuggestion(text, DEFAULTS.suggestionMaxChars);
|
|
23
|
+
if (!clean.ok) return { ok: false, text: '', kind: 'none' };
|
|
24
|
+
return { ok: true, text: clean.text, kind };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function firstFailedTool(evidence) {
|
|
28
|
+
const failed = Array.isArray(evidence?.failedTools) ? evidence.failedTools.filter((tool) => isObj(tool)) : [];
|
|
29
|
+
if (failed.length) return failed[failed.length - 1];
|
|
30
|
+
const calls = Array.isArray(evidence?.toolCalls) ? evidence.toolCalls : [];
|
|
31
|
+
const fromCalls = calls.filter((tool) => isObj(tool) && tool.ok === false);
|
|
32
|
+
return fromCalls.length ? fromCalls[fromCalls.length - 1] : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function nextOpenStep(plan) {
|
|
36
|
+
if (!isObj(plan) || !Array.isArray(plan.steps)) return null;
|
|
37
|
+
const doing = plan.steps.find((step) => isObj(step) && step.status === 'doing');
|
|
38
|
+
if (doing) return doing;
|
|
39
|
+
const todo = plan.steps.find((step) => isObj(step) && step.status === 'todo');
|
|
40
|
+
return todo ?? null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 生成启发式建议(模型不可用/超时/非法输出时的兜底)。
|
|
45
|
+
*
|
|
46
|
+
* @param {object} evidence collectFromEvents 的输出(也可传任意同名字段对象)
|
|
47
|
+
* @returns {{ok:true, text:string, kind:string}|{ok:false, text:'', kind:'none'}}
|
|
48
|
+
*/
|
|
49
|
+
export function heuristicSuggestion(evidence = {}) {
|
|
50
|
+
try {
|
|
51
|
+
const source = isObj(evidence) ? evidence : {};
|
|
52
|
+
const plan = isObj(source.plan) ? source.plan : null;
|
|
53
|
+
|
|
54
|
+
// 1. 失败工具 → 修报错
|
|
55
|
+
const failed = firstFailedTool(source);
|
|
56
|
+
if (failed) {
|
|
57
|
+
const name = clampChars(oneLine(failed.name) || '工具', 14);
|
|
58
|
+
const error = clampChars(oneLine(failed.error), 20);
|
|
59
|
+
if (error) return make(`先修 ${name} 的报错:${error}`, 'fix-error');
|
|
60
|
+
return make(`先修 ${name} 的报错再继续下一步`, 'fix-error');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 2. 有未完成步骤 → 下一个
|
|
64
|
+
const next = nextOpenStep(plan);
|
|
65
|
+
if (next) {
|
|
66
|
+
const title = clampChars(oneLine(next.title), 24) || '下一步';
|
|
67
|
+
return make(`接着做「${title}」,先跑通再补细节`, 'next-step');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 3. 提问回合 → 给倾向性决策
|
|
71
|
+
if (source.hasQuestion === true || looksLikeQuestion(source.lastAssistantText)) {
|
|
72
|
+
return make('按方案 A 继续,先把最小可用版本跑通', 'decide');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 4. 全部完成 → 验收
|
|
76
|
+
if (plan && statusFor(plan.steps) === 'completed') {
|
|
77
|
+
return make('功能已完成,先自测验收再收尾', 'accept');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 5. 否则 → 计划下一步 title / intent 起步
|
|
81
|
+
if (plan && Array.isArray(plan.steps) && plan.steps.length) {
|
|
82
|
+
const last = plan.steps[plan.steps.length - 1];
|
|
83
|
+
const title = clampChars(oneLine(last?.title), 22) || clampChars(oneLine(plan.intent), 22);
|
|
84
|
+
if (title) return make(`先收尾「${title}」并确认结果`, 'next-title');
|
|
85
|
+
}
|
|
86
|
+
const intent = clampChars(
|
|
87
|
+
oneLine(plan?.intent) || oneLine(source.intent) || oneLine(source.firstUserMessage) || '',
|
|
88
|
+
22,
|
|
89
|
+
);
|
|
90
|
+
if (intent) return make(`先摸清现状,把「${intent}」拆成 3 步开始`, 'intent');
|
|
91
|
+
return { ok: false, text: '', kind: 'none' };
|
|
92
|
+
} catch {
|
|
93
|
+
return { ok: false, text: '', kind: 'none' };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 骨架模板:按 intent 关键词选择 3~5 个里程碑。 */
|
|
98
|
+
const SKELETONS = [
|
|
99
|
+
{
|
|
100
|
+
match: /(bug|报错|错误|修复|崩溃|异常|失败|fix|error|crash)/i,
|
|
101
|
+
titles: ['复现并定位报错根因', '做最小修复并验证', '补回归测试防止复发'],
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
match: /(测试|用例|回归|覆盖率|test|spec)/i,
|
|
105
|
+
titles: ['梳理测试范围与现状', '补关键路径测试用例', '跑通回归并修复问题'],
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
match: /(设计|方案|架构|重构|优化|选型)/i,
|
|
109
|
+
titles: ['确认目标、约束与验收标准', '出 2 个方案并对比取舍', '落地最小可行版本', '验证效果并收尾'],
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
match: /(文档|readme|说明|注释|doc)/i,
|
|
113
|
+
titles: ['梳理要写的文档范围', '补写核心文档与示例', '校对并验证示例可运行'],
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
match: /(接口|api|联调|集成|部署|发布)/i,
|
|
117
|
+
titles: ['确认接口契约与环境', '实现并打通主流程', '联调异常路径', '验收并发布'],
|
|
118
|
+
},
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 降级计划骨架:无模型时按首条消息生成 3~5 步(交给 createSeedPlan 落版本 0.0)。
|
|
123
|
+
*
|
|
124
|
+
* @param {string} [intent] 用户目标(首条消息 / 现计划 intent)
|
|
125
|
+
* @returns {{ok:true, intent:string, steps:Array<{id:string,title:string,status:'todo',source:'model'}>}|{ok:false, code:string, error:string}}
|
|
126
|
+
*/
|
|
127
|
+
export function fallbackPlan(intent) {
|
|
128
|
+
try {
|
|
129
|
+
const goal = clampChars(oneLine(intent), 24);
|
|
130
|
+
let template = SKELETONS[0];
|
|
131
|
+
if (goal) {
|
|
132
|
+
for (const item of SKELETONS) {
|
|
133
|
+
if (item.match.test(goal)) {
|
|
134
|
+
template = item;
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (template === SKELETONS[0] && !SKELETONS[0].match.test(goal)) template = null;
|
|
139
|
+
} else {
|
|
140
|
+
template = null;
|
|
141
|
+
}
|
|
142
|
+
let titles = template ? [...template.titles] : ['摸清现状与约束', '实现核心改动', '自测并修复问题', '补测试与文档'];
|
|
143
|
+
if (goal) {
|
|
144
|
+
// 把目标自然嵌进第一/第二步,让骨架与用户意图相关。
|
|
145
|
+
titles = titles.map((title, index) => (index === 0 && !title.includes(goal) ? `${title}:${goal}` : title));
|
|
146
|
+
}
|
|
147
|
+
const steps = titles.map((title, index) => ({
|
|
148
|
+
id: `s${index + 1}`,
|
|
149
|
+
title: clampChars(title, DEFAULTS.maxTitleChars),
|
|
150
|
+
status: 'todo',
|
|
151
|
+
source: 'model',
|
|
152
|
+
}));
|
|
153
|
+
if (steps.length < DEFAULTS.minSteps || steps.length > DEFAULTS.maxSteps) {
|
|
154
|
+
return { ok: false, code: 'bad-skeleton', error: '骨架步骤数不在 3~5 之间' };
|
|
155
|
+
}
|
|
156
|
+
return { ok: true, intent: clampChars(oneLine(intent), DEFAULTS.maxIntentChars), steps };
|
|
157
|
+
} catch (err) {
|
|
158
|
+
return { ok: false, code: 'internal', error: `骨架生成异常:${err && err.message ? err.message : String(err)}` };
|
|
159
|
+
}
|
|
160
|
+
}
|