Files
X-Financial/web/src/utils/reimbursementTextInference.js
caoxiaozhu e1e515ecae feat: 新增预算中心本体与风险规则评分回填
后端新增预算本体解析模块和风险规则评分回填服务,优化规则
生成本体对齐和提示词构建,增强费用类型关键词和本体验证,
完善报销查询和审计接口,前端预算中心页面增加对话框和本
体工具函数,重构审计页面元数据和视图模型,补充单元测试。
2026-05-26 12:16:20 +08:00

289 lines
11 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.
const DEFAULT_SESSION_TYPE_EXPENSE = 'expense'
const DEFAULT_SESSION_TYPE_APPLICATION = 'application'
const DEFAULT_SESSION_TYPE_APPROVAL = 'approval'
const DEFAULT_SESSION_TYPE_KNOWLEDGE = 'knowledge'
const DEFAULT_INTENT_LABELS = {
query: '查询',
explain: '解释',
compare: '对比',
risk_check: '风险检查',
draft: '信息核对',
operate: '动作请求'
}
const DEFAULT_SCENARIO_LABELS = {
expense: '报销',
accounts_receivable: '应收',
accounts_payable: '应付',
knowledge: '知识',
unknown: '通用'
}
const DEFAULT_EXPENSE_TYPE_LABELS = {
travel: '差旅费',
hotel: '住宿费',
transport: '交通费',
meal: '业务招待费',
meeting: '会务费',
entertainment: '业务招待费',
marketing: '市场推广费',
office: '办公用品费',
training: '培训费',
software: '软件服务费',
communication: '通讯费',
welfare: '福利费',
other: '其他费用'
}
export const TRANSPORT_KEYWORD_PATTERN = /交通|市内交通|出行|打车|网约车|出租车|滴滴|车费|乘车|用车|叫车|约车|的士|车票|车资|地铁|公交|停车|过路费|通行费|高速费|油费/
const FLOW_INTENT_KEYWORDS = {
draft: ['报销', '报账', '草稿', '生成', '提交', '申请', '请走报销'],
query: ['查询', '查一下', '多少', '明细', '统计'],
risk_check: ['风险', '异常', '重复', '超标'],
explain: ['为什么', '依据', '规则', '怎么']
}
const EXPLICIT_EXPENSE_INTENT_PATTERN = /报销|报账|费用|发票|票据|单据|垫付|报销单|冲销|借款/
const NON_EXPENSE_INTENT_PATTERN = /怎么部署|如何部署|部署步骤|技术方案|排期|任务|工单|需求|代码|脚本|服务器配置|运维|实施计划|项目计划|会议纪要|周报|日报|总结/
const BUSINESS_ACTIVITY_PATTERN = /去|到|赴|前往|支撑|支持|部署|实施|驻场|出差|拜访|客户|项目|现场|电力|银行|医院|学校|园区|公司|集团|服务器/
function normalizeCompactText(value) {
return String(value || '').trim().replace(/\s+/g, '')
}
function resolveExpenseTypeLabel(type, fallbackLabel = '', expenseTypeLabels = DEFAULT_EXPENSE_TYPE_LABELS) {
const normalized = String(type || '').trim()
return expenseTypeLabels[normalized] || String(fallbackLabel || '').trim() || expenseTypeLabels.other
}
function resolveSemanticExpenseTypeLabel(semanticParse, expenseTypeLabels = DEFAULT_EXPENSE_TYPE_LABELS) {
const entities = Array.isArray(semanticParse?.entities_json) ? semanticParse.entities_json : []
const expenseTypeEntity = entities.find((item) => String(item?.type || '').trim() === 'expense_type')
if (expenseTypeEntity) {
return resolveExpenseTypeLabel(
String(expenseTypeEntity.normalized_value || '').trim(),
String(expenseTypeEntity.value || '').trim(),
expenseTypeLabels
)
}
return resolveExpenseTypeLabel(
String(semanticParse?.expense_type || semanticParse?.expense_type_code || '').trim(),
String(semanticParse?.expense_type_label || '').trim(),
expenseTypeLabels
)
}
export function inferLocalFlowCandidates(rawText) {
const text = String(rawText || '').trim()
const compact = normalizeCompactText(text)
let time = ''
const explicitTimeMatch = text.match(/发生时间[:]?\s*([0-9]{4}[-/年][0-9]{1,2}[-/月][0-9]{1,2}日?)/)
if (explicitTimeMatch?.[1]) {
time = explicitTimeMatch[1].replace(/年/g, '-').replace(/月/g, '-').replace(/日/g, '').replace(/\//g, '-')
} else {
const dateMatch = text.match(/([0-9]{4}[-/年][0-9]{1,2}[-/月][0-9]{1,2}日?)/)
if (dateMatch?.[1]) {
time = dateMatch[1].replace(/年/g, '-').replace(/月/g, '-').replace(/日/g, '').replace(/\//g, '-')
} else if (/今天|今日/.test(compact)) {
time = '今天'
} else if (/昨天|昨日/.test(compact)) {
time = '昨天'
} else if (/前天/.test(compact)) {
time = '前天'
}
}
let amount = ''
const amountMatch = text.match(/([0-9]+(?:\.[0-9]{1,2})?)\s*(?:元|员|圆|园|块|块钱|万元|万)/)
if (amountMatch?.[1]) {
const numericValue = Number(amountMatch[1])
if (Number.isFinite(numericValue)) {
amount = Number.isInteger(numericValue) ? `${numericValue}` : `${numericValue.toFixed(2)}`
}
}
let event = ''
let expenseType = ''
if (TRANSPORT_KEYWORD_PATTERN.test(compact)) {
event = '交通出行'
expenseType = '交通费'
} else if (/客户.*吃饭|请客户.*吃饭|客户用餐|客户接待|商务接待|招待|宴请|请客/.test(compact)) {
event = '业务招待'
expenseType = '业务招待费'
} else if (/出差|差旅|机票|飞机票|航班|高铁票|高铁|火车票|火车|动车|行程单|铁路客票/.test(compact)) {
event = '出差行程'
expenseType = '差旅费'
} else if (/住宿|住宿费|酒店|酒店发票|宾馆|民宿|房费|客房/.test(compact)) {
event = '住宿报销'
expenseType = '住宿费'
} else if (/餐费|工作餐|用餐|午餐|晚餐|早餐|餐饮|伙食|茶歇/.test(compact)) {
event = '业务招待'
expenseType = '业务招待费'
} else if (/会务|会议费|会议|参会|会场|场地费|论坛|展会/.test(compact)) {
event = '会务活动'
expenseType = '会务费'
} else if (/办公用品|办公耗材|办公设备|文具|打印纸|硒鼓|墨盒|键盘|鼠标|白板/.test(compact)) {
event = '办公采购'
expenseType = '办公用品费'
} else if (/培训|讲师费|课程费|教材|认证费|考试费/.test(compact)) {
event = '培训学习'
expenseType = '培训费'
} else if (/通讯费|话费|电话费|手机费|流量费|宽带费|网络费/.test(compact)) {
event = '通讯使用'
expenseType = '通讯费'
} else if (/福利费|团建|慰问|节日福利|体检费|员工关怀/.test(compact)) {
event = '员工福利'
expenseType = '福利费'
}
return {
time,
amount,
event,
expenseType
}
}
export function shouldRequestExpenseSceneSelection(rawText, options = {}) {
const sessionType = options.sessionType || DEFAULT_SESSION_TYPE_EXPENSE
if (sessionType !== DEFAULT_SESSION_TYPE_EXPENSE) {
return false
}
if (Number(options.attachmentCount || 0) > 0) {
return false
}
if (String(options.reviewAction || '').trim()) {
return false
}
if (options.hasSelectedExpenseType) {
return false
}
const compact = normalizeCompactText(rawText)
if (!compact) {
return false
}
const hasExpenseIntent = /报销|报账|费用|申请/.test(compact)
if (!hasExpenseIntent) {
return false
}
const candidates = inferLocalFlowCandidates(rawText)
return !candidates.expenseType
}
export function shouldRequestExpenseIntentConfirmation(rawText, options = {}) {
const sessionType = options.sessionType || DEFAULT_SESSION_TYPE_EXPENSE
if (sessionType !== DEFAULT_SESSION_TYPE_EXPENSE) {
return false
}
if (Number(options.attachmentCount || 0) > 0) {
return false
}
if (String(options.reviewAction || '').trim()) {
return false
}
if (options.hasConfirmedExpenseIntent || options.hasSelectedExpenseType) {
return false
}
const compact = normalizeCompactText(rawText)
if (!compact || compact.length < 6) {
return false
}
if (EXPLICIT_EXPENSE_INTENT_PATTERN.test(compact)) {
return false
}
if (NON_EXPENSE_INTENT_PATTERN.test(compact)) {
return false
}
return BUSINESS_ACTIVITY_PATTERN.test(compact)
}
export function buildLocalIntentPreview(rawText, sessionType = DEFAULT_SESSION_TYPE_EXPENSE, options = {}) {
if (sessionType === DEFAULT_SESSION_TYPE_KNOWLEDGE) {
return '初步识别为财务知识问答,正在准备检索范围'
}
if (sessionType === DEFAULT_SESSION_TYPE_APPLICATION) {
return '初步识别为费用申请事项,准备进入申请信息识别'
}
if (sessionType === DEFAULT_SESSION_TYPE_APPROVAL) {
return '初步识别为审核处理事项,准备进入单据查询或风险核对'
}
if (shouldRequestExpenseIntentConfirmation(rawText, { ...options, sessionType })) {
return '识别到业务事项描述,但是否发起报销尚不明确,需要先由用户确认'
}
if (shouldRequestExpenseSceneSelection(rawText, { ...options, sessionType })) {
return '初步识别为报销申请,但报销场景尚未明确,需要先由用户选择场景'
}
const compact = normalizeCompactText(rawText)
const intentLabels = options.intentLabels || DEFAULT_INTENT_LABELS
const intentKey = Object.entries(FLOW_INTENT_KEYWORDS).find(([, keywords]) =>
keywords.some((keyword) => compact.includes(keyword))
)?.[0] || 'draft'
const intentLabel = intentLabels[intentKey] || DEFAULT_INTENT_LABELS[intentKey] || '处理'
const candidates = inferLocalFlowCandidates(rawText)
const expenseTypeText = candidates.expenseType ? `,费用类型为${candidates.expenseType}` : ''
return `初步识别为报销场景,准备进入${intentLabel}${expenseTypeText}`
}
export function buildLocalExtractionProgressMessages(rawText, options = {}) {
const candidates = inferLocalFlowCandidates(rawText)
const messages = []
messages.push('正在提取发生时间...')
messages.push(
candidates.time
? `发现发生时间 ${candidates.time},继续提取金额...`
: '暂未定位到明确时间,继续提取金额...'
)
messages.push(
candidates.amount
? `发现金额 ${candidates.amount},继续识别事件类型...`
: '暂未定位到明确金额,继续识别事件类型...'
)
if (candidates.event || candidates.expenseType) {
const eventParts = [candidates.event, candidates.expenseType].filter(Boolean)
messages.push(`识别到${eventParts.join(' / ')},继续判断待补项...`)
} else {
messages.push('正在识别事件类型和费用分类...')
}
const attachmentHint = Number(options.attachmentCount || 0) > 0 ? '附件完整性' : '票据附件'
const pendingSlots = ['发生时间', '金额', attachmentHint]
if (candidates.expenseType === '业务招待费') {
pendingSlots.splice(2, 0, '客户名称', '参与人员')
} else if (candidates.expenseType === '住宿费') {
pendingSlots.splice(2, 0, '酒店/商户')
}
messages.push(`正在判断待补项:${pendingSlots.join('、')}`)
return messages
}
export function summarizeSemanticIntentDetail(semanticParse, options = {}) {
if (!semanticParse || typeof semanticParse !== 'object') {
return options.fallbackText || '意图识别完成'
}
const scenarioLabels = options.scenarioLabels || DEFAULT_SCENARIO_LABELS
const intentLabels = options.intentLabels || DEFAULT_INTENT_LABELS
const expenseTypeLabels = options.expenseTypeLabels || DEFAULT_EXPENSE_TYPE_LABELS
const scenarioLabel = scenarioLabels[String(semanticParse.scenario || '').trim()] || String(semanticParse.scenario || '').trim() || '通用'
const intentLabel = intentLabels[String(semanticParse.intent || '').trim()] || String(semanticParse.intent || '').trim() || '处理'
const expenseTypeLabel = resolveSemanticExpenseTypeLabel(semanticParse, expenseTypeLabels)
const expenseTypeText = expenseTypeLabel && expenseTypeLabel !== expenseTypeLabels.other
? `,费用类型为${expenseTypeLabel}`
: ''
return `已识别为${scenarioLabel}场景,当前目标是${intentLabel}${expenseTypeText}`
}