707 lines
28 KiB
Python
707 lines
28 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
from datetime import UTC, datetime, timedelta
|
||
from decimal import Decimal, InvalidOperation
|
||
from typing import Any
|
||
|
||
from sqlalchemy import or_, select
|
||
from sqlalchemy.orm import selectinload
|
||
|
||
from app.api.deps import CurrentUserContext
|
||
from app.core.agent_enums import AgentAssetStatus, AgentAssetType
|
||
from app.models.employee import Employee
|
||
from app.models.financial_record import ExpenseClaim
|
||
from app.schemas.agent_asset import AgentAssetListItem
|
||
from app.schemas.reimbursement import TravelReimbursementCalculatorRequest
|
||
from app.schemas.user_agent import (
|
||
UserAgentCitation,
|
||
UserAgentDraftPayload,
|
||
UserAgentExpenseQueryRecord,
|
||
UserAgentQueryPayload,
|
||
UserAgentQueryStatusGroup,
|
||
UserAgentReviewAction,
|
||
UserAgentReviewClaimGroup,
|
||
UserAgentReviewDocumentCard,
|
||
UserAgentReviewDocumentField,
|
||
UserAgentReviewEditField,
|
||
UserAgentReviewPayload,
|
||
UserAgentReviewRiskBrief,
|
||
UserAgentReviewSlotCard,
|
||
UserAgentRequest,
|
||
UserAgentSuggestedAction,
|
||
)
|
||
from app.services.agent_assets import AgentAssetService
|
||
from app.services.expense_claims import ExpenseClaimService
|
||
from app.services.expense_rule_runtime import ExpenseRuleRuntimeService, RuntimeTravelPolicy, resolve_document_type_label
|
||
from app.services.risk_ontology_bridge import resolve_rule_codes_for_risk_check
|
||
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
||
from app.services.user_agent_constants import *
|
||
|
||
|
||
class UserAgentReviewSlotMixin:
|
||
|
||
@staticmethod
|
||
def _resolve_conversation_history(payload: UserAgentRequest) -> list[dict[str, object]]:
|
||
history = payload.context_json.get("conversation_history")
|
||
if not isinstance(history, list):
|
||
return []
|
||
|
||
normalized: list[dict[str, object]] = []
|
||
for item in history[-8:]:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
role = str(item.get("role") or "").strip()
|
||
content = str(item.get("content") or "").strip()
|
||
if not role or not content:
|
||
continue
|
||
normalized.append({"role": role, "content": content})
|
||
return normalized
|
||
|
||
|
||
@staticmethod
|
||
def _resolve_domain(scenario: str) -> str | None:
|
||
if scenario == "expense":
|
||
return "expense"
|
||
if scenario == "accounts_receivable":
|
||
return "ar"
|
||
if scenario == "accounts_payable":
|
||
return "ap"
|
||
return None
|
||
|
||
|
||
@staticmethod
|
||
def _rank_rule_assets(
|
||
items: list[AgentAssetListItem],
|
||
payload: UserAgentRequest,
|
||
) -> list[AgentAssetListItem]:
|
||
def score(item: AgentAssetListItem) -> tuple[int, str]:
|
||
tags = {str(value) for value in item.scenario_json or []}
|
||
weight = 0
|
||
if payload.ontology.scenario in tags:
|
||
weight += 3
|
||
if payload.ontology.intent in tags:
|
||
weight += 2
|
||
for risk_flag in payload.ontology.risk_flags:
|
||
if risk_flag in tags:
|
||
weight += 4
|
||
return weight, item.code
|
||
|
||
ranked = sorted(items, key=score, reverse=True)
|
||
return [item for item in ranked if score(item)[0] > 0]
|
||
|
||
|
||
@staticmethod
|
||
def _extract_excerpt(content: str) -> str:
|
||
lines = [line.strip() for line in str(content).splitlines() if line.strip()]
|
||
cleaned: list[str] = []
|
||
for line in lines:
|
||
normalized = re.sub(r"^[#>\-\*\d\.\s`]+", "", line).strip()
|
||
if normalized:
|
||
cleaned.append(normalized)
|
||
if len(cleaned) >= 2:
|
||
break
|
||
return ";".join(cleaned[:2])
|
||
|
||
|
||
def _collect_entity_values(self, payload: UserAgentRequest) -> dict[str, str]:
|
||
values = {
|
||
"employee_name": "",
|
||
"customer": "",
|
||
"participants": "",
|
||
"amount": "",
|
||
"expense_type": "",
|
||
"expense_type_code": "",
|
||
}
|
||
participants: list[str] = []
|
||
for item in payload.ontology.entities:
|
||
if item.type == "employee" and not values["employee_name"]:
|
||
values["employee_name"] = item.value
|
||
elif item.type == "customer" and not values["customer"]:
|
||
values["customer"] = item.value
|
||
elif item.type == "amount" and item.role != "threshold" and not values["amount"]:
|
||
normalized_amount = str(item.normalized_value or "").strip()
|
||
values["amount"] = f"{normalized_amount}元" if normalized_amount else item.value
|
||
elif item.type == "expense_type" and not values["expense_type_code"]:
|
||
values["expense_type_code"] = item.normalized_value
|
||
values["expense_type"] = EXPENSE_TYPE_LABELS.get(
|
||
item.normalized_value,
|
||
item.value,
|
||
)
|
||
elif item.type in {"participant", "person"} and item.value.strip():
|
||
participants.append(item.value.strip())
|
||
if participants:
|
||
values["participants"] = "、".join(dict.fromkeys(participants))
|
||
return values
|
||
|
||
|
||
def _format_time_range(self, payload: UserAgentRequest) -> str:
|
||
time_range = payload.ontology.time_range
|
||
if time_range.start_date and time_range.end_date:
|
||
if time_range.start_date == time_range.end_date:
|
||
return time_range.start_date
|
||
normalized = f"{time_range.start_date} 至 {time_range.end_date}"
|
||
return normalized
|
||
if time_range.raw:
|
||
return time_range.raw
|
||
return ""
|
||
|
||
|
||
def _resolve_location_value(self, payload: UserAgentRequest) -> str:
|
||
review_form_values = self._resolve_review_form_values(payload)
|
||
for key in ("business_location", "location"):
|
||
value = str(review_form_values.get(key) or "").strip()
|
||
if value:
|
||
return value
|
||
|
||
if str(payload.context_json.get("entry_source") or "").strip() == "detail":
|
||
request_context = payload.context_json.get("request_context")
|
||
if isinstance(request_context, dict):
|
||
for key in ("city", "location"):
|
||
value = str(request_context.get(key) or "").strip()
|
||
if value:
|
||
return value
|
||
|
||
labeled_match = re.search(r"(?:业务地点|发生地点|地点)[::]\s*(?P<value>[^\n,。;]+)", payload.message)
|
||
if labeled_match:
|
||
return labeled_match.group("value").strip()
|
||
|
||
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(" ", ""):
|
||
return "客户现场"
|
||
return ""
|
||
|
||
|
||
@staticmethod
|
||
def _resolve_review_form_values(payload: UserAgentRequest) -> dict[str, str]:
|
||
values = payload.context_json.get("review_form_values")
|
||
if not isinstance(values, dict):
|
||
return {}
|
||
normalized: dict[str, str] = {}
|
||
for key, value in values.items():
|
||
cleaned_key = str(key or "").strip()
|
||
if not cleaned_key:
|
||
continue
|
||
normalized[cleaned_key] = str(value or "").strip()
|
||
return normalized
|
||
|
||
|
||
@staticmethod
|
||
def _build_slot_value(
|
||
*,
|
||
value: str = "",
|
||
raw_value: str = "",
|
||
normalized_value: str = "",
|
||
source: str = "system",
|
||
confidence: float = 0.0,
|
||
evidence: str = "",
|
||
) -> dict[str, str | float]:
|
||
return {
|
||
"value": str(value or "").strip(),
|
||
"raw_value": str(raw_value or "").strip(),
|
||
"normalized_value": str(normalized_value or "").strip(),
|
||
"source": str(source or "system").strip() or "system",
|
||
"confidence": float(confidence),
|
||
"evidence": str(evidence or "").strip(),
|
||
}
|
||
|
||
|
||
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("time_range")
|
||
or review_form_values.get("business_time")
|
||
or review_form_values.get("occurred_date")
|
||
or ""
|
||
).strip()
|
||
if edited_value:
|
||
raw_value = str(review_form_values.get("time_range_raw") or edited_value).strip()
|
||
return self._build_slot_value(
|
||
value=edited_value,
|
||
raw_value=raw_value,
|
||
normalized_value=edited_value,
|
||
source="user_form",
|
||
confidence=1.0,
|
||
evidence="来源于用户修改后的结构化表单。",
|
||
)
|
||
|
||
time_range = payload.ontology.time_range
|
||
if time_range.start_date and time_range.end_date:
|
||
normalized_value = (
|
||
time_range.start_date
|
||
if time_range.start_date == time_range.end_date
|
||
else f"{time_range.start_date} 至 {time_range.end_date}"
|
||
)
|
||
raw_value = str(time_range.raw or "").strip()
|
||
return self._build_slot_value(
|
||
value=normalized_value,
|
||
raw_value=raw_value,
|
||
normalized_value=normalized_value,
|
||
source="user_text",
|
||
confidence=0.92,
|
||
evidence="系统已根据当前日期将相对时间换算为标准日期。",
|
||
)
|
||
|
||
return self._build_slot_value()
|
||
|
||
|
||
def _build_location_slot(self, payload: UserAgentRequest) -> dict[str, str | float]:
|
||
review_form_values = self._resolve_review_form_values(payload)
|
||
for key in ("business_location", "location"):
|
||
value = str(review_form_values.get(key) or "").strip()
|
||
if value:
|
||
return self._build_slot_value(
|
||
value=value,
|
||
normalized_value=value,
|
||
source="user_form",
|
||
confidence=1.0,
|
||
evidence="来源于用户修改后的结构化表单。",
|
||
)
|
||
|
||
if str(payload.context_json.get("entry_source") or "").strip() == "detail":
|
||
request_context = payload.context_json.get("request_context")
|
||
if isinstance(request_context, dict):
|
||
for key in ("city", "location"):
|
||
value = str(request_context.get(key) or "").strip()
|
||
if value:
|
||
return self._build_slot_value(
|
||
value=value,
|
||
normalized_value=value,
|
||
source="detail_context",
|
||
confidence=0.68,
|
||
evidence="来源于当前关联单据,仅作为辅助上下文,需要用户再次核对。",
|
||
)
|
||
|
||
value = self._resolve_location_value(payload)
|
||
if value:
|
||
evidence = "用户在文本中明确描述了业务地点。"
|
||
if value == "客户现场":
|
||
evidence = "用户明确提到“客户现场”,但未提供具体城市或地址。"
|
||
return self._build_slot_value(
|
||
value=value,
|
||
normalized_value=value,
|
||
source="user_text",
|
||
confidence=0.82,
|
||
evidence=evidence,
|
||
)
|
||
return self._build_slot_value()
|
||
|
||
|
||
def _build_customer_slot(
|
||
self,
|
||
payload: UserAgentRequest,
|
||
*,
|
||
entity_map: dict[str, str],
|
||
) -> dict[str, str | float]:
|
||
review_form_values = self._resolve_review_form_values(payload)
|
||
value = str(review_form_values.get("customer_name") or "").strip()
|
||
if value:
|
||
return self._build_slot_value(
|
||
value=value,
|
||
normalized_value=value,
|
||
source="user_form",
|
||
confidence=1.0,
|
||
evidence="来源于用户修改后的结构化表单。",
|
||
)
|
||
|
||
value = entity_map.get("customer", "")
|
||
if value:
|
||
return self._build_slot_value(
|
||
value=value,
|
||
normalized_value=value,
|
||
source="user_text",
|
||
confidence=0.88,
|
||
evidence="用户在原始描述中直接提到了客户对象。",
|
||
)
|
||
return self._build_slot_value()
|
||
|
||
|
||
def _build_participants_slot(
|
||
self,
|
||
payload: UserAgentRequest,
|
||
*,
|
||
entity_map: dict[str, str],
|
||
) -> dict[str, str | float]:
|
||
review_form_values = self._resolve_review_form_values(payload)
|
||
value = str(review_form_values.get("participants") or "").strip()
|
||
if value:
|
||
return self._build_slot_value(
|
||
value=value,
|
||
normalized_value=value,
|
||
source="user_form",
|
||
confidence=1.0,
|
||
evidence="来源于用户修改后的结构化表单。",
|
||
)
|
||
|
||
value = entity_map.get("participants", "")
|
||
if value:
|
||
return self._build_slot_value(
|
||
value=value,
|
||
normalized_value=value,
|
||
source="user_text",
|
||
confidence=0.8,
|
||
evidence="用户在当前描述中补充了参与人员。",
|
||
)
|
||
return self._build_slot_value()
|
||
|
||
|
||
def _build_reason_slot(
|
||
self,
|
||
payload: UserAgentRequest,
|
||
*,
|
||
claim_groups: list[UserAgentReviewClaimGroup],
|
||
) -> dict[str, str | float]:
|
||
review_form_values = self._resolve_review_form_values(payload)
|
||
edited_value = str(review_form_values.get("reason") or "").strip()
|
||
if edited_value:
|
||
return self._build_slot_value(
|
||
value=edited_value,
|
||
raw_value=edited_value,
|
||
normalized_value=edited_value,
|
||
source="user_form",
|
||
confidence=1.0,
|
||
evidence="来源于用户修改后的结构化表单。",
|
||
)
|
||
|
||
inferred_reason = self._infer_reason_from_claim_groups(
|
||
claim_groups=claim_groups,
|
||
)
|
||
reason_value = self._resolve_reason_text(self._resolve_reason_source_text(payload))
|
||
if inferred_reason:
|
||
return self._build_slot_value(
|
||
value=inferred_reason,
|
||
raw_value=reason_value or inferred_reason,
|
||
normalized_value=inferred_reason,
|
||
source="ocr",
|
||
confidence=0.82,
|
||
evidence=(
|
||
"系统已根据票据识别结果预置场景类型;原始描述仍保留为补充说明。"
|
||
if reason_value
|
||
else "系统已根据票据识别场景补全通用事由,若需更具体说明可继续修改。"
|
||
),
|
||
)
|
||
|
||
if reason_value:
|
||
return self._build_slot_value(
|
||
value=reason_value,
|
||
raw_value=reason_value,
|
||
normalized_value=reason_value,
|
||
source="user_text",
|
||
confidence=0.76,
|
||
evidence="系统从用户原始描述中提取了本次费用事由,建议继续核对。",
|
||
)
|
||
return self._build_slot_value()
|
||
|
||
|
||
def _build_amount_slot(
|
||
self,
|
||
payload: UserAgentRequest,
|
||
*,
|
||
entity_map: dict[str, str],
|
||
ocr_documents: list[dict[str, object]],
|
||
) -> dict[str, str | float]:
|
||
review_form_values = self._resolve_review_form_values(payload)
|
||
edited_amount = str(review_form_values.get("amount") or "").strip()
|
||
if edited_amount:
|
||
normalized = self._normalize_amount_text(edited_amount)
|
||
return self._build_slot_value(
|
||
value=normalized,
|
||
raw_value=edited_amount,
|
||
normalized_value=normalized,
|
||
source="user_form",
|
||
confidence=1.0,
|
||
evidence="来源于用户修改后的结构化表单。",
|
||
)
|
||
|
||
amount_value = entity_map.get("amount", "")
|
||
if amount_value:
|
||
normalized = self._normalize_amount_text(amount_value)
|
||
return self._build_slot_value(
|
||
value=normalized,
|
||
raw_value=amount_value,
|
||
normalized_value=normalized,
|
||
source="user_text",
|
||
confidence=0.92,
|
||
evidence="用户在原始描述中直接给出了金额。",
|
||
)
|
||
|
||
ocr_total_amount = self._sum_ocr_amounts(ocr_documents)
|
||
if ocr_total_amount > 0:
|
||
normalized = f"{ocr_total_amount:.2f}元"
|
||
return self._build_slot_value(
|
||
value=normalized,
|
||
normalized_value=normalized,
|
||
source="ocr",
|
||
confidence=0.76,
|
||
evidence="金额来自 OCR 汇总结果,仍建议用户核对票据原文。",
|
||
)
|
||
return self._build_slot_value()
|
||
|
||
|
||
def _build_expense_type_slot(
|
||
self,
|
||
payload: UserAgentRequest,
|
||
*,
|
||
entity_map: dict[str, str],
|
||
ocr_documents: list[dict[str, object]],
|
||
) -> dict[str, str | float]:
|
||
review_form_values = self._resolve_review_form_values(payload)
|
||
edited_value = str(review_form_values.get("expense_type") or review_form_values.get("reimbursement_type") or "").strip()
|
||
if edited_value:
|
||
normalized_code, normalized_label = self._normalize_expense_type_input(edited_value)
|
||
return self._build_slot_value(
|
||
value=normalized_label,
|
||
raw_value=edited_value,
|
||
normalized_value=normalized_code,
|
||
source="user_form",
|
||
confidence=1.0,
|
||
evidence="来源于用户修改后的结构化表单。",
|
||
)
|
||
|
||
expense_type_code = entity_map.get("expense_type_code", "")
|
||
expense_type_value = EXPENSE_TYPE_LABELS.get(expense_type_code, entity_map.get("expense_type", ""))
|
||
if expense_type_value:
|
||
return self._build_slot_value(
|
||
value=expense_type_value,
|
||
raw_value=expense_type_value,
|
||
normalized_value=expense_type_code,
|
||
source="user_text",
|
||
confidence=0.9,
|
||
evidence="系统根据用户描述中的业务场景判断费用类型。",
|
||
)
|
||
|
||
inferred_label = self._infer_expense_type_from_documents(payload, ocr_documents) if ocr_documents else ""
|
||
if inferred_label:
|
||
normalized_code, normalized_label = self._normalize_expense_type_input(inferred_label)
|
||
return self._build_slot_value(
|
||
value=normalized_label,
|
||
raw_value=inferred_label,
|
||
normalized_value=normalized_code,
|
||
source="ocr",
|
||
confidence=0.74,
|
||
evidence="系统根据票据内容推断费用类型,仍建议用户确认。",
|
||
)
|
||
return self._build_slot_value()
|
||
|
||
|
||
def _build_merchant_slot(
|
||
self,
|
||
payload: UserAgentRequest,
|
||
*,
|
||
ocr_documents: list[dict[str, object]],
|
||
) -> dict[str, str | float]:
|
||
review_form_values = self._resolve_review_form_values(payload)
|
||
edited_value = str(review_form_values.get("merchant_name") or "").strip()
|
||
if edited_value:
|
||
return self._build_slot_value(
|
||
value=edited_value,
|
||
normalized_value=edited_value,
|
||
source="user_form",
|
||
confidence=1.0,
|
||
evidence="来源于用户修改后的结构化表单。",
|
||
)
|
||
|
||
merchant_value = ""
|
||
for document in ocr_documents:
|
||
if not self._is_hotel_document_item(document):
|
||
continue
|
||
merchant_value = self._extract_document_merchant_name(document)
|
||
if merchant_value:
|
||
break
|
||
if merchant_value:
|
||
return self._build_slot_value(
|
||
value=merchant_value,
|
||
normalized_value=merchant_value,
|
||
source="ocr",
|
||
confidence=0.72,
|
||
evidence="商户名称来自 OCR 票据识别结果,仍建议用户核对。",
|
||
)
|
||
return self._build_slot_value()
|
||
|
||
|
||
def _build_attachment_slot(self, payload: UserAgentRequest) -> dict[str, str | float]:
|
||
review_form_values = self._resolve_review_form_values(payload)
|
||
attachment_names = str(review_form_values.get("attachment_names") or "").strip()
|
||
if attachment_names:
|
||
return self._build_slot_value(
|
||
value=attachment_names,
|
||
normalized_value=attachment_names,
|
||
source="user_form",
|
||
confidence=1.0,
|
||
evidence="来源于用户修改后的结构化表单。",
|
||
)
|
||
|
||
count = self._resolve_attachment_count(payload)
|
||
if count > 0:
|
||
names = self._resolve_attachment_names(payload)
|
||
value = "、".join(names) if names else f"{count} 份附件"
|
||
return self._build_slot_value(
|
||
value=value,
|
||
raw_value=value,
|
||
normalized_value=str(count),
|
||
source="upload",
|
||
confidence=1.0,
|
||
evidence="系统已接收到用户上传的附件。",
|
||
)
|
||
return self._build_slot_value()
|
||
|
||
|
||
@staticmethod
|
||
def _normalize_amount_text(value: str) -> str:
|
||
cleaned = str(value or "").strip()
|
||
if not cleaned:
|
||
return ""
|
||
for alias, canonical in sorted(AMOUNT_UNIT_ALIASES.items(), key=lambda item: len(item[0]), reverse=True):
|
||
cleaned = cleaned.replace(alias, canonical)
|
||
match = AMOUNT_TEXT_PATTERN.search(cleaned)
|
||
if not match:
|
||
return cleaned
|
||
number = float(match.group(1))
|
||
return f"{number:.2f}元"
|
||
|
||
|
||
@staticmethod
|
||
def _normalize_expense_type_input(value: str) -> tuple[str, str]:
|
||
compact = str(value or "").replace(" ", "")
|
||
if "招待" in compact or ("客户" in compact and any(keyword in compact for keyword in ("吃饭", "用餐", "宴请", "请客"))):
|
||
return "entertainment", "业务招待费"
|
||
if any(keyword in compact for keyword in ("差旅", "出差", "机票", "行程")):
|
||
return "travel", "差旅费"
|
||
if any(keyword in compact for keyword in ("住宿", "酒店", "宾馆")):
|
||
return "hotel", "住宿费"
|
||
if any(keyword in compact for keyword in ("交通", "打车", "网约车", "出租车", "乘车", "用车", "叫车", "车费", "车资", "的士", "停车")):
|
||
return "transport", "交通费"
|
||
if any(keyword in compact for keyword in ("餐费", "用餐", "午餐", "晚餐", "早餐", "伙食")):
|
||
return "meal", "餐费"
|
||
if "会务" in compact:
|
||
return "meeting", "会务费"
|
||
if any(keyword in compact for keyword in ("办公费", "办公用品", "文具", "耗材", "办公耗材", "打印纸", "办公设备", "键盘", "鼠标", "白板")):
|
||
return "office", "办公费"
|
||
if any(keyword in compact for keyword in ("培训费", "培训", "讲师费", "课时费", "课程费")):
|
||
return "training", "培训费"
|
||
if any(keyword in compact for keyword in ("通讯费", "话费", "流量费", "宽带费")):
|
||
return "communication", "通讯费"
|
||
if any(keyword in compact for keyword in ("福利费", "团建", "慰问", "节日福利", "体检费")):
|
||
return "welfare", "福利费"
|
||
return "other", str(value or "").strip() or "其他费用"
|
||
|
||
|
||
def _resolve_required_review_keys(
|
||
self,
|
||
payload: UserAgentRequest,
|
||
*,
|
||
primary_expense_type: str,
|
||
claim_groups: list[UserAgentReviewClaimGroup],
|
||
) -> set[str]:
|
||
required = {"expense_type", "time_range", "amount", "reason", "attachments"}
|
||
scene_codes = {
|
||
str(item.group_code or "").strip()
|
||
for item in claim_groups
|
||
if str(item.group_code or "").strip()
|
||
}
|
||
if primary_expense_type:
|
||
scene_codes.add(primary_expense_type)
|
||
|
||
for scene_code in scene_codes:
|
||
required.update(SCENE_REQUIRED_SLOT_KEYS.get(scene_code, set()))
|
||
|
||
compact_message = re.sub(r"\s+", "", self._resolve_reason_source_text(payload) or payload.message)
|
||
if "entertainment" in scene_codes or (
|
||
"客户" in compact_message and any(keyword in compact_message for keyword in ("招待", "吃饭", "用餐", "宴请", "请客"))
|
||
):
|
||
required.update({"customer_name", "participants"})
|
||
|
||
return required
|
||
|
||
|
||
@staticmethod
|
||
def _infer_reason_from_claim_groups(
|
||
*,
|
||
claim_groups: list[UserAgentReviewClaimGroup],
|
||
) -> str:
|
||
if len(claim_groups) == 1:
|
||
document_indexes = list(claim_groups[0].document_indexes or [])
|
||
if not document_indexes:
|
||
return ""
|
||
|
||
expense_type = str(claim_groups[0].expense_type or "").strip()
|
||
group_code = str(claim_groups[0].group_code or "").strip()
|
||
if expense_type:
|
||
return INFERRED_REASON_LABELS.get(expense_type, "") or str(claim_groups[0].scene_label or "").strip()
|
||
if group_code:
|
||
return INFERRED_REASON_LABELS.get(group_code, "") or str(claim_groups[0].scene_label or "").strip()
|
||
return ""
|
||
|
||
|
||
@staticmethod
|
||
def _resolve_review_missing_slot_keys(
|
||
payload: UserAgentRequest,
|
||
*,
|
||
slot_cards: list[UserAgentReviewSlotCard],
|
||
) -> list[str]:
|
||
required_keys = {item.key for item in slot_cards if item.required}
|
||
slot_map = {item.key: item for item in slot_cards}
|
||
missing_keys = {
|
||
item.key
|
||
for item in slot_cards
|
||
if item.required and (item.status == "missing" or not str(item.value).strip())
|
||
}
|
||
for key in payload.ontology.missing_slots:
|
||
normalized_key = str(key or "").strip()
|
||
if (
|
||
normalized_key
|
||
and normalized_key in required_keys
|
||
and (
|
||
normalized_key not in slot_map
|
||
or slot_map[normalized_key].status == "missing"
|
||
or not str(slot_map[normalized_key].value).strip()
|
||
)
|
||
):
|
||
missing_keys.add(normalized_key)
|
||
|
||
ordered_keys: list[str] = []
|
||
for item in slot_cards:
|
||
if item.required and item.key in missing_keys and item.key not in ordered_keys:
|
||
ordered_keys.append(item.key)
|
||
return ordered_keys
|
||
|
||
|
||
def _make_slot_card(
|
||
self,
|
||
*,
|
||
key: str,
|
||
value: str,
|
||
raw_value: str,
|
||
normalized_value: str,
|
||
source: str,
|
||
confidence: float,
|
||
evidence: str,
|
||
required: bool = True,
|
||
) -> UserAgentReviewSlotCard:
|
||
is_missing = required and not str(value).strip()
|
||
source_key = source if source in SOURCE_LABELS else "system"
|
||
return UserAgentReviewSlotCard(
|
||
key=key,
|
||
label=SLOT_LABELS.get(key, key),
|
||
value=str(value or "").strip(),
|
||
raw_value=str(raw_value or "").strip(),
|
||
normalized_value=str(normalized_value or "").strip(),
|
||
source=source,
|
||
source_label=SOURCE_LABELS.get(source_key, "系统判断"),
|
||
confidence=confidence,
|
||
required=required,
|
||
confirmed=not is_missing and source in {"user_text", "user_form"},
|
||
status="missing" if is_missing else "identified" if source in {"user_text", "user_form"} else "inferred",
|
||
hint=f"建议补充 {SLOT_LABELS.get(key, key)}。"
|
||
if is_missing and required
|
||
else ("该字段来自系统辅助上下文,建议你再核对一次。" if source in {"detail_context", "ocr"} else ""),
|
||
evidence=evidence,
|
||
)
|
||
|