feat: 完善差旅票据行程提取与费用明细回填逻辑

增强文档智能识别的票据场景关键词和字段提取能力,优化
会话关联草稿报销单的解析路径,修复费用明细合并和票据
去重边界问题,前端改进报销创建和审批详情交互,补充单
元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-21 14:24:51 +08:00
parent b183b0bd5e
commit f28d7e6d16
24 changed files with 1565 additions and 433 deletions

View File

@@ -18,6 +18,47 @@ STATEFUL_CONTEXT_KEYS = (
"attachment_count",
"ocr_summary",
"ocr_documents",
"review_form_values",
"business_time_context",
)
REVIEW_FLOW_CONTEXT_KEYS = {
"request_context",
"attachment_names",
"attachment_count",
"ocr_summary",
"ocr_documents",
"review_form_values",
"business_time_context",
}
REVIEW_FLOW_CONTINUATION_KEYWORDS = (
"补充",
"继续",
"继续上传",
"当前",
"这张",
"这个",
"该单据",
"现有",
"已有",
"关联",
"合并",
"修改",
"更正",
"改成",
"调整",
"下一步",
"保存草稿",
)
NEW_EXPENSE_PROMPT_KEYWORDS = (
"申请报销",
"我要报销",
"我想报销",
"帮我报销",
"发起报销",
"提交报销",
"生成报销",
"创建报销",
"新建报销",
)
DEFAULT_CONVERSATION_RETENTION_DAYS = 3
@@ -182,10 +223,15 @@ class AgentConversationService:
*,
conversation: AgentConversation,
context_json: dict[str, Any],
message: str | None = None,
history_limit: int = 8,
) -> dict[str, Any]:
merged = dict(context_json or {})
state_json = dict(conversation.state_json or {})
should_hydrate_review_flow = self._should_hydrate_review_flow_context(
context_json=merged,
message=message,
)
merged["conversation_id"] = conversation.conversation_id
merged["conversation_history"] = self.list_message_history(
@@ -196,16 +242,53 @@ class AgentConversationService:
merged.setdefault("conversation_scenario", conversation.last_scenario)
if conversation.last_intent:
merged.setdefault("conversation_intent", conversation.last_intent)
if conversation.draft_claim_id and not str(merged.get("draft_claim_id") or "").strip():
if (
should_hydrate_review_flow
and conversation.draft_claim_id
and not str(merged.get("draft_claim_id") or "").strip()
):
merged["draft_claim_id"] = conversation.draft_claim_id
merged["conversation_state"] = state_json
for key in STATEFUL_CONTEXT_KEYS:
if key in REVIEW_FLOW_CONTEXT_KEYS and not should_hydrate_review_flow:
continue
if self._is_empty_value(merged.get(key)) and not self._is_empty_value(state_json.get(key)):
merged[key] = state_json.get(key)
return merged
@staticmethod
def _should_hydrate_review_flow_context(
*,
context_json: dict[str, Any],
message: str | None,
) -> bool:
if AgentConversationService._resolve_draft_claim_id(context_json):
return True
if str(context_json.get("review_action") or "").strip():
return True
if str(context_json.get("entry_source") or "").strip() == "detail":
return True
if not AgentConversationService._is_empty_value(context_json.get("attachment_names")):
return True
if not AgentConversationService._is_empty_value(context_json.get("ocr_documents")):
return True
if str(context_json.get("ocr_summary") or "").strip():
return True
try:
if int(context_json.get("attachment_count") or 0) > 0:
return True
except (TypeError, ValueError):
pass
compact_message = str(message or "").replace(" ", "")
if not compact_message:
return False
if any(keyword in compact_message for keyword in NEW_EXPENSE_PROMPT_KEYWORDS):
return False
return any(keyword in compact_message for keyword in REVIEW_FLOW_CONTINUATION_KEYWORDS)
def append_message(
self,
*,

View File

@@ -184,6 +184,7 @@ AMOUNT_PATTERNS = (
re.compile(r"([0-9]+(?:[.,][0-9]{1,2})?)\s*元"),
)
DATE_PATTERN = re.compile(r"((?:20\d{2}|19\d{2})[-/年.](?:1[0-2]|0?[1-9])[-/月.](?:3[01]|[12]\d|0?[1-9])日?)")
TIME_PATTERN = re.compile(r"(?<!\d)([01]?\d|2[0-3])[:]([0-5]\d)(?!\d)")
INVOICE_NUMBER_PATTERN = re.compile(r"(?:发票号码|票号|单号|订单号)[:\s]*([A-Za-z0-9-]{6,24})")
INVOICE_CODE_PATTERN = re.compile(r"(?:发票代码)[:\s]*([A-Za-z0-9-]{6,24})")
TRIP_NO_PATTERN = re.compile(r"(?:车次|航班(?:号)?)[:\s]*([A-Za-z0-9]{2,12})")
@@ -192,6 +193,58 @@ MERCHANT_PATTERNS = (
re.compile(r"(?:销售方(?:名称)?|商户(?:名称)?|开票方(?:名称)?|收款方(?:名称)?)[:\s]*([A-Za-z0-9\u4e00-\u9fa5()·&\\-]{2,40})"),
re.compile(r"([A-Za-z0-9\u4e00-\u9fa5()·&\\-]{2,40}(?:酒店|宾馆|饭店|酒楼|餐厅|航空|铁路|滴滴出行|停车场|服务区))"),
)
DATE_FIELD_KEYS = {
"date",
"time",
"issued_at",
"invoice_date",
"issue_date",
"travel_date",
"trip_date",
"journey_date",
"departure_date",
"departure_time",
"depart_date",
"depart_time",
"boarding_date",
"boarding_time",
"train_date",
"train_time",
"train_departure_time",
"scheduled_departure_time",
"flight_date",
"flight_time",
"ride_date",
"ride_time",
"pickup_time",
"start_time",
}
TRIP_DATE_LABEL_BY_DOCUMENT_TYPE = {
"train_ticket": "列车出发时间",
"flight_itinerary": "起飞日期",
"taxi_receipt": "乘车时间",
"transport_receipt": "乘车时间",
"parking_toll_receipt": "通行日期",
}
TRIP_DATE_FIELD_LABEL_TOKENS = (
"日期",
"时间",
"开票日期",
"发生时间",
"行程日期",
"出发日期",
"出发时间",
"列车出发时间",
"发车日期",
"发车时间",
"开车时间",
"乘车日期",
"乘车时间",
"起飞日期",
"航班日期",
"上车时间",
"用车时间",
)
class DocumentIntelligenceService:
@@ -212,7 +265,10 @@ class DocumentIntelligenceService:
compact = re.sub(r"\s+", "", raw_text).lower()
rule_match = _match_document_rule(compact)
base_rule = rule_match.rule or DEFAULT_RULE
fields = tuple(_extract_document_fields(raw_text))
fields = _apply_document_type_field_labels(
tuple(_extract_document_fields(raw_text, base_rule.document_type)),
base_rule.document_type,
)
rule_insight = DocumentInsight(
document_type=base_rule.document_type,
document_type_label=base_rule.document_type_label,
@@ -275,7 +331,10 @@ class DocumentIntelligenceService:
for item in parsed.evidence
if str(item or "").strip()
][:4]
normalized_fields = _normalize_llm_document_fields(parsed.fields)
normalized_fields = _apply_document_type_field_labels(
tuple(_normalize_llm_document_fields(parsed.fields)),
normalized_type,
)
return LlmDocumentClassification(
document_type=normalized_type,
@@ -312,7 +371,10 @@ class DocumentIntelligenceService:
scene_code=rule_insight.scene_code,
scene_label=rule_insight.scene_label,
expense_type=rule_insight.expense_type,
fields=merged_fields,
fields=_apply_document_type_field_labels(
merged_fields,
rule_insight.document_type,
),
classification_source=rule_insight.classification_source,
classification_confidence=rule_insight.classification_confidence,
evidence=rule_insight.evidence,
@@ -337,7 +399,10 @@ class DocumentIntelligenceService:
scene_code=rule_insight.scene_code,
scene_label=rule_insight.scene_label,
expense_type=rule_insight.expense_type,
fields=merged_fields,
fields=_apply_document_type_field_labels(
merged_fields,
rule_insight.document_type,
),
classification_source=rule_insight.classification_source,
classification_confidence=rule_insight.classification_confidence,
evidence=rule_insight.evidence,
@@ -354,7 +419,7 @@ class DocumentIntelligenceService:
scene_code=rule.scene_code if parsed.scene_code == "other" else parsed.scene_code,
scene_label=rule.scene_label if parsed.scene_label == "其他票据" else parsed.scene_label,
expense_type=rule.expense_type if parsed.expense_type == "other" else parsed.expense_type,
fields=merged_fields,
fields=_apply_document_type_field_labels(merged_fields, rule.document_type),
classification_source=source,
classification_confidence=max(parsed.confidence, rule_insight.classification_confidence),
evidence=tuple(parsed.evidence or rule_insight.evidence),
@@ -464,8 +529,49 @@ def _normalize_llm_document_field_key(key: str, label: str) -> str:
token in compact_label for token in ("金额", "价税合计", "合计", "总额", "总计", "票价", "支付金额", "实付金额", "实收金额")
):
return "amount"
if compact_key in {"date", "time", "issued_at", "invoice_date"} or any(
token in compact_label for token in ("日期", "时间", "开票日期", "发生时间")
if compact_key in {
"travel_date",
"trip_date",
"journey_date",
"departure_date",
"departure_time",
"depart_date",
"depart_time",
"boarding_date",
"boarding_time",
"train_date",
"train_time",
"train_departure_time",
"scheduled_departure_time",
"flight_date",
"flight_time",
"ride_date",
"ride_time",
"pickup_time",
"start_time",
} or any(
token in compact_label
for token in (
"行程日期",
"出发日期",
"出发时间",
"列车出发时间",
"发车日期",
"发车时间",
"开车时间",
"乘车日期",
"乘车时间",
"起飞日期",
"航班日期",
"上车时间",
"用车时间",
)
):
return "trip_date"
if compact_key in {"issued_at", "issue_date", "invoice_date"} or "开票日期" in compact_label:
return "invoice_date"
if compact_key in {"date", "time"} or any(
token in compact_label for token in ("日期", "时间", "发生时间")
):
return "date"
if compact_key in {"merchant_name", "merchant", "seller_name", "vendor_name"} or any(
@@ -504,7 +610,7 @@ def _normalize_llm_document_field_value(key: str, value: str) -> str:
return ""
text_value = format(candidate.quantize(Decimal("0.01")), "f").rstrip("0").rstrip(".")
return f"{text_value}"
if key == "date":
if key in {"date", "time", "invoice_date", "trip_date"}:
return _extract_date(raw_value) or _clean_field_value(raw_value)
if key == "route":
return _extract_route(raw_value) or _clean_field_value(
@@ -517,6 +623,8 @@ def _llm_document_field_label(key: str) -> str:
return {
"amount": "金额",
"date": "日期",
"invoice_date": "开票日期",
"trip_date": "行程日期",
"merchant_name": "商户",
"invoice_number": "票据号码",
"invoice_code": "发票代码",
@@ -525,6 +633,35 @@ def _llm_document_field_label(key: str) -> str:
}.get(key, key)
def _apply_document_type_field_labels(
fields: tuple[DocumentField, ...],
document_type: str,
) -> tuple[DocumentField, ...]:
date_label = TRIP_DATE_LABEL_BY_DOCUMENT_TYPE.get(
str(document_type or "").strip().lower()
)
if not date_label:
return fields
adjusted: list[DocumentField] = []
for field in fields:
compact_key = str(field.key or "").strip().lower()
compact_label = str(field.label or "").replace(" ", "")
if compact_key in {"issued_at", "issue_date", "invoice_date"} or any(
token in compact_label for token in ("开票日期", "发票日期")
):
adjusted.append(field)
continue
is_date_field = compact_key in DATE_FIELD_KEYS or any(
token in compact_label for token in TRIP_DATE_FIELD_LABEL_TOKENS
)
if is_date_field:
adjusted.append(DocumentField(key=field.key, label=date_label, value=field.value))
continue
adjusted.append(field)
return tuple(adjusted)
def _merge_document_fields(
base_fields: tuple[DocumentField, ...],
override_fields: tuple[DocumentField, ...],
@@ -540,13 +677,13 @@ def _merge_document_fields(
return tuple(merged[key] for key in order if key in merged)
def _extract_document_fields(text: str) -> list[DocumentField]:
def _extract_document_fields(text: str, document_type: str = "") -> list[DocumentField]:
fields: list[DocumentField] = []
amount = _extract_amount(text)
if amount:
fields.append(DocumentField(key="amount", label="金额", value=amount))
date_value = _extract_date(text)
date_value = _extract_date(text, document_type=document_type)
if date_value:
fields.append(DocumentField(key="date", label="日期", value=date_value))
@@ -594,10 +731,33 @@ def _extract_amount(text: str) -> str:
return f"{text_value}"
def _extract_date(text: str) -> str:
match = DATE_PATTERN.search(text)
if not match:
def _extract_date(text: str, *, document_type: str = "") -> str:
matches = list(DATE_PATTERN.finditer(text))
if not matches:
return ""
normalized_type = str(document_type or "").strip().lower()
if normalized_type in TRIP_DATE_LABEL_BY_DOCUMENT_TYPE:
candidates: list[tuple[int, int, bool, str]] = []
for index, match in enumerate(matches):
value = _format_date_match_with_time(text, match)
if not value:
continue
invoice_context = _is_invoice_date_context(text, match)
score = _score_trip_date_context(text, match, value, invoice_context)
candidates.append((score, index, invoice_context, value))
non_invoice_candidates = [candidate for candidate in candidates if not candidate[2]]
if non_invoice_candidates:
return max(non_invoice_candidates, key=lambda candidate: (candidate[0], -candidate[1]))[3]
if candidates:
return ""
return ""
return _format_date_match_with_time(text, matches[0])
def _format_date_match_with_time(text: str, match: re.Match[str]) -> str:
raw_value = str(match.group(1) or "").strip()
normalized = raw_value.replace("", "-").replace("", "-").replace("", "")
normalized = normalized.replace("/", "-").replace(".", "-")
@@ -605,7 +765,60 @@ def _extract_date(text: str) -> str:
if len(parts) != 3:
return raw_value
year, month, day = parts
return f"{year.zfill(4)}-{month.zfill(2)}-{day.zfill(2)}"
date_value = f"{year.zfill(4)}-{month.zfill(2)}-{day.zfill(2)}"
surrounding = str(text or "")[max(0, match.start() - 18): match.end() + 24]
time_match = TIME_PATTERN.search(surrounding)
if time_match:
hour = str(time_match.group(1) or "").zfill(2)
minute = str(time_match.group(2) or "").zfill(2)
return f"{date_value} {hour}:{minute}"
return date_value
def _is_invoice_date_context(text: str, match: re.Match[str]) -> bool:
window = str(text or "")[max(0, match.start() - 12): match.end() + 8]
compact = window.replace(" ", "")
return any(token in compact for token in ("开票日期", "发票日期", "开票时间", "开票"))
def _score_trip_date_context(
text: str,
match: re.Match[str],
value: str,
invoice_context: bool,
) -> int:
window = str(text or "")[max(0, match.start() - 32): match.end() + 32]
compact = window.replace(" ", "")
score = -20 if invoice_context else 0
if ":" in value or "" in value:
score += 8
if any(
token in compact
for token in (
"行程日期",
"出发日期",
"出发时间",
"列车出发时间",
"发车日期",
"发车时间",
"开车时间",
"乘车日期",
"乘车时间",
"起飞日期",
"起飞时间",
"航班日期",
"上车时间",
"用车时间",
)
):
score += 6
if any(token in compact for token in ("车次", "检票", "二等座", "一等座", "商务座", "软卧", "硬卧")):
score += 3
if re.search(r"[A-Z]\d{1,4}", compact):
score += 2
if re.search(r"[\u4e00-\u9fa5A-Za-z0-9()·]{2,20}(?:至|到|→|->|—||-)[\u4e00-\u9fa5A-Za-z0-9()·]{2,20}", compact):
score += 2
return score
def _extract_merchant(text: str) -> str:

View File

@@ -85,7 +85,80 @@ DOCUMENT_TYPE_ITEM_TYPE_MAP = {
"taxi_receipt": "ride_ticket",
"transport_receipt": "ride_ticket",
}
DOCUMENT_TYPE_SCENE_MAP = {
"train_ticket": "travel",
"flight_itinerary": "travel",
"hotel_invoice": "hotel",
"taxi_receipt": "transport",
"transport_receipt": "transport",
"parking_toll_receipt": "transport",
"meal_receipt": "meal",
"office_invoice": "office",
"meeting_invoice": "meeting",
"training_invoice": "training",
}
DOCUMENT_FACT_ITEM_TYPES = {"train_ticket", "flight_ticket", "hotel_ticket", "ride_ticket"}
ROUTE_DESCRIPTION_ITEM_TYPES = {"train_ticket", "flight_ticket", "ride_ticket"}
DOCUMENT_TRIP_DATE_LABELS = {
"train_ticket": "列车出发时间",
"flight_itinerary": "起飞日期",
"taxi_receipt": "乘车时间",
"transport_receipt": "乘车时间",
"parking_toll_receipt": "通行日期",
}
DOCUMENT_TRIP_DATE_REQUIREMENT_LABELS = {
"train_ticket": "列车出发时间或乘车日期",
"flight_itinerary": "起飞日期或航班日期",
"taxi_receipt": "乘车时间",
"transport_receipt": "乘车时间",
"parking_toll_receipt": "通行日期",
"hotel_invoice": "入住或离店日期",
}
DOCUMENT_TRIP_DATE_KEYS = {
"traveldate",
"tripdate",
"journeydate",
"departuredate",
"departuretime",
"departdate",
"departtime",
"boardingdate",
"boardingtime",
"traindate",
"traintime",
"traindeparturetime",
"scheduleddeparturetime",
"flightdate",
"flighttime",
"ridedate",
"ridetime",
"pickuptime",
"starttime",
}
DOCUMENT_GENERIC_DATE_KEYS = {"date", "time", "occurredat", "occurreddate", "businessdate"}
DOCUMENT_INVOICE_DATE_KEYS = {"issuedat", "issuedate", "invoicedate", "billingdate"}
DOCUMENT_TRIP_DATE_LABEL_TOKENS = (
"出发日期",
"出发时间",
"列车出发时间",
"发车日期",
"发车时间",
"开车时间",
"乘车日期",
"乘车时间",
"起飞日期",
"航班日期",
"行程日期",
"上车时间",
"用车时间",
"通行日期",
)
DOCUMENT_GENERIC_DATE_LABEL_TOKENS = ("日期", "时间", "发生时间", "业务发生日期")
DOCUMENT_INVOICE_DATE_LABEL_TOKENS = ("开票日期", "发票日期")
DOCUMENT_ROUTE_FORMAT_PATTERN = re.compile(
r"^[A-Za-z0-9\u4e00-\u9fa5()·]{2,40}\s*-\s*"
r"[A-Za-z0-9\u4e00-\u9fa5()·]{2,40}$"
)
DOCUMENT_ROUTE_TEXT_PATTERN = re.compile(
r"([A-Za-z0-9\u4e00-\u9fa5()·]{2,40})\s*(?:至|到|→|->|—||-)\s*"
r"([A-Za-z0-9\u4e00-\u9fa5()·]{2,40})"
@@ -103,15 +176,7 @@ DOCUMENT_ROUTE_DESTINATION_LABELS = {
"乘车终点",
}
GENERIC_ATTACHMENT_BACKFILL_ITEM_TYPES = {"", "other", "travel", "transport", "hotel"}
LOCATION_REQUIRED_EXPENSE_TYPES = {
"travel",
"train_ticket",
"flight_ticket",
"hotel_ticket",
"ride_ticket",
"meeting",
"entertainment",
}
LOCATION_REQUIRED_EXPENSE_TYPES = {"travel", "meeting", "entertainment"}
class ExpenseClaimSubmissionBlockedError(ValueError):
@@ -221,10 +286,14 @@ LEADING_REASON_TIME_PATTERNS = (
re.compile(
r"^\s*(?:识别事项(?:有)?[:]\s*)?"
r"(?:业务发生(?:时间|日期)|费用发生(?:时间|日期)|发生(?:时间|日期)|报销(?:时间|日期)|时间)[:]?\s*"
r"(?:19|20)\d{2}[-/年.]\d{1,2}[-/月.]\d{1,2}日?\s*[,。;;、]?\s*"
r"(?:19|20)\d{2}[-/年.]\d{1,2}[-/月.]\d{1,2}日?"
r"(?:\s*(?:至|到|~||—|-)\s*(?:19|20)\d{2}[-/年.]\d{1,2}[-/月.]\d{1,2}日?)?"
r"\s*[,。;;、]?\s*"
),
re.compile(
r"^\s*(?:19|20)\d{2}[-/年.]\d{1,2}[-/月.]\d{1,2}日?\s*[,。;;、]\s*"
r"^\s*(?:19|20)\d{2}[-/年.]\d{1,2}[-/月.]\d{1,2}日?"
r"(?:\s*(?:至|到|~||—|-)\s*(?:19|20)\d{2}[-/年.]\d{1,2}[-/月.]\d{1,2}日?)?"
r"\s*[,。;;、]\s*"
),
)
AI_REVIEW_LOOKBACK_DAYS = 90
@@ -641,6 +710,11 @@ class ExpenseClaimService:
document=ocr_document,
document_info=document_info,
)
self._backfill_item_date_from_attachment(
item=item,
document=ocr_document,
document_info=document_info,
)
self._backfill_item_reason_from_attachment(
item=item,
document=ocr_document,
@@ -1248,12 +1322,17 @@ class ExpenseClaimService:
"max_draft_count": MAX_DRAFT_CLAIMS_PER_USER,
}
amount = self._resolve_amount(ontology.entities, context_json=context_json)
occurred_at = self._resolve_occurred_at(ontology, context_json=context_json)
expense_type = self._resolve_expense_type(ontology.entities, context_json=context_json)
location = self._resolve_location(message=message, context_json=context_json)
reason = self._resolve_reason(
message=message,
amount = self._resolve_amount(ontology.entities, context_json=context_json)
occurred_at = self._resolve_occurred_at(ontology, context_json=context_json)
explicit_expense_type = self._resolve_explicit_review_expense_type(context_json)
inferred_expense_type = self._resolve_expense_type(ontology.entities, context_json=context_json)
locked_expense_type = explicit_expense_type
if not locked_expense_type and claim is not None and review_action in DOCUMENT_ASSOCIATION_REVIEW_ACTIONS:
locked_expense_type = str(claim.expense_type or "").strip()
expense_type = locked_expense_type or inferred_expense_type
location = self._resolve_location(message=message, context_json=context_json)
reason = self._resolve_reason(
message=message,
context_json=context_json,
allow_message_fallback=is_new_claim,
)
@@ -1356,19 +1435,21 @@ class ExpenseClaimService:
item_specs=document_specs,
)
self._sync_claim_from_items(claim)
else:
self._upsert_primary_item(
claim=claim,
else:
self._upsert_primary_item(
claim=claim,
occurred_at=final_occurred_at,
expense_type=final_expense_type,
amount=final_amount,
reason=final_reason,
location=final_location,
attachment_names=attachment_names,
)
self._sync_claim_from_items(claim)
self.db.commit()
self.db.refresh(claim)
)
self._sync_claim_from_items(claim)
if locked_expense_type:
claim.expense_type = locked_expense_type
self.db.commit()
self.db.refresh(claim)
except IntegrityError as exc:
self.db.rollback()
if (
@@ -2109,18 +2190,21 @@ class ExpenseClaimService:
destination = destination.removeprefix("").removeprefix("").strip()
if not origin or not destination or origin == destination:
return str(route or "").strip()
return f"{origin}{destination}"
return f"{origin}-{destination}"
@staticmethod
def _extract_document_route_from_text(text: str) -> str:
match = DOCUMENT_ROUTE_TEXT_PATTERN.search(str(text or ""))
if not match:
return ""
origin = str(match.group(1) or "").strip()
destination = str(match.group(2) or "").strip()
if not origin or not destination or origin == destination:
return ""
return f"{origin}-{destination}"
for match in DOCUMENT_ROUTE_TEXT_PATTERN.finditer(str(text or "")):
origin = str(match.group(1) or "").strip()
destination = str(match.group(2) or "").strip()
if not origin or not destination or origin == destination:
continue
if origin.isdigit() and destination.isdigit():
continue
if DOCUMENT_DATE_PATTERN.search(f"{origin}-{destination}"):
continue
return f"{origin}-{destination}"
return ""
@staticmethod
def _extract_document_labeled_text_value(text: str, labels: set[str]) -> str:
@@ -2202,20 +2286,55 @@ class ExpenseClaimService:
return amount
return None
def _resolve_document_item_date(self, document: dict[str, Any], *, fallback: date) -> date:
for field in list(document.get("document_fields") or []):
if not isinstance(field, dict):
continue
key = str(field.get("key") or "").strip().lower().replace("_", "")
label = str(field.get("label") or "").replace(" ", "")
def _resolve_document_item_date(self, document: dict[str, Any], *, fallback: date) -> date:
return self._resolve_document_item_date_candidate(document) or fallback
def _resolve_document_item_date_candidate(self, document: dict[str, Any]) -> date | None:
document_type = str(document.get("document_type") or "").strip().lower()
if document_type in DOCUMENT_TRIP_DATE_LABELS:
parsed = self._resolve_document_date_from_fields(
document,
keys=DOCUMENT_TRIP_DATE_KEYS,
labels=DOCUMENT_TRIP_DATE_LABEL_TOKENS,
)
if parsed is not None:
return parsed
parsed = self._resolve_document_date_from_fields(
document,
keys=DOCUMENT_GENERIC_DATE_KEYS,
labels=DOCUMENT_GENERIC_DATE_LABEL_TOKENS,
excluded_labels=DOCUMENT_INVOICE_DATE_LABEL_TOKENS,
)
if parsed is not None:
return parsed
parsed = self._parse_document_date(
" ".join(
[
str(document.get("summary") or "").strip(),
str(document.get("text") or "").strip(),
]
).strip()
)
if parsed is not None:
return parsed
return None
for field in list(document.get("document_fields") or []):
if not isinstance(field, dict):
continue
key = str(field.get("key") or "").strip().lower().replace("_", "")
label = str(field.get("label") or "").replace(" ", "")
value = str(field.get("value") or "").strip()
if not value:
continue
if key in {"date", "time", "issuedat", "invoicedate"} or any(
token in label for token in ("日期", "时间", "开票日期", "发生时间")
):
parsed = self._parse_document_date(value)
if parsed is not None:
if key in {"date", "time", "issuedat", "issuedate", "invoicedate"} or any(
token in label for token in ("日期", "时间", "开票日期", "发生时间")
):
parsed = self._parse_document_date(value)
if parsed is not None:
return parsed
parsed = self._parse_document_date(
@@ -2223,13 +2342,35 @@ class ExpenseClaimService:
[
str(document.get("summary") or "").strip(),
str(document.get("text") or "").strip(),
]
).strip()
)
return parsed or fallback
@staticmethod
def _parse_document_date(value: str) -> date | None:
]
).strip()
)
return parsed
def _resolve_document_date_from_fields(
self,
document: dict[str, Any],
*,
keys: set[str],
labels: tuple[str, ...],
excluded_labels: tuple[str, ...] = (),
) -> date | None:
for field in list(document.get("document_fields") or []):
if not isinstance(field, dict):
continue
key = str(field.get("key") or "").strip().lower().replace("_", "")
label = str(field.get("label") or "").replace(" ", "")
if excluded_labels and any(token in label for token in excluded_labels):
continue
if key not in keys and not any(token in label for token in labels):
continue
parsed = self._parse_document_date(str(field.get("value") or ""))
if parsed is not None:
return parsed
return None
@staticmethod
def _parse_document_date(value: str) -> date | None:
match = DOCUMENT_DATE_PATTERN.search(str(value or ""))
if not match:
return None
@@ -2462,15 +2603,11 @@ class ExpenseClaimService:
return item.normalized_value.strip()
return None
@staticmethod
def _resolve_expense_type(
entities: list[OntologyEntity],
*,
context_json: dict[str, Any],
) -> str | None:
review_form_values = context_json.get("review_form_values")
if isinstance(review_form_values, dict):
compact = str(
@staticmethod
def _resolve_explicit_review_expense_type(context_json: dict[str, Any]) -> str | None:
review_form_values = context_json.get("review_form_values")
if isinstance(review_form_values, dict):
compact = str(
review_form_values.get("expense_type")
or review_form_values.get("reimbursement_type")
or ""
@@ -2494,11 +2631,22 @@ class ExpenseClaimService:
return "training"
if any(word in compact for word in ("通讯费", "话费", "流量费", "宽带费")):
return "communication"
if any(word in compact for word in ("福利费", "团建", "慰问", "节日福利", "体检费")):
return "welfare"
for item in entities:
if item.type == "expense_type":
normalized = item.normalized_value.strip()
if any(word in compact for word in ("福利费", "团建", "慰问", "节日福利", "体检费")):
return "welfare"
return None
@staticmethod
def _resolve_expense_type(
entities: list[OntologyEntity],
*,
context_json: dict[str, Any],
) -> str | None:
explicit_expense_type = ExpenseClaimService._resolve_explicit_review_expense_type(context_json)
if explicit_expense_type:
return explicit_expense_type
for item in entities:
if item.type == "expense_type":
normalized = item.normalized_value.strip()
if normalized:
return normalized
return None
@@ -2569,10 +2717,16 @@ class ExpenseClaimService:
value = str(request_context.get(key) or "").strip()
if value:
return value
compact = str(message or "").replace(" ", "")
if "客户现场" in compact:
return "客户现场"
return None
compact = str(message or "").replace(" ", "")
city_match = re.search(
r"去(?P<city>[\u4e00-\u9fa5]{2,8}?)(?:出差|拜访|参会|见客户|客户现场|支撑|支持|部署|实施|处理|协助)",
compact,
)
if city_match:
return city_match.group("city").strip()
if "客户现场" in compact:
return "客户现场"
return None
@staticmethod
def _resolve_occurred_at(
@@ -3030,27 +3184,48 @@ class ExpenseClaimService:
filename=str(getattr(document, "filename", "") or ""),
summary=str(getattr(document, "summary", "") or ""),
text=str(getattr(document, "text", "") or ""),
)
raw_fields = list(getattr(document, "document_fields", []) or [])
normalized_fields: list[dict[str, str]] = []
for item in raw_fields:
key = ""
label = ""
)
document_type = str(getattr(document, "document_type", "") or "").strip()
if document_type in {"", "other"}:
document_type = insight.document_type
document_type_label = str(getattr(document, "document_type_label", "") or "").strip()
if not document_type_label or document_type_label == "其他单据":
document_type_label = insight.document_type_label
scene_code = str(getattr(document, "scene_code", "") or "").strip()
if scene_code in {"", "other"}:
scene_code = insight.scene_code
scene_label = str(getattr(document, "scene_label", "") or "").strip()
if not scene_label or scene_label == "其他票据":
scene_label = insight.scene_label
raw_fields = list(getattr(document, "document_fields", []) or [])
normalized_fields: list[dict[str, str]] = []
for item in raw_fields:
key = ""
label = ""
value = ""
if isinstance(item, dict):
key = str(item.get("key") or "").strip()
label = str(item.get("label") or "").strip()
value = str(item.get("value") or "").strip()
else:
key = str(getattr(item, "key", "") or "").strip()
label = str(getattr(item, "label", "") or "").strip()
value = str(getattr(item, "value", "") or "").strip()
if key and label and value:
normalized_fields.append(
{
"key": key,
"label": label,
"value": value,
else:
key = str(getattr(item, "key", "") or "").strip()
label = str(getattr(item, "label", "") or "").strip()
value = str(getattr(item, "value", "") or "").strip()
if key and label and value:
label = self._resolve_document_field_display_label(
document_type=document_type,
key=key,
label=label,
)
normalized_fields.append(
{
"key": key,
"label": label,
"value": value,
}
)
@@ -3061,34 +3236,52 @@ class ExpenseClaimService:
"label": field.label,
"value": field.value,
}
for field in insight.fields
if field.value
]
document_type = str(getattr(document, "document_type", "") or "").strip()
if document_type in {"", "other"}:
document_type = insight.document_type
document_type_label = str(getattr(document, "document_type_label", "") or "").strip()
if not document_type_label or document_type_label == "其他单据":
document_type_label = insight.document_type_label
scene_code = str(getattr(document, "scene_code", "") or "").strip()
if scene_code in {"", "other"}:
scene_code = insight.scene_code
scene_label = str(getattr(document, "scene_label", "") or "").strip()
if not scene_label or scene_label == "其他票据":
scene_label = insight.scene_label
return {
"document_type": document_type,
"document_type_label": document_type_label,
"scene_code": scene_code,
"scene_label": scene_label,
for field in insight.fields
if field.value
]
return {
"document_type": document_type,
"document_type_label": document_type_label,
"scene_code": scene_code,
"scene_label": scene_label,
"fields": normalized_fields,
}
@staticmethod
def _resolve_document_field_display_label(
*,
document_type: str,
key: str,
label: str,
) -> str:
trip_label = DOCUMENT_TRIP_DATE_LABELS.get(
str(document_type or "").strip().lower()
)
if not trip_label:
return label
normalized_key = str(key or "").strip().lower().replace("_", "")
normalized_label = str(label or "").replace(" ", "")
if normalized_key in DOCUMENT_INVOICE_DATE_KEYS or any(
token in normalized_label for token in DOCUMENT_INVOICE_DATE_LABEL_TOKENS
):
return label
is_date_field = (
normalized_key
in DOCUMENT_TRIP_DATE_KEYS
| DOCUMENT_GENERIC_DATE_KEYS
or any(
token in normalized_label
for token in (
*DOCUMENT_TRIP_DATE_LABEL_TOKENS,
*DOCUMENT_GENERIC_DATE_LABEL_TOKENS,
)
)
)
return trip_label if is_date_field else label
def _backfill_item_type_from_attachment(
self,
*,
@@ -3125,6 +3318,24 @@ class ExpenseClaimService:
if amount is not None and amount > Decimal("0.00"):
item.item_amount = amount
def _backfill_item_date_from_attachment(
self,
*,
item: ExpenseClaimItem,
document: Any,
document_info: dict[str, Any],
) -> None:
document_payload = {
"document_type": str(document_info.get("document_type") or "").strip(),
"scene_code": str(document_info.get("scene_code") or "").strip(),
"summary": str(getattr(document, "summary", "") or "").strip(),
"text": str(getattr(document, "text", "") or "").strip(),
"document_fields": list(document_info.get("fields") or []),
}
parsed = self._resolve_document_item_date_candidate(document_payload)
if parsed is not None:
item.item_date = parsed
def _backfill_item_reason_from_attachment(
self,
*,
@@ -3258,10 +3469,27 @@ class ExpenseClaimService:
normalized = str(expense_type or "").strip().lower()
return EXPENSE_TYPE_LABELS.get(normalized, "其他")
def _resolve_allowed_document_scenes(self, expense_type: str | None) -> set[str]:
normalized = str(expense_type or "").strip().lower()
policy = self._get_expense_scene_policy(normalized)
return set(policy.allowed_scene_codes) if policy is not None else set()
def _resolve_allowed_document_scenes(self, expense_type: str | None) -> set[str]:
normalized = str(expense_type or "").strip().lower()
policy = self._get_expense_scene_policy(normalized)
allowed_scenes = set(policy.allowed_scene_codes) if policy is not None else set()
allowed_scenes.update(EXPENSE_TYPE_ALLOWED_DOCUMENT_SCENES.get(normalized, set()))
return allowed_scenes
def _resolve_document_analysis_scenes(self, document_info: dict[str, Any], text: str) -> set[str]:
scenes: set[str] = set()
recognized_scene_code = str(document_info.get("scene_code") or "").strip().lower()
if recognized_scene_code and recognized_scene_code != "other":
scenes.add(recognized_scene_code)
recognized_document_type = str(document_info.get("document_type") or "").strip().lower()
mapped_scene = DOCUMENT_TYPE_SCENE_MAP.get(recognized_document_type)
if mapped_scene:
scenes.add(mapped_scene)
if scenes:
return scenes
return set(self._detect_expense_scenes(text).keys())
def _detect_expense_scenes(self, text: str) -> dict[str, list[str]]:
normalized = self._normalize_match_text(text)
@@ -3280,33 +3508,62 @@ class ExpenseClaimService:
unique_labels = list(dict.fromkeys(label for label in labels if label))
return "".join(unique_labels) if unique_labels else "其他"
def _build_purpose_mismatch_point(
self,
*,
item: ExpenseClaimItem,
document_scenes: set[str],
def _build_purpose_mismatch_point(
self,
*,
item: ExpenseClaimItem,
document_scenes: set[str],
) -> str | None:
if not document_scenes:
return None
allowed_scenes = self._resolve_allowed_document_scenes(item.item_type)
reason_text = str(item.item_reason or "").strip()
reason_scenes = set(self._detect_expense_scenes(reason_text).keys())
document_scene_labels = self._format_scene_labels(document_scenes)
if reason_scenes and document_scenes.isdisjoint(reason_scenes):
return (
f"用途字段:用户填写用途“{reason_text[:24]}”与票据内容不一致,"
f"当前附件更像{document_scene_labels}相关材料。"
)
if allowed_scenes and document_scenes.isdisjoint(allowed_scenes):
expense_label = self._resolve_expense_type_label(item.item_type)
return f"用途字段:当前费用项目为{expense_label},但附件内容更像{document_scene_labels}相关票据。"
return None
def _build_fallback_attachment_analysis(
allowed_scenes = self._resolve_allowed_document_scenes(item.item_type)
document_scene_labels = self._format_scene_labels(document_scenes)
if allowed_scenes and document_scenes.isdisjoint(allowed_scenes):
expense_label = self._resolve_expense_type_label(item.item_type)
return f"附件类型:当前费用项目为{expense_label},但附件内容更像{document_scene_labels}相关票据。"
return None
@staticmethod
def _is_valid_route_description(value: str) -> bool:
text = str(value or "").strip()
if not text:
return False
if DOCUMENT_DATE_PATTERN.search(text):
return False
return bool(DOCUMENT_ROUTE_FORMAT_PATTERN.match(text))
def _build_route_format_point(
self,
*,
item: ExpenseClaimItem,
document_info: dict[str, Any],
) -> str | None:
item_type = str(item.item_type or "").strip().lower()
document_type = str(document_info.get("document_type") or "").strip().lower()
route_required = item_type in ROUTE_DESCRIPTION_ITEM_TYPES or document_type in {
"train_ticket",
"flight_itinerary",
"taxi_receipt",
"transport_receipt",
}
if not route_required:
return None
reason = str(item.item_reason or "").strip()
if self._is_valid_route_description(reason):
return None
example = "广州南-北京南" if item_type != "ride_ticket" else "深圳北站-腾讯滨海大厦"
current = f"当前为“{reason[:30]}”," if reason else ""
return (
f"行程说明:{current}格式应为“始发地-目的地”,"
f"例如“{example}”,请按票据行程补充。"
)
def _build_fallback_attachment_analysis(
self,
*,
media_type: str | None,
@@ -3367,12 +3624,16 @@ class ExpenseClaimService:
item=item,
document_info=document_info,
)
document_scene_matches = self._detect_expense_scenes(text)
purpose_mismatch_point = self._build_purpose_mismatch_point(
item=item,
document_scenes=set(document_scene_matches.keys()),
)
recognized_document_type = str(document_info.get("document_type") or "other").strip().lower() or "other"
document_scenes = self._resolve_document_analysis_scenes(document_info, text)
purpose_mismatch_point = self._build_purpose_mismatch_point(
item=item,
document_scenes=document_scenes,
)
route_format_point = self._build_route_format_point(
item=item,
document_info=document_info,
)
recognized_document_type = str(document_info.get("document_type") or "other").strip().lower() or "other"
recognized_document_label = str(document_info.get("document_type_label") or "其他单据").strip() or "其他单据"
requirement_matches = bool(requirement_check.get("matches"))
mismatch_severity = str(requirement_check.get("mismatch_severity") or "high").strip().lower() or "high"
@@ -3406,17 +3667,23 @@ class ExpenseClaimService:
points.append("附件内容:未识别到有效文字,当前附件更像普通图片或内容过于模糊。")
if recognized_document_type == "other" and not has_ticket_keyword:
points.append("票据类型:未识别到发票、票据、电子行程单等关键字,暂无法判断票据类型。")
if not amount_candidates:
points.append("金额字段:未识别到可用于核对的金额。")
elif amount_mismatch:
candidate_text = "".join(str(candidate) for candidate in amount_candidates[:3])
points.append(f"金额字段:附件识别金额 {candidate_text} 元与报销金额 {item_amount} 元不一致。")
if not has_date_text:
points.append("日期字段:未识别到开票日期或业务发生日期。")
if not amount_candidates:
points.append("金额字段:未识别到可用于核对的金额。")
elif amount_mismatch:
candidate_text = "".join(str(candidate) for candidate in amount_candidates[:3])
points.append(f"金额字段:附件识别金额 {candidate_text} 元与报销金额 {item_amount} 元不一致。")
if not has_date_text:
date_requirement = DOCUMENT_TRIP_DATE_REQUIREMENT_LABELS.get(
recognized_document_type,
"开票日期或业务发生日期",
)
points.append(f"日期字段:未识别到{date_requirement}")
if not requirement_matches:
points.append(f"附件类型要求:{requirement_check.get('message')}")
if purpose_mismatch_point:
points.append(purpose_mismatch_point)
if purpose_mismatch_point:
points.append(purpose_mismatch_point)
if route_format_point:
points.append(route_format_point)
if avg_score and avg_score < 0.72:
points.append(f"识别质量OCR 置信度偏低({avg_score:.0%}),可能影响票据核验准确性。")
@@ -3451,20 +3718,23 @@ class ExpenseClaimService:
label = "高风险"
headline = "AI提示附件不符合票据校验条件"
summary = "当前附件存在明显异常,票据类型与当前费用场景不匹配,或无法作为有效报销材料。"
elif (
purpose_mismatch_point
or amount_mismatch
or issue_count >= 2
or warnings
elif (
purpose_mismatch_point
or route_format_point
or amount_mismatch
or issue_count >= 2
or warnings
or (avg_score and avg_score < 0.72)
or (not requirement_matches and mismatch_severity in {"medium", "low"})
):
severity = "medium"
label = "中风险"
headline = "AI提示附件存在明显待整改项"
summary = "当前附件可见部分内容,但金额、用途、日期或附件类型仍有缺失或不一致。"
suggestion = {
label = "中风险"
headline = "AI提示附件存在明显待整改项"
summary = "当前附件可见部分内容,但金额、用途、日期或附件类型仍有缺失或不一致。"
if route_format_point and issue_count == 1:
summary = "票据行程已识别,但费用明细说明未按“始发地-目的地”格式填写。"
suggestion = {
"high": "建议过滤当前不匹配的票据,重新上传符合当前费用场景的清晰原件。",
"medium": "建议根据风险点补齐清晰票据,或修正金额、日期、费用说明后再提交。",
"low": "建议人工再次核对金额和业务说明,确认后可继续流转。",
@@ -5183,9 +5453,11 @@ class ExpenseClaimService:
metadata["analysis"] = analysis
self._write_attachment_meta(file_path, metadata)
def _build_claim_attachment_risk_flags(self, ordered_items: list[ExpenseClaimItem]) -> list[dict[str, Any]]:
derived_flags: list[dict[str, Any]] = []
for index, item in enumerate(ordered_items, start=1):
def _build_claim_attachment_risk_flags(
self, ordered_items: list[ExpenseClaimItem]
) -> list[dict[str, Any]]:
derived_flags: list[dict[str, Any]] = []
for index, item in enumerate(ordered_items, start=1):
file_path = self._resolve_attachment_path(item.invoice_id)
if file_path is None or not file_path.exists():
continue
@@ -5196,21 +5468,34 @@ class ExpenseClaimService:
continue
severity = str(analysis.get("severity") or "").strip().lower()
if severity in {"", "pass", "low"}:
continue
summary = str(analysis.get("summary") or analysis.get("headline") or "").strip() or "附件存在待核对风险。"
label = str(analysis.get("label") or ("高风险" if severity == "high" else "中风险")).strip()
derived_flags.append(
{
"source": "attachment_analysis",
"item_id": item.id,
"severity": severity,
"label": label,
"message": f"费用明细第 {index} 条:{summary}",
}
)
return derived_flags
if severity in {"", "pass", "low"}:
continue
summary = (
str(analysis.get("summary") or analysis.get("headline") or "").strip()
or "附件存在待核对风险。"
)
points = [
str(point or "").strip()
for point in list(analysis.get("points") or [])
if str(point or "").strip()
]
message_detail = "".join(points[:3]) if points else summary
label = str(
analysis.get("label") or ("高风险" if severity == "high" else "中风险")
).strip()
derived_flags.append(
{
"source": "attachment_analysis",
"item_id": item.id,
"severity": severity,
"label": label,
"message": f"费用明细第 {index} 条:{message_detail}",
"summary": summary,
"points": points,
}
)
return derived_flags
def _get_expense_rule_catalog(self) -> Any:
cached = getattr(self, "_expense_rule_catalog", None)

View File

@@ -119,10 +119,11 @@ class OrchestratorService:
context_json=context_json,
)
conversation_id = conversation.conversation_id
context_json = self.conversation_service.hydrate_context_json(
conversation=conversation,
context_json=context_json,
)
context_json = self.conversation_service.hydrate_context_json(
conversation=conversation,
context_json=context_json,
message=payload.message,
)
route_json: dict[str, Any] = {
"orchestrated_by": AgentName.ORCHESTRATOR.value,

View File

@@ -180,7 +180,9 @@ SLOT_LABELS = {
"attachments": "票据附件",
}
DATE_TEXT_PATTERN = re.compile(r"(\d{4}[年/-]\d{1,2}[月/-]\d{1,2}日?)")
DATE_TEXT_PATTERN = re.compile(
r"(\d{4}[年/-]\d{1,2}[月/-]\d{1,2}日?(?:\s*[T ]?\s*(?:[01]?\d|2[0-3])[:][0-5]\d)?)"
)
AMOUNT_TEXT_PATTERN = re.compile(
r"(\d+(?:\.\d+)?)\s*(?:万元|万员|万圆|万园|万块|万元整|元整|块钱|块|元|员|圆|园|万)"
)
@@ -238,10 +240,14 @@ LEADING_REASON_TIME_PATTERNS = (
re.compile(
r"^\s*(?:识别事项(?:有)?[:]\s*)?"
r"(?:业务发生(?:时间|日期)|费用发生(?:时间|日期)|发生(?:时间|日期)|报销(?:时间|日期)|时间)[:]?\s*"
r"(?:19|20)\d{2}[-/年.]\d{1,2}[-/月.]\d{1,2}日?\s*[,。;;、]?\s*"
r"(?:19|20)\d{2}[-/年.]\d{1,2}[-/月.]\d{1,2}日?"
r"(?:\s*(?:至|到|~||—|-)\s*(?:19|20)\d{2}[-/年.]\d{1,2}[-/月.]\d{1,2}日?)?"
r"\s*[,。;;、]?\s*"
),
re.compile(
r"^\s*(?:19|20)\d{2}[-/年.]\d{1,2}[-/月.]\d{1,2}日?\s*[,。;;、]\s*"
r"^\s*(?:19|20)\d{2}[-/年.]\d{1,2}[-/月.]\d{1,2}日?"
r"(?:\s*(?:至|到|~||—|-)\s*(?:19|20)\d{2}[-/年.]\d{1,2}[-/月.]\d{1,2}日?)?"
r"\s*[,。;;、]\s*"
),
)
AMOUNT_UNIT_ALIASES = {
@@ -1936,6 +1942,7 @@ class UserAgentService:
can_proceed=can_proceed,
claim_groups=claim_groups,
draft_payload=draft_payload,
missing_slot_keys=missing_slot_keys,
)
edit_fields = self._build_review_edit_fields(
payload,
@@ -3200,7 +3207,9 @@ class UserAgentService:
can_proceed: bool,
claim_groups: list[UserAgentReviewClaimGroup],
draft_payload: UserAgentDraftPayload | None,
missing_slot_keys: set[str] | None = None,
) -> list[UserAgentReviewAction]:
missing_slot_keys = set(missing_slot_keys or set())
if self._is_review_association_choice_pending(payload):
claim_no = str(payload.tool_payload.get("association_candidate_claim_no") or "").strip()
link_label = f"关联到草稿 {claim_no}" if claim_no else "关联到现有草稿"
@@ -3212,9 +3221,13 @@ class UserAgentService:
emphasis="secondary",
),
UserAgentReviewAction(
label="修改识别信息",
label="选择报销类型" if "expense_type" in missing_slot_keys else "修改识别信息",
action_type="edit_review",
description="打开结构化模板,按已识别字段逐项修改。",
description=(
"先选择本次报销类型,后续票据会作为当前单据的补充继续核对。"
if "expense_type" in missing_slot_keys
else "打开结构化模板,按已识别字段逐项修改。"
),
emphasis="secondary",
),
UserAgentReviewAction(
@@ -3235,6 +3248,23 @@ class UserAgentService:
),
]
review_action = str(payload.context_json.get("review_action") or "").strip()
if "expense_type" in missing_slot_keys and not review_action:
return [
UserAgentReviewAction(
label="取消",
action_type="cancel_review",
description="放弃当前识别结果,并退出本次核对流程。",
emphasis="secondary",
),
UserAgentReviewAction(
label="选择报销类型",
action_type="edit_review",
description="先选择本次报销类型,后续票据会作为当前单据的补充继续核对。",
emphasis="primary",
),
]
primary_action = UserAgentReviewAction(
label="继续下一步" if can_proceed else "保存为草稿",
action_type="next_step" if can_proceed else "save_draft",
@@ -3258,9 +3288,13 @@ class UserAgentService:
emphasis="secondary",
),
UserAgentReviewAction(
label="修改识别信息",
label="选择报销类型" if "expense_type" in missing_slot_keys else "修改识别信息",
action_type="edit_review",
description="打开结构化模板,按已识别字段逐项修改。",
description=(
"先选择本次报销类型,后续票据会作为当前单据的补充继续核对。"
if "expense_type" in missing_slot_keys
else "打开结构化模板,按已识别字段逐项修改。"
),
emphasis="secondary",
),
]
@@ -3429,6 +3463,15 @@ class UserAgentService:
)
missing_labels = list(dict.fromkeys(missing_labels))
expense_type_slot = next((item for item in slot_cards if item.key == "expense_type"), None)
if expense_type_slot is not None and not str(expense_type_slot.value or "").strip():
return (
f"{self._build_review_intent_summary(payload, slot_cards=slot_cards, claim_groups=[])} "
"我已经先保留了当前识别出的时间、地点和事由,但还不能确定这张单据应该走哪类报销流程。"
"请先点击“选择报销类型”,在差旅费、交通费、住宿费等选项中选定;"
"选定后,后续上传的票据都会作为这张单据的补充继续核对,不会重新改判报销类型。"
)
review_payload = UserAgentReviewPayload(
intent_summary="",
body_message="",
@@ -4168,7 +4211,10 @@ class UserAgentService:
if labeled_match:
return labeled_match.group("value").strip()
city_match = re.search(r"去(?P<city>[\u4e00-\u9fa5]{2,8})(?:出差|拜访|参会|见客户|客户现场)", payload.message)
city_match = re.search(
r"去(?P<city>[\u4e00-\u9fa5]{2,8}?)(?:出差|拜访|参会|见客户|客户现场|支撑|支持|部署|实施|处理|协助)",
payload.message,
)
if city_match:
return city_match.group("city").strip()
if "客户现场" in payload.message.replace(" ", ""):
@@ -4210,9 +4256,9 @@ class UserAgentService:
def _build_time_slot(self, payload: UserAgentRequest) -> dict[str, str | float]:
review_form_values = self._resolve_review_form_values(payload)
edited_value = str(
review_form_values.get("occurred_date")
or review_form_values.get("time_range")
review_form_values.get("time_range")
or review_form_values.get("business_time")
or review_form_values.get("occurred_date")
or ""
).strip()
if edited_value:
@@ -4808,6 +4854,7 @@ class UserAgentService:
def _extract_document_fields(self, item: dict[str, object]) -> dict[str, str]:
raw_fields = item.get("document_fields")
normalized_fields: dict[str, str] = {}
document_type = str(item.get("document_type") or "").strip().lower()
if isinstance(raw_fields, list):
for field in raw_fields:
if not isinstance(field, dict):
@@ -4819,6 +4866,12 @@ class UserAgentService:
continue
normalized_label = self._normalize_document_field_label(key=key, label=label)
display_label = normalized_label or label
display_label = self._resolve_document_time_display_label(
document_type=document_type,
key=key,
label=label,
normalized_label=display_label,
)
normalized_value = self._normalize_document_field_value(
label=display_label,
value=value,
@@ -4834,13 +4887,49 @@ class UserAgentService:
normalized_fields["金额"] = amount_value
date_match = DATE_TEXT_PATTERN.search(text)
if date_match and "时间" not in normalized_fields:
normalized_fields["时间"] = date_match.group(1)
time_label = self._resolve_document_time_display_label(
document_type=document_type,
key="date",
label="日期",
normalized_label="时间",
)
normalized_fields[time_label] = date_match.group(1)
merchant = self._extract_document_merchant_name_from_text(text) if self._is_hotel_document_item(item) else ""
if merchant and "商户/酒店" not in normalized_fields:
normalized_fields["商户/酒店"] = merchant
return normalized_fields
@staticmethod
def _resolve_document_time_display_label(
*,
document_type: str,
key: str,
label: str,
normalized_label: str,
) -> str:
if normalized_label != "时间":
return normalized_label
label_by_type = {
"train_ticket": "列车出发时间",
"flight_itinerary": "起飞日期",
"taxi_receipt": "乘车时间",
"transport_receipt": "乘车时间",
"parking_toll_receipt": "通行日期",
}
normalized_type = str(document_type or "").strip().lower()
if normalized_type not in label_by_type:
return normalized_label
compact_key = str(key or "").strip().lower().replace("_", "")
compact_label = str(label or "").replace(" ", "")
if compact_key in {"date", "time", "issuedat", "issuedate", "invoicedate"}:
return label_by_type[normalized_type]
if any(token in compact_label for token in ("日期", "时间", "开票日期", "发生时间")):
return label_by_type[normalized_type]
return normalized_label
@staticmethod
def _normalize_document_field_label(*, key: str, label: str) -> str:
compact_key = str(key or "").strip().lower().replace("_", "")
@@ -4873,7 +4962,7 @@ class UserAgentService:
return ""
if normalized_label == "金额":
return self._extract_amount_text_from_value(raw_value) or raw_value
if normalized_label == "时间":
if normalized_label in {"时间", "出发日期", "列车出发时间", "起飞日期", "乘车时间", "通行日期"}:
match = DATE_TEXT_PATTERN.search(raw_value)
return match.group(1) if match else raw_value
return raw_value