Files
X-Financial/server/src/app/services/user_agent.py
caoxiaozhu d4d5d40569 feat: 新增预算费控模型与报销审批流引擎
后端新增预算费控服务和报销单审批流模块,引入申请人费用画像
算法,优化知识库 RAG 运行时和同步逻辑,完善报销单工作流常
量和明细同步,更新差旅报销规则电子表格,前端新增预算分析
组件和数字员工模型,完善审批对话框和洞察面板交互,优化侧
边栏和顶栏样式,补充单元测试。
2026-05-27 17:31:27 +08:00

255 lines
10 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 Session, 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,
UserAgentReviewEditField,
UserAgentReviewClaimGroup,
UserAgentReviewDocumentCard,
UserAgentReviewDocumentField,
UserAgentReviewPayload,
UserAgentReviewRiskBrief,
UserAgentReviewSlotCard,
UserAgentRequest,
UserAgentResponse,
UserAgentSuggestedAction,
)
from app.services.agent_assets import AgentAssetService
from app.services.agent_foundation import AgentFoundationService
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.runtime_chat import RuntimeChatService
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
from app.services.user_agent_documents import UserAgentDocumentService
from app.services.user_agent_knowledge import UserAgentKnowledgeMixin
from app.services.user_agent_constants import *
from app.services.user_agent_application import UserAgentApplicationMixin
from app.services.user_agent_response import UserAgentResponseMixin
from app.services.user_agent_review_core import UserAgentReviewCoreMixin
from app.services.user_agent_review_messages import UserAgentReviewMessageMixin
from app.services.user_agent_review_profile import UserAgentReviewProfileMixin
from app.services.user_agent_review_slots import UserAgentReviewSlotMixin
from app.services.user_agent_review_travel_policy import UserAgentReviewTravelPolicyMixin
from app.services.user_agent_review_travel_receipts import UserAgentReviewTravelReceiptMixin
class UserAgentService(
UserAgentResponseMixin,
UserAgentApplicationMixin,
UserAgentKnowledgeMixin,
UserAgentReviewCoreMixin,
UserAgentReviewTravelPolicyMixin,
UserAgentReviewTravelReceiptMixin,
UserAgentReviewMessageMixin,
UserAgentReviewProfileMixin,
UserAgentReviewSlotMixin,
):
def __init__(self, db: Session) -> None:
self.db = db
self.asset_service = AgentAssetService(db)
self.runtime_chat_service = RuntimeChatService(db)
self._document_service = UserAgentDocumentService(group_scene_labels=GROUP_SCENE_LABELS)
def respond(self, payload: UserAgentRequest) -> UserAgentResponse:
AgentFoundationService(self.db).ensure_foundation_ready()
citations = self._build_citations(payload)
risk_flags = self._resolve_risk_flags(payload)
if self._is_expense_application_request(payload):
return self._build_expense_application_response(
payload,
risk_flags=risk_flags,
)
suggested_actions = self._build_suggested_actions(payload)
if self._should_prompt_expense_scene_selection(payload):
return UserAgentResponse(
answer=self._build_expense_scene_selection_answer(payload),
citations=citations,
suggested_actions=suggested_actions,
query_payload=None,
draft_payload=None,
review_payload=None,
risk_flags=[],
requires_confirmation=False,
)
query_payload = self._build_query_payload(payload)
draft_payload = (
self._build_draft_payload(payload)
if self._should_build_draft_payload(payload)
else None
)
review_payload = self._build_review_payload(
payload,
citations=citations,
draft_payload=draft_payload,
)
review_answer = self._build_review_body_answer(
payload,
review_payload=review_payload,
draft_payload=draft_payload,
)
if payload.degraded and payload.tool_payload.get("message"):
return UserAgentResponse(
answer=review_answer or str(payload.tool_payload["message"]),
citations=citations,
suggested_actions=suggested_actions,
query_payload=query_payload,
draft_payload=draft_payload,
review_payload=review_payload,
risk_flags=risk_flags,
requires_confirmation=payload.requires_confirmation,
)
if review_answer:
return UserAgentResponse(
answer=review_answer,
citations=citations,
suggested_actions=suggested_actions,
query_payload=query_payload,
draft_payload=draft_payload,
review_payload=review_payload,
risk_flags=risk_flags,
requires_confirmation=payload.requires_confirmation,
)
guided_answer = None
if draft_payload is None or draft_payload.claim_id is None:
guided_answer = self._build_guided_answer(payload)
if guided_answer:
return UserAgentResponse(
answer=guided_answer,
citations=citations,
suggested_actions=suggested_actions,
query_payload=query_payload,
draft_payload=draft_payload,
review_payload=review_payload,
risk_flags=risk_flags,
requires_confirmation=payload.requires_confirmation,
)
# 知识库问答必须优先让模型基于召回证据组织答案,避免片段渲染抢答导致答非所问。
fallback_answer = self._build_fallback_answer(
payload,
citations=citations,
draft_payload=draft_payload,
)
answer = None
if not self._should_skip_model_answer(payload, review_payload):
answer = self._generate_answer_with_model(
payload,
citations=citations,
suggested_actions=suggested_actions,
risk_flags=risk_flags,
draft_payload=draft_payload,
fallback_answer=fallback_answer,
)
return UserAgentResponse(
answer=answer or fallback_answer,
citations=citations,
suggested_actions=suggested_actions,
query_payload=query_payload,
draft_payload=draft_payload,
review_payload=review_payload,
risk_flags=risk_flags,
requires_confirmation=payload.requires_confirmation,
)
def _classify_document(
self,
item: dict[str, object],
payload: UserAgentRequest,
) -> dict[str, str]:
entity_values = self._collect_entity_values(payload)
return self._document_service.classify_document(
item,
expense_type_code=entity_values.get("expense_type_code", ""),
has_customer=bool(entity_values.get("customer")),
)
@staticmethod
def _normalize_group_code(expense_type_code: str) -> str:
return UserAgentDocumentService.normalize_group_code(expense_type_code)
def _extract_document_fields(self, item: dict[str, object]) -> dict[str, str]:
return self._document_service.extract_document_fields(item)
@staticmethod
def _resolve_document_time_display_label(
*,
document_type: str,
key: str,
label: str,
normalized_label: str,
) -> str:
return UserAgentDocumentService.resolve_document_time_display_label(
document_type=document_type,
key=key,
label=label,
normalized_label=normalized_label,
)
@staticmethod
def _normalize_document_field_label(*, key: str, label: str) -> str:
return UserAgentDocumentService.normalize_document_field_label(key=key, label=label)
def _normalize_document_field_value(self, *, label: str, value: str) -> str:
return self._document_service.normalize_document_field_value(label=label, value=value)
def _extract_amount_text_from_value(self, value: str) -> str:
return self._document_service.extract_amount_text_from_value(value)
def _extract_document_merchant_name(self, item: dict[str, object]) -> str:
return self._document_service.extract_document_merchant_name(item)
@staticmethod
def _is_hotel_document_item(item: dict[str, object]) -> bool:
return UserAgentDocumentService.is_hotel_document_item(item)
@staticmethod
def _extract_document_merchant_name_from_text(text: str) -> str:
return UserAgentDocumentService.extract_document_merchant_name_from_text(text)
@staticmethod
def _extract_amount_from_card(card: UserAgentReviewDocumentCard) -> float:
return UserAgentDocumentService.extract_amount_from_card(card)
def _resolve_amount_value(self, payload: UserAgentRequest) -> float:
return self._document_service.resolve_amount_value(payload)
def _sum_ocr_amounts(self, ocr_documents: list[dict[str, object]]) -> float:
return self._document_service.sum_ocr_amounts(ocr_documents)
def _infer_expense_type_from_documents(
self,
payload: UserAgentRequest,
ocr_documents: list[dict[str, object]],
) -> str:
entity_values = self._collect_entity_values(payload)
return self._document_service.infer_expense_type_from_documents(
ocr_documents,
expense_type_code=entity_values.get("expense_type_code", ""),
has_customer=bool(entity_values.get("customer")),
)