fix(ai): recognize conversational document filters

This commit is contained in:
caoxiaozhu
2026-07-20 10:31:15 +08:00
parent 7a3755ac55
commit 2a9b3e3273
12 changed files with 250 additions and 54 deletions

View File

@@ -1,43 +1,5 @@
import { buildDraftAssociationQueryPayload } from '../views/scripts/travelReimbursementExpenseQueryModel.js'
const CITY_NAMES = [
'北京',
'上海',
'广州',
'深圳',
'武汉',
'南京',
'杭州',
'成都',
'重庆',
'西安',
'天津',
'苏州',
'长沙',
'郑州',
'青岛',
'厦门',
'宁波',
'无锡',
'合肥',
'福州',
'昆明',
'大连',
'沈阳',
'济南',
'哈尔滨',
'长春',
'南昌',
'太原',
'贵阳',
'南宁',
'石家庄',
'兰州',
'银川',
'西宁',
'海口',
'拉萨'
]
import { extractKnownCityNames } from './knownCityNames.js'
function normalizeText(value) {
return String(value || '')
@@ -102,11 +64,7 @@ function extractDateTokens(text) {
}
function extractCityTokens(text) {
const compact = normalizeText(text)
if (!compact) {
return []
}
return CITY_NAMES.filter((city) => compact.includes(city))
return extractKnownCityNames(text)
}
function collectFieldSignals(ocrDocuments = []) {

View File

@@ -1,4 +1,5 @@
import { compactText, formatDate, normalizeText, parseDate } from './aiDocumentQueryText.js'
import { extractKnownCityNames } from './knownCityNames.js'
const STATUS_FILTERS = [
{ label: '草稿', keys: ['draft'], pattern: /草稿|未提交/ },
@@ -202,9 +203,13 @@ function normalizeKeywordCandidate(value = '') {
function resolveKeywordFilter(prompt) {
const text = normalizeText(prompt)
const compact = compactText(prompt)
const explicitMatch = text.match(/(?:关于|有关|包含|含有|关键词|关键字|事由(?:是|为|包含|含有)?)[:\s]*(?<keyword>[\u4e00-\u9fa5A-Za-z0-9_-]{2,32})/u)
const explicitMatch = text.match(/(?:关于|有关|包含|含有|关键词|关键字|事由(?:是|为|包含|含有)?)[:\s“”"']*(?<keyword>[\u4e00-\u9fa5A-Za-z0-9_-]{2,32})/u)
const relatedMatch = compact.match(/(?<keyword>[\u4e00-\u9fa5A-Za-z0-9_-]{2,32})相关(?:的)?(?:单据|单子|申请单|报销单|审核单|审批单)/u)
const keyword = normalizeKeywordCandidate(explicitMatch?.groups?.keyword || relatedMatch?.groups?.keyword || '')
const cityNames = extractKnownCityNames(text)
const cityKeyword = cityNames.length === 1 ? cityNames[0] : ''
const keyword = normalizeKeywordCandidate(
explicitMatch?.groups?.keyword || relatedMatch?.groups?.keyword || cityKeyword
)
if (!keyword || /^(现在|当前|哪些|审核|审批|申请|报销|单据|单子)$/u.test(keyword)) {
return null
}

View File

@@ -66,6 +66,8 @@ const APPROVAL_PATTERN =
/待我审核|待审|审核|审批|审核意见|审批意见|审批通过|审批驳回|驳回|退回|审核中心|审批中心|领导审批|财务审核|处理意见/
const KNOWLEDGE_PATTERN =
/制度|政策|标准|规则|规定|流程|口径|依据|上限|额度|补贴|住宿标准|差旅标准|报销标准|票据要求|可不可以|能不能|怎么规定|如何计算|怎么算/
const DOCUMENT_QUERY_ACTION_PATTERN = /查询|查找|查看|筛选|过滤|列出|检索|找出|搜索|搜一下/
const DOCUMENT_QUERY_TARGET_PATTERN = /单据|单子|申请单|报销单|审核单|审批单|待办|草稿/
const EXPENSE_OPERATION_PATTERN = /发起报销|报销单|票据|发票|火车票|高铁票|机票|的士票|草稿|归集|上传|关联单据|继续下一步/
const CURRENT_CLAIM_RISK_PATTERN = /这张|当前|本单|该单|单据|风险|超标|异常|重复|待补/
const FINANCE_OPERATING_PATTERN = buildKeywordPattern([
@@ -123,6 +125,15 @@ export function hasReimbursementIntentSignal(rawText) {
return EXPENSE_PATTERN.test(normalizeText(rawText))
}
export function hasDocumentQueryIntentSignal(rawText) {
const text = normalizeText(rawText)
return Boolean(
text &&
DOCUMENT_QUERY_ACTION_PATTERN.test(text) &&
DOCUMENT_QUERY_TARGET_PATTERN.test(text)
)
}
export function hasExpenseApplicationIntentSignal(rawText) {
const text = normalizeText(rawText)
if (!text) {
@@ -172,6 +183,16 @@ export function inferAssistantScopeTarget(rawText, options = {}) {
return ''
}
if (hasDocumentQueryIntentSignal(text)) {
if (APPROVAL_PATTERN.test(text) && /待我审核|待审|审核单|审批单|待办/.test(text)) {
return ASSISTANT_SCOPE_SESSION_APPROVAL
}
if (/申请单|申请类单据/.test(text) && !/报销单|报销类单据/.test(text)) {
return ASSISTANT_SCOPE_SESSION_APPLICATION
}
return ASSISTANT_SCOPE_SESSION_EXPENSE
}
if (hasAmbiguousTravelFlowIntent(text)) {
return ASSISTANT_SCOPE_SESSION_STEWARD
}

View File

@@ -0,0 +1,46 @@
export const KNOWN_CITY_NAMES = [
'北京',
'上海',
'广州',
'深圳',
'武汉',
'南京',
'杭州',
'成都',
'重庆',
'西安',
'天津',
'苏州',
'长沙',
'郑州',
'青岛',
'厦门',
'宁波',
'无锡',
'合肥',
'福州',
'昆明',
'大连',
'沈阳',
'济南',
'哈尔滨',
'长春',
'南昌',
'太原',
'贵阳',
'南宁',
'石家庄',
'兰州',
'银川',
'西宁',
'海口',
'拉萨'
]
export function extractKnownCityNames(value = '') {
const compact = String(value || '').replace(/\s+/g, '')
if (!compact) {
return []
}
return KNOWN_CITY_NAMES.filter((city) => compact.includes(city))
}

View File

@@ -1,3 +1,5 @@
import { hasDocumentQueryIntentSignal } from '../../utils/assistantSessionScope.js'
export const GUIDED_FLOW_MODE_NONE = ''
export const GUIDED_FLOW_MODE_REIMBURSEMENT = 'reimbursement_guide'
export const GUIDED_FLOW_MODE_STATUS_QUERY = 'status_query_guide'
@@ -505,7 +507,7 @@ export function shouldConfirmGuidedInterruption(text, state) {
if (!normalized || NO_ATTACHMENT_TEXT_PATTERN.test(normalized)) {
return false
}
return INTERRUPTION_PATTERN.test(normalized)
return INTERRUPTION_PATTERN.test(normalized) || hasDocumentQueryIntentSignal(normalized)
}
export function buildGuidedInterruptionText(text) {

View File

@@ -153,6 +153,29 @@ test('AI document query combines natural-language filters', () => {
assert.match(buildAiDocumentQueryConditionSummary(intent), /金额不少于1000元/)
})
test('AI document query recognizes colloquial travel city filtering commands', () => {
const intent = resolveAiDocumentQueryIntent('出差上海的单据请筛选一下', { today })
const records = filterAiDocumentQueryRecords(claims, intent)
assert.equal(intent?.source, 'accessible')
assert.equal(intent?.expenseTypeFilter?.label, '差旅费')
assert.equal(intent?.keywordFilter?.label, '上海')
assert.deepEqual(
records.map((record) => record.documentNo),
['CL-20260221001', 'AP-20260220001']
)
})
test('AI document query recognizes quoted keywords generated by the guided query flow', () => {
const intent = resolveAiDocumentQueryIntent(
'帮我查询地点或事由包含“上海电力”的报销单据状态,筛选最近的 5 条记录',
{ today }
)
assert.equal(intent?.documentType, 'reimbursement')
assert.equal(intent?.keywordFilter?.label, '上海电力')
})
test('AI document query filters draft candidates by relative day', () => {
const intent = resolveAiDocumentQueryIntent('我的 3天前 草稿单据', { today: '2026-06-24' })
const records = filterAiDocumentQueryRecords([

View File

@@ -181,6 +181,14 @@ test('assistant session scope guard keeps business boundaries isolated', () => {
resolveAssistantScopeGuard('帮我查询待我审核的单据', SESSION_TYPE_EXPENSE).targetSessionType,
SESSION_TYPE_APPROVAL
)
assert.equal(
resolveAssistantScopeGuard('出差上海的单据请筛选一下', SESSION_TYPE_EXPENSE),
null
)
assert.equal(
resolveAssistantScopeGuard('出差上海的单据请筛选一下', SESSION_TYPE_APPLICATION).targetSessionType,
SESSION_TYPE_EXPENSE
)
assert.equal(
resolveAssistantScopeGuard('差旅住宿标准是多少', SESSION_TYPE_EXPENSE).targetSessionType,
SESSION_TYPE_KNOWLEDGE
@@ -414,6 +422,7 @@ test('guided reimbursement interrupts suspicious questions before expensive flow
const state = selectGuidedExpenseType(createGuidedReimbursementState(), 'transport')
assert.equal(shouldConfirmGuidedInterruption('送客户去机场', state), false)
assert.equal(shouldConfirmGuidedInterruption('帮我查询一下上周的报销状态?', state), true)
assert.equal(shouldConfirmGuidedInterruption('出差上海的单据请筛选一下', state), true)
assert.deepEqual(
buildGuidedInterruptionActions().map((action) => action.action_type),
[GUIDED_ACTION_CONTINUE_FILLING, GUIDED_ACTION_PROCESS_INTERRUPTION]