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 backend-provided off_topic summary verbatim', () => { // off_topic 文案由后端(含 LLM)生成,前端只负责透传,不再拼接标题/引导 const text = buildStewardPlanMessageText(OFF_TOPIC_PLAN) assert.equal(text, OFF_TOPIC_PLAN.summary) // 推荐话术本身不在正文里展示,而是作为按钮单独渲染,避免重复。 for (const prompt of OFF_TOPIC_PLAN.suggested_prompts) { assert.equal(text.includes(prompt), false, `正文不应包含推荐话术:${prompt}`) } }) test('buildStewardPlanMessageText adapts greeting vs meaningless vs off_business summaries', () => { // 问候场景:礼貌回应主人 const greetingText = buildStewardPlanMessageText({ plan_id: 'p-greeting', plan_status: 'off_topic', next_action: 'none', summary: '### 您好主人,很高兴为您服务\n\n请问您今天要办理什么业务?', suggested_prompts: ['我想要申请明天去北京出差3天'] }) assert.match(greetingText, /您好主人/) assert.match(greetingText, /请问您今天要办理什么业务/) // 无意义场景:温和解释 + 引导换种说法 const meaninglessText = buildStewardPlanMessageText({ plan_id: 'p-meaningless', plan_status: 'off_topic', next_action: 'none', summary: '### 这句话我暂时没识别到财务事项\n\n很抱歉主人,目前小财管家只能帮您整理**费用申请**和**费用报销**。', suggested_prompts: ['我想要申请明天去北京出差3天'] }) assert.match(meaninglessText, /这句话我暂时没识别到财务事项/) assert.match(meaninglessText, /很抱歉主人/) // 有意义但非业务场景:LLM 生成的文案(这里 mock 模拟) const llmText = '### 抱歉主人,这句话我暂时帮不上忙\n\n主人聊的是天气,小财管家目前只能帮您整理**费用申请**和**费用报销**。' const offBusinessText = buildStewardPlanMessageText({ plan_id: 'p-off-business', plan_status: 'off_topic', next_action: 'none', summary: llmText, suggested_prompts: ['我想要申请明天去北京出差3天'] }) assert.equal(offBusinessText, llmText) }) test('buildStewardPlanMessageText falls back to client template when summary is missing', () => { // 后端 summary 缺失时,前端有兜底文案保证体验不空白 const text = buildStewardPlanMessageText({ plan_id: 'p-empty', 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') })