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

@@ -0,0 +1,14 @@
# 对话口语化单据筛选意图识别失败
日期2026-07-20
文档路径document/development/2026-07-20/dev-logs/bugs/conversation-document-filter-intent.md
## 修复记录
- 10:19修复“出差上海的单据请筛选一下”等口语化单据筛选请求在对话中无法识别的问题。
- Git 提交检查:`git fetch --all --prune` 成功upstream 为 `origin/main`;未发现 upstream 新提交;本地 ahead 19 个提交:`07241b46 fix(docker): manage local postgres in default compose``787bc3a4 feat(platform): close AI expense value loop``242d68c3 feat(approval): add task workflow and waiver decisions``28b834ed fix(approval): replay immutable action responses``4940ebc4 feat(approval): add safe risk disposition workflow``ee88a36b feat(ai): add tenant-safe hierarchical expense learning``6bdf65bc feat(expenses): add authoritative pre-review workflow``ae3f02c3 feat(expense): add persistent zero-entry receipt association``54754b55 feat(ai): add personal expense application memory``211f85d9 feat(ai): unify verified expense application workflow``5b246307 feat(ai): issue verified application preview decisions``a662cfe6 feat(ai): add expense application feedback ledger``5ed34c2b feat(expenses): backfill historical claims into expense cases``11275e4b fix(migrations): enforce schema ownership safety``1347366b feat(expenses): secure timeline and draft events``22669a90 feat(expenses): show unified expense event timeline``a616b30c fix(expenses): unify AI application submission transaction``653eda05 feat(auth): add opaque bearer sessions``661990b2 feat(expenses): add transactional expense case events`
- 修改:前端会话范围守卫新增“筛选/过滤/查询 + 单据”信号识别,费用会话直接放行,申请会话建议切换到费用查询;引导式报销流程把此类请求识别为新意图,不再误当作当前表单字段答案。
- 修改:工作台单据查询统一复用城市名提取,支持从“出差上海的单据”提取 `expense_type=travel``location=上海`,并补齐带中文引号的显式关键词解析。
- 修改:后端语义入口放宽口语化单据查询硬门禁,补齐“出差”“筛选”“过滤”等规则候选;请求通过门禁后仍执行“规则候选 + 模型语义归一 + 结构化 SQL 筛选”的混合链路,最终按费用类型和地点确定性过滤数据。
- 操作:首次端到端回归暴露测试夹具缺少必填 `department_name`,补齐夹具后重新执行;未修改生产数据,也未提交代码。
- 验证:前端目标回归 `27/27` 通过;相关前端回归 `121/121` 通过;`npm run build` 成功2254 modules transformed容器内本体语义测试 `73/73` 通过;容器内费用查询编排回归 `3/3` 通过;精确端到端用例 `2/2` 通过;`git diff --check` 通过。
- 影响:“出差上海的单据请筛选一下”现在会稳定路由到费用查询,识别差旅费与上海并只返回上海相关单据;“上海的单据请筛选一下”等未显式写出费用类型的表达也可进入模型增强的语义解析,不再被判为非财务问题或误填入引导表单。

View File

