feat(steward): 前端支持 off_topic 与引导话术

- 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/文案/按钮/兼容路径
This commit is contained in:
caoxiaozhu
2026-06-18 14:15:30 +08:00
parent cce19e4c40
commit 43432534d8
5 changed files with 323 additions and 25 deletions

View File

@@ -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')
})