2026-05-19 17:24:13 +00:00
|
|
|
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
|
|
|
|
|
|
2026-05-21 10:57:06 +08:00
|
|
|
from app.api.deps import CurrentUserContext
|
2026-05-19 17:24:13 +00:00
|
|
|
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
|
2026-05-21 10:57:06 +08:00
|
|
|
from app.schemas.reimbursement import TravelReimbursementCalculatorRequest
|
2026-05-19 17:24:13 +00:00
|
|
|
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
|
2026-05-21 09:28:33 +08:00
|
|
|
from app.services.expense_rule_runtime import ExpenseRuleRuntimeService, RuntimeTravelPolicy, resolve_document_type_label
|
2026-05-19 17:24:13 +00:00
|
|
|
from app.services.risk_ontology_bridge import resolve_rule_codes_for_risk_check
|
|
|
|
|
from app.services.runtime_chat import RuntimeChatService
|
2026-05-21 10:57:06 +08:00
|
|
|
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
2026-05-22 10:42:31 +08:00
|
|
|
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_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,
|
|
|
|
|
UserAgentKnowledgeMixin,
|
|
|
|
|
UserAgentReviewCoreMixin,
|
|
|
|
|
UserAgentReviewTravelPolicyMixin,
|
|
|
|
|
UserAgentReviewTravelReceiptMixin,
|
|
|
|
|
UserAgentReviewMessageMixin,
|
|
|
|
|
UserAgentReviewProfileMixin,
|
|
|
|
|
UserAgentReviewSlotMixin,
|
|
|
|
|
):
|
2026-05-19 17:24:13 +00:00
|
|
|
def __init__(self, db: Session) -> None:
|
|
|
|
|
self.db = db
|
|
|
|
|
self.asset_service = AgentAssetService(db)
|
|
|
|
|
self.runtime_chat_service = RuntimeChatService(db)
|
2026-05-22 10:42:31 +08:00
|
|
|
self._document_service = UserAgentDocumentService(group_scene_labels=GROUP_SCENE_LABELS)
|
2026-05-19 17:24:13 +00:00
|
|
|
|
|
|
|
|
def respond(self, payload: UserAgentRequest) -> UserAgentResponse:
|
|
|
|
|
AgentFoundationService(self.db).ensure_foundation_ready()
|
|
|
|
|
citations = self._build_citations(payload)
|
|
|
|
|
suggested_actions = self._build_suggested_actions(payload)
|
2026-05-21 16:09:47 +08:00
|
|
|
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,
|
|
|
|
|
)
|
2026-05-19 17:24:13 +00:00
|
|
|
risk_flags = self._resolve_risk_flags(payload)
|
|
|
|
|
query_payload = self._build_query_payload(payload)
|
|
|
|
|
draft_payload = (
|
|
|
|
|
self._build_draft_payload(payload)
|
2026-05-20 09:36:01 +08:00
|
|
|
if self._should_build_draft_payload(payload)
|
2026-05-19 17:24:13 +00:00
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
fast_knowledge_answer = self._build_fast_knowledge_answer(
|
|
|
|
|
payload,
|
|
|
|
|
citations=citations,
|
|
|
|
|
)
|
|
|
|
|
if fast_knowledge_answer:
|
|
|
|
|
return UserAgentResponse(
|
|
|
|
|
answer=fast_knowledge_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,
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-22 10:42:31 +08:00
|
|
|
def _classify_document(
|
2026-05-19 17:24:13 +00:00
|
|
|
self,
|
2026-05-22 10:42:31 +08:00
|
|
|
item: dict[str, object],
|
2026-05-19 17:24:13 +00:00
|
|
|
payload: UserAgentRequest,
|
2026-05-22 10:42:31 +08:00
|
|
|
) -> 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")),
|
2026-05-19 17:24:13 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
2026-05-22 10:42:31 +08:00
|
|
|
def _normalize_group_code(expense_type_code: str) -> str:
|
|
|
|
|
return UserAgentDocumentService.normalize_group_code(expense_type_code)
|
2026-05-19 17:24:13 +00:00
|
|
|
|
2026-05-22 10:42:31 +08:00
|
|
|
def _extract_document_fields(self, item: dict[str, object]) -> dict[str, str]:
|
|
|
|
|
return self._document_service.extract_document_fields(item)
|
2026-05-19 17:24:13 +00:00
|
|
|
|
|
|
|
|
@staticmethod
|
2026-05-22 10:42:31 +08:00
|
|
|
def _resolve_document_time_display_label(
|
2026-05-19 17:24:13 +00:00
|
|
|
*,
|
2026-05-22 10:42:31 +08:00
|
|
|
document_type: str,
|
|
|
|
|
key: str,
|
|
|
|
|
label: str,
|
|
|
|
|
normalized_label: str,
|
2026-05-19 17:24:13 +00:00
|
|
|
) -> str:
|
2026-05-22 10:42:31 +08:00
|
|
|
return UserAgentDocumentService.resolve_document_time_display_label(
|
|
|
|
|
document_type=document_type,
|
|
|
|
|
key=key,
|
|
|
|
|
label=label,
|
|
|
|
|
normalized_label=normalized_label,
|
2026-05-19 17:24:13 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
2026-05-22 10:42:31 +08:00
|
|
|
def _normalize_document_field_label(*, key: str, label: str) -> str:
|
|
|
|
|
return UserAgentDocumentService.normalize_document_field_label(key=key, label=label)
|
2026-05-19 17:24:13 +00:00
|
|
|
|
2026-05-22 10:42:31 +08:00
|
|
|
def _normalize_document_field_value(self, *, label: str, value: str) -> str:
|
|
|
|
|
return self._document_service.normalize_document_field_value(label=label, value=value)
|
2026-05-19 17:24:13 +00:00
|
|
|
|
2026-05-22 10:42:31 +08:00
|
|
|
def _extract_amount_text_from_value(self, value: str) -> str:
|
|
|
|
|
return self._document_service.extract_amount_text_from_value(value)
|
2026-05-19 17:24:13 +00:00
|
|
|
|
2026-05-22 10:42:31 +08:00
|
|
|
def _extract_document_merchant_name(self, item: dict[str, object]) -> str:
|
|
|
|
|
return self._document_service.extract_document_merchant_name(item)
|
2026-05-19 17:24:13 +00:00
|
|
|
|
|
|
|
|
@staticmethod
|
2026-05-22 10:42:31 +08:00
|
|
|
def _is_hotel_document_item(item: dict[str, object]) -> bool:
|
|
|
|
|
return UserAgentDocumentService.is_hotel_document_item(item)
|
2026-05-19 17:24:13 +00:00
|
|
|
|
|
|
|
|
@staticmethod
|
2026-05-22 10:42:31 +08:00
|
|
|
def _extract_document_merchant_name_from_text(text: str) -> str:
|
|
|
|
|
return UserAgentDocumentService.extract_document_merchant_name_from_text(text)
|
2026-05-19 17:24:13 +00:00
|
|
|
|
|
|
|
|
@staticmethod
|
2026-05-22 10:42:31 +08:00
|
|
|
def _extract_amount_from_card(card: UserAgentReviewDocumentCard) -> float:
|
|
|
|
|
return UserAgentDocumentService.extract_amount_from_card(card)
|
2026-05-19 17:24:13 +00:00
|
|
|
|
2026-05-22 10:42:31 +08:00
|
|
|
def _resolve_amount_value(self, payload: UserAgentRequest) -> float:
|
|
|
|
|
return self._document_service.resolve_amount_value(payload)
|
2026-05-19 17:24:13 +00:00
|
|
|
|
2026-05-22 10:42:31 +08:00
|
|
|
def _sum_ocr_amounts(self, ocr_documents: list[dict[str, object]]) -> float:
|
|
|
|
|
return self._document_service.sum_ocr_amounts(ocr_documents)
|
2026-05-19 17:24:13 +00:00
|
|
|
|
2026-05-22 10:42:31 +08:00
|
|
|
def _infer_expense_type_from_documents(
|
2026-05-19 17:24:13 +00:00
|
|
|
self,
|
|
|
|
|
payload: UserAgentRequest,
|
2026-05-22 10:42:31 +08:00
|
|
|
ocr_documents: list[dict[str, object]],
|
2026-05-19 17:24:13 +00:00
|
|
|
) -> str:
|
2026-05-22 10:42:31 +08:00
|
|
|
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")),
|
2026-05-19 17:24:13 +00:00
|
|
|
)
|