Files
X-Financial/web/src/utils/assistantSessionScope.js
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

401 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import ontologyBusinessContract from '../../../shared/ontology_business_contract.json' with { type: 'json' }
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'
export const ASSISTANT_SCOPE_SESSION_APPROVAL = 'approval'
export const ASSISTANT_SCOPE_SESSION_KNOWLEDGE = 'knowledge'
export const ASSISTANT_SCOPE_SESSION_STEWARD = 'steward'
const FALLBACK_SESSION_SCOPE_CONFIG = {
[ASSISTANT_SCOPE_SESSION_STEWARD]: {
label: '小财管家',
icon: 'mdi mdi-account-tie-outline',
scope: '多任务拆解、附件归集、申请助手和报销助手统一调度'
},
[ASSISTANT_SCOPE_SESSION_APPLICATION]: {
label: '申请助手',
icon: 'mdi mdi-file-plus-outline',
scope: '费用申请、事前审批、申请材料清单、申请单状态查询'
},
[ASSISTANT_SCOPE_SESSION_EXPENSE]: {
label: '报销助手',
icon: 'mdi mdi-receipt-text-plus-outline',
scope: '发起报销、票据识别、草稿归集、报销单状态查询和报销信息核对'
},
[ASSISTANT_SCOPE_SESSION_APPROVAL]: {
label: '审核助手',
icon: 'mdi mdi-clipboard-check-outline',
scope: '待审单据查询、审批动作、风险解释和审核意见草稿'
},
[ASSISTANT_SCOPE_SESSION_KNOWLEDGE]: {
label: '财务知识助手',
icon: 'mdi mdi-book-open-page-variant-outline',
scope: '财务制度、报销标准、票据要求、流程规则和政策口径解释'
}
}
const ONTOLOGY_BUSINESS_CONTRACT = ontologyBusinessContract || {}
const SESSION_SCOPE_CONFIG = ONTOLOGY_BUSINESS_CONTRACT.sessions || FALLBACK_SESSION_SCOPE_CONFIG
const SESSION_SCOPE_TYPES = Object.keys(SESSION_SCOPE_CONFIG)
const BUSINESS_SIGNAL_GROUPS = ONTOLOGY_BUSINESS_CONTRACT.businessSignals || {}
const APPLICATION_PATTERN =
/费用申请|发起申请|申请单|事前申请|事前审批|前置审批|出差申请|申请出差|差旅申请|申请差旅|采购申请|用款申请|预算申请|申请材料|材料清单|先申请|立项申请/
const APPLICATION_PLANNING_PATTERN =
/计划|安排|准备|需要|打算|预计|申请|发起|提交|提出|先走|先办|要去|将要|下周|下月|明天|后天|近期|月底|去|到|赴|前往|参加/
const APPLICATION_BUSINESS_PATTERN =
/出差|差旅|客户现场|现场|客户|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|会务|驻场|上线|验收|采购|购置|用款|立项/
const APPLICATION_FUTURE_OR_DURATION_PATTERN =
/明天|后天|下周|下月|近期|月底|预计|计划|安排|准备|将要|[0-9]+天|[一二两三四五六七八九十]+天/
const APPLICATION_ROUTE_PATTERN =
/(?:去|到|赴|前往)[^,。;;?!\n]{0,24}(?:出差|差旅|客户|现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收)|(?:出差|差旅)[^,。;;?!\n]{0,24}(?:[0-9]+天|[一二两三四五六七八九十]+天|客户|现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收)/
const AMBIGUOUS_TRAVEL_DATE_RANGE_PATTERN =
/(?:\d{1,2}月)?\d{1,2}(?:日|号)?(?:-|—|~||至|到)\d{1,2}(?:日|号)?[^,。;;?!\n]{0,32}(?:出差|差旅|客户|现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收)/
const EXPLICIT_APPLICATION_ACTION_PATTERN =
/费用申请|发起申请|申请单|事前申请|事前审批|前置审批|出差申请|申请出差|差旅申请|申请差旅|采购申请|用款申请|预算申请|先申请|补办申请|补申请|补办出差申请|创建申请|提交申请/
const COMPLETED_EXPENSE_PATTERN =
/已经|已|昨天|前天|上周|上月|去年|花了|花销|消费|垫付|支付|付了|买了|采购了|招待了|发生了/
const EXPENSE_PATTERN =
/报销|报销单|票据|发票|火车票|高铁票|机票|飞机票|的士票|出租车|网约车|酒店票|住宿票|住宿单据|保存草稿|草稿|费用明细|归集|上传.*票|关联单据|继续下一步/
const APPROVAL_PATTERN =
/待我审核|待审|审核|审批|审核意见|审批意见|审批通过|审批驳回|驳回|退回|审核中心|审批中心|领导审批|财务审核|处理意见/
const KNOWLEDGE_PATTERN =
/制度|政策|标准|规则|规定|流程|口径|依据|上限|额度|补贴|住宿标准|差旅标准|报销标准|票据要求|可不可以|能不能|怎么规定|如何计算|怎么算/
const EXPENSE_OPERATION_PATTERN = /发起报销|报销单|票据|发票|火车票|高铁票|机票|的士票|草稿|归集|上传|关联单据|继续下一步/
const CURRENT_CLAIM_RISK_PATTERN = /这张|当前|本单|该单|单据|风险|超标|异常|重复|待补/
const FINANCE_OPERATING_PATTERN = buildKeywordPattern([
...(BUSINESS_SIGNAL_GROUPS.budget || []),
...(BUSINESS_SIGNAL_GROUPS.accounts_receivable || []),
...(BUSINESS_SIGNAL_GROUPS.accounts_payable || [])
])
const CONTEXTUAL_FOLLOW_UP_PATTERN = buildExactKeywordPattern(ONTOLOGY_BUSINESS_CONTRACT.contextualFollowUps || [])
export const SUPPORTED_BUSINESS_SCOPE_TEXT = Array.isArray(ONTOLOGY_BUSINESS_CONTRACT.supportedBusinessScopes)
? ONTOLOGY_BUSINESS_CONTRACT.supportedBusinessScopes
: [
'费用申请/事前审批',
'报销与票据识别',
'审批审核与风险解释',
'财务制度、报销标准和流程规则问答',
'预算、应收、应付等财务经营查询',
'小财管家多任务拆解和附件归集'
]
function escapeRegExp(value) {
return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function buildKeywordPattern(keywords = []) {
const source = keywords
.map((keyword) => String(keyword || '').trim())
.filter(Boolean)
.map(escapeRegExp)
.join('|')
return source ? new RegExp(source) : /$a/
}
function buildExactKeywordPattern(keywords = []) {
const source = keywords
.map((keyword) => String(keyword || '').trim())
.filter(Boolean)
.map(escapeRegExp)
.join('|')
return source ? new RegExp(`^(${source})$`) : /$a/
}
function normalizeSessionType(sessionType) {
const normalized = String(sessionType || '').trim()
return SESSION_SCOPE_TYPES.includes(normalized) ? normalized : ASSISTANT_SCOPE_SESSION_EXPENSE
}
function normalizeText(rawText) {
return String(rawText || '')
.replace(/\s+/g, '')
.toLowerCase()
}
export function hasReimbursementIntentSignal(rawText) {
return EXPENSE_PATTERN.test(normalizeText(rawText))
}
export function hasExpenseApplicationIntentSignal(rawText) {
const text = normalizeText(rawText)
if (!text) {
return false
}
if (APPLICATION_PATTERN.test(text)) {
return true
}
if (hasReimbursementIntentSignal(text) || COMPLETED_EXPENSE_PATTERN.test(text)) {
return false
}
if (KNOWLEDGE_PATTERN.test(text) && !EXPENSE_OPERATION_PATTERN.test(text)) {
return false
}
const hasBusinessSignal = APPLICATION_BUSINESS_PATTERN.test(text)
const planningScore = APPLICATION_PLANNING_PATTERN.test(text) ? 1 : 0
const timingScore = APPLICATION_FUTURE_OR_DURATION_PATTERN.test(text) ? 1 : 0
const routeScore = APPLICATION_ROUTE_PATTERN.test(text) ? 2 : 0
return hasBusinessSignal && planningScore + timingScore + routeScore >= 2
}
export function hasAmbiguousTravelFlowIntent(rawText) {
const text = normalizeText(rawText)
if (!text) {
return false
}
if (
EXPLICIT_APPLICATION_ACTION_PATTERN.test(text) ||
EXPENSE_PATTERN.test(text) ||
KNOWLEDGE_PATTERN.test(text)
) {
return false
}
return AMBIGUOUS_TRAVEL_DATE_RANGE_PATTERN.test(text)
}
function resolveScopeConfig(sessionType) {
return SESSION_SCOPE_CONFIG[normalizeSessionType(sessionType)] || SESSION_SCOPE_CONFIG[ASSISTANT_SCOPE_SESSION_EXPENSE]
}
export function inferAssistantScopeTarget(rawText, options = {}) {
const text = normalizeText(rawText)
if (!text) {
return ''
}
if (hasAmbiguousTravelFlowIntent(text)) {
return ASSISTANT_SCOPE_SESSION_STEWARD
}
const applicationMatched = hasExpenseApplicationIntentSignal(text)
const expenseMatched = EXPENSE_PATTERN.test(text)
const approvalMatched = APPROVAL_PATTERN.test(text)
const knowledgeMatched = KNOWLEDGE_PATTERN.test(text)
if (applicationMatched && expenseMatched) {
return ASSISTANT_SCOPE_SESSION_STEWARD
}
if (approvalMatched && /(待我审核|待审|审核意见|审批意见|审批通过|审批驳回|驳回|退回|审核中心|审批中心|处理意见)/.test(text)) {
return ASSISTANT_SCOPE_SESSION_APPROVAL
}
if (knowledgeMatched && !options.hasActiveReviewPayload && !EXPENSE_OPERATION_PATTERN.test(text)) {
return ASSISTANT_SCOPE_SESSION_KNOWLEDGE
}
if (expenseMatched && !applicationMatched) {
return ASSISTANT_SCOPE_SESSION_EXPENSE
}
if (applicationMatched && !expenseMatched) {
return ASSISTANT_SCOPE_SESSION_APPLICATION
}
if (FINANCE_OPERATING_PATTERN.test(text)) {
return ASSISTANT_SCOPE_SESSION_STEWARD
}
if (knowledgeMatched && !expenseMatched && !approvalMatched && !applicationMatched) {
return ASSISTANT_SCOPE_SESSION_KNOWLEDGE
}
if (knowledgeMatched && !options.hasActiveReviewPayload) {
return ASSISTANT_SCOPE_SESSION_KNOWLEDGE
}
if (approvalMatched) {
return ASSISTANT_SCOPE_SESSION_APPROVAL
}
if (expenseMatched) {
return ASSISTANT_SCOPE_SESSION_EXPENSE
}
if (applicationMatched) {
return ASSISTANT_SCOPE_SESSION_APPLICATION
}
return ''
}
function shouldAllowCurrentExpensePolicyQuestion(rawText, currentSessionType, targetSessionType, options = {}) {
if (
normalizeSessionType(currentSessionType) !== ASSISTANT_SCOPE_SESSION_EXPENSE ||
targetSessionType !== ASSISTANT_SCOPE_SESSION_KNOWLEDGE ||
!options.hasActiveReviewPayload
) {
return false
}
return CURRENT_CLAIM_RISK_PATTERN.test(normalizeText(rawText))
}
function buildScopeSwitchAction(targetSessionType, rawText, options = {}) {
const target = resolveScopeConfig(targetSessionType)
const carryText = String(rawText || '').trim()
return {
label: `切换到${target.label}`,
description: `带着这条内容进入${target.label}继续处理`,
icon: target.icon,
action_type: ASSISTANT_SCOPE_ACTION_SWITCH,
payload: {
session_type: normalizeSessionType(targetSessionType),
carry_text: carryText,
carry_files: Boolean(options.attachmentCount)
}
}
}
function buildScopeBoundaryText(currentSessionType, targetSessionType) {
const current = resolveScopeConfig(currentSessionType)
const target = resolveScopeConfig(targetSessionType)
return [
`我先暂停在「${current.label}」里继续处理这条消息。`,
'',
`当前助手的业务范围是:${current.scope}`,
'',
`您这条内容更适合交给「${target.label}」处理;它的业务范围是:${target.scope}`,
'',
`建议切换到「${target.label}」后继续,我会尽量把这条内容带过去,避免在错误的会话里把流程跑偏。`
].join('\n')
}
function shouldAllowContextualFollowUp(rawText, currentSessionType, options = {}) {
const text = normalizeText(rawText)
if (options.hasActiveReviewPayload && CURRENT_CLAIM_RISK_PATTERN.test(text)) {
return true
}
if (!text || !CONTEXTUAL_FOLLOW_UP_PATTERN.test(text)) {
return false
}
return Boolean(
options.hasActiveReviewPayload ||
options.hasPendingApplicationPreview ||
options.reviewAction ||
currentSessionType
)
}
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 [
{ ...applicationAction, label: '去申请助手', description: '发起费用申请和事前审批' },
{ ...expenseAction, label: '去报销助手', description: '继续处理报销和票据' },
{ ...knowledgeAction, label: '去知识助手', description: '查看标准和流程规则' },
{ ...approvalAction, label: '去审核助手', description: '查看待审单据和风险' }
]
}
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(rawText, options),
meta: ['意图不支持'],
suggestedActions,
actionType: ASSISTANT_SCOPE_ACTION_UNSUPPORTED
}
}
export function resolveAssistantScopeGuard(rawText, currentSessionType, options = {}) {
const normalizedCurrent = normalizeSessionType(currentSessionType)
const targetSessionType = inferAssistantScopeTarget(rawText, options)
if (!targetSessionType) {
if (shouldAllowContextualFollowUp(rawText, normalizedCurrent, options)) {
return null
}
return normalizeText(rawText) ? buildUnsupportedBusinessScopeGuard(rawText, options) : null
}
if (targetSessionType === normalizedCurrent) {
return null
}
if (shouldAllowCurrentExpensePolicyQuestion(rawText, normalizedCurrent, targetSessionType, options)) {
return null
}
const target = resolveScopeConfig(targetSessionType)
return {
targetSessionType,
targetLabel: target.label,
text: buildScopeBoundaryText(normalizedCurrent, targetSessionType),
meta: [`建议切换至${target.label}`],
suggestedActions: [buildScopeSwitchAction(targetSessionType, rawText, options)]
}
}