完整修改内容: - 拆分 TravelReimbursementCreateView:提取审核面板纯模型、消息操作、建议动作处理、生命周期 watcher/UI 映射、小财管家运行时、续办流程和运行时文本模型,减少主组件继续堆叠业务分支。 - 调整申请预览链路:新增本地申请意图 gate,完善复杂差旅申请的大模型复核判断、交通方式缺失/候选识别、规则中心交通费用预估合并和申请冲突处理。 - 优化小财管家流程:抽出 steward typewriter 分段策略,避免 Markdown 表格逐字闪烁;补齐跨助手 carry、字段补齐续办、文本确认提交和行程规划推荐动作。 - 调整消息与样式:移除申请预览日期 chip 样式,收敛申请卡片/报销草稿消息的展示与复制、朗读、反馈入口逻辑。 - 更新测试:将源码锚点迁移到新模块,覆盖申请预览、提交确认、小财管家续办、引导流和审核抽屉相关断言。 验证: - node --check web/src/views/scripts/TravelReimbursementCreateView.js 及新增拆分模块 - npm --prefix web run build - node --test web/tests/expense-application-fast-preview.test.mjs web/tests/expense-application-submit-rich-confirm.test.mjs web/tests/travel-reimbursement-guided-flow.test.mjs 说明: - 后端/规则/容器配置/Audit 页面等工作区已有改动未纳入本提交。 - 容器内后端定向 pytest 曾运行 timeout 180s /tmp/x-financial-server-venv/bin/pytest -q <相关后端测试>,180 秒超时且超时前已有失败标记,未作为通过项记录。 - TravelReimbursementCreateView 当前仍超过 800 行,后续仍需继续拆分;本提交先把新增职责模块控制在 800 行内,阻止主类/主模块继续膨胀。
319 lines
12 KiB
JavaScript
319 lines
12 KiB
JavaScript
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'
|
||
|
||
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 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
|
||
}
|
||
|
||
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 ''
|
||
}
|
||
|
||
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 buildUnsupportedBusinessScopeText() {
|
||
const message = ONTOLOGY_BUSINESS_CONTRACT.unsupportedIntentMessage || {}
|
||
return [
|
||
message.title || '此意图系统不支持。',
|
||
'',
|
||
`当前系统支持的业务范围:${SUPPORTED_BUSINESS_SCOPE_TEXT.join('、')}。`,
|
||
'',
|
||
message.body || '你这条内容没有识别到相关财务业务意图,系统暂不支持处理。',
|
||
'',
|
||
message.retryHint || '请重新描述你的财务业务要求,例如“申请下周去上海出差”“查询我的报销单进度”或“解释差旅住宿标准”。'
|
||
].join('\n')
|
||
}
|
||
|
||
function buildUnsupportedBusinessScopeGuard() {
|
||
return {
|
||
targetSessionType: '',
|
||
targetLabel: '不支持的意图',
|
||
blocked: true,
|
||
text: buildUnsupportedBusinessScopeText(),
|
||
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() : 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)]
|
||
}
|
||
}
|