@@ -83,6 +83,13 @@ ENGLISH_FINANCE_BUSINESS_KEYWORDS = (
class OntologyDetectionMixin: class OntologyDetectionMixin:
@staticmethod
def _has_document_query_signal(compact_query: str) -> bool:
return "单据" in compact_query and (
any(keyword in compact_query for keyword in QUERY_KEYWORDS)
or any(keyword in compact_query for keyword in STATUS_KEYWORDS)
)
@staticmethod @staticmethod
def _is_expense_application_context(context_json: dict[str, Any]) -> bool: def _is_expense_application_context(context_json: dict[str, Any]) -> bool:
document_type = str(context_json.get("document_type") or "").strip() document_type = str(context_json.get("document_type") or "").strip()
@@ -141,9 +148,7 @@ class OntologyDetectionMixin:
) )
): ):
return True return True
if "单据" in compact_query and ( if self._has_document_query_signal(compact_query):
"状态" in compact_query or any(keyword in compact_query for keyword in STATUS_KEYWORDS)
):
return True return True
if any(keyword in compact_query for keyword in EXPENSE_NARRATIVE_KEYWORDS): if any(keyword in compact_query for keyword in EXPENSE_NARRATIVE_KEYWORDS):
return True return True
@@ -224,9 +229,7 @@ class OntologyDetectionMixin:
best_scenario = "budget" best_scenario = "budget"
best_score = scores["budget"] best_score = scores["budget"]
if best_score <= 0: if best_score <= 0:
if "单据" in compact_query and any( if self._has_document_query_signal(compact_query):
keyword in compact_query for keyword in STATUS_KEYWORDS
):
return "expense", 0.14 return "expense", 0.14
return "unknown", 0.0 return "unknown", 0.0
@@ -539,7 +542,7 @@ class OntologyDetectionMixin:
"出现“客户”不等于应收,出现“供应商”不等于应付,必须结合动作词和业务目标判断。" "出现“客户”不等于应收,出现“供应商”不等于应付,必须结合动作词和业务目标判断。"
"预算编制、预算金额、成本中心、预算科目、预算预警、预算占用、" "预算编制、预算金额、成本中心、预算科目、预算预警、预算占用、"
"剩余预算、可用预算、超预算、预算不足等问题必须使用 budget 场景。" "剩余预算、可用预算、超预算、预算不足等问题必须使用 budget 场景。"
"只有明确查询、统计、列出、多少、明细、对比时才优先使用 query 或 compare。" "只有明确查询、筛选、过滤、统计、列出、多少、明细、对比时才优先使用 query 或 compare。"
"附件名称和 OCR 摘要只作为辅助证据,不能编造未出现的事实。" "附件名称和 OCR 摘要只作为辅助证据,不能编造未出现的事实。"
"如果用户明确提到打车、的士票、出租车票、网约车、乘车费、车费等交通票据," "如果用户明确提到打车、的士票、出租车票、网约车、乘车费、车费等交通票据,"
"即使句子里出现“客户”,也必须优先识别为 transport不要推断为 entertainment。" "即使句子里出现“客户”,也必须优先识别为 transport不要推断为 entertainment。"

View File

