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