fix(web): bind task API to resolved intake key when URS-only pool
When deliverables only expose user_requirements templates, requirement phrases still must POST intake on V0.1. Re-resolve task_key on retry and skip auto-retry for stage gate errors. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+18
-8
@@ -118,7 +118,7 @@ import {
|
||||
getTemplateDefinitionByTaskKey,
|
||||
buildTemplateASTForProject,
|
||||
} from './engine/templateFactory';
|
||||
import { matchTemplateDeliverable } from './engine/templateMatch';
|
||||
import { resolveTaskTemplateForMessage } from './engine/templateMatch';
|
||||
import {
|
||||
loadSessions,
|
||||
saveSessions,
|
||||
@@ -2054,7 +2054,7 @@ export default function App() {
|
||||
return {
|
||||
userMessage: detail || '该文档任务不在当前活动阶段,请先完成当前阶段工作流后再试',
|
||||
code,
|
||||
class: 'policy',
|
||||
class: 'transient',
|
||||
actions: ['retry'],
|
||||
};
|
||||
}
|
||||
@@ -2510,7 +2510,12 @@ export default function App() {
|
||||
// 长任务路径:按 taskRequest 重发 / 续跑工作区 / 按原意图重启
|
||||
if (target.taskRequest) {
|
||||
if (action === 'retry') {
|
||||
runTaskAttempt(target.taskRequest, messageId, 'run', 0);
|
||||
const userText = target.taskRequest.userMessage || '';
|
||||
const reResolved = resolveTaskTemplateForMessage(userText, currentProjectDeliverables);
|
||||
const retryTask = reResolved
|
||||
? { ...target.taskRequest, taskKey: reResolved.taskKey }
|
||||
: target.taskRequest;
|
||||
runTaskAttempt(retryTask, messageId, 'run', 0);
|
||||
} else if (action === 'continue') {
|
||||
const wsId = target.errorInfo?.workspaceId || target.taskWorkspaceId;
|
||||
if (!wsId) return;
|
||||
@@ -2707,11 +2712,12 @@ export default function App() {
|
||||
(text.includes('通过') || text.includes('同意')) &&
|
||||
((docAST.lifecycleState === 'reviewing' && currentDocRole === 'reviewer') ||
|
||||
(docAST.lifecycleState === 'approving' && currentDocRole === 'approver'));
|
||||
const matchedTemplate = matchTemplateDeliverable(text, currentProjectDeliverables);
|
||||
const resolvedTask = resolveTaskTemplateForMessage(text, currentProjectDeliverables);
|
||||
const isMockShortcut = isGenDocShortcut || isRejectShortcut || isModifyShortcut || isReviewPassShortcut;
|
||||
|
||||
// P2:模板命中的长任务走真实后端 task stream(等待态 + PhaseEventStrip live + 分型恢复)
|
||||
if (matchedTemplate) {
|
||||
if (resolvedTask) {
|
||||
const matchedTemplate = resolvedTask.template;
|
||||
const asstId = 'ast-' + Date.now();
|
||||
const currentProj = sessions.find((s) => s.sessionId === activeSessionId);
|
||||
// 项目五要素暂取会话标题/设备类型;客户名沿用 bizdev-cli 演示默认值,五要素完整建模留待后续
|
||||
@@ -2721,11 +2727,15 @@ export default function App() {
|
||||
console.error('[task] 无法解析会话所属项目', activeSessionId);
|
||||
return { accepted: false };
|
||||
}
|
||||
const taskKey = resolvedTask.taskKey;
|
||||
const matchedDeliverable = deliverables.find(
|
||||
(d) => d.projectId === projId && d.taskKey === matchedTemplate.taskKey,
|
||||
(d) => d.projectId === projId && d.taskKey === taskKey,
|
||||
);
|
||||
// #region agent log
|
||||
fetch('http://localhost:7284/ingest/86170f40-1203-4130-a0c7-f6e39ba00464',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'6c6caa'},body:JSON.stringify({sessionId:'6c6caa',location:'App.tsx:handleSend.task',message:'resolved task template',data:{taskKey,hasTemplate:Boolean(matchedTemplate),textPreview:text.slice(0,40)},timestamp:Date.now(),hypothesisId:'H7'})}).catch(()=>{});
|
||||
// #endregion
|
||||
const baseTask: TaskRunContext = {
|
||||
taskKey: matchedTemplate.taskKey,
|
||||
taskKey,
|
||||
projectName: currentProj?.title || '医疗器械研发演示项目',
|
||||
customerName: '北京水木东方',
|
||||
deviceType: currentProj?.deviceType || '医疗器械',
|
||||
@@ -2736,7 +2746,7 @@ export default function App() {
|
||||
userMessage: text,
|
||||
docId: canonicalDocIdForTask(
|
||||
projId,
|
||||
matchedTemplate.taskKey,
|
||||
taskKey,
|
||||
deliverables,
|
||||
matchedDeliverable?.docId,
|
||||
),
|
||||
|
||||
@@ -10,9 +10,16 @@ export function shouldAutoRetryChat(
|
||||
|
||||
/** 长任务 SSE:仍允许有限次瞬态自动重试 */
|
||||
export function shouldAutoRetryTask(
|
||||
info: Pick<ChatErrorInfo, 'class'>,
|
||||
info: Pick<ChatErrorInfo, 'class' | 'code'>,
|
||||
attemptIndex: number,
|
||||
): boolean {
|
||||
if (
|
||||
info.code === 'DOC_TASK_NOT_IN_ACTIVE_STAGE' ||
|
||||
info.code === 'DOC_DUTY_FORBIDDEN' ||
|
||||
info.code === 'ERR_FORBIDDEN'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return info.class === 'transient' && attemptIndex < 2;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { matchTemplateDeliverable } from './templateMatch';
|
||||
import {
|
||||
matchTemplateDeliverable,
|
||||
resolveRequirementsTaskKey,
|
||||
resolveTaskTemplateForMessage,
|
||||
} from './templateMatch';
|
||||
import { DeliverableItem } from '../types/workbench';
|
||||
|
||||
const base: Omit<DeliverableItem, 'taskKey' | 'title'> = {
|
||||
@@ -20,17 +24,37 @@ const base: Omit<DeliverableItem, 'taskKey' | 'title'> = {
|
||||
updatedAt: '',
|
||||
};
|
||||
|
||||
const pool: DeliverableItem[] = [
|
||||
{ ...base, docId: 'doc-intake', taskKey: 'user_requirements', title: '用户需求表', stage: 'V0.5' },
|
||||
{ ...base, docId: 'doc-urs', taskKey: 'intake', title: '用户初始需求表', stage: 'V0.1' },
|
||||
const ursOnlyPool: DeliverableItem[] = [
|
||||
{ ...base, docId: 'doc-urs', taskKey: 'user_requirements', title: '用户需求表', stage: 'V0.5' },
|
||||
];
|
||||
|
||||
test('matchTemplateDeliverable prefers intake for 初始需求表', () => {
|
||||
const hit = matchTemplateDeliverable('根据上传的文档,开始编写用户初始需求表', pool);
|
||||
const bothPool: DeliverableItem[] = [
|
||||
{ ...base, docId: 'doc-urs', taskKey: 'user_requirements', title: '用户需求表', stage: 'V0.5' },
|
||||
{ ...base, docId: 'doc-intake', taskKey: 'intake', title: '用户初始需求表', stage: 'V0.1' },
|
||||
];
|
||||
|
||||
test('resolveRequirementsTaskKey prefers intake for 初始需求表', () => {
|
||||
assert.equal(
|
||||
resolveRequirementsTaskKey('根据上传的文档,开始编写用户初始需求表'),
|
||||
'intake',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveRequirementsTaskKey selects user_requirements for explicit URS', () => {
|
||||
assert.equal(resolveRequirementsTaskKey('请编制用户需求表 URS'), 'user_requirements');
|
||||
});
|
||||
|
||||
test('matchTemplateDeliverable uses intake key when pool only has URS template', () => {
|
||||
const hit = matchTemplateDeliverable('开始编写用户初始需求表', ursOnlyPool);
|
||||
assert.equal(hit?.taskKey, 'intake');
|
||||
});
|
||||
|
||||
test('matchTemplateDeliverable selects user_requirements for explicit URS', () => {
|
||||
const hit = matchTemplateDeliverable('请编制用户需求表 URS', pool);
|
||||
assert.equal(hit?.taskKey, 'user_requirements');
|
||||
test('resolveTaskTemplateForMessage keeps intake key with URS-only pool', () => {
|
||||
const resolved = resolveTaskTemplateForMessage('开始编写用户初始需求表', ursOnlyPool);
|
||||
assert.equal(resolved?.taskKey, 'intake');
|
||||
});
|
||||
|
||||
test('matchTemplateDeliverable prefers intake when both templates exist', () => {
|
||||
const hit = matchTemplateDeliverable('根据上传的文档,开始编写用户初始需求表', bothPool);
|
||||
assert.equal(hit?.taskKey, 'intake');
|
||||
});
|
||||
|
||||
@@ -4,29 +4,58 @@ function templatePool(deliverables: DeliverableItem[]): DeliverableItem[] {
|
||||
return deliverables.filter((d) => d.isTemplateOnly);
|
||||
}
|
||||
|
||||
/** 在 intake / user_requirements 等同语候选里按文案与阶段选择正确任务。 */
|
||||
function pickRequirementsTask(text: string, pool: DeliverableItem[]): DeliverableItem | undefined {
|
||||
const intake = pool.find((d) => d.taskKey === 'intake');
|
||||
const userReq = pool.find((d) => d.taskKey === 'user_requirements');
|
||||
if (!intake && !userReq) return undefined;
|
||||
if (intake && !userReq) return intake;
|
||||
if (!intake && userReq) return userReq;
|
||||
|
||||
/** 从文案判定 intake(V0.1)还是 user_requirements(V0.5),与交付物池无关。 */
|
||||
export function resolveRequirementsTaskKey(text: string): 'intake' | 'user_requirements' {
|
||||
const wantsUrs =
|
||||
text.includes('用户需求表') ||
|
||||
text.includes('URS') ||
|
||||
(text.includes('用户需求') && !text.includes('初始'));
|
||||
if (wantsUrs) return userReq;
|
||||
if (wantsUrs) return 'user_requirements';
|
||||
|
||||
const wantsIntake =
|
||||
text.includes('初始需求') ||
|
||||
text.includes('需求摸底') ||
|
||||
text.includes('摸底') ||
|
||||
text.includes('初始需求表');
|
||||
if (wantsIntake) return intake;
|
||||
if (wantsIntake) return 'intake';
|
||||
|
||||
// 含糊表述默认 V0.1 摸底表,避免误触 V0.5 用户需求表导致阶段门禁 403
|
||||
return intake;
|
||||
return 'intake';
|
||||
}
|
||||
|
||||
function findTemplateByTaskKey(
|
||||
pool: DeliverableItem[],
|
||||
taskKey: string,
|
||||
): DeliverableItem | undefined {
|
||||
return pool.find((d) => d.taskKey === taskKey);
|
||||
}
|
||||
|
||||
/** 在 intake / user_requirements 等同语候选里按文案选择任务键,再匹配模板交付物。 */
|
||||
function pickRequirementsTask(text: string, pool: DeliverableItem[]): DeliverableItem | undefined {
|
||||
const taskKey = resolveRequirementsTaskKey(text);
|
||||
const hit = findTemplateByTaskKey(pool, taskKey);
|
||||
if (hit) return hit;
|
||||
// 池里可能只有 URS 模板;仍返回虚拟条目以便上层使用正确 taskKey
|
||||
const fallback = findTemplateByTaskKey(pool, taskKey === 'intake' ? 'user_requirements' : 'intake');
|
||||
if (fallback) {
|
||||
return { ...fallback, taskKey };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 根据用户输入解析应触发的文档任务键(不依赖交付物池顺序)。 */
|
||||
export function resolveTaskKeyFromMessage(text: string): string | undefined {
|
||||
if (text.includes('需求') || text.includes('摸底') || text.includes('用户')) {
|
||||
return resolveRequirementsTaskKey(text);
|
||||
}
|
||||
if (text.includes('调研') || text.includes('竞品')) return 'research';
|
||||
if (text.includes('可行性') || text.includes('注册') || text.includes('报告')) {
|
||||
if (text.includes('研发') || text.includes('dev')) return 'feasibility_dev';
|
||||
return 'feasibility_reg';
|
||||
}
|
||||
if (text.includes('设计') || text.includes('规格') || text.includes('架构')) return 'prelim_design';
|
||||
if (text.includes('报价') || text.includes('工时') || text.includes('预算')) return 'cdo_quote';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 根据用户输入在模板交付物中解析应触发的文档任务。 */
|
||||
@@ -35,23 +64,25 @@ export function matchTemplateDeliverable(
|
||||
deliverables: DeliverableItem[],
|
||||
): DeliverableItem | undefined {
|
||||
const pool = templatePool(deliverables);
|
||||
const taskKey = resolveTaskKeyFromMessage(text);
|
||||
if (!taskKey) return undefined;
|
||||
|
||||
if (text.includes('需求') || text.includes('摸底') || text.includes('用户')) {
|
||||
const hit = findTemplateByTaskKey(pool, taskKey);
|
||||
if (hit) return hit;
|
||||
|
||||
if (taskKey === 'intake' || taskKey === 'user_requirements') {
|
||||
return pickRequirementsTask(text, pool);
|
||||
}
|
||||
if (text.includes('调研') || text.includes('竞品')) {
|
||||
return pool.find((d) => d.taskKey === 'research');
|
||||
}
|
||||
if (text.includes('可行性') || text.includes('注册') || text.includes('报告')) {
|
||||
return pool.find(
|
||||
(d) => d.taskKey === 'feasibility_reg' || d.taskKey === 'feasibility_dev',
|
||||
);
|
||||
}
|
||||
if (text.includes('设计') || text.includes('规格') || text.includes('架构')) {
|
||||
return pool.find((d) => d.taskKey === 'prelim_design');
|
||||
}
|
||||
if (text.includes('报价') || text.includes('工时') || text.includes('预算')) {
|
||||
return pool.find((d) => d.taskKey === 'cdo_quote');
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 合并文案解析与模板匹配,保证 API 使用正确 task_key。 */
|
||||
export function resolveTaskTemplateForMessage(
|
||||
text: string,
|
||||
deliverables: DeliverableItem[],
|
||||
): { taskKey: string; template?: DeliverableItem } | undefined {
|
||||
const taskKey = resolveTaskKeyFromMessage(text);
|
||||
if (!taskKey) return undefined;
|
||||
const template = matchTemplateDeliverable(text, deliverables);
|
||||
return { taskKey, template };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user