@@ -36,6 +36,7 @@ SCENARIO_KEYWORDS = {
("单据报销", 0.18), ("单据报销", 0.18),
("报账", 0.20), ("报账", 0.20),
("差旅", 0.20), ("差旅", 0.20),
("出差", 0.20),
("费用", 0.14), ("费用", 0.14),
("发票", 0.14), ("发票", 0.14),
("票据", 0.12), ("票据", 0.12),
@@ -99,6 +100,8 @@ QUERY_KEYWORDS = (
"", "",
"查询", "查询",
"查看", "查看",
"筛选",
"过滤",
"列出", "列出",
"统计", "统计",
"汇总", "汇总",
@@ -158,6 +161,7 @@ EXPENSE_TYPE_KEYWORDS = build_expense_type_keyword_map()
EXPENSE_NARRATIVE_KEYWORDS = ( EXPENSE_NARRATIVE_KEYWORDS = (
"报销", "报销",
"报账", "报账",
"出差",
"招待", "招待",
"招待费", "招待费",
"花销", "花销",

View File

@@ -266,6 +266,46 @@ def test_semantic_ontology_service_extracts_entities_time_and_constraints() -> N
assert result.time_range.end_date == "2026-04-30" assert result.time_range.end_date == "2026-04-30"
def test_semantic_ontology_service_recognizes_colloquial_travel_city_filter(
monkeypatch,
) -> None:
session_factory = build_session_factory()
with session_factory() as db:
service = SemanticOntologyService(db)
monkeypatch.setattr(
service,
"_parse_with_model",
lambda **_kwargs: (None, [], "model_unavailable_for_rule_regression"),
)
result = service.parse(
OntologyParseRequest(
query="出差上海的单据请筛选一下",
user_id="pytest",
)
)
entity_map = {item.type: item.normalized_value for item in result.entities}
assert result.scenario == "expense"
assert result.intent == "query"
assert result.permission.level == "read"
assert entity_map["expense_type"] == "travel"
assert entity_map["location"] == "上海"
generic_result = service.parse(
OntologyParseRequest(
query="上海的单据请筛选一下",
user_id="pytest",
)
)
generic_entities = {
item.type: item.normalized_value for item in generic_result.entities
}
assert generic_result.scenario == "expense"
assert generic_result.intent == "query"
assert generic_entities["location"] == "上海"
def test_semantic_ontology_service_extracts_budget_query_fields() -> None: def test_semantic_ontology_service_extracts_budget_query_fields() -> None:
session_factory = build_session_factory() session_factory = build_session_factory()
with session_factory() as db: with session_factory() as db:

View File

@@ -562,6 +562,77 @@ def test_orchestrator_history_query_filters_location_time_and_returns_real_amoun
assert "321.45" in response.result["answer"] assert "321.45" in response.result["answer"]
def test_orchestrator_colloquial_travel_city_filter_returns_only_matching_documents(
monkeypatch,
) -> None:
monkeypatch.setattr(
"app.services.runtime_chat.RuntimeChatService.complete",
lambda *_args, **_kwargs: None,
)
session_factory = build_session_factory()
with session_factory() as db:
employee = Employee(
id="emp-colloquial-filter",
employee_no="E9021",
name="张三",
email="colloquial-filter@example.com",
)
db.add_all(
[
employee,
ExpenseClaim(
id="claim-colloquial-shanghai",
claim_no="EXP-SHANGHAI-001",
employee=employee,
employee_id=employee.id,
employee_name="张三",
department_name="交付部",
expense_type="travel",
reason="前往上海支持客户项目",
location="上海",
amount=Decimal("888.00"),
currency="CNY",
occurred_at=datetime(2026, 7, 8, 9, 0, tzinfo=UTC),
status="paid",
),
ExpenseClaim(
id="claim-colloquial-beijing",
claim_no="EXP-BEIJING-001",
employee=employee,
employee_id=employee.id,
employee_name="张三",
department_name="交付部",
expense_type="travel",
reason="前往北京支持客户项目",
location="北京",
amount=Decimal("666.00"),
currency="CNY",
occurred_at=datetime(2026, 7, 9, 9, 0, tzinfo=UTC),
status="paid",
),
]
)
db.commit()
response = run_for_fixture_tenant(
OrchestratorService(db),
OrchestratorRequest(
source="user_message",
user_id="colloquial-filter@example.com",
message="出差上海的单据请筛选一下",
),
)
query_payload = response.result["query_payload"]
assert response.status == "succeeded"
assert response.trace_summary.scenario == "expense"
assert response.trace_summary.intent == "query"
assert query_payload["record_count"] == 1
assert [item["claim_no"] for item in query_payload["records"]] == [
"EXP-SHANGHAI-001"
]
def test_orchestrator_archive_query_filters_archived_claims_and_limits_preview( def test_orchestrator_archive_query_filters_archived_claims_and_limits_preview(
monkeypatch, monkeypatch,
) -> None: ) -> None:

View File

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

View File

@@ -1,4 +1,5 @@
import { compactText, formatDate, normalizeText, parseDate } from './aiDocumentQueryText.js' import { compactText, formatDate, normalizeText, parseDate } from './aiDocumentQueryText.js'
import { extractKnownCityNames } from './knownCityNames.js'
const STATUS_FILTERS = [ const STATUS_FILTERS = [
{ label: '草稿', keys: ['draft'], pattern: /草稿|未提交/ }, { label: '草稿', keys: ['draft'], pattern: /草稿|未提交/ },
@@ -202,9 +203,13 @@ function normalizeKeywordCandidate(value = '') {
function resolveKeywordFilter(prompt) { function resolveKeywordFilter(prompt) {
const text = normalizeText(prompt) const text = normalizeText(prompt)
const compact = compactText(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 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)) { if (!keyword || /^(现在|当前|哪些|审核|审批|申请|报销|单据|单子)$/u.test(keyword)) {
return null return null
} }

View File

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

View File

@@ -153,6 +153,29 @@ test('AI document query combines natural-language filters', () => {
assert.match(buildAiDocumentQueryConditionSummary(intent), /金额不少于1000元/) 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', () => { test('AI document query filters draft candidates by relative day', () => {
const intent = resolveAiDocumentQueryIntent('我的 3天前 草稿单据', { today: '2026-06-24' }) const intent = resolveAiDocumentQueryIntent('我的 3天前 草稿单据', { today: '2026-06-24' })
const records = filterAiDocumentQueryRecords([ const records = filterAiDocumentQueryRecords([

View File

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