feat(ai): unify verified expense application workflow
This commit is contained in:
222
server/src/app/services/expense_application_preview_workflow.py
Normal file
222
server/src/app/services/expense_application_preview_workflow.py
Normal file
@@ -0,0 +1,222 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.schemas.expense_application_preview import (
|
||||
ExpenseApplicationPreviewDecisionCreate,
|
||||
ExpenseApplicationPreviewDecisionRead,
|
||||
)
|
||||
from app.schemas.ontology import OntologyParseResult, OntologyPermission
|
||||
from app.schemas.reimbursement import (
|
||||
ExpenseApplicationPreviewActionPayload,
|
||||
ExpenseApplicationPreviewActionResponse,
|
||||
ExpenseApplicationPreviewActionResult,
|
||||
)
|
||||
from app.schemas.user_agent import UserAgentRequest
|
||||
from app.services.expense_application_preview_decisions import (
|
||||
ExpenseApplicationPreviewDecisionService,
|
||||
PreviewDecisionConflictError,
|
||||
)
|
||||
from app.services.expense_application_request_context import (
|
||||
build_trusted_expense_application_context,
|
||||
)
|
||||
from app.services.user_agent import UserAgentService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ExpenseApplicationPreviewWorkflow:
|
||||
"""编排费用申请预览的签发、消费和草稿续签。"""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def issue(
|
||||
self,
|
||||
payload: ExpenseApplicationPreviewDecisionCreate,
|
||||
current_user: CurrentUserContext,
|
||||
) -> ExpenseApplicationPreviewDecisionRead:
|
||||
run_id = f"application-preview-decision:{payload.request_id}"
|
||||
request = UserAgentRequest(
|
||||
run_id=run_id,
|
||||
user_id=current_user.username or current_user.name,
|
||||
message=payload.message,
|
||||
ontology=OntologyParseResult(run_id=run_id),
|
||||
context_json=build_trusted_expense_application_context(current_user),
|
||||
tool_payload={},
|
||||
selected_capability_codes=[],
|
||||
degraded=False,
|
||||
requires_confirmation=False,
|
||||
)
|
||||
try:
|
||||
facts = UserAgentService(self.db)._resolve_expense_application_facts(request)
|
||||
issued = ExpenseApplicationPreviewDecisionService(self.db).issue(
|
||||
facts,
|
||||
current_user,
|
||||
conversation_id=payload.conversation_id or "",
|
||||
request_id=payload.request_id,
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(issued.decision)
|
||||
except (PreviewDecisionConflictError, ValueError):
|
||||
self.db.rollback()
|
||||
raise
|
||||
|
||||
return ExpenseApplicationPreviewDecisionRead(
|
||||
decision_id=issued.decision.id,
|
||||
decision_source=issued.decision.decision_source,
|
||||
expires_at=issued.decision.expires_at,
|
||||
application_preview={
|
||||
"fields": issued.fields,
|
||||
"decisionId": issued.decision.id,
|
||||
"decisionSource": issued.decision.decision_source,
|
||||
"decisionExpiresAt": issued.decision.expires_at.isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
def execute(
|
||||
self,
|
||||
payload: ExpenseApplicationPreviewActionPayload,
|
||||
current_user: CurrentUserContext,
|
||||
) -> ExpenseApplicationPreviewActionResponse:
|
||||
request = self._build_action_request(payload, current_user)
|
||||
try:
|
||||
user_agent_service = UserAgentService(self.db)
|
||||
facts = user_agent_service._resolve_expense_application_facts(request)
|
||||
resolved_step = user_agent_service._resolve_expense_application_step(request, facts)
|
||||
resolved_action = "save_draft" if resolved_step == "draft" else "submit"
|
||||
if payload.action_type and payload.action_type != resolved_action:
|
||||
raise ValueError("动作类型与申请内容不一致。")
|
||||
|
||||
preview_decision_service = ExpenseApplicationPreviewDecisionService(self.db)
|
||||
preview_decision = None
|
||||
if payload.decision_id:
|
||||
preview_decision = preview_decision_service.require_for_action(
|
||||
payload.decision_id,
|
||||
current_user,
|
||||
conversation_id=payload.conversation_id or "",
|
||||
request_id=payload.request_id or "",
|
||||
action_type=resolved_action,
|
||||
final_values=facts,
|
||||
)
|
||||
else:
|
||||
preview_decision_service.reject_active_decision_bypass(
|
||||
current_user,
|
||||
conversation_id=payload.conversation_id or "",
|
||||
)
|
||||
|
||||
consumed_preview_decision_id = (
|
||||
preview_decision.id if preview_decision is not None else ""
|
||||
)
|
||||
user_agent_response = user_agent_service._build_expense_application_response(
|
||||
request,
|
||||
risk_flags=[],
|
||||
learning_current_user=current_user,
|
||||
learning_preview_decision=preview_decision,
|
||||
learning_action_request_id=payload.request_id or "",
|
||||
)
|
||||
except (PreviewDecisionConflictError, ValueError):
|
||||
self.db.rollback()
|
||||
raise
|
||||
|
||||
next_preview_decision = None
|
||||
if (
|
||||
preview_decision is not None
|
||||
and resolved_action == "save_draft"
|
||||
and user_agent_response.draft_payload is not None
|
||||
):
|
||||
try:
|
||||
next_preview_decision = (
|
||||
ExpenseApplicationPreviewDecisionService(self.db)
|
||||
.issue(
|
||||
facts,
|
||||
current_user,
|
||||
conversation_id=payload.conversation_id or "",
|
||||
request_id=(
|
||||
f"next:{consumed_preview_decision_id}:{payload.request_id or ''}"
|
||||
)[:120],
|
||||
decision_source="server_draft",
|
||||
)
|
||||
.decision
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(next_preview_decision)
|
||||
except Exception as error:
|
||||
# 主业务动作已提交;续签失败只能降级,不能反转保存草稿结果。
|
||||
self.db.rollback()
|
||||
next_preview_decision = None
|
||||
logger.warning(
|
||||
"费用申请草稿已保存,但后续预览决策签发失败:decision_id=%s error_type=%s",
|
||||
consumed_preview_decision_id,
|
||||
type(error).__name__,
|
||||
)
|
||||
|
||||
return ExpenseApplicationPreviewActionResponse(
|
||||
status="succeeded",
|
||||
conversation_id=payload.conversation_id,
|
||||
result=ExpenseApplicationPreviewActionResult(
|
||||
message=user_agent_response.answer,
|
||||
answer=user_agent_response.answer,
|
||||
suggested_actions=[
|
||||
action.model_dump(mode="json")
|
||||
for action in user_agent_response.suggested_actions
|
||||
],
|
||||
risk_flags=user_agent_response.risk_flags,
|
||||
requires_confirmation=user_agent_response.requires_confirmation,
|
||||
draft_payload=(
|
||||
user_agent_response.draft_payload.model_dump(mode="json")
|
||||
if user_agent_response.draft_payload is not None
|
||||
else None
|
||||
),
|
||||
decision_id=(
|
||||
next_preview_decision.id if next_preview_decision is not None else None
|
||||
),
|
||||
decision_expires_at=(
|
||||
next_preview_decision.expires_at
|
||||
if next_preview_decision is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_action_request(
|
||||
payload: ExpenseApplicationPreviewActionPayload,
|
||||
current_user: CurrentUserContext,
|
||||
) -> UserAgentRequest:
|
||||
context_json = build_trusted_expense_application_context(
|
||||
current_user,
|
||||
payload.context_json,
|
||||
)
|
||||
if payload.action_type == "save_draft":
|
||||
context_json["application_action"] = "save_draft"
|
||||
context_json["application_save_mode"] = True
|
||||
elif payload.action_type == "submit":
|
||||
context_json.pop("application_action", None)
|
||||
context_json.pop("application_save_mode", None)
|
||||
|
||||
run_id = f"application-preview-action:{payload.conversation_id or current_user.username}"
|
||||
return UserAgentRequest(
|
||||
run_id=run_id,
|
||||
user_id=current_user.username or current_user.name,
|
||||
message=payload.message,
|
||||
ontology=OntologyParseResult(
|
||||
scenario="expense",
|
||||
intent="operate",
|
||||
permission=OntologyPermission(
|
||||
level="approval_required",
|
||||
allowed=True,
|
||||
reason="application preview fast action",
|
||||
),
|
||||
confidence=1.0,
|
||||
run_id=run_id,
|
||||
),
|
||||
context_json=context_json,
|
||||
tool_payload={},
|
||||
selected_capability_codes=[],
|
||||
degraded=False,
|
||||
requires_confirmation=False,
|
||||
)
|
||||
Reference in New Issue
Block a user