2026-05-26 09:15:14 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
TRAVEL_ROUTE_CITY_SEMANTIC_TYPE = "travel_route_city_consistency"
|
|
|
|
|
|
LEGACY_CITY_CONSISTENCY_SEMANTIC_TYPE = "travel_city_consistency"
|
|
|
|
|
|
CITY_CONSISTENCY_SEMANTIC_TYPES = {
|
|
|
|
|
|
TRAVEL_ROUTE_CITY_SEMANTIC_TYPE,
|
|
|
|
|
|
LEGACY_CITY_CONSISTENCY_SEMANTIC_TYPE,
|
|
|
|
|
|
}
|
|
|
|
|
|
CITY_CONSISTENCY_SEMANTIC_TYPE = TRAVEL_ROUTE_CITY_SEMANTIC_TYPE
|
|
|
|
|
|
|
|
|
|
|
|
RISK_LEVEL_LABELS = {
|
|
|
|
|
|
"low": "低风险",
|
|
|
|
|
|
"medium": "中风险",
|
|
|
|
|
|
"high": "高风险",
|
|
|
|
|
|
"critical": "极高风险",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
CITY_ATTACHMENT_FIELDS = ("attachment.route_cities", "attachment.hotel_city")
|
|
|
|
|
|
CITY_REFERENCE_FIELDS = ("claim.location", "item.item_location")
|
2026-06-03 15:46:56 +08:00
|
|
|
|
CITY_HOME_FIELDS: tuple[str, ...] = ()
|
2026-05-26 09:15:14 +08:00
|
|
|
|
CITY_EXCEPTION_FIELDS = ("claim.reason", "item.item_reason")
|
|
|
|
|
|
CITY_EXCEPTION_KEYWORDS = ("绕行", "跨城办事", "跨城", "临时改签", "改签", "变更")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_city_consistency_rule(text: str) -> bool:
|
|
|
|
|
|
normalized = str(text or "")
|
|
|
|
|
|
has_city_subject = any(
|
|
|
|
|
|
term in normalized
|
|
|
|
|
|
for term in ("交通票", "住宿票", "住宿发票", "票据", "附件", "行程城市", "住宿城市")
|
|
|
|
|
|
)
|
|
|
|
|
|
has_reference = any(
|
|
|
|
|
|
term in normalized
|
|
|
|
|
|
for term in ("申报目的地", "申报地点", "明细地点", "发生地点", "意图城市", "目的地")
|
|
|
|
|
|
)
|
|
|
|
|
|
has_relation = any(
|
|
|
|
|
|
term in normalized
|
|
|
|
|
|
for term in ("一致", "不一致", "形成一致关系", "匹配", "无法与", "对应")
|
|
|
|
|
|
)
|
|
|
|
|
|
has_route_anomaly = any(term in normalized for term in ("绕行", "跨城", "中转", "周转", "改签"))
|
|
|
|
|
|
return has_city_subject and has_reference and (has_relation or has_route_anomaly)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_city_consistency_draft(
|
|
|
|
|
|
draft: dict[str, Any],
|
|
|
|
|
|
*,
|
|
|
|
|
|
natural_language: str,
|
|
|
|
|
|
fields: list[Any],
|
|
|
|
|
|
risk_level: str,
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
del natural_language
|
|
|
|
|
|
field_by_key = {field.key: field for field in fields}
|
|
|
|
|
|
field_keys = [
|
|
|
|
|
|
key
|
|
|
|
|
|
for key in (
|
|
|
|
|
|
*CITY_ATTACHMENT_FIELDS,
|
|
|
|
|
|
*CITY_REFERENCE_FIELDS,
|
|
|
|
|
|
*CITY_HOME_FIELDS,
|
|
|
|
|
|
*CITY_EXCEPTION_FIELDS,
|
|
|
|
|
|
)
|
|
|
|
|
|
if key in field_by_key
|
|
|
|
|
|
]
|
|
|
|
|
|
risk_label = RISK_LEVEL_LABELS.get(risk_level, "风险")
|
|
|
|
|
|
condition_summary = (
|
|
|
|
|
|
"判断公式:A=交通票行程城市∪住宿发票城市,B=申报目的地∪明细发生地点,"
|
2026-06-03 15:46:56 +08:00
|
|
|
|
"若A或B为空则要求补充识别;若A与B无交集且无合理说明,"
|
|
|
|
|
|
"或票据路线中存在无法由本次票据起终点和申报目的地解释的额外城市,"
|
|
|
|
|
|
"则命中目的地不一致/中途周转异常风险。"
|
2026-05-26 09:15:14 +08:00
|
|
|
|
)
|
|
|
|
|
|
flow = draft.get("flow") if isinstance(draft.get("flow"), dict) else {}
|
|
|
|
|
|
return {
|
|
|
|
|
|
**draft,
|
|
|
|
|
|
"template_key": "field_compare_v1",
|
|
|
|
|
|
"field_keys": field_keys,
|
|
|
|
|
|
"semantic_type": TRAVEL_ROUTE_CITY_SEMANTIC_TYPE,
|
|
|
|
|
|
"condition_summary": condition_summary,
|
|
|
|
|
|
"keywords": [],
|
|
|
|
|
|
"exception_keywords": list(CITY_EXCEPTION_KEYWORDS),
|
|
|
|
|
|
"flow": {
|
|
|
|
|
|
**flow,
|
|
|
|
|
|
"start": "差旅报销单据提交,并上传交通票据、住宿票据或其他可识别城市的附件",
|
2026-06-03 15:46:56 +08:00
|
|
|
|
"evidence": "读取申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由",
|
|
|
|
|
|
"decision": "附件城市是否覆盖申报行程,且票据路线是否出现无法由本次票据起终点和申报目的地解释的中转城市",
|
|
|
|
|
|
"pass": "票据城市覆盖申报行程,且未出现无法由本次票据起终点和申报目的地解释的额外城市",
|
2026-05-26 09:15:14 +08:00
|
|
|
|
"fail": f"票据路线存在目的地不一致或额外中转城市,命中{risk_label}并要求补充说明或退回修改",
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_city_consistency_params(draft: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
|
exception_keywords = list(draft.get("exception_keywords") or CITY_EXCEPTION_KEYWORDS)
|
|
|
|
|
|
return {
|
|
|
|
|
|
"semantic_type": TRAVEL_ROUTE_CITY_SEMANTIC_TYPE,
|
|
|
|
|
|
"attachment_city_fields": list(CITY_ATTACHMENT_FIELDS),
|
|
|
|
|
|
"reference_city_fields": list(CITY_REFERENCE_FIELDS),
|
|
|
|
|
|
"home_city_fields": list(CITY_HOME_FIELDS),
|
|
|
|
|
|
"exception_fields": list(CITY_EXCEPTION_FIELDS),
|
|
|
|
|
|
"exception_keywords": exception_keywords,
|
|
|
|
|
|
"keywords": [],
|
|
|
|
|
|
"route_anomaly_policy": "flag_unexpected_intermediate_cities",
|
|
|
|
|
|
"exception_handling": "exception_text_is_evidence_not_auto_pass_for_route_anomaly",
|
|
|
|
|
|
"formula": (
|
|
|
|
|
|
"A=UNION(attachment.route_cities, attachment.hotel_city); "
|
|
|
|
|
|
"B=UNION(claim.location, item.item_location); "
|
|
|
|
|
|
"HIT WHEN (A∩B=∅ AND NOT CONTAINS_ANY(exception_fields, exception_keywords)) "
|
2026-06-03 15:46:56 +08:00
|
|
|
|
"OR EXISTS(city IN route_cities WHERE city NOT EXPLAINED BY B OR ROUTE_ENDPOINTS)"
|
2026-05-26 09:15:14 +08:00
|
|
|
|
),
|
|
|
|
|
|
"conditions": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"left_group": list(CITY_ATTACHMENT_FIELDS),
|
|
|
|
|
|
"operator": "route_city_consistency",
|
|
|
|
|
|
"right_group": list(CITY_REFERENCE_FIELDS),
|
2026-06-03 15:46:56 +08:00
|
|
|
|
"home_group": [],
|
2026-05-26 09:15:14 +08:00
|
|
|
|
"exception_fields": list(CITY_EXCEPTION_FIELDS),
|
|
|
|
|
|
"exception_keywords": exception_keywords,
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|