feat(ai): unify verified expense application workflow
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
@@ -11,24 +10,18 @@ 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.expense_application_preview_workflow import (
|
||||
ExpenseApplicationPreviewWorkflow,
|
||||
)
|
||||
from app.services.user_agent import UserAgentService
|
||||
|
||||
router = APIRouter(prefix="/reimbursements")
|
||||
logger = logging.getLogger(__name__)
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
|
||||
|
||||
@@ -48,48 +41,13 @@ def issue_expense_application_preview_decision(
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
) -> ExpenseApplicationPreviewDecisionRead:
|
||||
run_id = f"application-preview-decision:{payload.request_id}"
|
||||
context_json = build_trusted_expense_application_context(current_user)
|
||||
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=context_json,
|
||||
tool_payload={},
|
||||
selected_capability_codes=[],
|
||||
degraded=False,
|
||||
requires_confirmation=False,
|
||||
)
|
||||
try:
|
||||
facts = UserAgentService(db)._resolve_expense_application_facts(request)
|
||||
issued = ExpenseApplicationPreviewDecisionService(db).issue(
|
||||
facts,
|
||||
current_user,
|
||||
conversation_id=payload.conversation_id or "",
|
||||
request_id=payload.request_id,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(issued.decision)
|
||||
return ExpenseApplicationPreviewWorkflow(db).issue(payload, current_user)
|
||||
except PreviewDecisionConflictError as error:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||
except ValueError as error:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||
|
||||
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(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/application-preview-action",
|
||||
@@ -104,127 +62,9 @@ def run_application_preview_action(
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
) -> ExpenseApplicationPreviewActionResponse:
|
||||
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}"
|
||||
request = 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,
|
||||
)
|
||||
try:
|
||||
user_agent_service = UserAgentService(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(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 "",
|
||||
)
|
||||
return ExpenseApplicationPreviewWorkflow(db).execute(payload, current_user)
|
||||
except PreviewDecisionConflictError as error:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||
except ValueError as error:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||
|
||||
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(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
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(next_preview_decision)
|
||||
except Exception as error:
|
||||
# 业务动作已经在 UserAgent 内提交;续签仅是派生能力,失败不能反转成功结果。
|
||||
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
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -5,7 +5,12 @@ from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.api.deps import (
|
||||
CurrentUserContext,
|
||||
get_current_user,
|
||||
get_db,
|
||||
get_optional_current_user,
|
||||
)
|
||||
from app.schemas.common import ErrorResponse
|
||||
from app.schemas.orchestrator import (
|
||||
ConversationDeleteResponse,
|
||||
@@ -18,6 +23,11 @@ from app.services.orchestrator import OrchestratorService
|
||||
|
||||
router = APIRouter(prefix="/orchestrator")
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
|
||||
OptionalCurrentUser = Annotated[
|
||||
CurrentUserContext | None,
|
||||
Depends(get_optional_current_user),
|
||||
]
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -32,9 +42,24 @@ DbSession = Annotated[Session, Depends(get_db)]
|
||||
}
|
||||
},
|
||||
)
|
||||
def run_orchestrator(payload: OrchestratorRequest, db: DbSession) -> OrchestratorResponse:
|
||||
def run_orchestrator(
|
||||
payload: OrchestratorRequest,
|
||||
db: DbSession,
|
||||
current_user: OptionalCurrentUser,
|
||||
) -> OrchestratorResponse:
|
||||
if current_user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="请先登录后再使用智能助手。",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
if payload.source in {"schedule", "system_event"} and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="只有平台管理员可以从外部触发调度或系统事件。",
|
||||
)
|
||||
try:
|
||||
return OrchestratorService(db).run(payload)
|
||||
return OrchestratorService(db).run(payload, current_user=current_user)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
|
||||
@@ -46,14 +71,25 @@ def run_orchestrator(payload: OrchestratorRequest, db: DbSession) -> Orchestrato
|
||||
description="返回当前用户最近一段可恢复的对话会话及完整消息历史。",
|
||||
)
|
||||
def get_latest_conversation(
|
||||
user_id: Annotated[str, Query(min_length=1, description="当前用户 ID。")],
|
||||
db: DbSession,
|
||||
session_type: Annotated[str | None, Query(description="会话类型,例如 expense / knowledge。")] = None,
|
||||
prefer_recoverable: Annotated[bool, Query(description="是否优先返回最近一条可恢复的会话。")] = False,
|
||||
current_user: CurrentUser,
|
||||
user_id: Annotated[
|
||||
str | None,
|
||||
Query(description="兼容旧客户端;服务端始终使用当前登录用户。"),
|
||||
] = None,
|
||||
session_type: Annotated[
|
||||
str | None,
|
||||
Query(description="会话类型,例如 expense / knowledge。"),
|
||||
] = None,
|
||||
prefer_recoverable: Annotated[
|
||||
bool,
|
||||
Query(description="是否优先返回最近一条可恢复的会话。"),
|
||||
] = False,
|
||||
) -> ConversationLookupResponse:
|
||||
service = AgentConversationService(db)
|
||||
conversation = service.get_latest_conversation_for_user(
|
||||
user_id=user_id,
|
||||
user_id=current_user.username,
|
||||
tenant_id=current_user.tenant_id,
|
||||
source="user_message",
|
||||
session_type=session_type,
|
||||
prefer_recoverable=prefer_recoverable,
|
||||
@@ -75,12 +111,18 @@ def get_latest_conversation(
|
||||
)
|
||||
def delete_single_conversation(
|
||||
conversation_id: str,
|
||||
user_id: Annotated[str, Query(min_length=1, description="当前用户 ID。")],
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
user_id: Annotated[
|
||||
str | None,
|
||||
Query(description="兼容旧客户端;服务端始终使用当前登录用户。"),
|
||||
] = None,
|
||||
) -> ConversationDeleteResponse:
|
||||
del user_id
|
||||
deleted_count = AgentConversationService(db).delete_conversation(
|
||||
conversation_id=conversation_id,
|
||||
user_id=user_id,
|
||||
user_id=current_user.username,
|
||||
tenant_id=current_user.tenant_id,
|
||||
source="user_message",
|
||||
)
|
||||
return ConversationDeleteResponse(deleted_count=deleted_count)
|
||||
@@ -93,12 +135,20 @@ def delete_single_conversation(
|
||||
description="删除当前用户在智能体工作台中的全部历史会话,用于显式开启全新对话。",
|
||||
)
|
||||
def delete_user_conversations(
|
||||
user_id: Annotated[str, Query(min_length=1, description="当前用户 ID。")],
|
||||
db: DbSession,
|
||||
session_type: Annotated[str | None, Query(description="可选,会话类型,例如 expense / knowledge。")] = None,
|
||||
current_user: CurrentUser,
|
||||
user_id: Annotated[
|
||||
str | None,
|
||||
Query(description="兼容旧客户端;服务端始终使用当前登录用户。"),
|
||||
] = None,
|
||||
session_type: Annotated[
|
||||
str | None,
|
||||
Query(description="可选,会话类型,例如 expense / knowledge。"),
|
||||
] = None,
|
||||
) -> ConversationDeleteResponse:
|
||||
deleted_count = AgentConversationService(db).delete_user_conversations(
|
||||
user_id=user_id,
|
||||
user_id=current_user.username,
|
||||
tenant_id=current_user.tenant_id,
|
||||
source="user_message",
|
||||
session_type=session_type,
|
||||
)
|
||||
|
||||
@@ -160,7 +160,16 @@ class StewardActionExecuteRequest(BaseModel):
|
||||
action_step: StewardActionStep | None = Field(default=None, description="规划侧生成的动作步骤快照。")
|
||||
confirmed: bool = Field(default=False, description="用户是否已确认执行该动作。")
|
||||
context_json: dict[str, Any] = Field(default_factory=dict, description="前端或运行时补充上下文。")
|
||||
client_trace_id: str = Field(default="", description="前端幂等或追踪 ID。")
|
||||
client_trace_id: str = Field(
|
||||
default="",
|
||||
max_length=120,
|
||||
description="前端稳定幂等 ID;申请预览签发、保存和提交动作必须显式提供。",
|
||||
)
|
||||
decision_id: str = Field(
|
||||
default="",
|
||||
max_length=36,
|
||||
description="服务端签发的申请预览决策 ID;保存和提交申请时必须显式提供。",
|
||||
)
|
||||
|
||||
|
||||
class StewardActionExecuteResponse(BaseModel):
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.models.agent_conversation import AgentConversation, AgentConversationMe
|
||||
from app.services.settings import SettingsService
|
||||
|
||||
STATEFUL_CONTEXT_KEYS = (
|
||||
"tenant_id",
|
||||
"session_type",
|
||||
"entry_source",
|
||||
"request_context",
|
||||
@@ -21,6 +22,7 @@ STATEFUL_CONTEXT_KEYS = (
|
||||
"review_form_values",
|
||||
"steward_state",
|
||||
"business_time_context",
|
||||
"application_preview_decision",
|
||||
)
|
||||
REVIEW_FLOW_CONTEXT_KEYS = {
|
||||
"draft_claim_id",
|
||||
@@ -83,12 +85,18 @@ class AgentConversationService:
|
||||
|
||||
normalized_id = str(conversation_id or "").strip()
|
||||
normalized_user_id = str(user_id or "").strip() or None
|
||||
incoming_tenant_id = self._normalize_tenant_id(context_json.get("tenant_id"))
|
||||
incoming_session_type = str(context_json.get("session_type") or "").strip() or "expense"
|
||||
incoming_draft_claim_id = self._resolve_draft_claim_id(context_json)
|
||||
conversation = self.get_conversation(normalized_id) if normalized_id else None
|
||||
if conversation is not None and conversation.user_id != normalized_user_id:
|
||||
normalized_id = ""
|
||||
conversation = None
|
||||
if conversation is not None and incoming_tenant_id:
|
||||
existing_tenant_id = self._conversation_tenant_id(conversation)
|
||||
if existing_tenant_id != incoming_tenant_id:
|
||||
normalized_id = ""
|
||||
conversation = None
|
||||
if conversation is not None:
|
||||
existing_session_type = str((conversation.state_json or {}).get("session_type") or "").strip() or "expense"
|
||||
if existing_session_type != incoming_session_type:
|
||||
@@ -189,6 +197,7 @@ class AgentConversationService:
|
||||
self,
|
||||
*,
|
||||
user_id: str | None,
|
||||
tenant_id: str | None = None,
|
||||
source: str | None = "user_message",
|
||||
session_type: str | None = None,
|
||||
prefer_recoverable: bool = False,
|
||||
@@ -204,6 +213,13 @@ class AgentConversationService:
|
||||
stmt = stmt.where(AgentConversation.source == source)
|
||||
stmt = stmt.order_by(AgentConversation.updated_at.desc(), AgentConversation.created_at.desc())
|
||||
conversations = list(self.db.scalars(stmt).all())
|
||||
normalized_tenant_id = self._normalize_tenant_id(tenant_id)
|
||||
if normalized_tenant_id:
|
||||
conversations = [
|
||||
conversation
|
||||
for conversation in conversations
|
||||
if self._conversation_tenant_id(conversation) == normalized_tenant_id
|
||||
]
|
||||
normalized_session_type = str(session_type or "").strip()
|
||||
if not normalized_session_type:
|
||||
return conversations[0] if conversations else None
|
||||
@@ -448,6 +464,7 @@ class AgentConversationService:
|
||||
self,
|
||||
*,
|
||||
user_id: str | None,
|
||||
tenant_id: str | None = None,
|
||||
source: str | None = "user_message",
|
||||
session_type: str | None = None,
|
||||
) -> int:
|
||||
@@ -459,6 +476,13 @@ class AgentConversationService:
|
||||
if source:
|
||||
stmt = stmt.where(AgentConversation.source == source)
|
||||
conversations = list(self.db.scalars(stmt).all())
|
||||
normalized_tenant_id = self._normalize_tenant_id(tenant_id)
|
||||
if normalized_tenant_id:
|
||||
conversations = [
|
||||
conversation
|
||||
for conversation in conversations
|
||||
if self._conversation_tenant_id(conversation) == normalized_tenant_id
|
||||
]
|
||||
normalized_session_type = str(session_type or "").strip()
|
||||
if normalized_session_type:
|
||||
conversations = [
|
||||
@@ -513,6 +537,7 @@ class AgentConversationService:
|
||||
*,
|
||||
conversation_id: str | None,
|
||||
user_id: str | None = None,
|
||||
tenant_id: str | None = None,
|
||||
source: str | None = "user_message",
|
||||
) -> int:
|
||||
normalized_id = str(conversation_id or "").strip()
|
||||
@@ -527,6 +552,13 @@ class AgentConversationService:
|
||||
if normalized_user_id and str(conversation.user_id or "").strip() != normalized_user_id:
|
||||
return 0
|
||||
|
||||
normalized_tenant_id = self._normalize_tenant_id(tenant_id)
|
||||
if (
|
||||
normalized_tenant_id
|
||||
and self._conversation_tenant_id(conversation) != normalized_tenant_id
|
||||
):
|
||||
return 0
|
||||
|
||||
normalized_source = str(source or "").strip()
|
||||
if normalized_source and str(conversation.source or "").strip() != normalized_source:
|
||||
return 0
|
||||
@@ -609,6 +641,14 @@ class AgentConversationService:
|
||||
return len(value) == 0
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _normalize_tenant_id(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
@classmethod
|
||||
def _conversation_tenant_id(cls, conversation: AgentConversation) -> str:
|
||||
return cls._normalize_tenant_id((conversation.state_json or {}).get("tenant_id"))
|
||||
|
||||
@staticmethod
|
||||
def _resolve_title(context_json: dict[str, Any]) -> str | None:
|
||||
request_context = context_json.get("request_context")
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.ai_application_preview import AIApplicationPreviewDecision
|
||||
from app.models.auth_session import AuthSession
|
||||
from app.models.expense_case import BusinessEvent
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.services.expense_application_snapshot import (
|
||||
@@ -51,6 +52,13 @@ class ExpenseApplicationPreviewDecisionService:
|
||||
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
|
||||
actor_id = self._actor_id(current_user)
|
||||
auth_session_id = self._auth_session_id(current_user)
|
||||
# 真实 Bearer 会话存在时锁住会话行,串行化同一登录会话内的并发签发,
|
||||
# 避免两个不同 request_id 同时越过 active snapshot 查询。
|
||||
self.db.scalar(
|
||||
select(AuthSession.id)
|
||||
.where(AuthSession.id == auth_session_id)
|
||||
.with_for_update()
|
||||
)
|
||||
normalized_request_id = self._required_text(request_id, "预览签发请求 ID", 120)
|
||||
values = safe_fact_snapshot(facts)
|
||||
if not values.get("time") and not values.get("location") and not values.get("reason"):
|
||||
@@ -75,16 +83,39 @@ class ExpenseApplicationPreviewDecisionService:
|
||||
return IssuedPreviewDecision(existing, preview_response_fields(facts))
|
||||
|
||||
now = datetime.now(UTC)
|
||||
source = decision_source or self._decision_source(facts)
|
||||
normalized_conversation_id = str(conversation_id or "").strip()[:120]
|
||||
snapshot_fingerprint = hmac_fingerprint(
|
||||
values,
|
||||
key_version=FINGERPRINT_KEY_VERSION,
|
||||
)
|
||||
active_same_snapshot = self.db.scalar(
|
||||
select(AIApplicationPreviewDecision)
|
||||
.where(
|
||||
AIApplicationPreviewDecision.tenant_id == tenant_id,
|
||||
AIApplicationPreviewDecision.actor_id == actor_id,
|
||||
AIApplicationPreviewDecision.auth_session_id == auth_session_id,
|
||||
AIApplicationPreviewDecision.conversation_id == normalized_conversation_id,
|
||||
AIApplicationPreviewDecision.status == "issued",
|
||||
AIApplicationPreviewDecision.expires_at > now,
|
||||
AIApplicationPreviewDecision.fingerprint_key_version
|
||||
== FINGERPRINT_KEY_VERSION,
|
||||
AIApplicationPreviewDecision.snapshot_fingerprint == snapshot_fingerprint,
|
||||
)
|
||||
.order_by(AIApplicationPreviewDecision.created_at.desc())
|
||||
.with_for_update()
|
||||
)
|
||||
if active_same_snapshot is not None:
|
||||
return IssuedPreviewDecision(
|
||||
active_same_snapshot,
|
||||
preview_response_fields(facts),
|
||||
)
|
||||
|
||||
source = decision_source or self._decision_source(facts)
|
||||
decision = AIApplicationPreviewDecision(
|
||||
tenant_id=tenant_id,
|
||||
actor_id=actor_id,
|
||||
auth_session_id=auth_session_id,
|
||||
conversation_id=str(conversation_id or "").strip()[:120],
|
||||
conversation_id=normalized_conversation_id,
|
||||
decision_type="expense_application_prefill",
|
||||
decision_source=source,
|
||||
field_keys_json=sorted(values),
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -6,6 +6,7 @@ from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.core.agent_enums import (
|
||||
AgentAssetStatus,
|
||||
AgentAssetType,
|
||||
@@ -25,21 +26,24 @@ from app.schemas.orchestrator import (
|
||||
from app.schemas.user_agent import UserAgentRequest
|
||||
from app.services.agent_assets import AgentAssetService
|
||||
from app.services.agent_conversations import AgentConversationService
|
||||
from app.services.auth import AuthService
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.agent_foundation import AgentFoundationService
|
||||
from app.services.agent_runs import AgentRunService
|
||||
from app.services.agent_traces import AgentTraceService
|
||||
from app.services.auth import AuthService
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
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_application_workflow import (
|
||||
OrchestratorExpenseApplicationWorkflow,
|
||||
)
|
||||
from app.services.orchestrator_expense_query import OrchestratorDatabaseQueryBuilder
|
||||
from app.services.user_agent import UserAgentService
|
||||
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")
|
||||
|
||||
@@ -51,6 +55,7 @@ SCENARIO_TO_DOMAIN = {
|
||||
"unknown": "system",
|
||||
}
|
||||
|
||||
|
||||
class OrchestratorService:
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
@@ -62,6 +67,7 @@ class OrchestratorService:
|
||||
self.trace_service = AgentTraceService(db)
|
||||
self.ontology_service = SemanticOntologyService(db)
|
||||
self.user_agent_service = UserAgentService(db)
|
||||
self.expense_application_workflow = OrchestratorExpenseApplicationWorkflow(db)
|
||||
self.database_query_builder = OrchestratorDatabaseQueryBuilder(db)
|
||||
self.execution_engine = OrchestratorExecutionEngine(
|
||||
db=db,
|
||||
@@ -73,7 +79,14 @@ class OrchestratorService:
|
||||
trace_service=self.trace_service,
|
||||
)
|
||||
|
||||
def run(self, payload: OrchestratorRequest) -> OrchestratorResponse:
|
||||
def run(
|
||||
self,
|
||||
payload: OrchestratorRequest,
|
||||
*,
|
||||
current_user: CurrentUserContext | None = None,
|
||||
) -> OrchestratorResponse:
|
||||
if current_user is not None:
|
||||
payload = self._build_authenticated_user_payload(payload, current_user)
|
||||
AgentFoundationService(self.db).ensure_foundation_ready()
|
||||
context_json = self._hydrate_user_context(
|
||||
user_id=payload.user_id,
|
||||
@@ -230,6 +243,24 @@ class OrchestratorService:
|
||||
route_json["task_code"] = task_asset.code
|
||||
route_json["task_name"] = task_asset.name
|
||||
|
||||
authenticated_application_outcome = None
|
||||
if (
|
||||
is_expense_application_context
|
||||
and current_user is not None
|
||||
and ontology.permission.level != AgentPermissionLevel.FORBIDDEN.value
|
||||
):
|
||||
authenticated_application_outcome = (
|
||||
self.expense_application_workflow.execute(
|
||||
payload=payload,
|
||||
current_user=current_user,
|
||||
run_id=run.run_id,
|
||||
conversation_id=conversation_id,
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
selected_capability_codes=selected_capability_codes,
|
||||
)
|
||||
)
|
||||
|
||||
if ontology.permission.level == AgentPermissionLevel.FORBIDDEN.value:
|
||||
outcome = ExecutionOutcome(
|
||||
status=AgentRunStatus.BLOCKED.value,
|
||||
@@ -246,6 +277,11 @@ class OrchestratorService:
|
||||
route_reason = "permission_forbidden"
|
||||
route_json["stage"] = "blocked"
|
||||
route_json["route_reason"] = route_reason
|
||||
elif authenticated_application_outcome is not None:
|
||||
outcome = authenticated_application_outcome
|
||||
route_reason = "authenticated_application_preview_workflow"
|
||||
route_json["stage"] = "application_preview_workflow"
|
||||
route_json["route_reason"] = route_reason
|
||||
elif ontology.clarification_required:
|
||||
if selected_agent == AgentName.USER_AGENT.value and ontology.scenario == "expense":
|
||||
clarification_response = self.user_agent_service.respond(
|
||||
@@ -502,6 +538,54 @@ class OrchestratorService:
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_authenticated_user_payload(
|
||||
payload: OrchestratorRequest,
|
||||
current_user: CurrentUserContext,
|
||||
) -> OrchestratorRequest:
|
||||
"""用登录态覆盖所有身份字段,并丢弃客户端伪造的可信预览状态。"""
|
||||
context_json = dict(payload.context_json or {})
|
||||
for key in (
|
||||
"application_preview",
|
||||
"application_preview_decision",
|
||||
"decision_id",
|
||||
"preview_decision_id",
|
||||
"auth_session_id",
|
||||
"requested_by_username",
|
||||
"requested_by_name",
|
||||
"actor",
|
||||
"actor_id",
|
||||
):
|
||||
context_json.pop(key, None)
|
||||
context_json.update(
|
||||
{
|
||||
"tenant_id": current_user.tenant_id,
|
||||
"role_codes": list(current_user.role_codes),
|
||||
"is_admin": current_user.is_admin,
|
||||
"username": current_user.username,
|
||||
"user_id": current_user.username,
|
||||
"name": current_user.name,
|
||||
"department": current_user.department_name,
|
||||
"department_name": current_user.department_name,
|
||||
"cost_center": current_user.cost_center,
|
||||
"position": current_user.position,
|
||||
"grade": current_user.grade,
|
||||
"employee_no": current_user.employee_no,
|
||||
"employee_id": current_user.employee_id,
|
||||
"manager_name": current_user.manager_name,
|
||||
"requested_by_username": current_user.username,
|
||||
"requested_by_name": current_user.name,
|
||||
"actor": current_user.username,
|
||||
"actor_id": current_user.username,
|
||||
}
|
||||
)
|
||||
return payload.model_copy(
|
||||
update={
|
||||
"user_id": current_user.username,
|
||||
"context_json": context_json,
|
||||
}
|
||||
)
|
||||
|
||||
def _record_trace_event(self, **kwargs: Any) -> None:
|
||||
self.trace_service.record_event_safe(**kwargs)
|
||||
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.core.agent_enums import AgentRunStatus
|
||||
from app.schemas.expense_application_preview import ExpenseApplicationPreviewDecisionCreate
|
||||
from app.schemas.ontology import OntologyParseResult
|
||||
from app.schemas.orchestrator import OrchestratorRequest
|
||||
from app.schemas.reimbursement import ExpenseApplicationPreviewActionPayload
|
||||
from app.schemas.user_agent import UserAgentRequest
|
||||
from app.services.expense_application_preview_decisions import PreviewDecisionConflictError
|
||||
from app.services.expense_application_preview_workflow import ExpenseApplicationPreviewWorkflow
|
||||
from app.services.expense_application_snapshot import (
|
||||
canonical_preview_fields,
|
||||
content_fingerprint,
|
||||
preview_response_fields,
|
||||
safe_fact_snapshot,
|
||||
)
|
||||
from app.services.orchestrator_execution import ExecutionOutcome, OrchestratorExecutionEngine
|
||||
from app.services.user_agent import UserAgentService
|
||||
from app.services.user_agent_application import (
|
||||
APPLICATION_SAVE_DRAFT_KEYWORDS,
|
||||
APPLICATION_SHORT_CONFIRMATIONS,
|
||||
APPLICATION_SUBMIT_KEYWORDS,
|
||||
)
|
||||
|
||||
|
||||
class OrchestratorExpenseApplicationWorkflow:
|
||||
"""为通用 Orchestrator 提供认证的申请预览签发、消费和会话状态编排。"""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
self.user_agent_service = UserAgentService(db)
|
||||
|
||||
def execute(
|
||||
self,
|
||||
*,
|
||||
payload: OrchestratorRequest,
|
||||
current_user: CurrentUserContext,
|
||||
run_id: str,
|
||||
conversation_id: str | None,
|
||||
ontology: OntologyParseResult,
|
||||
context_json: dict[str, Any],
|
||||
selected_capability_codes: list[str],
|
||||
) -> ExecutionOutcome | None:
|
||||
application_context = dict(context_json or {})
|
||||
decision_state = self._resolve_decision_state(application_context)
|
||||
# 申请事实只允许来自服务端会话历史、当前输入和登录态,不能反向信任客户端 preview。
|
||||
application_context.pop("application_preview", None)
|
||||
application_context.pop("application_preview_decision", None)
|
||||
request = UserAgentRequest(
|
||||
run_id=run_id,
|
||||
user_id=current_user.username,
|
||||
message=payload.message or "",
|
||||
ontology=ontology,
|
||||
context_json=application_context,
|
||||
tool_payload={},
|
||||
selected_capability_codes=selected_capability_codes,
|
||||
degraded=False,
|
||||
requires_confirmation=False,
|
||||
)
|
||||
facts = self.user_agent_service._resolve_expense_application_facts(request)
|
||||
step = self.user_agent_service._resolve_expense_application_step(request, facts)
|
||||
requested_action = self._resolve_requested_action(payload.message, decision_state)
|
||||
if requested_action:
|
||||
decision_preview = decision_state.get("application_preview")
|
||||
if isinstance(decision_preview, dict):
|
||||
for key, value in canonical_preview_fields(decision_preview).items():
|
||||
if not str(facts.get(key) or "").strip():
|
||||
facts[key] = value
|
||||
step = "draft" if requested_action == "save_draft" else "submitted"
|
||||
if step not in {"preview", "draft", "submitted"}:
|
||||
return None
|
||||
|
||||
current_preview = {"fields": preview_response_fields(facts)}
|
||||
if step == "preview":
|
||||
return self._issue_or_refresh_preview(
|
||||
request=request,
|
||||
facts=facts,
|
||||
decision_state=decision_state,
|
||||
current_preview=current_preview,
|
||||
current_user=current_user,
|
||||
conversation_id=conversation_id,
|
||||
context_json=context_json,
|
||||
)
|
||||
return self._execute_action(
|
||||
payload=payload,
|
||||
current_user=current_user,
|
||||
conversation_id=conversation_id,
|
||||
application_context=application_context,
|
||||
context_json=context_json,
|
||||
decision_state=decision_state,
|
||||
current_preview=current_preview,
|
||||
facts=facts,
|
||||
step=step,
|
||||
)
|
||||
|
||||
def _issue_or_refresh_preview(
|
||||
self,
|
||||
*,
|
||||
request: UserAgentRequest,
|
||||
facts: dict[str, Any],
|
||||
decision_state: dict[str, Any],
|
||||
current_preview: dict[str, Any],
|
||||
current_user: CurrentUserContext,
|
||||
conversation_id: str | None,
|
||||
context_json: dict[str, Any],
|
||||
) -> ExecutionOutcome:
|
||||
preview_response = self.user_agent_service._build_expense_application_response(
|
||||
request,
|
||||
risk_flags=[],
|
||||
)
|
||||
result = OrchestratorExecutionEngine._build_user_agent_result(
|
||||
preview_response,
|
||||
degraded=False,
|
||||
)
|
||||
if self._is_issued_state(decision_state):
|
||||
issued_preview = {
|
||||
**current_preview,
|
||||
"decisionId": str(decision_state.get("decision_id") or "").strip(),
|
||||
"decisionSource": str(decision_state.get("decision_source") or "").strip(),
|
||||
"decisionExpiresAt": str(decision_state.get("expires_at") or "").strip(),
|
||||
}
|
||||
decision_id = issued_preview["decisionId"]
|
||||
decision_source = issued_preview["decisionSource"]
|
||||
expires_at = issued_preview["decisionExpiresAt"]
|
||||
else:
|
||||
issue_response = ExpenseApplicationPreviewWorkflow(self.db).issue(
|
||||
ExpenseApplicationPreviewDecisionCreate(
|
||||
message=self._build_facts_message(facts),
|
||||
conversation_id=conversation_id,
|
||||
request_id=self._build_issue_request_id(conversation_id, facts),
|
||||
),
|
||||
current_user,
|
||||
)
|
||||
issued_preview = dict(issue_response.application_preview or {})
|
||||
decision_id = issue_response.decision_id
|
||||
decision_source = issue_response.decision_source
|
||||
expires_at = issue_response.expires_at.isoformat()
|
||||
|
||||
context_json["application_preview_decision"] = {
|
||||
"status": "issued",
|
||||
"decision_id": decision_id,
|
||||
"decision_source": decision_source,
|
||||
"expires_at": expires_at,
|
||||
"application_preview": issued_preview,
|
||||
}
|
||||
result.update(
|
||||
{
|
||||
"application_preview": issued_preview,
|
||||
"decision_id": decision_id,
|
||||
"decision_source": decision_source,
|
||||
"decision_expires_at": expires_at,
|
||||
}
|
||||
)
|
||||
return ExecutionOutcome(
|
||||
status=AgentRunStatus.SUCCEEDED.value,
|
||||
result=result,
|
||||
degraded=False,
|
||||
tool_count=0,
|
||||
failed_tool_count=0,
|
||||
)
|
||||
|
||||
def _execute_action(
|
||||
self,
|
||||
*,
|
||||
payload: OrchestratorRequest,
|
||||
current_user: CurrentUserContext,
|
||||
conversation_id: str | None,
|
||||
application_context: dict[str, Any],
|
||||
context_json: dict[str, Any],
|
||||
decision_state: dict[str, Any],
|
||||
current_preview: dict[str, Any],
|
||||
facts: dict[str, Any],
|
||||
step: str,
|
||||
) -> ExecutionOutcome:
|
||||
action_type = "save_draft" if step == "draft" else "submit"
|
||||
decision_id = str(decision_state.get("decision_id") or "").strip()
|
||||
if not self._is_issued_state(decision_state) or not decision_id:
|
||||
context_json["application_preview_decision"] = {"status": "missing"}
|
||||
return self._build_blocked_outcome(
|
||||
"当前申请还没有经过服务端核对,请重新生成申请预览后再继续。"
|
||||
)
|
||||
|
||||
action_preview = {
|
||||
**current_preview,
|
||||
"decisionId": decision_id,
|
||||
"decisionSource": str(decision_state.get("decision_source") or "").strip(),
|
||||
"decisionExpiresAt": str(decision_state.get("expires_at") or "").strip(),
|
||||
}
|
||||
action_context = {**application_context, "application_preview": action_preview}
|
||||
if action_type == "save_draft":
|
||||
action_context["application_action"] = "save_draft"
|
||||
action_context["application_save_mode"] = True
|
||||
|
||||
try:
|
||||
action_response = ExpenseApplicationPreviewWorkflow(self.db).execute(
|
||||
ExpenseApplicationPreviewActionPayload(
|
||||
source=payload.source,
|
||||
user_id=current_user.username,
|
||||
conversation_id=conversation_id,
|
||||
action_type=action_type,
|
||||
decision_id=decision_id,
|
||||
request_id=self._build_action_request_id(decision_id, action_type),
|
||||
message=self._build_action_message(action_type, facts),
|
||||
context_json=action_context,
|
||||
),
|
||||
current_user,
|
||||
)
|
||||
except (PreviewDecisionConflictError, ValueError) as error:
|
||||
context_json["application_preview_decision"] = {
|
||||
"status": "invalid",
|
||||
"reason": str(error),
|
||||
}
|
||||
return self._build_blocked_outcome(str(error))
|
||||
|
||||
result = action_response.result.model_dump(mode="json")
|
||||
result["degraded"] = False
|
||||
next_decision_id = str(action_response.result.decision_id or "").strip()
|
||||
if next_decision_id:
|
||||
self._store_next_decision(
|
||||
context_json=context_json,
|
||||
action_preview=action_preview,
|
||||
decision_id=next_decision_id,
|
||||
expires_at=action_response.result.decision_expires_at,
|
||||
result=result,
|
||||
)
|
||||
else:
|
||||
context_json["application_preview_decision"] = {
|
||||
"status": "consumed",
|
||||
"decision_id": decision_id,
|
||||
"action_type": action_type,
|
||||
}
|
||||
return ExecutionOutcome(
|
||||
status=AgentRunStatus.SUCCEEDED.value,
|
||||
result=result,
|
||||
degraded=False,
|
||||
tool_count=0,
|
||||
failed_tool_count=0,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _store_next_decision(
|
||||
*,
|
||||
context_json: dict[str, Any],
|
||||
action_preview: dict[str, Any],
|
||||
decision_id: str,
|
||||
expires_at: Any,
|
||||
result: dict[str, Any],
|
||||
) -> None:
|
||||
next_preview = {
|
||||
**action_preview,
|
||||
"decisionId": decision_id,
|
||||
"decisionSource": "server_draft",
|
||||
"decisionExpiresAt": expires_at.isoformat() if expires_at else "",
|
||||
}
|
||||
context_json["application_preview_decision"] = {
|
||||
"status": "issued",
|
||||
"decision_id": decision_id,
|
||||
"decision_source": "server_draft",
|
||||
"expires_at": next_preview["decisionExpiresAt"],
|
||||
"application_preview": next_preview,
|
||||
}
|
||||
result["application_preview"] = next_preview
|
||||
|
||||
@staticmethod
|
||||
def _resolve_decision_state(context_json: dict[str, Any]) -> dict[str, Any]:
|
||||
state = context_json.get("application_preview_decision")
|
||||
if isinstance(state, dict):
|
||||
return dict(state)
|
||||
conversation_state = context_json.get("conversation_state")
|
||||
if isinstance(conversation_state, dict):
|
||||
state = conversation_state.get("application_preview_decision")
|
||||
if isinstance(state, dict):
|
||||
return dict(state)
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _is_issued_state(state: dict[str, Any]) -> bool:
|
||||
return (
|
||||
str(state.get("status") or "").strip() == "issued"
|
||||
and bool(str(state.get("decision_id") or "").strip())
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _resolve_requested_action(
|
||||
cls,
|
||||
message: str | None,
|
||||
decision_state: dict[str, Any],
|
||||
) -> str:
|
||||
if not cls._is_issued_state(decision_state):
|
||||
return ""
|
||||
compact_message = re.sub(r"\s+", "", str(message or ""))
|
||||
if any(keyword in compact_message for keyword in APPLICATION_SAVE_DRAFT_KEYWORDS):
|
||||
return "save_draft"
|
||||
if (
|
||||
any(keyword in compact_message for keyword in APPLICATION_SUBMIT_KEYWORDS)
|
||||
or compact_message in APPLICATION_SHORT_CONFIRMATIONS
|
||||
):
|
||||
return "submit"
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _build_facts_message(facts: dict[str, Any]) -> str:
|
||||
labels = (
|
||||
("application_type", "申请类型"),
|
||||
("time", "申请时间"),
|
||||
("location", "地点"),
|
||||
("reason", "事由"),
|
||||
("days", "天数"),
|
||||
("transport_mode", "出行方式"),
|
||||
("amount", "系统预估费用"),
|
||||
)
|
||||
return "\n".join(
|
||||
f"{label}:{value}"
|
||||
for key, label in labels
|
||||
if (value := str(facts.get(key) or "").strip())
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _build_action_message(cls, action_type: str, facts: dict[str, Any]) -> str:
|
||||
action_text = "保存草稿" if action_type == "save_draft" else "确认提交"
|
||||
return f"{cls._build_facts_message(facts)}\n\n{action_text}"
|
||||
|
||||
@staticmethod
|
||||
def _build_issue_request_id(
|
||||
conversation_id: str | None,
|
||||
facts: dict[str, Any],
|
||||
) -> str:
|
||||
fingerprint = content_fingerprint(safe_fact_snapshot(facts)).split(":", 1)[-1][:24]
|
||||
conversation_key = str(conversation_id or "new").strip() or "new"
|
||||
return f"orchestrator-preview:{conversation_key}:{fingerprint}"[:120]
|
||||
|
||||
@staticmethod
|
||||
def _build_action_request_id(decision_id: str, action_type: str) -> str:
|
||||
return f"orchestrator-action:{decision_id}:{action_type}"[:120]
|
||||
|
||||
@staticmethod
|
||||
def _build_blocked_outcome(message: str) -> ExecutionOutcome:
|
||||
normalized_message = str(message or "").strip() or "申请预览状态已失效,请重新生成。"
|
||||
return ExecutionOutcome(
|
||||
status=AgentRunStatus.BLOCKED.value,
|
||||
result={
|
||||
"message": normalized_message,
|
||||
"answer": normalized_message,
|
||||
"suggested_actions": [],
|
||||
"risk_flags": [],
|
||||
"requires_confirmation": True,
|
||||
"degraded": False,
|
||||
},
|
||||
degraded=False,
|
||||
tool_count=0,
|
||||
failed_tool_count=0,
|
||||
)
|
||||
@@ -6,7 +6,9 @@ from typing import Any
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.schemas.expense_application_preview import ExpenseApplicationPreviewDecisionCreate
|
||||
from app.schemas.ontology import OntologyParseResult, OntologyPermission
|
||||
from app.schemas.reimbursement import ExpenseApplicationPreviewActionPayload
|
||||
from app.schemas.steward import (
|
||||
StewardActionExecuteRequest,
|
||||
StewardActionExecuteResponse,
|
||||
@@ -14,6 +16,7 @@ from app.schemas.steward import (
|
||||
)
|
||||
from app.schemas.user_agent import UserAgentRequest
|
||||
from app.services.attachment_association_jobs import AttachmentAssociationJobRunner
|
||||
from app.services.expense_application_preview_workflow import ExpenseApplicationPreviewWorkflow
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.steward_intent_registry import (
|
||||
all_noop_actions,
|
||||
@@ -40,7 +43,6 @@ APPLICATION_SIDE_EFFECT_ACTIONS = {"save_application_draft", "submit_application
|
||||
REIMBURSEMENT_SIDE_EFFECT_ACTIONS = {"create_reimbursement_draft", "link_existing_application", "associate_attachments"}
|
||||
NOOP_ACTIONS = {
|
||||
"fill_application_fields",
|
||||
"build_application_preview",
|
||||
"fill_reimbursement_fields",
|
||||
"build_reimbursement_preview",
|
||||
"validate_required_fields",
|
||||
@@ -97,7 +99,8 @@ class StewardActionExecutor:
|
||||
)
|
||||
|
||||
task = request.task
|
||||
noop_actions = NOOP_ACTIONS | all_noop_actions()
|
||||
# 申请预览已经接入服务端决策签发,不得再被注册表中的历史 NOOP 声明吞掉。
|
||||
noop_actions = (NOOP_ACTIONS | all_noop_actions()) - {"build_application_preview"}
|
||||
if task is None and action_type not in noop_actions:
|
||||
return self._blocked(
|
||||
action_type,
|
||||
@@ -132,6 +135,8 @@ class StewardActionExecutor:
|
||||
return intent.executor(self, request, current_user, trace)
|
||||
|
||||
# 兼容回退:注册表未命中时按旧逻辑分发
|
||||
if action_type == "build_application_preview":
|
||||
return self._issue_application_preview(request, current_user, trace)
|
||||
if action_type == "run_duplicate_precheck":
|
||||
return self._run_duplicate_precheck(request, current_user, trace)
|
||||
if action_type in APPLICATION_SIDE_EFFECT_ACTIONS:
|
||||
@@ -155,10 +160,55 @@ class StewardActionExecutor:
|
||||
) -> StewardActionExecuteResponse:
|
||||
"""registry 入口:分发申请类副作用动作。"""
|
||||
action_type = self._normalize_action_type(request.action_type)
|
||||
if action_type == "build_application_preview":
|
||||
return self._issue_application_preview(request, current_user, trace)
|
||||
if action_type == "run_duplicate_precheck":
|
||||
return self._run_duplicate_precheck(request, current_user, trace)
|
||||
return self._execute_application_action(request, current_user, action_type, trace)
|
||||
|
||||
def _issue_application_preview(
|
||||
self,
|
||||
request: StewardActionExecuteRequest,
|
||||
current_user: CurrentUserContext,
|
||||
trace: list[dict[str, Any]],
|
||||
) -> StewardActionExecuteResponse:
|
||||
request_id = self._resolve_required_client_trace_id(request)
|
||||
if not request_id:
|
||||
return self._blocked(
|
||||
"build_application_preview",
|
||||
"生成申请核对表需要稳定的 client_trace_id。",
|
||||
blocked_reasons=["missing_client_trace_id"],
|
||||
trace=[*trace, self._trace("blocked", reason="missing_client_trace_id")],
|
||||
)
|
||||
|
||||
try:
|
||||
issued = ExpenseApplicationPreviewWorkflow(self.db).issue(
|
||||
ExpenseApplicationPreviewDecisionCreate(
|
||||
message=self._resolve_message(request),
|
||||
conversation_id=request.conversation_id,
|
||||
request_id=request_id,
|
||||
),
|
||||
current_user,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return self._failed("build_application_preview", str(exc), trace)
|
||||
|
||||
result_payload = issued.model_dump(mode="json")
|
||||
return StewardActionExecuteResponse(
|
||||
action_type="build_application_preview",
|
||||
status="succeeded",
|
||||
message="申请核对表已生成,请确认后再保存或提交。",
|
||||
result_payload=result_payload,
|
||||
trace=[
|
||||
*trace,
|
||||
self._trace(
|
||||
"completed",
|
||||
service="ExpenseApplicationPreviewWorkflow",
|
||||
decision_id=issued.decision_id,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
def _dispatch_reimbursement_action(
|
||||
self,
|
||||
request: StewardActionExecuteRequest,
|
||||
@@ -236,43 +286,70 @@ class StewardActionExecutor:
|
||||
trace=[*trace, self._trace("blocked", reason="precheck_not_passed")],
|
||||
)
|
||||
|
||||
payload = self._build_application_user_agent_request(
|
||||
request,
|
||||
current_user,
|
||||
action_type=action_type,
|
||||
force_submit_message=action_type == "submit_application",
|
||||
decision_id = str(request.decision_id or "").strip()
|
||||
if not decision_id:
|
||||
return self._blocked(
|
||||
action_type,
|
||||
"保存或提交申请必须携带服务端签发的 decision_id。",
|
||||
blocked_reasons=["missing_decision_id"],
|
||||
trace=[*trace, self._trace("blocked", reason="missing_decision_id")],
|
||||
)
|
||||
request_id = self._resolve_required_client_trace_id(request)
|
||||
if not request_id:
|
||||
return self._blocked(
|
||||
action_type,
|
||||
"保存或提交申请需要稳定的 client_trace_id。",
|
||||
blocked_reasons=["missing_client_trace_id"],
|
||||
trace=[*trace, self._trace("blocked", reason="missing_client_trace_id")],
|
||||
)
|
||||
|
||||
resolved_action = "save_draft" if action_type == "save_application_draft" else "submit"
|
||||
message = self._resolve_message(request)
|
||||
if resolved_action == "submit" and "确认提交" not in message and "直接提交" not in message:
|
||||
message = "\n".join([message, "确认提交"]).strip()
|
||||
if resolved_action == "save_draft" and "保存草稿" not in message:
|
||||
message = "\n".join([message, "保存草稿"]).strip()
|
||||
payload = ExpenseApplicationPreviewActionPayload(
|
||||
source="steward_action",
|
||||
user_id=current_user.username,
|
||||
conversation_id=request.conversation_id,
|
||||
action_type=resolved_action,
|
||||
decision_id=decision_id,
|
||||
request_id=request_id,
|
||||
message=message,
|
||||
context_json=self._build_application_context_json(
|
||||
request,
|
||||
current_user,
|
||||
action_type,
|
||||
),
|
||||
)
|
||||
try:
|
||||
user_agent_response = UserAgentService(self.db)._build_expense_application_response(
|
||||
workflow_response = ExpenseApplicationPreviewWorkflow(self.db).execute(
|
||||
payload,
|
||||
risk_flags=[],
|
||||
current_user,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return self._failed(action_type, str(exc), trace)
|
||||
|
||||
draft_payload = (
|
||||
user_agent_response.draft_payload.model_dump(mode="json")
|
||||
if user_agent_response.draft_payload is not None
|
||||
else None
|
||||
)
|
||||
result_payload = {
|
||||
"answer": user_agent_response.answer,
|
||||
"suggested_actions": [
|
||||
action.model_dump(mode="json")
|
||||
for action in user_agent_response.suggested_actions
|
||||
],
|
||||
"requires_confirmation": user_agent_response.requires_confirmation,
|
||||
"draft_payload": draft_payload,
|
||||
}
|
||||
result_payload = workflow_response.result.model_dump(mode="json")
|
||||
draft_payload = result_payload.get("draft_payload")
|
||||
status = "succeeded" if draft_payload is not None else "blocked"
|
||||
blocked_reasons = [] if draft_payload is not None else ["application_not_persisted"]
|
||||
return StewardActionExecuteResponse(
|
||||
action_type=action_type,
|
||||
status=status,
|
||||
message=user_agent_response.answer,
|
||||
message=workflow_response.result.message,
|
||||
blocked_reasons=blocked_reasons,
|
||||
result_payload=result_payload,
|
||||
trace=[*trace, self._trace("completed", service="UserAgentService")],
|
||||
trace=[
|
||||
*trace,
|
||||
self._trace(
|
||||
"completed",
|
||||
service="ExpenseApplicationPreviewWorkflow",
|
||||
decision_id=decision_id,
|
||||
renewed_decision_id=workflow_response.result.decision_id or "",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
def _execute_reimbursement_action(
|
||||
@@ -568,6 +645,10 @@ class StewardActionExecutor:
|
||||
suffix = task_id or datetime.now(UTC).strftime("%Y%m%d%H%M%S%f")
|
||||
return f"steward-action:{action_type}:{suffix}"
|
||||
|
||||
@staticmethod
|
||||
def _resolve_required_client_trace_id(request: StewardActionExecuteRequest) -> str:
|
||||
return str(request.client_trace_id or "").strip()
|
||||
|
||||
@staticmethod
|
||||
def _trace(stage: str, **extra: Any) -> dict[str, Any]:
|
||||
return {
|
||||
|
||||
@@ -14,6 +14,11 @@ from app.services.steward_action_executor import StewardActionExecutor
|
||||
|
||||
ACTION_CHECKPOINT_KEY = "steward_action_checkpoint"
|
||||
TERMINAL_ACTION_STATUSES = {"succeeded", "blocked", "failed"}
|
||||
DECISION_BOUND_APPLICATION_ACTIONS = {
|
||||
"build_application_preview",
|
||||
"save_application_draft",
|
||||
"submit_application",
|
||||
}
|
||||
|
||||
|
||||
class StewardGraphActionState(TypedDict, total=False):
|
||||
@@ -171,6 +176,7 @@ class StewardGraphActionRuntime:
|
||||
user_id=current_user.username,
|
||||
source="user_message",
|
||||
context_json={
|
||||
"tenant_id": current_user.tenant_id,
|
||||
"session_type": "steward",
|
||||
"entry_source": "steward_action_executor",
|
||||
"steward_state": dict((request.context_json or {}).get("steward_state") or {}),
|
||||
@@ -190,6 +196,9 @@ class StewardGraphActionRuntime:
|
||||
if trace_id:
|
||||
return trace_id
|
||||
action_type = str(request.action_type or "").strip()
|
||||
if action_type in DECISION_BOUND_APPLICATION_ACTIONS:
|
||||
# 这些动作的 request_id 同时用于决策签发/消费,禁止生成不可重放的隐式 ID。
|
||||
return ""
|
||||
task_id = str(request.task.task_id if request.task is not None else "").strip()
|
||||
if action_type and task_id:
|
||||
return f"{action_type}:{task_id}"
|
||||
|
||||
Reference in New Issue
Block a user