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

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

View File

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

View File

@@ -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):

View File

@@ -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")

View File

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

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

View File

@@ -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)

View File

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

View File

@@ -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 {

View File

@@ -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}"

View File

@@ -198,6 +198,21 @@ def test_server_preview_decision_is_consumed_with_verified_feedback() -> None:
assert len(list(db.scalars(select(AIDecision)).all())) == 1
def test_same_snapshot_with_different_issue_request_reuses_active_decision() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
first = issue_preview(client, request_id="issue-preview-same-snapshot-1")
second = issue_preview(client, request_id="issue-preview-same-snapshot-2")
assert second["decision_id"] == first["decision_id"]
with session_factory() as db:
decisions = list(db.scalars(select(AIApplicationPreviewDecision)).all())
assert len(decisions) == 1
assert decisions[0].status == "issued"
def test_server_preview_decision_detects_server_side_field_edit() -> None:
client, session_factory = build_client()
with session_factory() as db:

View File

@@ -0,0 +1,407 @@
from __future__ import annotations
from collections.abc import Generator
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import (
CurrentUserContext,
get_current_user,
get_db,
get_optional_current_user,
)
from app.db.base import Base
from app.main import create_app
from app.models.agent_conversation import AgentConversation
from app.schemas.orchestrator import (
OrchestratorRequest,
OrchestratorResponse,
OrchestratorTraceSummary,
)
from app.services.orchestrator import OrchestratorService
@pytest.fixture
def http_context() -> Generator[
tuple[TestClient, FastAPI, sessionmaker[Session]],
None,
None,
]:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
app = create_app()
def override_db() -> Generator[Session, None, None]:
with session_factory() as db:
yield db
app.dependency_overrides[get_db] = override_db
client = TestClient(app)
try:
yield client, app, session_factory
finally:
client.close()
app.dependency_overrides.clear()
engine.dispose()
def authenticated_user() -> CurrentUserContext:
return CurrentUserContext(
username="signed-in@example.com",
name="登录用户",
role_codes=["user"],
is_admin=False,
tenant_id="tenant-signed-in",
department_name="财务共享部",
cost_center="CC-SIGNED-IN",
position="费用专员",
grade="P5",
employee_no="E-SIGNED-IN",
manager_name="直属经理",
employee_id="employee-signed-in",
auth_session_id="session-signed-in",
)
def install_authenticated_user(app: FastAPI) -> CurrentUserContext:
current_user = authenticated_user()
app.dependency_overrides[get_current_user] = lambda: current_user
app.dependency_overrides[get_optional_current_user] = lambda: current_user
return current_user
def install_admin_user(app: FastAPI) -> CurrentUserContext:
current_user = authenticated_user()
current_user.role_codes = ["admin"]
current_user.is_admin = True
app.dependency_overrides[get_current_user] = lambda: current_user
app.dependency_overrides[get_optional_current_user] = lambda: current_user
return current_user
@pytest.mark.parametrize("source", ["user_message", "system_event", "schedule"])
def test_orchestrator_run_requires_authentication_for_every_external_source(
http_context: tuple[TestClient, FastAPI, sessionmaker[Session]],
source: str,
) -> None:
client, _, _ = http_context
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": source,
"user_id": "forged-user@example.com",
"message": "帮我申请差旅费用",
"context_json": {},
},
)
assert response.status_code == 401
assert response.headers["www-authenticate"] == "Bearer"
assert response.json()["detail"] == "请先登录后再使用智能助手。"
@pytest.mark.parametrize("source", ["system_event", "schedule"])
def test_non_admin_cannot_trigger_privileged_orchestrator_sources(
http_context: tuple[TestClient, FastAPI, sessionmaker[Session]],
source: str,
) -> None:
client, app, _ = http_context
install_authenticated_user(app)
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": source,
"user_id": "forged-admin@example.com",
"message": "运行全局风险扫描",
"context_json": {
"role_codes": ["admin"],
"is_admin": True,
},
},
)
assert response.status_code == 403
assert response.json()["detail"] == "只有平台管理员可以从外部触发调度或系统事件。"
@pytest.mark.parametrize("source", ["system_event", "schedule"])
def test_admin_can_trigger_privileged_orchestrator_sources(
http_context: tuple[TestClient, FastAPI, sessionmaker[Session]],
monkeypatch: pytest.MonkeyPatch,
source: str,
) -> None:
client, app, _ = http_context
expected_user = install_admin_user(app)
captured: dict[str, object] = {}
def fake_run(
self: OrchestratorService,
payload: OrchestratorRequest,
*,
current_user: CurrentUserContext | None = None,
) -> OrchestratorResponse:
del self
captured["source"] = payload.source
captured["current_user"] = current_user
return OrchestratorResponse(
run_id="run-admin-source",
selected_agent="hermes",
route_reason="test_admin_source",
permission_level="read",
status="succeeded",
result={},
requires_confirmation=False,
trace_summary=OrchestratorTraceSummary(
scenario="system",
intent="risk_check",
),
)
monkeypatch.setattr(OrchestratorService, "run", fake_run)
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": source,
"message": "运行全局风险扫描",
"context_json": {},
},
)
assert response.status_code == 200, response.text
assert captured["source"] == source
assert captured["current_user"] == expected_user
@pytest.mark.parametrize("source", ["user_message", "system_event", "schedule"])
def test_authenticated_payload_overrides_all_scheduler_identity_aliases(
source: str,
) -> None:
current_user = authenticated_user()
payload = OrchestratorRequest(
source=source,
user_id="forged-user@example.com",
message="测试身份覆盖",
context_json={
"username": "forged-user@example.com",
"user_id": "forged-user@example.com",
"tenant_id": "tenant-forged",
"role_codes": ["admin"],
"is_admin": True,
"requested_by_username": "forged-requester@example.com",
"requested_by_name": "伪造请求人",
"actor": "forged-actor@example.com",
"actor_id": "forged-actor-id",
"auth_session_id": "forged-session",
},
)
trusted = OrchestratorService._build_authenticated_user_payload(payload, current_user)
assert trusted.user_id == current_user.username
assert trusted.context_json["tenant_id"] == current_user.tenant_id
assert trusted.context_json["role_codes"] == current_user.role_codes
assert trusted.context_json["is_admin"] is current_user.is_admin
assert trusted.context_json["requested_by_username"] == current_user.username
assert trusted.context_json["requested_by_name"] == current_user.name
assert trusted.context_json["actor"] == current_user.username
assert trusted.context_json["actor_id"] == current_user.username
assert "auth_session_id" not in trusted.context_json
def test_authenticated_run_passes_server_identity_to_orchestrator(
http_context: tuple[TestClient, FastAPI, sessionmaker[Session]],
monkeypatch: pytest.MonkeyPatch,
) -> None:
client, app, _ = http_context
expected_user = install_authenticated_user(app)
captured: dict[str, object] = {}
def fake_run(
self: OrchestratorService,
payload: OrchestratorRequest,
*,
current_user: CurrentUserContext | None = None,
) -> OrchestratorResponse:
del self
captured["payload"] = payload
captured["current_user"] = current_user
return OrchestratorResponse(
run_id="run-auth-binding",
conversation_id="conversation-auth-binding",
selected_agent="user_agent",
route_reason="test",
permission_level="user",
status="succeeded",
result={},
requires_confirmation=False,
trace_summary=OrchestratorTraceSummary(
scenario="expense",
intent="operate",
),
)
monkeypatch.setattr(OrchestratorService, "run", fake_run)
response = client.post(
"/api/v1/orchestrator/run",
json={
"source": "user_message",
"user_id": "forged-user@example.com",
"message": "保存申请草稿",
"context_json": {
"username": "forged-user@example.com",
"tenant_id": "tenant-forged",
"role_codes": ["admin"],
"is_admin": True,
"employee_no": "E-FORGED",
"auth_session_id": "session-forged",
},
},
)
assert response.status_code == 200, response.text
assert captured["current_user"] == expected_user
assert captured["payload"].user_id == "forged-user@example.com"
assert captured["payload"].context_json["tenant_id"] == "tenant-forged"
def test_latest_conversation_uses_authenticated_username(
http_context: tuple[TestClient, FastAPI, sessionmaker[Session]],
) -> None:
client, app, session_factory = http_context
current_user = install_authenticated_user(app)
with session_factory() as db:
db.add_all(
[
AgentConversation(
conversation_id="conversation-signed-in",
user_id=current_user.username,
source="user_message",
title="登录用户会话",
state_json={
"tenant_id": current_user.tenant_id,
"session_type": "expense",
},
),
AgentConversation(
conversation_id="conversation-same-user-other-tenant",
user_id=current_user.username,
source="user_message",
title="其他租户同名用户会话",
state_json={
"tenant_id": "tenant-other",
"session_type": "expense",
"application_preview_decision": {
"decision_id": "decision-other-tenant",
"application_preview": {
"fields": {"reason": "其他租户敏感项目"}
},
},
},
),
AgentConversation(
conversation_id="conversation-forged-user",
user_id="forged-user@example.com",
source="user_message",
title="伪造用户会话",
state_json={
"tenant_id": "tenant-forged",
"session_type": "expense",
},
),
]
)
db.commit()
response = client.get(
"/api/v1/orchestrator/conversations/latest",
params={"user_id": "forged-user@example.com", "session_type": "expense"},
)
assert response.status_code == 200, response.text
payload = response.json()
assert payload["found"] is True
assert payload["conversation"]["conversation_id"] == "conversation-signed-in"
assert payload["conversation"]["user_id"] == current_user.username
def test_delete_conversations_uses_authenticated_username(
http_context: tuple[TestClient, FastAPI, sessionmaker[Session]],
) -> None:
client, app, session_factory = http_context
current_user = install_authenticated_user(app)
with session_factory() as db:
db.add_all(
[
AgentConversation(
conversation_id="conversation-owner-single",
user_id=current_user.username,
source="user_message",
state_json={
"tenant_id": current_user.tenant_id,
"session_type": "expense",
},
),
AgentConversation(
conversation_id="conversation-owner-bulk",
user_id=current_user.username,
source="user_message",
state_json={
"tenant_id": current_user.tenant_id,
"session_type": "expense",
},
),
AgentConversation(
conversation_id="conversation-same-user-other-tenant",
user_id=current_user.username,
source="user_message",
state_json={
"tenant_id": "tenant-other",
"session_type": "expense",
},
),
AgentConversation(
conversation_id="conversation-attacker",
user_id="forged-user@example.com",
source="user_message",
state_json={
"tenant_id": "tenant-forged",
"session_type": "expense",
},
),
]
)
db.commit()
single_response = client.delete(
"/api/v1/orchestrator/conversations/conversation-owner-single",
params={"user_id": "forged-user@example.com"},
)
bulk_response = client.delete(
"/api/v1/orchestrator/conversations",
params={"user_id": "forged-user@example.com", "session_type": "expense"},
)
assert single_response.status_code == 200, single_response.text
assert single_response.json()["deleted_count"] == 1
assert bulk_response.status_code == 200, bulk_response.text
assert bulk_response.json()["deleted_count"] == 1
with session_factory() as db:
remaining = list(db.scalars(select(AgentConversation)).all())
assert {item.conversation_id for item in remaining} == {
"conversation-attacker",
"conversation-same-user-other-tenant",
}

