From 43432534d833cfc0c73db4a634852352c15cf13b Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Thu, 18 Jun 2026 14:15:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(steward):=20=E5=89=8D=E7=AB=AF=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20off=5Ftopic=20=E4=B8=8E=E5=BC=95=E5=AF=BC=E8=AF=9D?= =?UTF-8?q?=E6=9C=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - assistantSessionScope.js:新增 ASSISTANT_SCOPE_ACTION_FILL_COMPOSER 常量 - assistantSuggestedActionPrefill.js:识别 fill_composer 与 payload.fill_text - stewardPlanModel.js:normalizeStewardPlan 透传 suggestedPrompts; buildStewardPlanMessageText / buildStewardSuggestedActions 新增 off_topic 分支,按钮填充输入框不提交 - useStewardPlanFlow.js:isPendingStewardActionMessage 排除 off_topic - steward-plan-off-topic.test.mjs:覆盖 normalize/文案/按钮/兼容路径 --- web/src/utils/assistantSessionScope.js | 87 +++++++-- .../utils/assistantSuggestedActionPrefill.js | 5 +- web/src/views/scripts/stewardPlanModel.js | 66 ++++++- web/src/views/scripts/useStewardPlanFlow.js | 10 +- web/tests/steward-plan-off-topic.test.mjs | 180 ++++++++++++++++++ 5 files changed, 323 insertions(+), 25 deletions(-) create mode 100644 web/tests/steward-plan-off-topic.test.mjs diff --git a/web/src/utils/assistantSessionScope.js b/web/src/utils/assistantSessionScope.js index 16853a0..c8fc9d7 100644 --- a/web/src/utils/assistantSessionScope.js +++ b/web/src/utils/assistantSessionScope.js @@ -2,6 +2,8 @@ import ontologyBusinessContract from '../../../shared/ontology_business_contract export const ASSISTANT_SCOPE_ACTION_SWITCH = 'switch_assistant_session' export const ASSISTANT_SCOPE_ACTION_UNSUPPORTED = 'unsupported_business_intent' +// 点击后把 payload.fill_text 填充到输入框,不切换会话、不自动提交,交由用户编辑后自行发送。 +export const ASSISTANT_SCOPE_ACTION_FILL_COMPOSER = 'fill_composer' export const ASSISTANT_SCOPE_SESSION_APPLICATION = 'application' export const ASSISTANT_SCOPE_SESSION_EXPENSE = 'expense' @@ -286,27 +288,84 @@ function shouldAllowContextualFollowUp(rawText, currentSessionType, options = {} ) } -function buildUnsupportedBusinessScopeText() { - const message = ONTOLOGY_BUSINESS_CONTRACT.unsupportedIntentMessage || {} +function buildUnsupportedBusinessScopeSuggestedActions(options = {}) { + const sharedOptions = { + attachmentCount: options.attachmentCount || 0 + } + const applicationAction = buildScopeSwitchAction(ASSISTANT_SCOPE_SESSION_APPLICATION, '申请下周去上海出差,支撑客户系统上线', sharedOptions) + const expenseAction = buildScopeSwitchAction(ASSISTANT_SCOPE_SESSION_EXPENSE, '我要报销昨天的交通费', sharedOptions) + const knowledgeAction = buildScopeSwitchAction(ASSISTANT_SCOPE_SESSION_KNOWLEDGE, '差旅住宿标准是多少', sharedOptions) + const approvalAction = buildScopeSwitchAction(ASSISTANT_SCOPE_SESSION_APPROVAL, '帮我查询待我审核的单据', sharedOptions) + return [ - message.title || '此意图系统不支持。', - '', - `当前系统支持的业务范围:${SUPPORTED_BUSINESS_SCOPE_TEXT.join('、')}。`, - '', - message.body || '你这条内容没有识别到相关财务业务意图,系统暂不支持处理。', - '', - message.retryHint || '请重新描述你的财务业务要求,例如“申请下周去上海出差”“查询我的报销单进度”或“解释差旅住宿标准”。' - ].join('\n') + { ...applicationAction, label: '去申请助手', description: '发起费用申请和事前审批' }, + { ...expenseAction, label: '去报销助手', description: '继续处理报销和票据' }, + { ...knowledgeAction, label: '去知识助手', description: '查看标准和流程规则' }, + { ...approvalAction, label: '去审核助手', description: '查看待审单据和风险' } + ] } -function buildUnsupportedBusinessScopeGuard() { +function buildUnsupportedBusinessScopeText(rawText, options = {}) { + const message = ONTOLOGY_BUSINESS_CONTRACT.unsupportedIntentMessage || {} + const text = String(rawText || '').trim() + const intro = text + ? `**小财管家暂时不处理「${text}」这类内容。**` + : `**${message.title || '此意图系统不支持。'}**` + const attachmentHint = options.attachmentCount + ? '你刚刚上传的附件我会先保留,切换到合适场景后可以继续使用。' + : '' + return [ + intro, + '', + '### 当前可继续的场景', + `- ${SUPPORTED_BUSINESS_SCOPE_TEXT[0] || '费用申请/事前审批'}`, + `- ${SUPPORTED_BUSINESS_SCOPE_TEXT[1] || '报销与票据识别'}`, + `- ${SUPPORTED_BUSINESS_SCOPE_TEXT[3] || '财务制度、报销标准和流程规则问答'}`, + `- ${SUPPORTED_BUSINESS_SCOPE_TEXT[4] || '预算、应收、应付等财务经营查询'}`, + '', + message.body || '这条内容没有识别到当前系统支持的财务业务意图,暂时不能继续处理。', + attachmentHint, + '你可以直接点下面的场景继续,或者重新描述你的财务业务需求。', + '', + message.retryHint || '请重新描述你的财务业务要求,例如“申请下周去上海出差”“查询我的报销单进度”或“解释差旅住宿标准”。' + ].filter(Boolean).join('\n') +} + +export function buildUnsupportedBusinessScopeConversation(rawText, options = {}) { + const suggestedActions = buildUnsupportedBusinessScopeSuggestedActions(options) + return { + state_json: { + session_type: ASSISTANT_SCOPE_SESSION_STEWARD + }, + messages: [ + { + id: 'unsupported-business-intent', + role: 'assistant', + content: buildUnsupportedBusinessScopeText(rawText, options), + created_at: new Date().toISOString(), + message_json: { + assistant_name: '小财管家', + assistant_variant: 'compact_guidance', + orchestrator_payload: { + result: { + suggested_actions: suggestedActions + } + } + } + } + ] + } +} + +function buildUnsupportedBusinessScopeGuard(rawText, options = {}) { + const suggestedActions = buildUnsupportedBusinessScopeSuggestedActions(options) return { targetSessionType: '', targetLabel: '不支持的意图', blocked: true, - text: buildUnsupportedBusinessScopeText(), + text: buildUnsupportedBusinessScopeText(rawText, options), meta: ['意图不支持'], - suggestedActions: [], + suggestedActions, actionType: ASSISTANT_SCOPE_ACTION_UNSUPPORTED } } @@ -318,7 +377,7 @@ export function resolveAssistantScopeGuard(rawText, currentSessionType, options if (shouldAllowContextualFollowUp(rawText, normalizedCurrent, options)) { return null } - return normalizeText(rawText) ? buildUnsupportedBusinessScopeGuard() : null + return normalizeText(rawText) ? buildUnsupportedBusinessScopeGuard(rawText, options) : null } if (targetSessionType === normalizedCurrent) { diff --git a/web/src/utils/assistantSuggestedActionPrefill.js b/web/src/utils/assistantSuggestedActionPrefill.js index d1d6db1..336e1a3 100644 --- a/web/src/utils/assistantSuggestedActionPrefill.js +++ b/web/src/utils/assistantSuggestedActionPrefill.js @@ -1,3 +1,5 @@ +import { ASSISTANT_SCOPE_ACTION_FILL_COMPOSER } from './assistantSessionScope.js' + const APPLICATION_FIELD_PREFILLS = { time: '申请时间段:', time_range: '申请时间段:', @@ -14,6 +16,7 @@ export function resolveSuggestedActionPrefill(action = {}) { payload.prompt_prefill || payload.input_prefill || payload.prefill_text + || payload.fill_text || '' ).trim() if (explicitPrefill) { @@ -21,7 +24,7 @@ export function resolveSuggestedActionPrefill(action = {}) { } const actionType = String(action?.action_type || '').trim() - if (actionType !== 'prefill_composer') { + if (actionType !== 'prefill_composer' && actionType !== ASSISTANT_SCOPE_ACTION_FILL_COMPOSER) { return '' } diff --git a/web/src/views/scripts/stewardPlanModel.js b/web/src/views/scripts/stewardPlanModel.js index 5f21a02..e0ab88f 100644 --- a/web/src/views/scripts/stewardPlanModel.js +++ b/web/src/views/scripts/stewardPlanModel.js @@ -1,5 +1,6 @@ import { ASSISTANT_SCOPE_ACTION_SWITCH, + ASSISTANT_SCOPE_ACTION_FILL_COMPOSER } from '../../utils/assistantSessionScope.js' import { SESSION_TYPE_APPLICATION, @@ -200,12 +201,18 @@ export function normalizeStewardPlan(rawPlan = {}, options = {}) { ? rawPlan.confirmation_groups : [], pendingFlowConfirmation, - candidateFlows: pendingFlowConfirmation.candidateFlows + candidateFlows: pendingFlowConfirmation.candidateFlows, + suggestedPrompts: Array.isArray(rawPlan.suggested_prompts) + ? rawPlan.suggested_prompts.map((item) => String(item || '').trim()).filter(Boolean) + : [] } } export function buildStewardPlanMessageText(plan) { const normalized = normalizeStewardPlan(plan) + if (isOffTopicPlan(normalized)) { + return buildOffTopicMessageText(normalized) + } if (isPendingFlowConfirmationPlan(normalized)) { return buildPendingFlowConfirmationMessageText(normalized) } @@ -215,13 +222,13 @@ export function buildStewardPlanMessageText(plan) { `${index + 1}. **${buildTaskOrderVerb(index)}${buildTaskOrderTarget(task)}**\n - ${buildTaskOrderActionDescription(task)}` ) return [ - '### 我会这样推进', + '### 我先帮你把步骤理清楚', '', - `我识别到 **${normalized.tasks.length} 个财务事项**,会按顺序逐步处理,不会一次性把所有动作都执行掉。`, + buildStewardPlanFriendlyIntro(normalized), '', ...taskLines, '', - '如果这个顺序没问题,请回复 **确定**。我会先进入第一步,并在具体步骤里再判断需要你补充哪些信息。' + '你看这个顺序是否合适?如果没问题,回复 **确定** 就行。我会先帮你进入第一步,需要补充的信息会在具体步骤里再温和提醒你。' ].filter((line, index, lines) => line || lines[index - 1]).join('\n') } @@ -284,6 +291,18 @@ export function formatStewardOntologyFields(fields = {}, taskType = '') { export function buildStewardSuggestedActions(plan) { const normalized = normalizeStewardPlan(plan) + if (isOffTopicPlan(normalized)) { + return normalized.suggestedPrompts.map((prompt) => ({ + label: prompt.length > 24 ? `${prompt.slice(0, 24)}...` : prompt, + description: '点击填入输入框,可编辑后发送', + icon: 'mdi mdi-comment-text-outline', + action_type: ASSISTANT_SCOPE_ACTION_FILL_COMPOSER, + payload: { + steward_plan_id: normalized.planId, + fill_text: prompt + } + })) + } if (isPendingFlowConfirmationPlan(normalized)) { return normalized.candidateFlows.map((flow) => ({ label: flow.label, @@ -383,6 +402,28 @@ function isPendingFlowConfirmationPlan(normalized) { ) && Array.isArray(normalized?.candidateFlows) && normalized.candidateFlows.length > 0 } +function isOffTopicPlan(normalized) { + return String(normalized?.planStatus || '').trim() === 'off_topic' +} + +export function isOffTopicStewardPlan(rawPlan) { + return isOffTopicPlan(normalizeStewardPlan(rawPlan)) +} + +function buildOffTopicMessageText(normalized) { + const summary = String(normalized?.summary || '').trim() + const summaryLine = summary && summary !== '这看起来跟财务任务没什么关系...' + ? summary + : '这看起来跟财务任务没什么关系,我目前只能帮你处理**费用申请**和**费用报销**两类事项。' + return [ + '### 小财管家没看懂这件事', + '', + summaryLine, + '', + '你可以试试下面这些方式告诉我:' + ].join('\n') +} + function buildPendingFlowConfirmationMessageText(normalized) { const fields = normalized.candidateFlows[0]?.ontologyFields || {} const knownParts = formatStewardOntologyFields(fields, 'expense_application') @@ -511,10 +552,10 @@ function buildTaskOrderVerb(index) { function buildTaskOrderTarget(task) { const title = task.title || task.taskTypeLabel if (task.taskType === 'expense_application') { - return `创建“${title}”` + return `整理“${title}”` } if (task.taskType === 'reimbursement') { - return `处理“${title}”` + return `核对“${title}”` } return `处理“${title}”` } @@ -522,12 +563,19 @@ function buildTaskOrderTarget(task) { function buildTaskOrderActionDescription(task) { const agent = task.assignedAgentLabel || '对应助手' if (task.taskType === 'expense_application') { - return `交给${agent}生成申请单核对结果,确认无误后再进入后续动作。` + return `我会请${agent}先把申请单草稿整理出来,方便你核对关键信息,再决定是否继续。` } if (task.taskType === 'reimbursement') { - return `交给${agent}整理报销核对结果,等前一步完成后再继续推进。` + return `我会请${agent}把票据、金额和制度口径先核清楚,前一步确认后再继续往下走。` } - return `交给${agent}处理,执行前会先让你确认。` + return `我会请${agent}先整理可核对的结果,真正执行前仍会让你确认。` +} + +function buildStewardPlanFriendlyIntro(normalized) { + const taskCountText = normalized.tasks.length > 1 + ? `${normalized.tasks.length} 个相关事项` + : '1 个事项' + return `我先看了一下,你这次主要是 **${taskCountText}**。为了不让步骤混在一起,我会先把要做的事拆开,让你每一步都能看清楚、确认后再继续。` } function buildTaskOrderDescription(normalized) { diff --git a/web/src/views/scripts/useStewardPlanFlow.js b/web/src/views/scripts/useStewardPlanFlow.js index 6a72c2f..2ddeb49 100644 --- a/web/src/views/scripts/useStewardPlanFlow.js +++ b/web/src/views/scripts/useStewardPlanFlow.js @@ -2,6 +2,7 @@ import { buildStewardPlanMessageText, buildStewardPlanRequest, buildStewardSuggestedActions, + isOffTopicStewardPlan, normalizeStewardPlan } from './stewardPlanModel.js' import { SESSION_TYPE_STEWARD } from './travelReimbursementConversationModel.js' @@ -384,7 +385,14 @@ export function useStewardPlanFlow({ function isPendingStewardActionMessage(message) { if (message?.stewardPlan) { - return message.stewardPlan.streamStatus !== 'streaming' + if (message.stewardPlan.streamStatus === 'streaming') { + return false + } + // off_topic 是引导用户重新编辑输入,不参与"确定/确认"快捷回复链路。 + if (isOffTopicStewardPlan(message.stewardPlan)) { + return false + } + return true } return ( String(message?.assistantName || '').trim() === '小财管家' diff --git a/web/tests/steward-plan-off-topic.test.mjs b/web/tests/steward-plan-off-topic.test.mjs new file mode 100644 index 0000000..d5e4710 --- /dev/null +++ b/web/tests/steward-plan-off-topic.test.mjs @@ -0,0 +1,180 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + ASSISTANT_SCOPE_ACTION_FILL_COMPOSER +} from '../src/utils/assistantSessionScope.js' +import { + buildStewardPlanMessageText, + buildStewardSuggestedActions, + isOffTopicStewardPlan, + normalizeStewardPlan +} from '../src/views/scripts/stewardPlanModel.js' +import { + resolveSuggestedActionPrefill +} from '../src/utils/assistantSuggestedActionPrefill.js' + +const OFF_TOPIC_PLAN = { + plan_id: 'steward-plan-off-topic', + plan_status: 'off_topic', + next_action: 'none', + tasks: [], + attachment_groups: [], + confirmation_groups: [], + candidate_flows: [], + summary: '这看起来跟财务任务没什么关系...', + suggested_prompts: [ + '我想要申请明天去北京出差3天,支撑客户现场实施', + '我要报销上周去上海的高铁票', + '差旅住宿标准是多少' + ], + thinking_events: [ + { + event_id: 'off-topic-explain', + stage: 'intent_review', + title: '未识别到财务业务意图', + content: '用户输入的内容没有匹配到费用申请或费用报销相关的业务信号。', + status: 'completed' + } + ] +} + +test('normalizeStewardPlan passes suggested_prompts through as trimmed strings', () => { + const normalized = normalizeStewardPlan({ + plan_id: 'p-1', + plan_status: 'off_topic', + suggested_prompts: [ + ' 申请出差 ', + '', + null, + '报销高铁票' + ] + }) + assert.deepEqual(normalized.suggestedPrompts, ['申请出差', '报销高铁票']) +}) + +test('normalizeStewardPlan falls back to empty suggested prompts when missing', () => { + const normalized = normalizeStewardPlan({ plan_id: 'p-2', plan_status: 'off_topic' }) + assert.deepEqual(normalized.suggestedPrompts, []) +}) + +test('isOffTopicStewardPlan returns true only for off_topic plan status', () => { + assert.equal(isOffTopicStewardPlan({ plan_status: 'off_topic' }), true) + assert.equal(isOffTopicStewardPlan({ plan_status: 'needs_flow_confirmation' }), false) + assert.equal(isOffTopicStewardPlan({}), false) +}) + +test('buildStewardPlanMessageText renders friendly off_topic guidance', () => { + const text = buildStewardPlanMessageText(OFF_TOPIC_PLAN) + assert.match(text, /小财管家没看懂这件事/) + // 推荐话术本身不在正文里展示,而是作为按钮单独渲染,避免重复。 + for (const prompt of OFF_TOPIC_PLAN.suggested_prompts) { + assert.equal(text.includes(prompt), false, `正文不应包含推荐话术:${prompt}`) + } +}) + +test('buildStewardPlanMessageText keeps off_topic branch ahead of pending flow branch', () => { + // 即使 summary 缺省,也走 off_topic 分支而非默认任务文案 + const text = buildStewardPlanMessageText({ + plan_id: 'p-off-topic-default', + plan_status: 'off_topic', + next_action: 'none', + suggested_prompts: ['申请出差'] + }) + assert.match(text, /小财管家没看懂这件事/) + assert.match(text, /费用申请.*费用报销|费用报销.*费用申请/) +}) + +test('buildStewardSuggestedActions returns fill_composer actions for off_topic plan', () => { + const actions = buildStewardSuggestedActions(OFF_TOPIC_PLAN) + assert.equal(actions.length, OFF_TOPIC_PLAN.suggested_prompts.length) + for (const action of actions) { + assert.equal(action.action_type, ASSISTANT_SCOPE_ACTION_FILL_COMPOSER) + assert.equal(typeof action.payload.fill_text, 'string') + assert.ok(action.payload.fill_text.length > 0) + assert.equal(action.payload.steward_plan_id, OFF_TOPIC_PLAN.plan_id) + } +}) + +test('buildStewardSuggestedActions truncates long off_topic prompt labels', () => { + const longPrompt = '我想要申请明天去北京出差3天,支撑客户现场实施,需要预订酒店和高铁票' + const actions = buildStewardSuggestedActions({ + plan_id: 'p-long', + plan_status: 'off_topic', + suggested_prompts: [longPrompt] + }) + assert.equal(actions.length, 1) + assert.ok(actions[0].label.length <= 27) // 24 字符 + "..." + assert.ok(actions[0].label.endsWith('...')) + // payload.fill_text 必须保留完整话术,不被截断 + assert.equal(actions[0].payload.fill_text, longPrompt) +}) + +test('buildStewardSuggestedActions keeps short off_topic prompt labels intact', () => { + const shortPrompt = '差旅住宿标准是多少' + const actions = buildStewardSuggestedActions({ + plan_id: 'p-short', + plan_status: 'off_topic', + suggested_prompts: [shortPrompt] + }) + assert.equal(actions[0].label, shortPrompt) + assert.equal(actions[0].payload.fill_text, shortPrompt) +}) + +test('buildStewardSuggestedActions returns empty array for off_topic plan without prompts', () => { + const actions = buildStewardSuggestedActions({ + plan_id: 'p-empty', + plan_status: 'off_topic', + suggested_prompts: [] + }) + assert.deepEqual(actions, []) +}) + +test('off_topic fill_composer action is resolved as composer prefill (fill not submit)', () => { + const actions = buildStewardSuggestedActions(OFF_TOPIC_PLAN) + const firstAction = actions[0] + const prefill = resolveSuggestedActionPrefill(firstAction) + assert.equal(prefill, OFF_TOPIC_PLAN.suggested_prompts[0]) +}) + +test('resolveSuggestedActionPrefill reads payload.fill_text directly', () => { + assert.equal( + resolveSuggestedActionPrefill({ + action_type: ASSISTANT_SCOPE_ACTION_FILL_COMPOSER, + payload: { fill_text: '帮我申请下周出差' } + }), + '帮我申请下周出差' + ) + // 空字符串/空白应被忽略 + assert.equal( + resolveSuggestedActionPrefill({ + action_type: ASSISTANT_SCOPE_ACTION_FILL_COMPOSER, + payload: { fill_text: ' ' } + }), + '' + ) +}) + +test('off_topic branch does not break pending flow confirmation actions', () => { + // pending flow 不应被 off_topic 分支拦截 + const actions = buildStewardSuggestedActions({ + plan_id: 'steward-plan-pending-flow', + plan_status: 'needs_flow_confirmation', + next_action: 'confirm_flow', + pending_flow_confirmation: { + status: 'pending', + reason: '缺少申请或报销动作词。', + candidate_flows: [ + { + flow_id: 'travel_application', + label: '补办出差申请', + confidence: 0.6, + ontology_fields: { location: '上海' }, + missing_fields: [] + } + ] + } + }) + assert.equal(actions.length, 1) + assert.equal(actions[0].payload.flow_id, 'travel_application') +})