feat: 增强差旅报销审核流程与票据智能推理

优化本体解析和编排器的差旅场景处理能力,完善报销单草稿
保存和费用明细同步逻辑,前端报销创建页面增加行程推理和
票据审核交互,新增助手会话快照工具函数,补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-21 16:09:47 +08:00
parent f28d7e6d16
commit e701fa01da
33 changed files with 3033 additions and 337 deletions

View File

@@ -62,11 +62,13 @@ AMOUNT_PATTERN = re.compile(
TOP_N_PATTERN = re.compile(r"(?:top|TOP|前|最高的?|最低的?)\s*(?P<top>\d+)")
SCENARIO_KEYWORDS = {
"expense": (
("报销", 0.20),
("", 0.20),
("差旅", 0.20),
("费用", 0.14),
"expense": (
("报销", 0.20),
("销单", 0.20),
("单据报销", 0.18),
("报账", 0.20),
("差旅", 0.20),
("费用", 0.14),
("发票", 0.14),
("票据", 0.12),
("借款", 0.12),
@@ -249,16 +251,51 @@ MISSING_SLOT_LABELS = {
"document_id": "单据号",
}
STATUS_KEYWORDS = {
"逾期": "overdue",
"审批": "pending",
"": "pending",
"已审批": "approved",
"通过": "approved",
"已付款": "paid",
"未付款": "unpaid",
"未回款": "unreceived",
}
STATUS_KEYWORDS = {
"草稿": "draft",
"提交": "draft",
"补充": "supplement",
"退回": "returned",
"退回": "returned",
"进行中": "review",
"审批中": "review",
"审核中": "review",
"流转中": "review",
"已提交": "submitted",
"逾期": "overdue",
"待审批": "pending",
"待审": "pending",
"已审批": "approved",
"已通过": "approved",
"已审核": "approved",
"已入账": "paid",
"已付款": "paid",
"未付款": "unpaid",
"未回款": "unreceived",
}
LOCATION_KEYWORDS = (
"北京",
"上海",
"广州",
"深圳",
"杭州",
"南京",
"苏州",
"成都",
"重庆",
"天津",
"武汉",
"西安",
"郑州",
"长沙",
"青岛",
"厦门",
"宁波",
"合肥",
"济南",
"福州",
)
PRIVILEGED_ROLE_CODES = {"manager", "finance", "approver", "executive"}
CONTEXTUAL_SCENARIOS = {"expense", "accounts_receivable", "accounts_payable", "knowledge"}
@@ -683,9 +720,13 @@ class SemanticOntologyService:
scores[scenario] += weight
best_scenario = max(scores, key=scores.get)
best_score = scores[best_scenario]
if best_score <= 0:
return "unknown", 0.0
best_score = scores[best_scenario]
if best_score <= 0:
if "单据" in compact_query and any(
keyword in compact_query for keyword in STATUS_KEYWORDS
):
return "expense", 0.14
return "unknown", 0.0
if best_scenario == "knowledge":
business_scores = [
@@ -701,18 +742,52 @@ class SemanticOntologyService:
return best_scenario, round(min(best_score, 0.34), 2)
def _detect_intent(
self,
compact_query: str,
def _detect_intent(
self,
compact_query: str,
*,
scenario: str,
entities: list[OntologyEntity],
time_range: OntologyTimeRange,
) -> tuple[str, float]:
if any(keyword in compact_query for keyword in OPERATE_KEYWORDS):
return "operate", 0.30
if any(keyword in compact_query for keyword in DRAFT_KEYWORDS):
return "draft", 0.26
) -> tuple[str, float]:
if any(keyword in compact_query for keyword in OPERATE_KEYWORDS):
return "operate", 0.30
status_document_query = (
"单据" in compact_query
and any(keyword in compact_query for keyword in STATUS_KEYWORDS)
and not any(keyword in compact_query for keyword in DRAFT_KEYWORDS if keyword != "草稿")
)
historical_document_query = any(
keyword in compact_query
for keyword in ("报销的单据", "报销单据", "报销过的单据", "报销记录")
)
if scenario == "expense" and any(
keyword in compact_query
for keyword in (
"报销了吗",
"报销了么",
"报销了没",
"报销了没有",
"报销没",
"单据状态",
"审批状态",
"报销进度",
"到哪了",
"到了哪",
"有没有报销",
"是否报销",
"进行中的单据",
"草稿单据",
"草稿的单据",
"待补充单据",
"审批中的单据",
"已提交单据",
"已入账单据",
)
) or (scenario == "expense" and (status_document_query or historical_document_query)):
return "query", 0.24
if any(keyword in compact_query for keyword in DRAFT_KEYWORDS):
return "draft", 0.26
if scenario == "expense" and self._is_generic_expense_prompt(compact_query):
return "draft", 0.24
if any(keyword in compact_query for keyword in COMPARE_KEYWORDS):
@@ -1177,13 +1252,16 @@ class SemanticOntologyService:
upsert(self._make_entity("receivable", code, code.upper()))
for code in re.findall(r"AP-\d{6}-\d{3}", query, flags=re.IGNORECASE):
upsert(self._make_entity("payable", code, code.upper()))
for code in re.findall(r"INV-[A-Z]+-\d+", query, flags=re.IGNORECASE):
upsert(self._make_entity("invoice", code, code.upper()))
for code in re.findall(r"CTR-[A-Z]+-\d+", query, flags=re.IGNORECASE):
upsert(self._make_entity("contract", code, code.upper()))
for label, normalized in EXPENSE_TYPE_KEYWORDS.items():
if label in query:
for code in re.findall(r"INV-[A-Z]+-\d+", query, flags=re.IGNORECASE):
upsert(self._make_entity("invoice", code, code.upper()))
for code in re.findall(r"CTR-[A-Z]+-\d+", query, flags=re.IGNORECASE):
upsert(self._make_entity("contract", code, code.upper()))
for location in LOCATION_KEYWORDS:
if location in query:
upsert(self._make_entity("location", location, location, role="filter", confidence=0.86))
for label, normalized in EXPENSE_TYPE_KEYWORDS.items():
if label in query:
upsert(self._make_entity("expense_type", label, normalized, role="filter"))
has_customer_entertainment_signal = "客户" in query and any(
@@ -1339,11 +1417,17 @@ class SemanticOntologyService:
start = date(today.year, start_month, 1)
end = date(today.year, end_month, calendar.monthrange(today.year, end_month)[1])
return self._range(start, end, "本季度", "quarter"), 0.10
if "今年" in query:
return (
self._range(date(today.year, 1, 1), date(today.year, 12, 31), "今年", "year"),
0.10,
)
if "今年" in query:
return (
self._range(date(today.year, 1, 1), date(today.year, 12, 31), "今年", "year"),
0.10,
)
if "去年" in query or "上一年" in query:
year = today.year - 1
return (
self._range(date(year, 1, 1), date(year, 12, 31), "去年", "year"),
0.10,
)
match = DATE_RANGE_PATTERN.search(query)
if match:
@@ -1491,10 +1575,11 @@ class SemanticOntologyService:
"employee",
"department",
"customer",
"vendor",
"project",
"expense_type",
}:
"vendor",
"project",
"location",
"expense_type",
}:
upsert(
OntologyConstraint(
field=entity.type,