View File

@@ -4,13 +4,16 @@ from datetime import UTC, date, datetime
from decimal import Decimal
import pytest
from sqlalchemy import create_engine
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import CurrentUserContext
from app.db.base import Base
from app.models.agent_asset import AgentAsset
from app.models.agent_run import AgentRun
from app.models.ai_application_preview import AIApplicationPreviewDecision
from app.models.ai_learning import AIDecision, AIDecisionFeedback
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.schemas.ontology import OntologyParseResult, OntologyPermission
@@ -881,3 +884,135 @@ def test_orchestrator_application_submit_bypasses_generic_operation_block(
assert "当前仅返回确认摘要" not in submitted.result["answer"]
assert "申请单据已生成,并已进入审批流程" in submitted.result["answer"]
assert submitted.result["draft_payload"]["status"] == "submitted"
def test_authenticated_orchestrator_application_draft_consumes_server_preview_decision(
monkeypatch,
) -> None:
monkeypatch.setattr(
"app.services.runtime_chat.RuntimeChatService.complete",
lambda *_args, **_kwargs: None,
)
def parse_application_for_run(self, request, run_id): # noqa: ANN001
return OntologyParseResult(
scenario="expense",
intent="operate",
entities=[],
permission=OntologyPermission(
level="approval_required",
allowed=True,
reason="费用申请由可信预览工作流执行。",
),
confidence=0.99,
missing_slots=[],
ambiguity=[],
clarification_required=False,
clarification_question=None,
run_id=run_id,
)
monkeypatch.setattr(
"app.services.ontology.SemanticOntologyService.parse_for_run",
parse_application_for_run,
)
session_factory = build_session_factory()
current_user = CurrentUserContext(
username="trusted-application@example.com",
name="可信申请员工",
role_codes=["user"],
is_admin=False,
tenant_id="tenant-orchestrator-preview",
department_name="交付部",
position="实施顾问",
grade="P4",
employee_no="E-ORCH-001",
employee_id="employee-orchestrator-preview",
manager_name="陈硕",
auth_session_id="session-orchestrator-preview",
)
context_json = {
"session_type": "application",
"entry_source": "application",
"user_id": "forged@example.com",
"tenant_id": "forged-tenant",
"is_admin": True,
"manager_name": "伪造审批人",
}
with session_factory() as db:
service = OrchestratorService(db)
first = service.run(
OrchestratorRequest(
source="user_message",
user_id="forged@example.com",
message=(
"发生时间2026-05-25\n"
"地点:上海\n"
"事由:支持上海国网服务器部署\n"
"天数3天"
),
context_json=context_json,
),
current_user=current_user,
)
preview = service.run(
OrchestratorRequest(
source="user_message",
user_id="forged@example.com",
conversation_id=first.conversation_id,
message="飞机",
context_json=context_json,
),
current_user=current_user,
)
decision_id = str(preview.result.get("decision_id") or "")
assert preview.status == "blocked"
assert preview.requires_confirmation is True
assert decision_id
assert preview.result["application_preview"]["decisionId"] == decision_id
decision = db.get(AIApplicationPreviewDecision, decision_id)
assert decision is not None
assert decision.status == "issued"
assert decision.actor_id == current_user.employee_id
assert decision.tenant_id == current_user.tenant_id
conversation = service.conversation_service.get_conversation(first.conversation_id or "")
assert conversation is not None
assert conversation.state_json["application_preview_decision"]["decision_id"] == decision_id
saved = service.run(
OrchestratorRequest(
source="user_message",
user_id="another-forged@example.com",
conversation_id=first.conversation_id,
message="保存草稿",
context_json={
**context_json,
"application_preview_decision": {
"decision_id": "forged-decision",
"status": "issued",
},
},
),
current_user=current_user,
)
assert saved.status == "succeeded", saved.result
assert saved.result["draft_payload"]["status"] == "draft"
assert saved.result["draft_payload"]["draft_type"] == "expense_application"
assert saved.result["draft_payload"]["claim_no"]
assert saved.result["decision_id"]
assert saved.result["decision_id"] != decision_id
db.refresh(decision)
assert decision.status == "consumed"
assert decision.consumed_action == "save_draft"
learning_decision = db.scalar(
select(AIDecision).where(AIDecision.preview_decision_id == decision_id)
)
assert learning_decision is not None
feedback = db.scalar(
select(AIDecisionFeedback).where(
AIDecisionFeedback.decision_id == learning_decision.id
)
)
assert feedback is not None
assert feedback.feedback_type == "accepted"

View File

@@ -13,6 +13,8 @@ from app.api.deps import get_db
from app.db.base import Base
from app.main import create_app
from app.models.agent_conversation import AgentConversation
from app.models.ai_application_preview import AIApplicationPreviewDecision
from app.models.ai_learning import AIDecisionFeedback
from app.models.employee import Employee
from app.models.expense_case import BusinessEvent, ExpenseCaseLink
from app.models.financial_record import ExpenseClaim
@@ -72,7 +74,11 @@ def auth_headers() -> dict[str, str]:
"x-auth-username": "zhangsan@example.com",
"x-auth-name": "Zhang San",
"x-auth-employee-no": "E90001",
"x-auth-employee-id": "steward-action-employee",
"x-auth-session-id": "session-steward-action",
"x-auth-tenant-id": "tenant-steward-action",
"x-auth-role-codes": "user",
"x-auth-department": "Delivery",
"x-auth-position": "Engineer",
"x-auth-grade": "P4",
"x-auth-manager-name": "Leader",
@@ -130,6 +136,33 @@ def claim_count(db: Session) -> int:
return len(db.scalars(select(ExpenseClaim)).all())
def issue_application_preview(
client: TestClient,
*,
conversation_id: str,
client_trace_id: str,
task: dict[str, object] | None = None,
) -> dict[str, object]:
response = client.post(
"/api/v1/steward/actions/execute",
headers=auth_headers(),
json={
"action_type": "build_application_preview",
"message": (
"2026-02-20 至 2026-02-23去上海出差"
"辅助国网仿生产服务器部署交通火车申请金额3000元"
),
"conversation_id": conversation_id,
"client_trace_id": client_trace_id,
"task": task or base_application_task(),
},
)
assert response.status_code == 200, response.text
payload = response.json()
assert payload["status"] == "succeeded", payload
return payload["result_payload"]
def seed_approved_application(db: Session) -> None:
application = ExpenseClaim(
id="application-action-approved",
@@ -249,16 +282,108 @@ def test_steward_action_executor_records_pending_interrupt_in_conversation_state
assert checkpoint["actions"]["trace-submit-pending"]["status"] == "needs_confirmation"
def test_steward_action_executor_builds_canonical_application_preview_decision() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
first = issue_application_preview(
client,
conversation_id="conv-action-preview",
client_trace_id="trace-build-preview",
)
second = issue_application_preview(
client,
conversation_id="conv-action-preview",
client_trace_id="trace-build-preview",
)
assert first["decision_id"]
assert first["decision_id"] == second["decision_id"]
assert first["application_preview"]["decisionId"] == first["decision_id"]
assert first["application_preview"]["fields"]["location"] == "上海市"
assert second["idempotent_replay"] is True
def test_steward_action_checkpoint_does_not_replay_across_tenants() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
shared_conversation_id = "conv-action-cross-tenant"
shared_trace_id = "trace-build-cross-tenant"
tenant_a_headers = {
**auth_headers(),
"x-auth-tenant-id": "tenant-steward-a",
}
tenant_b_headers = {
**auth_headers(),
"x-auth-tenant-id": "tenant-steward-b",
}
request_payload = {
"action_type": "build_application_preview",
"message": (
"2026-02-20 至 2026-02-23去上海出差"
"辅助国网仿生产服务器部署交通火车申请金额3000元"
),
"conversation_id": shared_conversation_id,
"client_trace_id": shared_trace_id,
"task": base_application_task(),
}
tenant_a_response = client.post(
"/api/v1/steward/actions/execute",
headers=tenant_a_headers,
json=request_payload,
)
tenant_b_response = client.post(
"/api/v1/steward/actions/execute",
headers=tenant_b_headers,
json=request_payload,
)
assert tenant_a_response.status_code == 200, tenant_a_response.text
assert tenant_b_response.status_code == 200, tenant_b_response.text
tenant_a_payload = tenant_a_response.json()
tenant_b_payload = tenant_b_response.json()
assert tenant_a_payload["status"] == "succeeded"
assert tenant_b_payload["status"] == "succeeded"
assert tenant_b_payload["result_payload"].get("idempotent_replay") is not True
assert (
tenant_a_payload["result_payload"]["decision_id"]
!= tenant_b_payload["result_payload"]["decision_id"]
)
with session_factory() as db:
decisions = list(db.scalars(select(AIApplicationPreviewDecision)).all())
assert {decision.tenant_id for decision in decisions} == {
"tenant-steward-a",
"tenant-steward-b",
}
conversations = list(db.scalars(select(AgentConversation)).all())
assert len(conversations) == 2
assert {conversation.state_json.get("tenant_id") for conversation in conversations} == {
"tenant-steward-a",
"tenant-steward-b",
}
def test_steward_action_executor_reuses_checkpoint_for_duplicate_trace_without_duplicate_draft() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
issued = issue_application_preview(
client,
conversation_id="conv-action-draft",
client_trace_id="trace-build-draft-preview",
)
request_payload = {
"action_type": "save_application_draft",
"message": "2026-02-20 至 2026-02-23去上海出差辅助国网仿生产服务器部署交通火车保存草稿",
"conversation_id": "conv-action-draft",
"client_trace_id": "trace-save-draft",
"decision_id": issued["decision_id"],
"task": base_application_task("save_draft"),
}
first_response = client.post(
@@ -325,12 +450,20 @@ def test_steward_action_executor_saves_application_draft_from_action_step() -> N
with session_factory() as db:
seed_employee(db)
issued = issue_application_preview(
client,
conversation_id="conv-action-save",
client_trace_id="trace-build-save-preview",
)
response = client.post(
"/api/v1/steward/actions/execute",
headers=auth_headers(),
json={
"action_type": "save_application_draft",
"message": "2026-02-20 至 2026-02-23去上海出差辅助国网仿生产服务器部署交通火车保存草稿",
"conversation_id": "conv-action-save",
"client_trace_id": "trace-save-application-action",
"decision_id": issued["decision_id"],
"task": base_application_task("save_draft"),
},
)
@@ -342,22 +475,116 @@ def test_steward_action_executor_saves_application_draft_from_action_step() -> N
assert draft_payload["draft_type"] == "expense_application"
assert draft_payload["status"] == "draft"
assert draft_payload["claim_no"].startswith("A")
assert payload["result_payload"]["decision_id"]
assert payload["result_payload"]["decision_id"] != issued["decision_id"]
with session_factory() as db:
claim = db.scalars(select(ExpenseClaim)).one()
assert claim.status == "draft"
assert claim.reason == "辅助国网仿生产服务器部署"
consumed_decision = db.get(AIApplicationPreviewDecision, issued["decision_id"])
assert consumed_decision is not None
assert consumed_decision.status == "consumed"
feedback = db.scalars(select(AIDecisionFeedback)).one()
assert feedback.verification_status == "server_verified"
event = db.scalars(
select(BusinessEvent).where(BusinessEvent.aggregate_id == claim.id)
).one()
assert event.event_type == "claim_draft_created"
assert event.actor_id == "zhangsan@example.com"
assert event.correlation_id == "steward-action:save_application_draft:task_app_001"
assert event.correlation_id == "application-preview-action:conv-action-save"
link = db.scalars(
select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == claim.id)
).one()
assert link.relation_type == "application"
def test_steward_action_executor_blocks_save_without_decision_and_stable_trace() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
response = client.post(
"/api/v1/steward/actions/execute",
headers=auth_headers(),
json={
"action_type": "save_application_draft",
"message": "保存申请草稿",
"task": base_application_task("save_draft"),
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "blocked"
assert payload["blocked_reasons"] == ["missing_decision_id"]
issued = issue_application_preview(
client,
conversation_id="conv-action-missing-trace",
client_trace_id="trace-build-missing-trace",
)
missing_trace_response = client.post(
"/api/v1/steward/actions/execute",
headers=auth_headers(),
json={
"action_type": "save_application_draft",
"message": "保存申请草稿",
"conversation_id": "conv-action-missing-trace",
"decision_id": issued["decision_id"],
"task": base_application_task("save_draft"),
},
)
missing_trace_payload = missing_trace_response.json()
assert missing_trace_payload["status"] == "blocked"
assert missing_trace_payload["blocked_reasons"] == ["missing_client_trace_id"]
with session_factory() as db:
assert claim_count(db) == 0
def test_steward_action_executor_submits_verified_application_after_confirmation_and_precheck() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
issued = issue_application_preview(
client,
conversation_id="conv-action-verified-submit",
client_trace_id="trace-build-submit-preview",
task=base_application_task("submit"),
)
response = client.post(
"/api/v1/steward/actions/execute",
headers=auth_headers(),
json={
"action_type": "submit_application",
"message": (
"2026-02-20 至 2026-02-23去上海出差"
"辅助国网仿生产服务器部署,交通火车,直接提交"
),
"conversation_id": "conv-action-verified-submit",
"client_trace_id": "trace-submit-verified-application",
"decision_id": issued["decision_id"],
"task": base_application_task("submit"),
"confirmed": True,
"context_json": {
"precheck_result": {
"status": "ok",
"blocking": False,
}
},
},
)
assert response.status_code == 200, response.text
payload = response.json()
assert payload["status"] == "succeeded"
assert payload["result_payload"]["draft_payload"]["status"] == "submitted"
assert payload["result_payload"]["decision_id"] is None
with session_factory() as db:
claim = db.scalars(select(ExpenseClaim)).one()
assert claim.status == "submitted"
def test_steward_action_executor_creates_reimbursement_draft_from_action_step() -> None:
client, session_factory = build_client()
with session_factory() as db: