fix(ai): recognize conversational document filters
This commit is contained in:
@@ -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` 通过。
|
||||||
|
- 影响:“出差上海的单据请筛选一下”现在会稳定路由到费用查询,识别差旅费与上海并只返回上海相关单据;“上海的单据请筛选一下”等未显式写出费用类型的表达也可进入模型增强的语义解析,不再被判为非财务问题或误填入引导表单。
|
||||||
@@ -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。"
|
||||||
|
|||||||
@@ -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 = (
|
||||||
"报销",
|
"报销",
|
||||||
"报账",
|
"报账",
|
||||||
|
"出差",
|
||||||
"招待",
|
"招待",
|
||||||
"招待费",
|
"招待费",
|
||||||
"花销",
|
"花销",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 = []) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
46
web/src/utils/knownCityNames.js
Normal file
46
web/src/utils/knownCityNames.js
Normal 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))
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user