Files
X-Financial/web/src/utils/assistantSessionScope.js
Codex 8b952c9a26 refactor(travel): split reimbursement create workflow
完整修改内容:

- 拆分 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 行内,阻止主类/主模块继续膨胀。
2026-06-13 14:53:23 +00:00

319 lines
12 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'
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)]
}
}