2 Commits

Author SHA1 Message Date
caoxiaozhu
43432534d8 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/文案/按钮/兼容路径
2026-06-18 14:15:30 +08:00
caoxiaozhu
cce19e4c40 feat(steward): 拦截业务无关输入返回 off_topic 计划
- schemas/steward.py:StewardPlanResponse 新增 suggested_prompts 字段
- steward_planner.py:新增 STEWARD_BUSINESS_SIGNAL_KEYWORDS 与
  _is_business_irrelevant_input 守卫,在 build_plan 入口前置;
  新增 _build_off_topic_plan 构造 plan_status=off_topic 的引导计划
- steward_intent_agent.py:system prompt 追加业务无关约束
- test_steward_planner.py:覆盖 123/你好/纯标点走 off_topic,
  并验证正常业务输入不受守卫影响
2026-06-18 14:15:20 +08:00
9 changed files with 466 additions and 25 deletions

View File

@@ -128,6 +128,10 @@ class StewardPlanResponse(BaseModel):
)
candidate_flows: list[StewardCandidateFlow] = Field(default_factory=list, description="等待用户确认的候选流程快捷列表。")
model_call_traces: list[dict[str, Any]] = Field(default_factory=list, description="模型工具调用轨迹。")
suggested_prompts: list[str] = Field(
default_factory=list,
description="当 plan_status 为 off_topic 等场景时,给用户的推荐话术示例。",
)
class StewardSlotOption(BaseModel):

View File

