feat: 完善文档中心与报销申请交互及侧边栏重构
后端优化编排器报销查询和本体检测精度,增强报销单草稿保 存和附件回填逻辑,前端重构侧边栏组件支持折叠和图标导 航,完善文档中心状态筛选和详情提示,报销创建和审批详情 页优化会话管理和费用明细交互,新增助手应用服务和预设动 作工具函数,补充单元测试覆盖。
This commit is contained in:
@@ -537,12 +537,12 @@ def return_expense_claim(
|
||||
@router.post(
|
||||
"/claims/{claim_id}/approve",
|
||||
response_model=ExpenseClaimRead,
|
||||
summary="审批通过报销单",
|
||||
description="直属领导审批通过后流转到财务审批;财务终审通过后进入归档入账。",
|
||||
summary="审批通过单据",
|
||||
description="费用申请由直属领导审批通过后完成;报销单直属领导审批后流转到财务审批,财务终审通过后进入归档入账。",
|
||||
responses={
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
"model": ErrorResponse,
|
||||
"description": "报销单不存在。",
|
||||
"description": "单据不存在。",
|
||||
},
|
||||
status.HTTP_400_BAD_REQUEST: {
|
||||
"model": ErrorResponse,
|
||||
|
||||
@@ -11,7 +11,8 @@ import jwt
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.core.config import get_settings
|
||||
from app.schemas.agent_asset import AgentAssetOnlyOfficeConfigRead
|
||||
from app.models.agent_asset import AgentAsset
|
||||
from app.schemas.agent_asset import AgentAssetOnlyOfficeConfigRead, AgentAssetRead
|
||||
from app.services.agent_asset_spreadsheet import (
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
||||
FINANCE_RULES_LIBRARY,
|
||||
@@ -40,9 +41,7 @@ class OnlyOfficeCallbackPayload:
|
||||
class AgentAssetOnlyOfficeMixin:
|
||||
@staticmethod
|
||||
def _resolve_onlyoffice_settings():
|
||||
from app.services import agent_assets
|
||||
|
||||
return agent_assets.resolve_onlyoffice_settings()
|
||||
return resolve_onlyoffice_settings()
|
||||
|
||||
def build_rule_spreadsheet_onlyoffice_config(
|
||||
self,
|
||||
|
||||
@@ -258,6 +258,8 @@ class AgentConversationService:
|
||||
)
|
||||
if not should_hydrate_review_flow:
|
||||
for key in REVIEW_FLOW_CONTEXT_KEYS:
|
||||
if key == "business_time_context" and not self._is_empty_value(merged.get(key)):
|
||||
continue
|
||||
merged.pop(key, None)
|
||||
|
||||
merged["conversation_id"] = conversation.conversation_id
|
||||
|
||||
@@ -143,6 +143,40 @@ class ExpenseClaimService(
|
||||
self._attachment_storage = ExpenseClaimAttachmentStorage()
|
||||
self._attachment_presentation = ExpenseClaimAttachmentPresentation(self._attachment_storage)
|
||||
|
||||
@staticmethod
|
||||
def _is_expense_application_claim(claim: ExpenseClaim) -> bool:
|
||||
claim_no = str(getattr(claim, "claim_no", "") or "").strip().upper()
|
||||
expense_type = str(getattr(claim, "expense_type", "") or "").strip().lower()
|
||||
document_type = str(
|
||||
getattr(claim, "document_type_code", "")
|
||||
or getattr(claim, "document_type", "")
|
||||
or ""
|
||||
).strip().lower()
|
||||
return (
|
||||
claim_no.startswith("APP-")
|
||||
or expense_type == "application"
|
||||
or expense_type.endswith("_application")
|
||||
or document_type in {"application", "expense_application"}
|
||||
)
|
||||
|
||||
def _validate_application_claim_for_submission(self, claim: ExpenseClaim) -> list[str]:
|
||||
issues: list[str] = []
|
||||
if self._is_missing_value(claim.employee_name):
|
||||
issues.append("申请人未完善")
|
||||
if self._is_missing_value(claim.department_name):
|
||||
issues.append("所属部门未完善")
|
||||
if self._is_missing_value(claim.expense_type):
|
||||
issues.append("申请类型未完善")
|
||||
if self._is_missing_value(claim.reason):
|
||||
issues.append("申请事由未完善")
|
||||
if self._is_missing_value(claim.location):
|
||||
issues.append("业务地点未完善")
|
||||
if claim.amount is None or claim.amount <= Decimal("0.00"):
|
||||
issues.append("预计总费用未完善")
|
||||
if claim.occurred_at is None:
|
||||
issues.append("申请时间未完善")
|
||||
return issues
|
||||
|
||||
def list_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
|
||||
stmt = (
|
||||
select(ExpenseClaim)
|
||||
@@ -389,18 +423,51 @@ class ExpenseClaimService(
|
||||
|
||||
self._ensure_draft_claim(claim)
|
||||
self._access_policy.backfill_claim_identity_from_current_user(claim, current_user)
|
||||
self._sync_claim_from_items(claim)
|
||||
missing_fields = self._validate_claim_for_submission(claim)
|
||||
is_application_claim = self._is_expense_application_claim(claim)
|
||||
if not is_application_claim:
|
||||
self._sync_claim_from_items(claim)
|
||||
missing_fields = (
|
||||
self._validate_application_claim_for_submission(claim)
|
||||
if is_application_claim
|
||||
else self._validate_claim_for_submission(claim)
|
||||
)
|
||||
if missing_fields:
|
||||
raise ExpenseClaimSubmissionBlockedError(missing_fields)
|
||||
|
||||
before_json = self._serialize_claim(claim)
|
||||
review_result = self._run_ai_submission_review(claim)
|
||||
if is_application_claim:
|
||||
submitted_at = datetime.now(UTC)
|
||||
preserved_flags = [
|
||||
flag
|
||||
for flag in list(claim.risk_flags_json or [])
|
||||
if not (
|
||||
isinstance(flag, dict)
|
||||
and str(flag.get("source") or "").strip() in {"submission_review", "attachment_analysis"}
|
||||
)
|
||||
]
|
||||
submit_flag = {
|
||||
"source": "application_submission",
|
||||
"event_type": "expense_application_submission",
|
||||
"severity": "info",
|
||||
"label": "申请提交",
|
||||
"message": "费用申请已提交至直属领导审批,并同步纳入预算管理口径。",
|
||||
"previous_status": str(claim.status or "").strip(),
|
||||
"previous_approval_stage": str(claim.approval_stage or "").strip(),
|
||||
"next_status": "submitted",
|
||||
"next_approval_stage": "直属领导审批",
|
||||
"created_at": submitted_at.isoformat(),
|
||||
}
|
||||
claim.status = "submitted"
|
||||
claim.approval_stage = "直属领导审批"
|
||||
claim.risk_flags_json = [*preserved_flags, submit_flag]
|
||||
claim.submitted_at = submitted_at
|
||||
else:
|
||||
review_result = self._run_ai_submission_review(claim)
|
||||
|
||||
claim.status = str(review_result.get("status") or "supplement")
|
||||
claim.approval_stage = str(review_result.get("approval_stage") or "待补充")
|
||||
claim.risk_flags_json = list(review_result.get("risk_flags") or [])
|
||||
claim.submitted_at = datetime.now(UTC) if claim.status == "submitted" else None
|
||||
claim.status = str(review_result.get("status") or "supplement")
|
||||
claim.approval_stage = str(review_result.get("approval_stage") or "待补充")
|
||||
claim.risk_flags_json = list(review_result.get("risk_flags") or [])
|
||||
claim.submitted_at = datetime.now(UTC) if claim.status == "submitted" else None
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
@@ -562,19 +629,29 @@ class ExpenseClaimService(
|
||||
|
||||
normalized_status = str(claim.status or "").strip().lower()
|
||||
if normalized_status != "submitted":
|
||||
raise ValueError("只有审批中的报销单可以审批通过。")
|
||||
raise ValueError("只有审批中的单据可以审批通过。")
|
||||
|
||||
previous_stage = str(claim.approval_stage or "").strip()
|
||||
is_application_claim = self._is_expense_application_claim(claim)
|
||||
if previous_stage == "直属领导审批":
|
||||
if not self._access_policy.can_approve_claim(current_user, claim):
|
||||
raise ValueError("只有当前直属领导审批人可以审批通过该报销单。")
|
||||
raise ValueError("只有当前直属领导审批人可以审批通过该单据。")
|
||||
approval_source = "manual_approval"
|
||||
event_type = "expense_claim_approval"
|
||||
label = "领导审批通过"
|
||||
next_status = "submitted"
|
||||
next_stage = "财务审批"
|
||||
default_message = "{operator} 已审批通过,流转至{next_stage}。"
|
||||
if is_application_claim:
|
||||
event_type = "expense_application_approval"
|
||||
label = "领导审批通过"
|
||||
next_status = "approved"
|
||||
next_stage = "审批完成"
|
||||
default_message = "{operator} 已审批通过,申请流程完成。"
|
||||
else:
|
||||
event_type = "expense_claim_approval"
|
||||
label = "领导审批通过"
|
||||
next_status = "submitted"
|
||||
next_stage = "财务审批"
|
||||
default_message = "{operator} 已审批通过,流转至{next_stage}。"
|
||||
elif previous_stage == "财务审批":
|
||||
if is_application_claim:
|
||||
raise ValueError("费用申请无需财务审批,直属领导审批通过后即完成。")
|
||||
if not self._access_policy.can_approve_claim(current_user, claim):
|
||||
raise ValueError("只有财务人员可以完成财务终审。")
|
||||
approval_source = "finance_approval"
|
||||
@@ -606,7 +683,7 @@ class ExpenseClaimService(
|
||||
],
|
||||
"previous_status": str(claim.status or "").strip(),
|
||||
"previous_approval_stage": previous_stage,
|
||||
"next_status": "submitted",
|
||||
"next_status": next_status,
|
||||
"next_approval_stage": next_stage,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
@@ -78,10 +78,12 @@ class OntologyDetectionMixin:
|
||||
document_type = str(context_json.get("document_type") or "").strip()
|
||||
application_stage = str(context_json.get("application_stage") or "").strip()
|
||||
entry_source = str(context_json.get("entry_source") or "").strip()
|
||||
session_type = str(context_json.get("session_type") or "").strip()
|
||||
return (
|
||||
document_type in EXPENSE_APPLICATION_CONTEXT_TYPES
|
||||
or application_stage in EXPENSE_APPLICATION_CONTEXT_TYPES
|
||||
or entry_source in {"documents_application", "expense_application"}
|
||||
or session_type in EXPENSE_APPLICATION_CONTEXT_TYPES
|
||||
or entry_source in {"application", "documents_application", "expense_application"}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -40,10 +40,12 @@ class OntologyExtractionMixin:
|
||||
document_type = str(context_json.get("document_type") or "").strip()
|
||||
application_stage = str(context_json.get("application_stage") or "").strip()
|
||||
entry_source = str(context_json.get("entry_source") or "").strip()
|
||||
session_type = str(context_json.get("session_type") or "").strip()
|
||||
return (
|
||||
document_type in EXPENSE_APPLICATION_CONTEXT_TYPES
|
||||
or application_stage in EXPENSE_APPLICATION_CONTEXT_TYPES
|
||||
or entry_source in {"documents_application", "expense_application"}
|
||||
or session_type in EXPENSE_APPLICATION_CONTEXT_TYPES
|
||||
or entry_source in {"application", "documents_application", "expense_application"}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -31,6 +32,11 @@ from app.services.knowledge import KnowledgeService
|
||||
from app.services.ontology import SemanticOntologyService
|
||||
from app.services.orchestrator_execution import ExecutionOutcome, OrchestratorExecutionEngine
|
||||
from app.services.orchestrator_expense_query import OrchestratorDatabaseQueryBuilder
|
||||
from app.services.user_agent_application import (
|
||||
APPLICATION_CONTEXT_VALUES,
|
||||
APPLICATION_SHORT_CONFIRMATIONS,
|
||||
APPLICATION_SUBMIT_KEYWORDS,
|
||||
)
|
||||
from app.services.user_agent import UserAgentService
|
||||
|
||||
logger = get_logger("app.services.orchestrator")
|
||||
@@ -131,9 +137,11 @@ class OrchestratorService:
|
||||
)
|
||||
selected_capability_codes = self.execution_engine._flatten_capability_codes(capabilities)
|
||||
is_expense_review_action = self.execution_engine._is_expense_review_action(context_json)
|
||||
is_expense_application_context = self._is_expense_application_context(context_json)
|
||||
requires_confirmation = (
|
||||
ontology.permission.level == AgentPermissionLevel.APPROVAL_REQUIRED.value
|
||||
and not is_expense_review_action
|
||||
and not is_expense_application_context
|
||||
)
|
||||
|
||||
route_json = {
|
||||
@@ -188,8 +196,16 @@ class OrchestratorService:
|
||||
"parse_strategy": ontology.parse_strategy,
|
||||
}
|
||||
)
|
||||
clarification_status = (
|
||||
AgentRunStatus.SUCCEEDED.value
|
||||
if self._is_application_submit_result(clarification_result)
|
||||
else AgentRunStatus.BLOCKED.value
|
||||
)
|
||||
if clarification_status == AgentRunStatus.SUCCEEDED.value:
|
||||
clarification_result["clarification_required"] = False
|
||||
clarification_result["missing_slots"] = []
|
||||
outcome = ExecutionOutcome(
|
||||
status=AgentRunStatus.BLOCKED.value,
|
||||
status=clarification_status,
|
||||
result=clarification_result,
|
||||
degraded=False,
|
||||
tool_count=0,
|
||||
@@ -233,11 +249,23 @@ class OrchestratorService:
|
||||
context_json=context_json,
|
||||
)
|
||||
|
||||
result_requires_confirmation = bool(outcome.result.get("requires_confirmation"))
|
||||
response_requires_confirmation = requires_confirmation or (
|
||||
is_expense_application_context and result_requires_confirmation
|
||||
)
|
||||
final_status = (
|
||||
AgentRunStatus.BLOCKED.value
|
||||
if requires_confirmation
|
||||
and outcome.status == AgentRunStatus.SUCCEEDED.value
|
||||
and ontology.permission.level == AgentPermissionLevel.APPROVAL_REQUIRED.value
|
||||
if outcome.status == AgentRunStatus.SUCCEEDED.value
|
||||
and (
|
||||
(
|
||||
requires_confirmation
|
||||
and ontology.permission.level == AgentPermissionLevel.APPROVAL_REQUIRED.value
|
||||
)
|
||||
or (
|
||||
is_expense_application_context
|
||||
and result_requires_confirmation
|
||||
)
|
||||
)
|
||||
else outcome.status
|
||||
)
|
||||
response_status = self._normalize_response_status(final_status)
|
||||
@@ -259,7 +287,7 @@ class OrchestratorService:
|
||||
ontology_json=self.execution_engine._build_ontology_json(ontology),
|
||||
route_json={
|
||||
**route_json,
|
||||
"requires_confirmation": requires_confirmation,
|
||||
"requires_confirmation": response_requires_confirmation,
|
||||
"degraded": outcome.degraded,
|
||||
},
|
||||
permission_level=ontology.permission.level,
|
||||
@@ -297,7 +325,7 @@ class OrchestratorService:
|
||||
"route_reason": route_reason,
|
||||
"permission_level": ontology.permission.level,
|
||||
"status": response_status,
|
||||
"requires_confirmation": requires_confirmation,
|
||||
"requires_confirmation": response_requires_confirmation,
|
||||
"trace_summary": trace_summary.model_dump(),
|
||||
"result": outcome.result,
|
||||
},
|
||||
@@ -311,7 +339,7 @@ class OrchestratorService:
|
||||
permission_level=ontology.permission.level,
|
||||
status=response_status,
|
||||
result=outcome.result,
|
||||
requires_confirmation=requires_confirmation,
|
||||
requires_confirmation=response_requires_confirmation,
|
||||
trace_summary=trace_summary,
|
||||
)
|
||||
except Exception as exc:
|
||||
@@ -462,3 +490,54 @@ class OrchestratorService:
|
||||
if status == AgentRunStatus.BLOCKED.value:
|
||||
return "blocked"
|
||||
return "succeeded"
|
||||
|
||||
@staticmethod
|
||||
def _is_expense_application_context(context_json: dict[str, Any]) -> bool:
|
||||
context_json = context_json or {}
|
||||
context_values = {
|
||||
str(context_json.get("session_type") or "").strip(),
|
||||
str(context_json.get("entry_source") or "").strip(),
|
||||
str(context_json.get("document_type") or "").strip(),
|
||||
str(context_json.get("application_stage") or "").strip(),
|
||||
}
|
||||
conversation_state = context_json.get("conversation_state")
|
||||
if isinstance(conversation_state, dict):
|
||||
context_values.update(
|
||||
{
|
||||
str(conversation_state.get("session_type") or "").strip(),
|
||||
str(conversation_state.get("entry_source") or "").strip(),
|
||||
str(conversation_state.get("document_type") or "").strip(),
|
||||
str(conversation_state.get("application_stage") or "").strip(),
|
||||
}
|
||||
)
|
||||
if context_values & APPLICATION_CONTEXT_VALUES:
|
||||
return True
|
||||
|
||||
history = context_json.get("conversation_history")
|
||||
if not isinstance(history, list):
|
||||
return False
|
||||
current_message = re.sub(r"\s+", "", str(context_json.get("user_input_text") or ""))
|
||||
looks_like_submit = (
|
||||
any(keyword in current_message for keyword in APPLICATION_SUBMIT_KEYWORDS)
|
||||
or current_message in APPLICATION_SHORT_CONFIRMATIONS
|
||||
or not current_message
|
||||
)
|
||||
if not looks_like_submit:
|
||||
return False
|
||||
for item in history[-6:]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
content = str(item.get("content") or "")
|
||||
if "#application-submit" in content or ("费用申请" in content and "确认" in content):
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _is_application_submit_result(result: dict[str, Any]) -> bool:
|
||||
draft_payload = result.get("draft_payload")
|
||||
return (
|
||||
isinstance(draft_payload, dict)
|
||||
and str(draft_payload.get("draft_type") or "").strip() == "expense_application"
|
||||
and str(draft_payload.get("status") or "").strip() == "submitted"
|
||||
and bool(str(draft_payload.get("claim_no") or draft_payload.get("claim_id") or "").strip())
|
||||
)
|
||||
|
||||
@@ -44,6 +44,7 @@ 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
|
||||
@@ -55,6 +56,7 @@ from app.services.user_agent_review_travel_receipts import UserAgentReviewTravel
|
||||
|
||||
class UserAgentService(
|
||||
UserAgentResponseMixin,
|
||||
UserAgentApplicationMixin,
|
||||
UserAgentKnowledgeMixin,
|
||||
UserAgentReviewCoreMixin,
|
||||
UserAgentReviewTravelPolicyMixin,
|
||||
@@ -72,6 +74,12 @@ class UserAgentService(
|
||||
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(
|
||||
@@ -84,7 +92,6 @@ class UserAgentService(
|
||||
risk_flags=[],
|
||||
requires_confirmation=False,
|
||||
)
|
||||
risk_flags = self._resolve_risk_flags(payload)
|
||||
query_payload = self._build_query_payload(payload)
|
||||
draft_payload = (
|
||||
self._build_draft_payload(payload)
|
||||
|
||||
958
server/src/app/services/user_agent_application.py
Normal file
958
server/src/app/services/user_agent_application.py
Normal file
@@ -0,0 +1,958 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.schemas.user_agent import (
|
||||
UserAgentDraftPayload,
|
||||
UserAgentRequest,
|
||||
UserAgentResponse,
|
||||
UserAgentSuggestedAction,
|
||||
)
|
||||
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
|
||||
|
||||
APPLICATION_CONTEXT_VALUES = {
|
||||
"application",
|
||||
"documents_application",
|
||||
"expense_application",
|
||||
"pre_approval",
|
||||
"preapproval",
|
||||
}
|
||||
APPLICATION_BASE_FIELDS = ("time", "location", "reason")
|
||||
APPLICATION_TRANSPORT_OPTIONS = ("飞机", "火车", "轮船")
|
||||
APPLICATION_TRANSPORT_KEYWORDS = {
|
||||
"飞机": ("飞机", "机票", "航班", "乘机", "坐飞机"),
|
||||
"火车": ("火车", "高铁", "动车", "铁路", "列车"),
|
||||
"轮船": ("轮船", "船", "客轮", "邮轮", "坐船"),
|
||||
}
|
||||
APPLICATION_DESTINATION_PREFIXES = (
|
||||
"上海",
|
||||
"北京",
|
||||
"广州",
|
||||
"深圳",
|
||||
"杭州",
|
||||
"南京",
|
||||
"苏州",
|
||||
"成都",
|
||||
"重庆",
|
||||
"武汉",
|
||||
"西安",
|
||||
"天津",
|
||||
"宁波",
|
||||
"青岛",
|
||||
"长沙",
|
||||
"郑州",
|
||||
"济南",
|
||||
"合肥",
|
||||
"福州",
|
||||
"厦门",
|
||||
"昆明",
|
||||
"南昌",
|
||||
"沈阳",
|
||||
"大连",
|
||||
"无锡",
|
||||
"佛山",
|
||||
"东莞",
|
||||
)
|
||||
APPLICATION_REASON_VERBS = (
|
||||
"支撑",
|
||||
"支持",
|
||||
"部署",
|
||||
"上线",
|
||||
"实施",
|
||||
"驻场",
|
||||
"拜访",
|
||||
"验收",
|
||||
"会议",
|
||||
"采购",
|
||||
"培训",
|
||||
"协助",
|
||||
"处理",
|
||||
"办理",
|
||||
"参加",
|
||||
"进行",
|
||||
)
|
||||
APPLICATION_SUBMIT_KEYWORDS = (
|
||||
"确认提交",
|
||||
"确认申请",
|
||||
"提交审核",
|
||||
"确认无误提交",
|
||||
"直接提交",
|
||||
)
|
||||
APPLICATION_SHORT_CONFIRMATIONS = {"提交", "确认", "是", "好的", "可以", "没问题"}
|
||||
|
||||
|
||||
class UserAgentApplicationMixin:
|
||||
@staticmethod
|
||||
def _is_expense_application_request(payload: UserAgentRequest) -> bool:
|
||||
context_json = payload.context_json or {}
|
||||
context_values = {
|
||||
str(context_json.get("session_type") or "").strip(),
|
||||
str(context_json.get("entry_source") or "").strip(),
|
||||
str(context_json.get("document_type") or "").strip(),
|
||||
str(context_json.get("application_stage") or "").strip(),
|
||||
}
|
||||
conversation_state = context_json.get("conversation_state")
|
||||
if isinstance(conversation_state, dict):
|
||||
context_values.update(
|
||||
{
|
||||
str(conversation_state.get("session_type") or "").strip(),
|
||||
str(conversation_state.get("entry_source") or "").strip(),
|
||||
str(conversation_state.get("document_type") or "").strip(),
|
||||
str(conversation_state.get("application_stage") or "").strip(),
|
||||
}
|
||||
)
|
||||
if context_values & APPLICATION_CONTEXT_VALUES:
|
||||
return True
|
||||
|
||||
history = context_json.get("conversation_history")
|
||||
if not isinstance(history, list):
|
||||
return False
|
||||
compact_message = re.sub(r"\s+", "", str(payload.message or ""))
|
||||
looks_like_submit = (
|
||||
any(keyword in compact_message for keyword in APPLICATION_SUBMIT_KEYWORDS)
|
||||
or compact_message in APPLICATION_SHORT_CONFIRMATIONS
|
||||
)
|
||||
if not looks_like_submit:
|
||||
return False
|
||||
return any(
|
||||
isinstance(item, dict)
|
||||
and str(item.get("role") or "").strip() == "assistant"
|
||||
and (
|
||||
"#application-submit" in str(item.get("content") or "")
|
||||
or ("费用申请" in str(item.get("content") or "") and "确认" in str(item.get("content") or ""))
|
||||
)
|
||||
for item in history[-6:]
|
||||
)
|
||||
|
||||
def _build_expense_application_response(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
*,
|
||||
risk_flags: list[str],
|
||||
) -> UserAgentResponse:
|
||||
facts = self._resolve_expense_application_facts(payload)
|
||||
step = self._resolve_expense_application_step(payload, facts)
|
||||
application_claim = None
|
||||
if step == "submitted":
|
||||
application_claim = self._create_expense_application_record(payload, facts)
|
||||
facts["application_no"] = application_claim.claim_no
|
||||
facts["application_claim_id"] = application_claim.id
|
||||
facts["manager_name"] = self._resolve_application_manager_name(payload, application_claim)
|
||||
return UserAgentResponse(
|
||||
answer=self._build_expense_application_answer(payload, facts=facts, step=step),
|
||||
citations=[],
|
||||
suggested_actions=self._build_expense_application_actions(step, facts),
|
||||
query_payload=None,
|
||||
draft_payload=self._build_submitted_application_payload(application_claim, facts),
|
||||
review_payload=None,
|
||||
risk_flags=risk_flags,
|
||||
requires_confirmation=step == "preview",
|
||||
)
|
||||
|
||||
def _build_expense_application_answer(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
*,
|
||||
facts: dict[str, str],
|
||||
step: str,
|
||||
) -> str:
|
||||
recognized_table = self._build_application_summary_table(facts, include_empty=False)
|
||||
|
||||
if step == "ask_missing":
|
||||
missing_fields = self._resolve_application_missing_fields(facts)
|
||||
missing_text = "、".join(
|
||||
self._display_application_slot_label(item)
|
||||
for item in missing_fields
|
||||
)
|
||||
return "\n\n".join(
|
||||
[
|
||||
"我已按「费用申请 / 事前审批」来处理这条内容。",
|
||||
"已识别信息:\n" + recognized_table,
|
||||
f"当前还需要补充:{missing_text}。",
|
||||
"请一次性补齐上述字段,我会继续生成模拟申请结果并让你确认是否提交。",
|
||||
]
|
||||
)
|
||||
|
||||
if step == "submitted":
|
||||
application_no = str(facts.get("application_no") or "").strip() or self._build_application_claim_no(payload, facts)
|
||||
manager_name = str(facts.get("manager_name") or "").strip() or "直属领导"
|
||||
return "\n\n".join(
|
||||
[
|
||||
f"当前操作已完成,单据已经推送给 {manager_name} 进行审核,请耐心等待。",
|
||||
f"申请单号:{application_no}",
|
||||
"申请信息:\n" + self._build_application_summary_table(facts),
|
||||
f"当前状态:{manager_name}审核中。",
|
||||
"预算处理:预计总费用已作为预算占用参考,等待领导审核确认。",
|
||||
]
|
||||
)
|
||||
|
||||
return "\n\n".join(
|
||||
[
|
||||
"这是模拟的费用申请结果,请核对:",
|
||||
self._build_application_summary_table(facts),
|
||||
"请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。",
|
||||
]
|
||||
)
|
||||
|
||||
def _resolve_expense_application_facts(self, payload: UserAgentRequest) -> dict[str, str]:
|
||||
facts = {
|
||||
"time": "",
|
||||
"location": "",
|
||||
"reason": "",
|
||||
"days": "",
|
||||
"transport_mode": "",
|
||||
"amount": "",
|
||||
"application_type": "",
|
||||
}
|
||||
for message, is_current in self._iter_application_user_messages(payload):
|
||||
partial = {
|
||||
"time": self._resolve_application_time(payload, message=message) if is_current else self._resolve_application_time_from_text(message),
|
||||
"location": self._resolve_application_location(payload, message=message, use_entities=is_current),
|
||||
"reason": self._resolve_application_reason(message),
|
||||
"days": self._resolve_application_days(message),
|
||||
"transport_mode": self._resolve_application_transport_mode(message),
|
||||
"amount": self._resolve_application_amount(payload, message=message) if is_current else self._resolve_application_amount_from_text(message),
|
||||
"application_type": self._resolve_application_type_from_text(message),
|
||||
}
|
||||
for key, value in partial.items():
|
||||
if value:
|
||||
facts[key] = value
|
||||
|
||||
if not facts["application_type"]:
|
||||
facts["application_type"] = self._infer_application_type(facts)
|
||||
facts["time"] = self._expand_application_time_with_days(
|
||||
facts.get("time", ""),
|
||||
facts.get("days", ""),
|
||||
)
|
||||
return facts
|
||||
|
||||
def _resolve_expense_application_step(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
facts: dict[str, str],
|
||||
) -> str:
|
||||
if self._resolve_application_missing_base_fields(facts):
|
||||
return "ask_missing"
|
||||
if self._resolve_application_missing_followup_fields(facts):
|
||||
return "ask_missing"
|
||||
if self._is_application_submit_confirmation(payload):
|
||||
return "submitted"
|
||||
return "preview"
|
||||
|
||||
@staticmethod
|
||||
def _iter_application_user_messages(payload: UserAgentRequest) -> list[tuple[str, bool]]:
|
||||
messages: list[tuple[str, bool]] = []
|
||||
history = (payload.context_json or {}).get("conversation_history")
|
||||
if isinstance(history, list):
|
||||
for item in history:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if str(item.get("role") or "").strip() != "user":
|
||||
continue
|
||||
content = str(item.get("content") or "").strip()
|
||||
if content:
|
||||
messages.append((content, False))
|
||||
current_message = str(payload.message or "").strip()
|
||||
if current_message:
|
||||
messages.append((current_message, True))
|
||||
return messages
|
||||
|
||||
@staticmethod
|
||||
def _resolve_application_missing_base_fields(facts: dict[str, str]) -> list[str]:
|
||||
return [field for field in APPLICATION_BASE_FIELDS if not str(facts.get(field) or "").strip()]
|
||||
|
||||
@staticmethod
|
||||
def _resolve_application_missing_followup_fields(facts: dict[str, str]) -> list[str]:
|
||||
return [
|
||||
field
|
||||
for field in ("transport_mode", "amount")
|
||||
if not str(facts.get(field) or "").strip()
|
||||
]
|
||||
|
||||
def _resolve_application_missing_fields(self, facts: dict[str, str]) -> list[str]:
|
||||
return [
|
||||
*self._resolve_application_missing_base_fields(facts),
|
||||
*self._resolve_application_missing_followup_fields(facts),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _resolve_application_time(payload: UserAgentRequest, *, message: str | None = None) -> str:
|
||||
if message and UserAgentApplicationMixin._resolve_application_time_from_text(message):
|
||||
return UserAgentApplicationMixin._resolve_application_time_from_text(message)
|
||||
|
||||
context_time = UserAgentApplicationMixin._resolve_application_time_from_context(payload.context_json or {})
|
||||
if context_time:
|
||||
return context_time
|
||||
|
||||
time_range = payload.ontology.time_range
|
||||
if time_range.start_date and time_range.end_date:
|
||||
return (
|
||||
time_range.start_date
|
||||
if time_range.start_date == time_range.end_date
|
||||
else f"{time_range.start_date} 至 {time_range.end_date}"
|
||||
)
|
||||
return str(time_range.raw or "").strip()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_application_time_from_text(message: str) -> str:
|
||||
labeled = UserAgentApplicationMixin._resolve_application_labeled_value(
|
||||
message,
|
||||
("发生时间", "业务发生时间", "申请时间", "时间"),
|
||||
)
|
||||
if labeled:
|
||||
return labeled
|
||||
match = re.search(
|
||||
r"(?P<date>20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?)",
|
||||
str(message or ""),
|
||||
)
|
||||
return match.group("date").rstrip("日") if match else ""
|
||||
|
||||
@staticmethod
|
||||
def _resolve_application_time_from_context(context_json: dict[str, object]) -> str:
|
||||
business_time_context = context_json.get("business_time_context")
|
||||
if not isinstance(business_time_context, dict):
|
||||
return ""
|
||||
start_date = str(business_time_context.get("start_date") or "").strip()
|
||||
end_date = str(business_time_context.get("end_date") or start_date).strip()
|
||||
display_value = str(business_time_context.get("display_value") or "").strip()
|
||||
if start_date and end_date:
|
||||
return start_date if start_date == end_date else f"{start_date} 至 {end_date}"
|
||||
return display_value
|
||||
|
||||
@staticmethod
|
||||
def _resolve_application_labeled_value(message: str, labels: tuple[str, ...]) -> str:
|
||||
label_pattern = "|".join(re.escape(label) for label in labels)
|
||||
match = re.search(
|
||||
rf"(?:{label_pattern})[::]\s*(?P<value>[^\n,。;;]+)",
|
||||
str(message or ""),
|
||||
)
|
||||
return match.group("value").strip() if match else ""
|
||||
|
||||
def _resolve_application_entity_or_label(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
entity_type: str,
|
||||
labels: tuple[str, ...],
|
||||
) -> str:
|
||||
entity_value = next(
|
||||
(
|
||||
str(item.normalized_value or item.value or "").strip()
|
||||
for item in payload.ontology.entities
|
||||
if item.type == entity_type
|
||||
and str(item.normalized_value or item.value or "").strip()
|
||||
),
|
||||
"",
|
||||
)
|
||||
return entity_value or self._resolve_application_labeled_value(payload.message, labels)
|
||||
|
||||
def _resolve_application_location(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
*,
|
||||
message: str,
|
||||
use_entities: bool,
|
||||
) -> str:
|
||||
entity_or_labeled = (
|
||||
self._resolve_application_entity_or_label(payload, "location", ("地点", "业务地点", "发生地点"))
|
||||
if use_entities
|
||||
else self._resolve_application_labeled_value(message, ("地点", "业务地点", "发生地点"))
|
||||
)
|
||||
return entity_or_labeled or self._resolve_application_location_from_text(message)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_application_location_from_text(message: str) -> str:
|
||||
compact = re.sub(r"\s+", "", str(message or ""))
|
||||
if not compact:
|
||||
return ""
|
||||
|
||||
for pattern in (
|
||||
r"(?:出差|去|到|赴|前往)(?P<target>[\u4e00-\u9fa5]{1,24})",
|
||||
r"(?P<target>[\u4e00-\u9fa5]{1,12})(?:出差|驻场)",
|
||||
):
|
||||
match = re.search(pattern, compact)
|
||||
if not match:
|
||||
continue
|
||||
target = str(match.group("target") or "").strip()
|
||||
location = UserAgentApplicationMixin._normalize_application_location_target(target)
|
||||
if location:
|
||||
return location
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _normalize_application_location_target(target: str) -> str:
|
||||
text = str(target or "").strip("::,,。;;")
|
||||
if not text:
|
||||
return ""
|
||||
known = next((item for item in APPLICATION_DESTINATION_PREFIXES if text.startswith(item)), "")
|
||||
if known:
|
||||
return known
|
||||
|
||||
verb_indexes = [
|
||||
index
|
||||
for keyword in APPLICATION_REASON_VERBS
|
||||
for index in [text.find(keyword)]
|
||||
if index > 0
|
||||
]
|
||||
if verb_indexes:
|
||||
return text[: min(verb_indexes)]
|
||||
return text[:12]
|
||||
|
||||
@staticmethod
|
||||
def _resolve_application_days(message: str) -> str:
|
||||
labeled = UserAgentApplicationMixin._resolve_application_labeled_value(
|
||||
message,
|
||||
("天数", "出差天数", "申请天数"),
|
||||
)
|
||||
if labeled:
|
||||
return labeled if labeled.endswith("天") else f"{labeled}天"
|
||||
match = re.search(r"(?P<days>\d+|[一二两三四五六七八九十]{1,3})\s*天", str(message or ""))
|
||||
return f"{match.group('days')}天" if match else ""
|
||||
|
||||
@staticmethod
|
||||
def _resolve_application_reason(message: str) -> str:
|
||||
labeled = UserAgentApplicationMixin._resolve_application_labeled_value(
|
||||
message,
|
||||
("事由", "申请事由", "出差事由", "原因", "用途"),
|
||||
)
|
||||
if labeled:
|
||||
return labeled
|
||||
|
||||
text = str(message or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
candidates: list[str] = []
|
||||
for segment in re.split(r"[\n,。;;]+", text):
|
||||
candidate = UserAgentApplicationMixin._cleanup_application_reason_candidate(segment)
|
||||
if candidate:
|
||||
candidates.append(candidate)
|
||||
|
||||
if not candidates:
|
||||
return ""
|
||||
return max(candidates, key=len)
|
||||
|
||||
@staticmethod
|
||||
def _cleanup_application_reason_candidate(segment: str) -> str:
|
||||
text = str(segment or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
text = re.sub(
|
||||
r"^(?:发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|天数|出差天数|申请天数|出行方式|交通方式|交通工具|预计总费用|预计费用|预计金额|申请金额|预算|金额)[::]\s*",
|
||||
"",
|
||||
text,
|
||||
)
|
||||
if re.fullmatch(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?", text):
|
||||
return ""
|
||||
if re.fullmatch(r"(?P<days>\d+|[一二两三四五六七八九十]{1,3})\s*天", text):
|
||||
return ""
|
||||
if re.fullmatch(r"(?:¥|¥)?\s*\d+(?:\.\d+)?\s*(?:元|块|万元)?", text):
|
||||
return ""
|
||||
if "时间" in text and re.search(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}", text):
|
||||
return ""
|
||||
if re.fullmatch(r"(?:去|到|前往)?[\u4e00-\u9fa5]{1,8}出差(?P<days>\d+|[一二两三四五六七八九十]{1,3})?天?", text):
|
||||
return ""
|
||||
|
||||
text = re.sub(r"^.*?(?:出差|前往|去|到|赴)[\u4e00-\u9fa5]{1,8}(?:出差)?(?P<days>\d+|[一二两三四五六七八九十]{1,3})?天?[,,\s]*", "", text)
|
||||
text = re.sub(r"^(?:出差|申请|费用申请|业务|本次|去|到|前往)\s*", "", text)
|
||||
text = text.strip(" ::,,。;;")
|
||||
if not text:
|
||||
return ""
|
||||
if re.fullmatch(r"[\u4e00-\u9fa5]{1,8}", text) and not any(keyword in text for keyword in APPLICATION_REASON_VERBS):
|
||||
return ""
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def _expand_application_time_with_days(time_text: str, days_text: str) -> str:
|
||||
normalized_time = str(time_text or "").strip()
|
||||
if not normalized_time or re.search(r"\s*(?:至|到|~|-{2,}|—)\s*", normalized_time):
|
||||
return normalized_time
|
||||
|
||||
days = UserAgentApplicationMixin._resolve_application_days_count(days_text)
|
||||
if not days:
|
||||
return normalized_time
|
||||
|
||||
match = re.search(
|
||||
r"(?P<date>20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?)",
|
||||
normalized_time,
|
||||
)
|
||||
if not match:
|
||||
return normalized_time
|
||||
|
||||
parsed_start = UserAgentApplicationMixin._parse_application_date(match.group("date"))
|
||||
if parsed_start is None:
|
||||
return normalized_time
|
||||
|
||||
end_date = parsed_start + timedelta(days=days)
|
||||
return f"{parsed_start:%Y-%m-%d} 至 {end_date:%Y-%m-%d}"
|
||||
|
||||
@staticmethod
|
||||
def _resolve_application_days_count(days_text: str) -> int:
|
||||
text = str(days_text or "").strip()
|
||||
if not text:
|
||||
return 0
|
||||
digit_match = re.search(r"\d+", text)
|
||||
if digit_match:
|
||||
return max(0, int(digit_match.group(0)))
|
||||
|
||||
chinese_match = re.search(r"[一二两三四五六七八九十]{1,3}", text)
|
||||
if not chinese_match:
|
||||
return 0
|
||||
return UserAgentApplicationMixin._parse_chinese_number(chinese_match.group(0))
|
||||
|
||||
@staticmethod
|
||||
def _parse_chinese_number(value: str) -> int:
|
||||
digits = {
|
||||
"一": 1,
|
||||
"二": 2,
|
||||
"两": 2,
|
||||
"三": 3,
|
||||
"四": 4,
|
||||
"五": 5,
|
||||
"六": 6,
|
||||
"七": 7,
|
||||
"八": 8,
|
||||
"九": 9,
|
||||
}
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return 0
|
||||
if text == "十":
|
||||
return 10
|
||||
if "十" in text:
|
||||
left, _, right = text.partition("十")
|
||||
tens = digits.get(left, 1) if left else 1
|
||||
ones = digits.get(right, 0) if right else 0
|
||||
return tens * 10 + ones
|
||||
return digits.get(text, 0)
|
||||
|
||||
@staticmethod
|
||||
def _parse_application_date(value: str) -> datetime | None:
|
||||
normalized = str(value or "").strip().rstrip("日").replace("年", "-").replace("月", "-")
|
||||
normalized = normalized.replace("/", "-").replace(".", "-")
|
||||
parts = [part for part in normalized.split("-") if part]
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
try:
|
||||
year, month, day = (int(part) for part in parts)
|
||||
return datetime(year, month, day)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def _resolve_application_amount(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
*,
|
||||
message: str | None = None,
|
||||
) -> str:
|
||||
entity_amount = next(
|
||||
(
|
||||
str(item.normalized_value or item.value or "").strip()
|
||||
for item in payload.ontology.entities
|
||||
if item.type == "amount"
|
||||
and str(item.normalized_value or item.value or "").strip()
|
||||
),
|
||||
"",
|
||||
)
|
||||
if entity_amount:
|
||||
return entity_amount if entity_amount.endswith("元") else f"{entity_amount}元"
|
||||
return self._resolve_application_amount_from_text(message or payload.message)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_application_amount_from_text(message: str) -> str:
|
||||
labeled = UserAgentApplicationMixin._resolve_application_labeled_value(
|
||||
message,
|
||||
("预计总费用", "预计费用", "预计金额", "申请金额", "预算", "费用", "金额"),
|
||||
)
|
||||
if labeled:
|
||||
return UserAgentApplicationMixin._normalize_application_amount(labeled)
|
||||
match = re.search(
|
||||
r"(?P<amount>\d+(?:\.\d+)?\s*万?\s*(?:元|块|人民币))",
|
||||
str(message or ""),
|
||||
)
|
||||
return UserAgentApplicationMixin._normalize_application_amount(match.group("amount")) if match else ""
|
||||
|
||||
@staticmethod
|
||||
def _normalize_application_amount(value: str) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
return ""
|
||||
normalized = re.sub(r"\s+", "", normalized)
|
||||
if normalized.endswith(("元", "块")) or "人民币" in normalized:
|
||||
return normalized.replace("块", "元").replace("人民币", "")
|
||||
return f"{normalized}元"
|
||||
|
||||
@staticmethod
|
||||
def _resolve_application_transport_mode(message: str) -> str:
|
||||
compact_message = re.sub(r"\s+", "", str(message or ""))
|
||||
for transport, keywords in APPLICATION_TRANSPORT_KEYWORDS.items():
|
||||
if any(keyword in compact_message for keyword in keywords):
|
||||
return transport
|
||||
labeled = UserAgentApplicationMixin._resolve_application_labeled_value(
|
||||
message,
|
||||
("出行方式", "交通方式", "交通工具", "出行工具"),
|
||||
)
|
||||
if labeled:
|
||||
for transport, keywords in APPLICATION_TRANSPORT_KEYWORDS.items():
|
||||
if transport in labeled or any(keyword in labeled for keyword in keywords):
|
||||
return transport
|
||||
return labeled
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _resolve_application_type_from_text(message: str) -> str:
|
||||
return UserAgentApplicationMixin._resolve_application_labeled_value(
|
||||
message,
|
||||
("申请类型", "费用类型"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_application_missing_slots(payload: UserAgentRequest) -> list[str]:
|
||||
return [
|
||||
str(item or "").strip()
|
||||
for item in payload.ontology.missing_slots
|
||||
if str(item or "").strip()
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _display_application_slot_label(slot: str) -> str:
|
||||
return {
|
||||
"expense_type": "申请类型",
|
||||
"amount": "预计金额/预算",
|
||||
"time_range": "发生时间",
|
||||
"time": "发生时间",
|
||||
"location": "地点",
|
||||
"reason": "申请事由",
|
||||
"days": "天数",
|
||||
"transport_mode": "出行方式",
|
||||
"attachments": "申请材料/附件",
|
||||
"customer_name": "业务对象",
|
||||
"participants": "参与人员",
|
||||
}.get(str(slot or "").strip(), str(slot or "").strip())
|
||||
|
||||
def _build_expense_application_actions(
|
||||
self,
|
||||
step: str,
|
||||
facts: dict[str, str],
|
||||
) -> list[UserAgentSuggestedAction]:
|
||||
if step == "ask_missing":
|
||||
missing_fields = self._resolve_application_missing_fields(facts)
|
||||
return [
|
||||
UserAgentSuggestedAction(
|
||||
label="一次性补充申请信息",
|
||||
action_type="prefill_composer",
|
||||
description="在输入框预填所有待补充字段,填写后一次提交。",
|
||||
payload={
|
||||
"application_fields": missing_fields,
|
||||
"prompt_prefill": self._build_application_prefill_template(missing_fields),
|
||||
"missing_fields": missing_fields,
|
||||
},
|
||||
)
|
||||
]
|
||||
if step == "preview":
|
||||
return []
|
||||
if step == "submitted":
|
||||
return []
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _resolve_application_prefill_config(field: str) -> tuple[str, str]:
|
||||
config = {
|
||||
"time": ("补充发生时间", "申请时间段:"),
|
||||
"location": ("补充地点", "地点:"),
|
||||
"reason": ("补充申请事由", "事由:"),
|
||||
"days": ("补充天数", "天数:"),
|
||||
"transport_mode": ("补充出行方式", "出行方式:"),
|
||||
"amount": ("补充预计总费用", "预计总费用:"),
|
||||
}
|
||||
return config.get(field, ("补充申请信息", ""))
|
||||
|
||||
@classmethod
|
||||
def _build_application_prefill_template(cls, fields: list[str]) -> str:
|
||||
lines = [
|
||||
prefill
|
||||
for field in fields
|
||||
for _, prefill in [cls._resolve_application_prefill_config(field)]
|
||||
if prefill
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
@classmethod
|
||||
def _build_application_prefill_action(cls, field: str) -> UserAgentSuggestedAction:
|
||||
label, prefill = cls._resolve_application_prefill_config(field)
|
||||
return UserAgentSuggestedAction(
|
||||
label=label,
|
||||
action_type="prefill_composer",
|
||||
description=f"在输入框预填“{prefill}”,用户补充后再提交。",
|
||||
payload={
|
||||
"application_field": field,
|
||||
"prompt_prefill": prefill,
|
||||
"missing_fields": [field],
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _infer_application_type(facts: dict[str, str]) -> str:
|
||||
text = " ".join(str(facts.get(key) or "") for key in ("reason", "transport_mode", "days"))
|
||||
if "采购" in text:
|
||||
return "采购费用申请"
|
||||
if "会议" in text or "会务" in text:
|
||||
return "会务费用申请"
|
||||
return "差旅费用申请"
|
||||
|
||||
@staticmethod
|
||||
def _build_application_summary(facts: dict[str, str]) -> str:
|
||||
return "\n".join(
|
||||
f"{label}:{value or '待补充'}"
|
||||
for label, value in (
|
||||
("申请类型", facts.get("application_type", "")),
|
||||
("发生时间", facts.get("time", "")),
|
||||
("地点", facts.get("location", "")),
|
||||
("事由", facts.get("reason", "")),
|
||||
("天数", facts.get("days", "")),
|
||||
("出行方式", facts.get("transport_mode", "")),
|
||||
("预计总费用", facts.get("amount", "")),
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_application_summary_table(
|
||||
facts: dict[str, str],
|
||||
*,
|
||||
include_empty: bool = True,
|
||||
) -> str:
|
||||
rows = [
|
||||
("申请类型", facts.get("application_type", "")),
|
||||
("发生时间", facts.get("time", "")),
|
||||
("地点", facts.get("location", "")),
|
||||
("事由", facts.get("reason", "")),
|
||||
("天数", facts.get("days", "")),
|
||||
("出行方式", facts.get("transport_mode", "")),
|
||||
("预计总费用", facts.get("amount", "")),
|
||||
]
|
||||
visible_rows = rows if include_empty else [(label, value) for label, value in rows if str(value or "").strip()]
|
||||
if not visible_rows:
|
||||
visible_rows = [("申请描述", "已收到,正在按费用申请上下文继续整理")]
|
||||
lines = ["| 字段 | 内容 |", "| --- | --- |"]
|
||||
lines.extend(f"| {label} | {value or '待补充'} |" for label, value in visible_rows)
|
||||
return "\n".join(lines)
|
||||
|
||||
def _create_expense_application_record(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
facts: dict[str, str],
|
||||
) -> ExpenseClaim:
|
||||
claim_no = self._build_application_claim_no(payload, facts)
|
||||
existing = self.db.scalar(
|
||||
select(ExpenseClaim)
|
||||
.where(ExpenseClaim.claim_no == claim_no)
|
||||
.limit(1)
|
||||
)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
current_user = self._build_application_current_user(payload)
|
||||
access_policy = ExpenseClaimAccessPolicy(self.db)
|
||||
employee = access_policy.resolve_current_employee(current_user)
|
||||
department_name = str(current_user.department_name or "").strip() or "待补充"
|
||||
department_id = None
|
||||
employee_id = None
|
||||
employee_name = str(current_user.username or current_user.name or payload.user_id or "anonymous").strip()
|
||||
|
||||
if employee is not None:
|
||||
employee_id = employee.id
|
||||
employee_name = str(employee.name or employee.employee_no or employee.email or employee_name).strip()
|
||||
department_id = employee.organization_unit_id
|
||||
if employee.organization_unit is not None and employee.organization_unit.name:
|
||||
department_name = str(employee.organization_unit.name).strip()
|
||||
|
||||
claim = ExpenseClaim(
|
||||
claim_no=claim_no,
|
||||
employee_id=employee_id,
|
||||
employee_name=employee_name,
|
||||
department_id=department_id,
|
||||
department_name=department_name,
|
||||
project_code=None,
|
||||
expense_type=self._resolve_application_expense_type_code(facts),
|
||||
reason=str(facts.get("reason") or "费用申请").strip() or "费用申请",
|
||||
location=str(facts.get("location") or "待补充").strip() or "待补充",
|
||||
amount=self._parse_application_amount_to_decimal(facts.get("amount", "")),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=self._parse_application_occurred_at(facts.get("time", "")),
|
||||
submitted_at=datetime.now(UTC),
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
self.db.add(claim)
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
return claim
|
||||
|
||||
def _resolve_application_manager_name(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
claim: ExpenseClaim | None = None,
|
||||
) -> str:
|
||||
if claim is not None:
|
||||
manager_name = ExpenseClaimAccessPolicy.resolve_claim_manager_name(claim)
|
||||
if manager_name and not ExpenseClaimAccessPolicy.is_missing_value(manager_name):
|
||||
return manager_name
|
||||
|
||||
context_json = payload.context_json or {}
|
||||
for key in ("manager_name", "managerName", "direct_manager_name", "directManagerName"):
|
||||
value = str(context_json.get(key) or "").strip()
|
||||
if value and not ExpenseClaimAccessPolicy.is_missing_value(value):
|
||||
return value
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _build_application_current_user(payload: UserAgentRequest) -> CurrentUserContext:
|
||||
context_json = payload.context_json or {}
|
||||
raw_role_codes = context_json.get("role_codes")
|
||||
if isinstance(raw_role_codes, list):
|
||||
role_codes = [str(item).strip() for item in raw_role_codes if str(item).strip()]
|
||||
else:
|
||||
role_codes = [item.strip() for item in str(raw_role_codes or "").split(",") if item.strip()]
|
||||
username = str(
|
||||
payload.user_id
|
||||
or context_json.get("username")
|
||||
or context_json.get("user_id")
|
||||
or context_json.get("employee_no")
|
||||
or context_json.get("name")
|
||||
or "anonymous"
|
||||
).strip()
|
||||
name = str(context_json.get("name") or context_json.get("user_name") or username).strip()
|
||||
return CurrentUserContext(
|
||||
username=username or name or "anonymous",
|
||||
name=name or username or "anonymous",
|
||||
role_codes=role_codes,
|
||||
is_admin=bool(context_json.get("is_admin")),
|
||||
department_name=str(
|
||||
context_json.get("department_name")
|
||||
or context_json.get("department")
|
||||
or context_json.get("departmentName")
|
||||
or ""
|
||||
).strip(),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_application_expense_type_code(facts: dict[str, str]) -> str:
|
||||
application_type = str(facts.get("application_type") or "").strip()
|
||||
if "差旅" in application_type:
|
||||
return "travel_application"
|
||||
if "采购" in application_type:
|
||||
return "purchase_application"
|
||||
if "会务" in application_type or "会议" in application_type:
|
||||
return "meeting_application"
|
||||
return "expense_application"
|
||||
|
||||
@staticmethod
|
||||
def _parse_application_amount_to_decimal(amount_text: str) -> Decimal:
|
||||
normalized = str(amount_text or "").replace(",", "").replace(",", "").strip()
|
||||
match = re.search(r"\d+(?:\.\d+)?", normalized)
|
||||
if not match:
|
||||
return Decimal("0.00")
|
||||
try:
|
||||
return Decimal(match.group(0)).quantize(Decimal("0.01"))
|
||||
except (InvalidOperation, ValueError):
|
||||
return Decimal("0.00")
|
||||
|
||||
@staticmethod
|
||||
def _parse_application_occurred_at(time_text: str) -> datetime:
|
||||
normalized = str(time_text or "")
|
||||
match = re.search(r"(20\d{2})[-/.年](\d{1,2})[-/.月](\d{1,2})", normalized)
|
||||
if match:
|
||||
year, month, day = (int(part) for part in match.groups())
|
||||
return datetime(year, month, day, tzinfo=UTC)
|
||||
return datetime.now(UTC)
|
||||
|
||||
def _build_submitted_application_payload(
|
||||
self,
|
||||
claim: ExpenseClaim | None,
|
||||
facts: dict[str, str],
|
||||
) -> UserAgentDraftPayload | None:
|
||||
if claim is None:
|
||||
return None
|
||||
return UserAgentDraftPayload(
|
||||
draft_type="expense_application",
|
||||
title=str(facts.get("application_type") or "费用申请").strip() or "费用申请",
|
||||
body=self._build_application_summary(facts),
|
||||
confirmation_required=False,
|
||||
claim_id=claim.id,
|
||||
claim_no=claim.claim_no,
|
||||
status=claim.status,
|
||||
approval_stage=claim.approval_stage,
|
||||
)
|
||||
|
||||
def _is_application_submit_confirmation(self, payload: UserAgentRequest) -> bool:
|
||||
compact_message = re.sub(r"\s+", "", str(payload.message or ""))
|
||||
if any(keyword in compact_message for keyword in APPLICATION_SUBMIT_KEYWORDS):
|
||||
return True
|
||||
if compact_message not in APPLICATION_SHORT_CONFIRMATIONS:
|
||||
return False
|
||||
history = (payload.context_json or {}).get("conversation_history")
|
||||
if not isinstance(history, list):
|
||||
return False
|
||||
return any(
|
||||
isinstance(item, dict)
|
||||
and str(item.get("role") or "").strip() == "assistant"
|
||||
and (
|
||||
"是否确认提交" in str(item.get("content") or "")
|
||||
or "当前状态:待确认提交" in str(item.get("content") or "")
|
||||
or "#application-submit" in str(item.get("content") or "")
|
||||
or "确认无误后" in str(item.get("content") or "")
|
||||
)
|
||||
for item in history[-4:]
|
||||
)
|
||||
|
||||
def _build_simulated_application_no(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
facts: dict[str, str],
|
||||
) -> str:
|
||||
return self._build_simulated_application_no_from_facts(
|
||||
facts,
|
||||
fallback_seed=str(payload.run_id or ""),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_simulated_application_no_from_facts(
|
||||
facts: dict[str, str],
|
||||
*,
|
||||
fallback_seed: str = "",
|
||||
) -> str:
|
||||
raw_date = str(facts.get("time") or "")
|
||||
match = re.search(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}", raw_date)
|
||||
date_text = match.group(0) if match else datetime.now().strftime("%Y-%m-%d")
|
||||
digits = re.sub(r"\D", "", date_text)[:8].ljust(8, "0")
|
||||
seed = re.sub(r"[^A-Za-z0-9]", "", fallback_seed)[-6:] or "SIM001"
|
||||
return f"APP-{digits}-{seed.upper()}"
|
||||
|
||||
def _build_application_claim_no(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
facts: dict[str, str],
|
||||
) -> str:
|
||||
context_json = payload.context_json or {}
|
||||
seed_source = "|".join(
|
||||
str(item or "").strip()
|
||||
for item in (
|
||||
context_json.get("conversation_id"),
|
||||
payload.user_id,
|
||||
facts.get("time"),
|
||||
facts.get("location"),
|
||||
facts.get("reason"),
|
||||
facts.get("amount"),
|
||||
)
|
||||
)
|
||||
digest = hashlib.sha1(seed_source.encode("utf-8")).hexdigest()[:6]
|
||||
return self._build_simulated_application_no_from_facts(facts, fallback_seed=digest)
|
||||
@@ -497,7 +497,7 @@ def test_spreadsheet_change_records_include_all_modified_sheets() -> None:
|
||||
def test_editable_spreadsheet_onlyoffice_config_enables_forcesave(monkeypatch) -> None:
|
||||
with build_session() as db:
|
||||
monkeypatch.setattr(
|
||||
"app.services.agent_assets.resolve_onlyoffice_settings",
|
||||
"app.services.agent_asset_onlyoffice.resolve_onlyoffice_settings",
|
||||
lambda: OnlyOfficeRuntimeConfig(
|
||||
enabled=True,
|
||||
public_url="http://onlyoffice.example.com",
|
||||
|
||||
@@ -2883,6 +2883,133 @@ def test_direct_manager_can_approve_subordinate_claim_to_finance_review() -> Non
|
||||
)
|
||||
|
||||
|
||||
def test_application_submit_skips_ai_review_and_receipt_requirements(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="application-owner@example.com",
|
||||
name="张三",
|
||||
role_codes=["employee"],
|
||||
is_admin=True,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
claim = ExpenseClaim(
|
||||
claim_no="APP-20260525-SUBMIT",
|
||||
employee_name="张三",
|
||||
department_name="交付部",
|
||||
project_code="PRJ-A",
|
||||
expense_type="travel_application",
|
||||
reason="支撑国网服务器上线部署",
|
||||
location="上海",
|
||||
amount=Decimal("12000.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
|
||||
submitted_at=None,
|
||||
status="draft",
|
||||
approval_stage="待提交",
|
||||
risk_flags_json=[
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "medium",
|
||||
"message": "旧 AI 预审提示不应保留到申请单提交结果。",
|
||||
}
|
||||
],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
service = ExpenseClaimService(db)
|
||||
|
||||
def fail_ai_review(_claim: ExpenseClaim) -> dict[str, object]:
|
||||
raise AssertionError("费用申请提交不应进入 AI 预审")
|
||||
|
||||
monkeypatch.setattr(service, "_run_ai_submission_review", fail_ai_review)
|
||||
|
||||
submitted = service.submit_claim(claim_id, current_user)
|
||||
|
||||
assert submitted is not None
|
||||
assert submitted.status == "submitted"
|
||||
assert submitted.approval_stage == "直属领导审批"
|
||||
assert submitted.invoice_count == 0
|
||||
assert submitted.items == []
|
||||
assert not any(
|
||||
isinstance(flag, dict) and flag.get("source") == "submission_review"
|
||||
for flag in submitted.risk_flags_json
|
||||
)
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "application_submission"
|
||||
and flag.get("event_type") == "expense_application_submission"
|
||||
and flag.get("next_approval_stage") == "直属领导审批"
|
||||
for flag in submitted.risk_flags_json
|
||||
)
|
||||
|
||||
|
||||
def test_direct_manager_can_approve_application_claim_to_completed_stage() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="manager-application-approve@example.com",
|
||||
name="李经理",
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
manager = Employee(
|
||||
employee_no="E8112",
|
||||
name="李经理",
|
||||
email="manager-application-approve@example.com",
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="E8113",
|
||||
name="张三",
|
||||
email="zhangsan-application-approve@example.com",
|
||||
manager=manager,
|
||||
)
|
||||
db.add_all([manager, employee])
|
||||
db.flush()
|
||||
claim = ExpenseClaim(
|
||||
claim_no="APP-20260525-APPROVE",
|
||||
employee_id=employee.id,
|
||||
employee_name="张三",
|
||||
department_name="交付部",
|
||||
project_code="PRJ-A",
|
||||
expense_type="travel_application",
|
||||
reason="支撑国网服务器上线部署",
|
||||
location="上海",
|
||||
amount=Decimal("12000.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
approved = ExpenseClaimService(db).approve_claim(
|
||||
claim_id,
|
||||
current_user,
|
||||
opinion="业务必要,同意申请。",
|
||||
)
|
||||
|
||||
assert approved is not None
|
||||
assert approved.status == "approved"
|
||||
assert approved.approval_stage == "审批完成"
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "manual_approval"
|
||||
and flag.get("event_type") == "expense_application_approval"
|
||||
and flag.get("opinion") == "业务必要,同意申请。"
|
||||
and flag.get("previous_approval_stage") == "直属领导审批"
|
||||
and flag.get("next_status") == "approved"
|
||||
and flag.get("next_approval_stage") == "审批完成"
|
||||
for flag in approved.risk_flags_json
|
||||
)
|
||||
|
||||
|
||||
def test_finance_can_approve_claim_to_archive_stage() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance-approve@example.com",
|
||||
|
||||
@@ -649,6 +649,40 @@ def test_semantic_ontology_service_requires_attachment_for_meeting_application()
|
||||
assert "attachments" in result.missing_slots
|
||||
|
||||
|
||||
def test_semantic_ontology_service_treats_application_session_as_application_context() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
result = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=(
|
||||
"发生时间:2026-05-25\n"
|
||||
"地点:上海\n"
|
||||
"事由:支持上海国网服务器部署\n"
|
||||
"天数:3天"
|
||||
),
|
||||
user_id="pytest",
|
||||
context_json={
|
||||
"session_type": "application",
|
||||
"entry_source": "application",
|
||||
"attachment_count": 0,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert result.scenario == "expense"
|
||||
assert result.intent == "draft"
|
||||
assert any(
|
||||
item.type == "document_type" and item.normalized_value == "expense_application"
|
||||
for item in result.entities
|
||||
)
|
||||
assert any(
|
||||
item.type == "workflow_stage" and item.normalized_value == "pre_approval"
|
||||
for item in result.entities
|
||||
)
|
||||
assert "expense_type" in result.missing_slots
|
||||
assert "amount" in result.missing_slots
|
||||
|
||||
|
||||
def test_semantic_ontology_service_uses_model_parse_when_available(monkeypatch) -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy.pool import StaticPool
|
||||
from app.db.base import Base
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.schemas.ontology import OntologyParseResult, OntologyPermission
|
||||
from app.schemas.orchestrator import OrchestratorRequest
|
||||
from app.services.agent_conversations import AgentConversationService
|
||||
from app.services.orchestrator import OrchestratorService
|
||||
@@ -228,6 +229,50 @@ def test_conversation_hydration_does_not_reuse_review_type_for_fresh_expense_pro
|
||||
assert continued_context["review_form_values"]["expense_type"] == "差旅费"
|
||||
|
||||
|
||||
def test_conversation_hydration_preserves_incoming_application_time_context() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
service = AgentConversationService(db)
|
||||
conversation = service.get_or_create_conversation(
|
||||
conversation_id="conv-application-time-context",
|
||||
user_id="emp-application-time@example.com",
|
||||
source="user_message",
|
||||
context_json={
|
||||
"session_type": "application",
|
||||
"entry_source": "application",
|
||||
"business_time_context": {
|
||||
"mode": "single",
|
||||
"start_date": "2026-05-01",
|
||||
"end_date": "2026-05-01",
|
||||
"display_value": "2026-05-01",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
stale_context = service.hydrate_context_json(
|
||||
conversation=conversation,
|
||||
context_json={"session_type": "application", "entry_source": "application"},
|
||||
message="apply travel expense",
|
||||
)
|
||||
fresh_context = service.hydrate_context_json(
|
||||
conversation=conversation,
|
||||
context_json={
|
||||
"session_type": "application",
|
||||
"entry_source": "application",
|
||||
"business_time_context": {
|
||||
"mode": "single",
|
||||
"start_date": "2026-05-25",
|
||||
"end_date": "2026-05-25",
|
||||
"display_value": "2026-05-25",
|
||||
},
|
||||
},
|
||||
message="apply travel expense",
|
||||
)
|
||||
|
||||
assert "business_time_context" not in stale_context
|
||||
assert fresh_context["business_time_context"]["start_date"] == "2026-05-25"
|
||||
|
||||
|
||||
def test_conversation_scope_creates_new_session_for_different_claim() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
@@ -543,3 +588,225 @@ def test_orchestrator_prompts_scene_choices_before_review_for_fresh_ambiguous_ex
|
||||
assert result.get("draft_payload") is None
|
||||
assert "请先在下面选择报销场景" in result["answer"]
|
||||
assert [item["label"] for item in result["suggested_actions"][:3]] == ["差旅费", "交通费", "住宿费"]
|
||||
|
||||
|
||||
def test_orchestrator_application_session_does_not_use_reimbursement_scene_prompt(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"app.services.runtime_chat.RuntimeChatService.complete",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
session_factory = build_session_factory()
|
||||
message = (
|
||||
"发生时间:2026-05-25\n"
|
||||
"地点:上海\n"
|
||||
"事由:支持上海国网服务器部署\n"
|
||||
"天数:3天"
|
||||
)
|
||||
with session_factory() as db:
|
||||
response = OrchestratorService(db).run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-session@example.com",
|
||||
message=message,
|
||||
context_json={
|
||||
"session_type": "application",
|
||||
"entry_source": "application",
|
||||
"name": "申请员工",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
result = response.result
|
||||
assert response.status == "blocked"
|
||||
assert response.trace_summary.scenario == "expense"
|
||||
assert "费用申请" in result["answer"]
|
||||
assert "| 发生时间 | 2026-05-25" in result["answer"]
|
||||
assert "请先在下面选择报销场景" not in result["answer"]
|
||||
assert result.get("review_payload") is None
|
||||
|
||||
|
||||
def test_orchestrator_application_session_guides_transport_amount_and_submit(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"app.services.runtime_chat.RuntimeChatService.complete",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
session_factory = build_session_factory()
|
||||
initial_message = (
|
||||
"发生时间:2026-05-25\n"
|
||||
"地点:上海\n"
|
||||
"事由:支持上海国网服务器部署\n"
|
||||
"天数:3天"
|
||||
)
|
||||
context_json = {
|
||||
"session_type": "application",
|
||||
"entry_source": "application",
|
||||
"name": "申请员工",
|
||||
"manager_name": "陈硕",
|
||||
}
|
||||
with session_factory() as db:
|
||||
service = OrchestratorService(db)
|
||||
|
||||
first = service.run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-flow@example.com",
|
||||
message=initial_message,
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
second = service.run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-flow@example.com",
|
||||
conversation_id=first.conversation_id,
|
||||
message="飞机",
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
third = service.run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-flow@example.com",
|
||||
conversation_id=first.conversation_id,
|
||||
message="预计总费用:12000元",
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
fourth = service.run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-flow@example.com",
|
||||
conversation_id=first.conversation_id,
|
||||
message="确认提交",
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
|
||||
assert first.status == "blocked"
|
||||
assert "当前还需要补充:出行方式、预计金额/预算" in first.result["answer"]
|
||||
assert [item["label"] for item in first.result["suggested_actions"]] == ["一次性补充申请信息"]
|
||||
assert first.result["suggested_actions"][0]["payload"]["prompt_prefill"] == "出行方式:\n预计总费用:"
|
||||
|
||||
assert "当前还需要补充:预计金额/预算" in second.result["answer"]
|
||||
assert [item["label"] for item in second.result["suggested_actions"]] == ["一次性补充申请信息"]
|
||||
assert second.result["suggested_actions"][0]["action_type"] == "prefill_composer"
|
||||
assert second.result["suggested_actions"][0]["payload"]["prompt_prefill"] == "预计总费用:"
|
||||
|
||||
assert "这是模拟的费用申请结果" in third.result["answer"]
|
||||
assert "| 事由 | 支持上海国网服务器部署 |" in third.result["answer"]
|
||||
assert "请核对上述信息无误" in third.result["answer"]
|
||||
assert "[确认](#application-submit)" in third.result["answer"]
|
||||
assert third.status == "blocked"
|
||||
assert third.result["requires_confirmation"] is True
|
||||
assert third.result["suggested_actions"] == []
|
||||
|
||||
assert fourth.status == "succeeded"
|
||||
assert fourth.result["clarification_required"] is False
|
||||
assert fourth.result["missing_slots"] == []
|
||||
assert "当前操作已完成,单据已经推送给 陈硕 进行审核,请耐心等待" in fourth.result["answer"]
|
||||
assert "当前状态:陈硕审核中" in fourth.result["answer"]
|
||||
assert fourth.result["suggested_actions"] == []
|
||||
application_claims = [
|
||||
claim
|
||||
for claim in db.query(ExpenseClaim).all()
|
||||
if claim.claim_no.startswith("APP-20260525-")
|
||||
]
|
||||
assert len(application_claims) == 1
|
||||
assert application_claims[0].status == "submitted"
|
||||
assert application_claims[0].approval_stage == "直属领导审批"
|
||||
assert fourth.result["draft_payload"]["claim_no"] == application_claims[0].claim_no
|
||||
|
||||
|
||||
def test_orchestrator_application_submit_bypasses_generic_operation_block(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"app.services.runtime_chat.RuntimeChatService.complete",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
session_factory = build_session_factory()
|
||||
initial_message = (
|
||||
"发生时间:2026-05-25\n"
|
||||
"地点:上海\n"
|
||||
"事由:支持上海国网服务器部署\n"
|
||||
"天数:3天"
|
||||
)
|
||||
context_json = {
|
||||
"session_type": "application",
|
||||
"entry_source": "application",
|
||||
"name": "申请员工",
|
||||
"manager_name": "陈硕",
|
||||
}
|
||||
with session_factory() as db:
|
||||
service = OrchestratorService(db)
|
||||
|
||||
first = service.run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-approval-required@example.com",
|
||||
message=initial_message,
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
service.run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-approval-required@example.com",
|
||||
conversation_id=first.conversation_id,
|
||||
message="飞机",
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
preview = service.run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-approval-required@example.com",
|
||||
conversation_id=first.conversation_id,
|
||||
message="预计总费用:12000元",
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
|
||||
def approval_required_parse_for_run(self, request, run_id): # noqa: ANN001
|
||||
return OntologyParseResult(
|
||||
scenario="expense",
|
||||
intent="operate",
|
||||
entities=[],
|
||||
permission=OntologyPermission(
|
||||
level="approval_required",
|
||||
allowed=False,
|
||||
reason="操作类请求需要人工审批确认。",
|
||||
),
|
||||
confidence=0.95,
|
||||
missing_slots=[],
|
||||
ambiguity=[],
|
||||
clarification_required=False,
|
||||
clarification_question=None,
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.ontology.SemanticOntologyService.parse_for_run",
|
||||
approval_required_parse_for_run,
|
||||
)
|
||||
submitted = service.run(
|
||||
OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id="application-approval-required@example.com",
|
||||
conversation_id=first.conversation_id,
|
||||
message="确认提交",
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
|
||||
assert preview.status == "blocked"
|
||||
assert submitted.status == "succeeded"
|
||||
assert submitted.requires_confirmation is False
|
||||
assert "操作类请求需要人工审批确认" not in submitted.result["answer"]
|
||||
assert "当前仅返回确认摘要" not in submitted.result["answer"]
|
||||
assert "当前操作已完成,单据已经推送给 陈硕 进行审核,请耐心等待" in submitted.result["answer"]
|
||||
assert submitted.result["draft_payload"]["status"] == "submitted"
|
||||
|
||||
@@ -364,6 +364,70 @@ def test_approve_claim_endpoint_routes_direct_manager_claim_to_finance_review()
|
||||
assert "manager-approve-api@example.com" not in approval_events[0]["message"]
|
||||
|
||||
|
||||
def test_approve_application_endpoint_completes_after_direct_manager_review() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
manager = Employee(
|
||||
id="mgr-application-approve-1",
|
||||
employee_no="E21002",
|
||||
name="李经理",
|
||||
email="manager-application-approve-api@example.com",
|
||||
)
|
||||
employee = Employee(
|
||||
id="emp-application-approve-1",
|
||||
employee_no="E11002",
|
||||
name="张三",
|
||||
email="zhangsan-application-approve-api@example.com",
|
||||
manager=manager,
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
id="claim-application-approve-1",
|
||||
claim_no="APP-20260525-API001",
|
||||
employee_id=employee.id,
|
||||
employee_name="张三",
|
||||
department_id="dept-1",
|
||||
department_name="交付部",
|
||||
project_code=None,
|
||||
expense_type="travel_application",
|
||||
reason="支撑国网服务器上线部署",
|
||||
location="上海",
|
||||
amount=Decimal("12000.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 25, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add_all([manager, employee, claim])
|
||||
db.commit()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/reimbursements/claims/claim-application-approve-1/approve",
|
||||
json={"opinion": "业务必要,同意申请。"},
|
||||
headers={
|
||||
"X-Auth-Username": "manager-application-approve-api@example.com",
|
||||
"X-Auth-Name": "manager-application-approve-api@example.com",
|
||||
"X-Auth-Role-Codes": "manager",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["status"] == "approved"
|
||||
assert payload["approval_stage"] == "审批完成"
|
||||
assert any(
|
||||
item["source"] == "manual_approval"
|
||||
and item["event_type"] == "expense_application_approval"
|
||||
and item["opinion"] == "业务必要,同意申请。"
|
||||
and item["operator"] == "李经理"
|
||||
and item["next_status"] == "approved"
|
||||
and item["next_approval_stage"] == "审批完成"
|
||||
for item in payload["risk_flags_json"]
|
||||
)
|
||||
|
||||
|
||||
def test_claim_item_pdf_attachment_preview_returns_generated_image(monkeypatch, tmp_path) -> None:
|
||||
preview_bytes = b"fake-preview-png"
|
||||
preview_data_url = f"data:image/png;base64,{base64.b64encode(preview_bytes).decode('ascii')}"
|
||||
|
||||
@@ -29,6 +29,41 @@ def build_session_factory() -> sessionmaker[Session]:
|
||||
return sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
|
||||
def build_application_user_agent_response(
|
||||
db: Session,
|
||||
message: str,
|
||||
*,
|
||||
history: list[dict[str, object]] | None = None,
|
||||
context_overrides: dict[str, object] | None = None,
|
||||
):
|
||||
context_json = {
|
||||
"session_type": "application",
|
||||
"entry_source": "application",
|
||||
"attachment_count": 0,
|
||||
}
|
||||
if context_overrides:
|
||||
context_json.update(context_overrides)
|
||||
if history is not None:
|
||||
context_json["conversation_history"] = history
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=message,
|
||||
user_id="pytest",
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
return UserAgentService(db).respond(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest",
|
||||
message=message,
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
tool_payload={"clarification_required": ontology.clarification_required},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_user_agent_query_returns_readable_answer_and_actions() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
@@ -137,6 +172,216 @@ def test_user_agent_knowledge_prompt_enforces_knowledge_boundary() -> None:
|
||||
assert '"knowledge_answer_evidence": []' in messages[1]["content"]
|
||||
|
||||
|
||||
def test_user_agent_application_context_uses_application_language() -> None:
|
||||
session_factory = build_session_factory()
|
||||
message = (
|
||||
"发生时间:2026-05-25\n"
|
||||
"地点:上海\n"
|
||||
"事由:支持上海国网服务器部署\n"
|
||||
"天数:3天"
|
||||
)
|
||||
context_json = {
|
||||
"session_type": "application",
|
||||
"entry_source": "application",
|
||||
"attachment_count": 0,
|
||||
}
|
||||
with session_factory() as db:
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=message,
|
||||
user_id="pytest",
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
response = UserAgentService(db).respond(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest",
|
||||
message=message,
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
tool_payload={"clarification_required": True},
|
||||
)
|
||||
)
|
||||
|
||||
assert "费用申请" in response.answer
|
||||
assert "| 字段 | 内容 |" in response.answer
|
||||
assert "| 发生时间 | 2026-05-25 至 2026-05-28 |" in response.answer
|
||||
assert "支持上海国网服务器部署" in response.answer
|
||||
assert "当前还需要补充:出行方式、预计金额/预算" in response.answer
|
||||
assert "请先在下面选择报销场景" not in response.answer
|
||||
assert response.review_payload is None
|
||||
assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"]
|
||||
assert response.suggested_actions[0].payload["prompt_prefill"] == "出行方式:\n预计总费用:"
|
||||
|
||||
|
||||
def test_user_agent_application_infers_natural_reason_and_expands_single_date() -> None:
|
||||
session_factory = build_session_factory()
|
||||
message = "发生时间:2026-05-25\n去上海出差3天,支撑上海国网服务器部署"
|
||||
with session_factory() as db:
|
||||
response = build_application_user_agent_response(db, message)
|
||||
|
||||
assert "| 发生时间 | 2026-05-25 至 2026-05-28 |" in response.answer
|
||||
assert "| 地点 | 上海 |" in response.answer
|
||||
assert "| 事由 | 支撑上海国网服务器部署 |" in response.answer
|
||||
assert "当前还需要先补充:申请事由" not in response.answer
|
||||
assert "当前还需要补充:出行方式、预计金额/预算" in response.answer
|
||||
assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"]
|
||||
|
||||
|
||||
def test_user_agent_application_uses_selected_time_and_natural_language_fields() -> None:
|
||||
session_factory = build_session_factory()
|
||||
message = "出差上海,支撑国网服务器上线部署"
|
||||
context_json = {
|
||||
"session_type": "application",
|
||||
"entry_source": "application",
|
||||
"business_time_context": {
|
||||
"mode": "single",
|
||||
"start_date": "2026-05-25",
|
||||
"end_date": "2026-05-25",
|
||||
"display_value": "2026-05-25",
|
||||
},
|
||||
}
|
||||
with session_factory() as db:
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=message,
|
||||
user_id="pytest",
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
response = UserAgentService(db).respond(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest",
|
||||
message=message,
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
tool_payload={"clarification_required": True},
|
||||
)
|
||||
)
|
||||
|
||||
assert "| 发生时间 | 2026-05-25 |" in response.answer
|
||||
assert "| 地点 | 上海 |" in response.answer
|
||||
assert "| 事由 | 支撑国网服务器上线部署 |" in response.answer
|
||||
assert "当前还需要补充:出行方式、预计金额/预算" in response.answer
|
||||
assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"]
|
||||
assert response.suggested_actions[0].action_type == "prefill_composer"
|
||||
assert response.suggested_actions[0].payload["prompt_prefill"] == "出行方式:\n预计总费用:"
|
||||
|
||||
|
||||
def test_user_agent_application_asks_amount_after_transport_choice() -> None:
|
||||
session_factory = build_session_factory()
|
||||
initial_message = (
|
||||
"发生时间:2026-05-25\n"
|
||||
"地点:上海\n"
|
||||
"事由:支持上海国网服务器部署\n"
|
||||
"天数:3天"
|
||||
)
|
||||
with session_factory() as db:
|
||||
response = build_application_user_agent_response(
|
||||
db,
|
||||
"飞机",
|
||||
history=[{"role": "user", "content": initial_message}],
|
||||
)
|
||||
|
||||
assert "| 出行方式 | 飞机 |" in response.answer
|
||||
assert "当前还需要补充:预计金额/预算" in response.answer
|
||||
assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"]
|
||||
assert response.suggested_actions[0].action_type == "prefill_composer"
|
||||
assert response.suggested_actions[0].payload["prompt_prefill"] == "预计总费用:"
|
||||
|
||||
|
||||
def test_user_agent_application_missing_base_actions_prefill_composer() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
response = build_application_user_agent_response(
|
||||
db,
|
||||
"地点:上海\n事由:支撑国网服务器部署\n天数:3天",
|
||||
)
|
||||
|
||||
assert "当前还需要补充:发生时间、出行方式、预计金额/预算" in response.answer
|
||||
assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"]
|
||||
assert response.suggested_actions[0].action_type == "prefill_composer"
|
||||
assert response.suggested_actions[0].payload["prompt_prefill"] == "申请时间段:\n出行方式:\n预计总费用:"
|
||||
|
||||
|
||||
def test_user_agent_application_builds_preview_when_amount_is_ready() -> None:
|
||||
session_factory = build_session_factory()
|
||||
initial_message = (
|
||||
"发生时间:2026-05-25\n"
|
||||
"地点:上海\n"
|
||||
"事由:支持上海国网服务器部署\n"
|
||||
"天数:3天"
|
||||
)
|
||||
with session_factory() as db:
|
||||
response = build_application_user_agent_response(
|
||||
db,
|
||||
"预计总费用:12000元",
|
||||
history=[
|
||||
{"role": "user", "content": initial_message},
|
||||
{"role": "user", "content": "飞机"},
|
||||
],
|
||||
)
|
||||
|
||||
assert "这是模拟的费用申请结果" in response.answer
|
||||
assert "| 字段 | 内容 |" in response.answer
|
||||
assert "| 事由 | 支持上海国网服务器部署 |" in response.answer
|
||||
assert "| 出行方式 | 飞机 |" in response.answer
|
||||
assert "| 预计总费用 | 12000元 |" in response.answer
|
||||
assert "请核对上述信息无误" in response.answer
|
||||
assert "[确认](#application-submit)" in response.answer
|
||||
assert response.requires_confirmation is True
|
||||
assert response.suggested_actions == []
|
||||
|
||||
|
||||
def test_user_agent_application_submit_enters_leader_review() -> None:
|
||||
session_factory = build_session_factory()
|
||||
initial_message = (
|
||||
"发生时间:2026-05-25\n"
|
||||
"地点:上海\n"
|
||||
"事由:支持上海国网服务器部署\n"
|
||||
"天数:3天"
|
||||
)
|
||||
preview_answer = (
|
||||
"这是模拟的费用申请结果,请核对:\n"
|
||||
"| 字段 | 内容 |\n"
|
||||
"| --- | --- |\n"
|
||||
"| 申请类型 | 差旅费用申请 |\n"
|
||||
"| 发生时间 | 2026-05-25 |\n"
|
||||
"| 地点 | 上海 |\n"
|
||||
"| 事由 | 支持上海国网服务器部署 |\n"
|
||||
"| 天数 | 3天 |\n"
|
||||
"| 出行方式 | 飞机 |\n"
|
||||
"| 预计总费用 | 12000元 |\n\n"
|
||||
"请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。"
|
||||
)
|
||||
with session_factory() as db:
|
||||
response = build_application_user_agent_response(
|
||||
db,
|
||||
"确认提交",
|
||||
context_overrides={"manager_name": "陈硕"},
|
||||
history=[
|
||||
{"role": "user", "content": initial_message},
|
||||
{"role": "user", "content": "飞机"},
|
||||
{"role": "user", "content": "预计总费用:12000元"},
|
||||
{"role": "assistant", "content": preview_answer},
|
||||
],
|
||||
)
|
||||
|
||||
assert "当前操作已完成,单据已经推送给 陈硕 进行审核,请耐心等待" in response.answer
|
||||
assert "当前状态:陈硕审核中" in response.answer
|
||||
assert "预算占用参考" in response.answer
|
||||
assert "APP-20260525-" in response.answer
|
||||
assert response.suggested_actions == []
|
||||
claim = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("APP-20260525-%")).one()
|
||||
assert claim.status == "submitted"
|
||||
assert claim.approval_stage == "直属领导审批"
|
||||
assert claim.expense_type == "travel_application"
|
||||
assert claim.amount == Decimal("12000.00")
|
||||
assert claim.employee_name == "pytest"
|
||||
|
||||
|
||||
def test_user_agent_knowledge_fallback_is_honest_and_personalized() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
|
||||
Reference in New Issue
Block a user