diff --git a/document/development/2026-07-20/dev-logs/bugs/conversation-document-filter-intent.md b/document/development/2026-07-20/dev-logs/bugs/conversation-document-filter-intent.md new file mode 100644 index 0000000..b89bb22 --- /dev/null +++ b/document/development/2026-07-20/dev-logs/bugs/conversation-document-filter-intent.md @@ -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` 通过。 + - 影响:“出差上海的单据请筛选一下”现在会稳定路由到费用查询,识别差旅费与上海并只返回上海相关单据;“上海的单据请筛选一下”等未显式写出费用类型的表达也可进入模型增强的语义解析,不再被判为非财务问题或误填入引导表单。 diff --git a/server/src/app/services/ontology_detection.py b/server/src/app/services/ontology_detection.py index 160a0d9..9af548f 100644 --- a/server/src/app/services/ontology_detection.py +++ b/server/src/app/services/ontology_detection.py @@ -83,6 +83,13 @@ ENGLISH_FINANCE_BUSINESS_KEYWORDS = ( 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 def _is_expense_application_context(context_json: dict[str, Any]) -> bool: document_type = str(context_json.get("document_type") or "").strip() @@ -141,9 +148,7 @@ class OntologyDetectionMixin: ) ): return True - if "单据" in compact_query and ( - "状态" in compact_query or any(keyword in compact_query for keyword in STATUS_KEYWORDS) - ): + if self._has_document_query_signal(compact_query): return True if any(keyword in compact_query for keyword in EXPENSE_NARRATIVE_KEYWORDS): return True @@ -224,9 +229,7 @@ class OntologyDetectionMixin: best_scenario = "budget" best_score = scores["budget"] if best_score <= 0: - if "单据" in compact_query and any( - keyword in compact_query for keyword in STATUS_KEYWORDS - ): + if self._has_document_query_signal(compact_query): return "expense", 0.14 return "unknown", 0.0 @@ -539,7 +542,7 @@ class OntologyDetectionMixin: "出现“客户”不等于应收,出现“供应商”不等于应付,必须结合动作词和业务目标判断。" "预算编制、预算金额、成本中心、预算科目、预算预警、预算占用、" "剩余预算、可用预算、超预算、预算不足等问题必须使用 budget 场景。" - "只有明确查询、统计、列出、多少、明细、对比时才优先使用 query 或 compare。" + "只有明确查询、筛选、过滤、统计、列出、多少、明细、对比时才优先使用 query 或 compare。" "附件名称和 OCR 摘要只作为辅助证据,不能编造未出现的事实。" "如果用户明确提到打车、的士票、出租车票、网约车、乘车费、车费等交通票据," "即使句子里出现“客户”,也必须优先识别为 transport,不要推断为 entertainment。" diff --git a/server/src/app/services/ontology_rules.py b/server/src/app/services/ontology_rules.py index 8d58936..20f5c0a 100644 --- a/server/src/app/services/ontology_rules.py +++ b/server/src/app/services/ontology_rules.py @@ -36,6 +36,7 @@ SCENARIO_KEYWORDS = { ("单据报销", 0.18), ("报账", 0.20), ("差旅", 0.20), + ("出差", 0.20), ("费用", 0.14), ("发票", 0.14), ("票据", 0.12), @@ -99,6 +100,8 @@ QUERY_KEYWORDS = ( "查", "查询", "查看", + "筛选", + "过滤", "列出", "统计", "汇总", @@ -158,6 +161,7 @@ EXPENSE_TYPE_KEYWORDS = build_expense_type_keyword_map() EXPENSE_NARRATIVE_KEYWORDS = ( "报销", "报账", + "出差", "招待", "招待费", "花销", diff --git a/server/tests/test_ontology_service.py b/server/tests/test_ontology_service.py index cc5fcfa..f40b076 100644 --- a/server/tests/test_ontology_service.py +++ b/server/tests/test_ontology_service.py @@ -266,6 +266,46 @@ def test_semantic_ontology_service_extracts_entities_time_and_constraints() -> N 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: session_factory = build_session_factory() with session_factory() as db: diff --git a/server/tests/test_orchestrator_review_flow.py b/server/tests/test_orchestrator_review_flow.py index 4d573f7..c9179d5 100644 --- a/server/tests/test_orchestrator_review_flow.py +++ b/server/tests/test_orchestrator_review_flow.py @@ -562,6 +562,77 @@ def test_orchestrator_history_query_filters_location_time_and_returns_real_amoun 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( monkeypatch, ) -> None: diff --git a/web/src/utils/aiAttachmentAssociationModel.js b/web/src/utils/aiAttachmentAssociationModel.js index 12ec9db..345ece3 100644 --- a/web/src/utils/aiAttachmentAssociationModel.js +++ b/web/src/utils/aiAttachmentAssociationModel.js @@ -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 = []) { diff --git a/web/src/utils/aiDocumentQueryIntent.js b/web/src/utils/aiDocumentQueryIntent.js index eaf23be..cbfa655 100644 --- a/web/src/utils/aiDocumentQueryIntent.js +++ b/web/src/utils/aiDocumentQueryIntent.js @@ -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]*(?[\u4e00-\u9fa5A-Za-z0-9_-]{2,32})/u) + const explicitMatch = text.match(/(?:关于|有关|包含|含有|关键词|关键字|事由(?:是|为|包含|含有)?)[::\s“”"']*(?[\u4e00-\u9fa5A-Za-z0-9_-]{2,32})/u) const relatedMatch = compact.match(/(?[\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 } diff --git a/web/src/utils/assistantSessionScope.js b/web/src/utils/assistantSessionScope.js index 4939d6c..8a0f957 100644 --- a/web/src/utils/assistantSessionScope.js +++ b/web/src/utils/assistantSessionScope.js @@ -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 } diff --git a/web/src/utils/knownCityNames.js b/web/src/utils/knownCityNames.js new file mode 100644 index 0000000..f201a73 --- /dev/null +++ b/web/src/utils/knownCityNames.js @@ -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)) +} diff --git a/web/src/views/scripts/travelReimbursementGuidedFlowModel.js b/web/src/views/scripts/travelReimbursementGuidedFlowModel.js index 1776dd8..67384f3 100644 --- a/web/src/views/scripts/travelReimbursementGuidedFlowModel.js +++ b/web/src/views/scripts/travelReimbursementGuidedFlowModel.js @@ -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) { diff --git a/web/tests/ai-document-query-model.test.mjs b/web/tests/ai-document-query-model.test.mjs index 2e791bc..dfef45a 100644 --- a/web/tests/ai-document-query-model.test.mjs +++ b/web/tests/ai-document-query-model.test.mjs @@ -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([ diff --git a/web/tests/travel-reimbursement-guided-flow.test.mjs b/web/tests/travel-reimbursement-guided-flow.test.mjs index 67b18bc..a4e4ca1 100644 --- a/web/tests/travel-reimbursement-guided-flow.test.mjs +++ b/web/tests/travel-reimbursement-guided-flow.test.mjs @@ -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]