feat: 增强差旅报销审核流程与票据智能推理
优化本体解析和编排器的差旅场景处理能力,完善报销单草稿 保存和费用明细同步逻辑,前端报销创建页面增加行程推理和 票据审核交互,新增助手会话快照工具函数,补充单元测试。
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user