feat(ai): unify verified expense application workflow

This commit is contained in:
caoxiaozhu
2026-07-14 16:03:05 +08:00
parent 5b24630710
commit 211f85d981
33 changed files with 2793 additions and 405 deletions

View 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,
)