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:
weichen
2026-09-13 03:30:13 +00:00
co-authored by Cursor
parent d08cbdc2de
commit 0c5256c60c
4 changed files with 116 additions and 44 deletions
+18 -8
View File
@@ -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,
),
+8 -1
View File
@@ -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;
}
+33 -9
View File
@@ -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');
});
+57 -26
View File
@@ -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 };
}