@@ -115,6 +115,10 @@ class StewardIntentAgent:
"如果输入里出现 occurred_date、transport_type、reason_value 等别名,必须映射为 canonical 字段。"
"相对日期必须以 base_date 为准转换为明确日期。"
"thinking_events 只能是面向用户的过程摘要,不能暴露内部推理链。"
"如果用户输入与出差、费用、报销、申请等财务事项完全无关"
"(例如纯数字、问候、闲聊、无意义字符、单字符重复),"
"必须让 tasks 返回空数组,并在 thinking_events 中明确说明“未识别到财务事项”,"
"不要强行把无关输入识别为 expense_application 或 reimbursement 任务。"
),
},
{

View File

@@ -49,6 +49,27 @@ CITY_NAMES = (
"无锡",
)
# 业务信号关键词:用于判定输入是否与小财管家支持的财务事项相关。
# 只要清洗后的消息命中其中任意一个关键词,就视为业务相关;否则进入 off_topic 拦截。
STEWARD_BUSINESS_SIGNAL_KEYWORDS: tuple[str, ...] = (
# 动作词
"申请", "报销", "草稿", "提交", "审批", "保存", "发起", "创建", "核对", "归集",
# 差旅场景
"出差", "差旅", "费用", "交通", "住宿", "招待", "酒店", "机票", "航班", "高铁",
"动车", "火车", "出租车", "的士", "网约车", "打车", "地铁", "公交", "用餐", "餐饮", "宴请",
# 票据/凭证
"票据", "发票", "凭证", "行程单", "付款截图", "付款", "小票", "收据",
# 业务对象
"客户", "项目", "拜访", "会议", "培训", "部署", "实施", "支撑", "支持", "协助",
"调研", "驻场", "上线", "验收", "审核",
# 时间信号
"昨天", "前天", "明天", "后天", "下周", "下月", "近期", "月底", "今天", "上周", "上月",
# 金额/数量("天"用于"出差3天"等表达)
"金额", "", "", "", "", "",
# 复用城市名信号
*CITY_NAMES,
)
APPLICATION_SPLIT_PATTERN = re.compile(r"(?:^|[,。;;])[^,。;;]*?(?:申请|出差申请|差旅申请)[^,。;;]*")
REIMBURSEMENT_PATTERN = re.compile(r"(?:我要报销|还需要报销|需要报销|报销)([^,。;;?!\n]+)")
MONTH_DAY_PATTERN = re.compile(r"(?P<month>\d{1,2})\s*月\s*(?P<day>\d{1,2})\s*(?:日|号)?")
@@ -107,6 +128,9 @@ class StewardPlannerService:
raise ValueError("小财管家需要一段任务描述。")
base_date = self._resolve_base_date(request.client_now_iso, request.context_json)
# 业务无关输入拦截(纯数字、问候、闲聊、乱码等):在进入 LLM/规则兜底之前直接返回 off_topic 计划。
if self._is_business_irrelevant_input(message, request):
return self._build_off_topic_plan(request)
model_call_traces: list[dict[str, Any]] = []
fallback_reason = ""
if self.intent_agent is not None and self._should_use_model_intent_recognition(message, base_date, request):
@@ -159,6 +183,51 @@ class StewardPlannerService:
return False
return self._has_multiple_financial_demands(message)
@staticmethod
def _is_business_irrelevant_input(message: str, request: StewardPlanRequest) -> bool:
"""判断输入是否与小财管家支持的财务事项完全无关。
判定规则:消息去除所有空白后不含任何业务信号关键词,且没有上传附件,
即视为业务无关输入(如纯数字、问候、闲聊、乱码)。
"""
if request.attachments:
return False
compact = re.sub(r"\s+", "", message)
if not compact:
return False
return not any(keyword in compact for keyword in STEWARD_BUSINESS_SIGNAL_KEYWORDS)
def _build_off_topic_plan(self, request: StewardPlanRequest) -> StewardPlanResponse:
"""业务无关输入的兜底计划:明确告知用户未识别到财务事项,并给出话术示例。"""
return StewardPlanResponse(
plan_id=f"steward_plan_{uuid.uuid4().hex[:12]}",
plan_status="off_topic",
planning_source="rule_fallback",
next_action="none",
summary="这看起来跟财务任务没什么关系,小财管家没识别到费用申请或费用报销的意图。",
thinking_events=[
StewardThinkingEvent(
event_id="intent_agent_off_topic",
stage="off_topic",
title="未识别到财务事项",
content=(
"我检查了这句话,没有发现费用申请、报销、出差、交通、招待等财务线索。"
"如果你确实是要处理财务任务,可以参考下面的示例换一种说法。"
),
)
],
tasks=[],
attachment_groups=[],
confirmation_groups=[],
candidate_flows=[],
suggested_prompts=[
"我想要申请明天去北京出差3天支撑客户现场实施",
"我要报销昨天的交通费",
"报销上周出差上海的费用",
],
model_call_traces=[],
)
def _build_rule_fallback_plan(
self,
request: StewardPlanRequest,

View File

@@ -547,3 +547,69 @@ def test_steward_plan_endpoint_persists_application_and_reimbursement_state() ->
assert state["flows"]["travel_reimbursement"]["fields"]["time_range"] == "2026-06-03"
assert state["flows"]["travel_reimbursement"]["fields"]["expense_type"] == "transport"
assert all("invented_field" not in flow["fields"] for flow in state["flows"].values())
def test_steward_planner_returns_off_topic_for_business_irrelevant_input() -> None:
payload = StewardPlanRequest(
message="123",
client_now_iso="2026-06-04T09:30:00+08:00",
)
result = StewardPlannerService().build_plan(payload)
assert result.plan_status == "off_topic"
assert result.next_action == "none"
assert result.tasks == []
assert result.attachment_groups == []
assert result.confirmation_groups == []
assert result.candidate_flows == []
assert result.planning_source == "rule_fallback"
assert len(result.suggested_prompts) == 3
assert result.thinking_events[0].stage == "off_topic"
def test_steward_planner_returns_off_topic_for_pure_greeting() -> None:
payload = StewardPlanRequest(
message="你好",
client_now_iso="2026-06-04T09:30:00+08:00",
)
result = StewardPlannerService().build_plan(payload)
assert result.plan_status == "off_topic"
assert result.next_action == "none"
assert result.tasks == []
assert result.candidate_flows == []
assert result.planning_source == "rule_fallback"
assert len(result.suggested_prompts) == 3
assert result.thinking_events[0].stage == "off_topic"
def test_steward_planner_returns_off_topic_for_pure_punctuation() -> None:
payload = StewardPlanRequest(
message="??? !!!",
client_now_iso="2026-06-04T09:30:00+08:00",
)
result = StewardPlannerService().build_plan(payload)
assert result.plan_status == "off_topic"
assert result.next_action == "none"
assert result.tasks == []
assert result.candidate_flows == []
assert result.planning_source == "rule_fallback"
assert len(result.suggested_prompts) == 3
assert result.thinking_events[0].stage == "off_topic"
def test_steward_planner_preserves_normal_business_flow_after_guard() -> None:
payload = StewardPlanRequest(
message="我要报销昨天的交通费",
client_now_iso="2026-06-04T09:30:00+08:00",
)
result = StewardPlannerService().build_plan(payload)
assert result.plan_status != "off_topic"
assert len(result.tasks) >= 1
assert [task.task_type for task in result.tasks] == ["reimbursement"]

View File

@@ -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) {

View File

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

View File

@@ -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) {

View File

@@ -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() === '小财管家'

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