diff --git a/web/src/App.tsx b/web/src/App.tsx index 3c9e4739..8434e9a9 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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, ), diff --git a/web/src/api/chatRetry.ts b/web/src/api/chatRetry.ts index 953d9352..1ab9cbde 100644 --- a/web/src/api/chatRetry.ts +++ b/web/src/api/chatRetry.ts @@ -10,9 +10,16 @@ export function shouldAutoRetryChat( /** 长任务 SSE:仍允许有限次瞬态自动重试 */ export function shouldAutoRetryTask( - info: Pick, + info: Pick, 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; } diff --git a/web/src/engine/templateMatch.test.ts b/web/src/engine/templateMatch.test.ts index 1832998f..ce30e523 100644 --- a/web/src/engine/templateMatch.test.ts +++ b/web/src/engine/templateMatch.test.ts @@ -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 = { @@ -20,17 +24,37 @@ const base: Omit = { 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'); }); diff --git a/web/src/engine/templateMatch.ts b/web/src/engine/templateMatch.ts index 050eb3af..2e9251b2 100644 --- a/web/src/engine/templateMatch.ts +++ b/web/src/engine/templateMatch.ts @@ -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 }; +}