Files
X-Financial/server/src/app/services/steward_action_executor.py
caoxiaozhu eaada4bc57 refactor(server): steward 意图改用声明式注册表编排
- 新增 steward_intent_registry,IntentDescriptor 统一描述意图的识别关键词、动作步骤构建、字段白名单与副作用集合,替代分散的 if/else
- 新增 steward_intent_bootstrap 注册 expense_application 等意图;新增 steward_query_executors 提供差旅标准查询的无副作用执行与城市/席别标签化输出
- action_contracts/action_executor/graph_planner/intent_agent/model_plan_builder/planner_extraction/fallback 适配注册表,识别与执行分发自动从注册表取数
- 新增 intent_registry/query_executors 测试,更新 intent_agent 测试
2026-06-25 11:50:02 +08:00

609 lines
24 KiB
Python

from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.schemas.ontology import OntologyParseResult, OntologyPermission
from app.schemas.steward import (
StewardActionExecuteRequest,
StewardActionExecuteResponse,
StewardTask,
)
from app.schemas.user_agent import UserAgentRequest
from app.services.attachment_association_jobs import AttachmentAssociationJobRunner
from app.services.expense_claims import ExpenseClaimService
from app.services.steward_intent_registry import (
all_noop_actions,
all_side_effect_actions,
resolve_intent_by_action,
)
from app.services.user_agent import UserAgentService
from app.services.user_agent_application_dates import resolve_application_days_from_time_range
SUPPORTED_ACTIONS = {
"fill_application_fields",
"build_application_preview",
"fill_reimbursement_fields",
"build_reimbursement_preview",
"validate_required_fields",
"run_duplicate_precheck",
"save_application_draft",
"submit_application",
"create_reimbursement_draft",
"link_existing_application",
"associate_attachments",
}
APPLICATION_SIDE_EFFECT_ACTIONS = {"save_application_draft", "submit_application", "run_duplicate_precheck"}
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",
}
TRANSPORT_MODE_LABELS = {
"train": "火车",
"rail": "火车",
"high_speed_rail": "火车",
"flight": "飞机",
"airplane": "飞机",
"plane": "飞机",
"ship": "轮船",
"boat": "轮船",
"taxi": "出租车",
}
EXPENSE_TYPE_LABELS = {
"travel": "差旅费",
"transport": "交通费",
"hotel": "住宿费",
"meal": "业务招待费",
"meeting": "会务费",
"office": "办公用品费",
"other": "其他费用",
}
APPLICATION_TYPE_LABELS = {
"travel": "差旅费用申请",
"travel_application": "差旅费用申请",
"transport": "交通费用申请",
"hotel": "住宿费用申请",
"meeting": "会务费用申请",
"office": "办公费用申请",
}
class StewardActionExecutor:
"""执行 LangGraph 规划出的确定性白名单动作。"""
def __init__(self, db: Session) -> None:
self.db = db
def execute(
self,
request: StewardActionExecuteRequest,
current_user: CurrentUserContext,
) -> StewardActionExecuteResponse:
action_type = self._normalize_action_type(request.action_type)
trace = [self._trace("received", action_type=action_type, plan_id=request.plan_id)]
supported = SUPPORTED_ACTIONS | all_side_effect_actions() | all_noop_actions()
if action_type not in supported:
return self._blocked(
action_type,
f"不支持的小财管家动作:{action_type or '空动作'}",
trace=[*trace, self._trace("blocked", reason="unsupported_action")],
)
task = request.task
noop_actions = NOOP_ACTIONS | all_noop_actions()
if task is None and action_type not in noop_actions:
return self._blocked(
action_type,
"动作缺少任务快照,无法安全执行。",
trace=[*trace, self._trace("blocked", reason="missing_task")],
)
blocked_reasons = self._resolve_task_blockers(task)
if blocked_reasons:
return self._blocked(
action_type,
"当前任务仍有必填信息缺失,已停止执行动作。",
blocked_reasons=blocked_reasons,
trace=[*trace, self._trace("blocked", reason="missing_fields")],
)
if action_type in noop_actions:
return StewardActionExecuteResponse(
action_type=action_type,
status="succeeded",
message="动作已确认,无需调用外部业务服务。",
result_payload={
"task_id": task.task_id if task is not None else "",
"action_type": action_type,
},
trace=[*trace, self._trace("completed", mode="noop")],
)
# 优先走注册表:查到 action 所属意图的 executor 即委托执行
intent = resolve_intent_by_action(action_type)
if intent is not None and intent.executor is not None:
return intent.executor(self, 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:
return self._execute_application_action(request, current_user, action_type, trace)
if action_type in REIMBURSEMENT_SIDE_EFFECT_ACTIONS:
return self._execute_reimbursement_action(request, current_user, action_type, trace)
if action_type == "associate_attachments":
return self._execute_associate_attachments_action(request, current_user, trace)
return self._blocked(
action_type,
f"动作 {action_type} 暂未接入执行器。",
trace=[*trace, self._trace("blocked", reason="unwired_action")],
)
def _dispatch_application_action(
self,
request: StewardActionExecuteRequest,
current_user: CurrentUserContext,
trace: list[dict[str, Any]],
) -> StewardActionExecuteResponse:
"""registry 入口:分发申请类副作用动作。"""
action_type = self._normalize_action_type(request.action_type)
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 _dispatch_reimbursement_action(
self,
request: StewardActionExecuteRequest,
current_user: CurrentUserContext,
trace: list[dict[str, Any]],
) -> StewardActionExecuteResponse:
"""registry 入口:分发报销类副作用动作。"""
action_type = self._normalize_action_type(request.action_type)
if action_type == "associate_attachments":
return self._execute_associate_attachments_action(request, current_user, trace)
return self._execute_reimbursement_action(request, current_user, action_type, trace)
def _run_duplicate_precheck(
self,
request: StewardActionExecuteRequest,
current_user: CurrentUserContext,
trace: list[dict[str, Any]],
) -> StewardActionExecuteResponse:
payload = self._build_application_user_agent_request(
request,
current_user,
action_type="submit_application",
force_submit_message=False,
)
service = UserAgentService(self.db)
facts = service._resolve_expense_application_facts(payload)
duplicate = service._find_duplicate_expense_application_record(payload, facts)
if duplicate is not None:
result_payload = {
"status": "blocked",
"blocking": True,
"duplicate_claim_id": str(duplicate.id or ""),
"duplicate_claim_no": str(duplicate.claim_no or ""),
"duplicate_stage": str(duplicate.approval_stage or ""),
}
return StewardActionExecuteResponse(
action_type="run_duplicate_precheck",
status="blocked",
message="检测到同一申请人、同一申请类型、同一时间段已有申请单,已停止直接提交。",
blocked_reasons=["duplicate_application"],
result_payload=result_payload,
trace=[*trace, self._trace("blocked", reason="duplicate_application")],
)
result_payload = {"status": "ok", "blocking": False}
return StewardActionExecuteResponse(
action_type="run_duplicate_precheck",
status="succeeded",
message="未发现重复或冲突申请,可以继续提交。",
result_payload=result_payload,
trace=[*trace, self._trace("completed", mode="duplicate_precheck")],
)
def _execute_application_action(
self,
request: StewardActionExecuteRequest,
current_user: CurrentUserContext,
action_type: str,
trace: list[dict[str, Any]],
) -> StewardActionExecuteResponse:
if action_type == "submit_application" and not request.confirmed:
return StewardActionExecuteResponse(
action_type=action_type,
status="needs_confirmation",
requires_confirmation=True,
message="提交申请前需要用户确认。请确认申请核对表无误后再提交。",
trace=[*trace, self._trace("needs_confirmation")],
)
if action_type == "submit_application":
precheck_blocker = self._resolve_submit_precheck_blocker(request.context_json)
if precheck_blocker:
return self._blocked(
action_type,
precheck_blocker,
blocked_reasons=["precheck_not_passed"],
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",
)
try:
user_agent_response = UserAgentService(self.db)._build_expense_application_response(
payload,
risk_flags=[],
)
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,
}
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,
blocked_reasons=blocked_reasons,
result_payload=result_payload,
trace=[*trace, self._trace("completed", service="UserAgentService")],
)
def _execute_reimbursement_action(
self,
request: StewardActionExecuteRequest,
current_user: CurrentUserContext,
action_type: str,
trace: list[dict[str, Any]],
) -> StewardActionExecuteResponse:
context_json = self._build_reimbursement_context_json(request, current_user)
run_id = self._build_run_id(request, action_type)
ontology = OntologyParseResult(
scenario="expense",
intent="draft",
permission=OntologyPermission(
level="draft_write",
allowed=True,
reason="小财管家白名单动作创建报销草稿。",
),
confidence=1.0,
run_id=run_id,
)
try:
result = ExpenseClaimService(self.db).save_or_submit_from_ontology(
run_id=run_id,
user_id=current_user.username,
message=self._resolve_message(request),
ontology=ontology,
context_json=context_json,
)
except ValueError as exc:
return self._failed(action_type, str(exc), trace)
persisted = str(result.get("claim_id") or "").strip() and str(result.get("status") or "").strip() == "draft"
return StewardActionExecuteResponse(
action_type=action_type,
status="succeeded" if persisted else "blocked",
message=str(result.get("message") or "报销草稿已生成。").strip(),
blocked_reasons=[] if persisted else ["reimbursement_not_persisted"],
result_payload=result,
trace=[*trace, self._trace("completed", service="ExpenseClaimService")],
)
def _execute_associate_attachments_action(
self,
request: StewardActionExecuteRequest,
current_user: CurrentUserContext,
trace: list[dict[str, Any]],
) -> StewardActionExecuteResponse:
receipt_ids = self._resolve_receipt_ids(request)
if not receipt_ids:
return self._blocked(
"associate_attachments",
"关联附件前需要先提供票据夹 receipt_ids。",
blocked_reasons=["missing_receipt_ids"],
trace=[*trace, self._trace("blocked", reason="missing_receipt_ids")],
)
try:
result = AttachmentAssociationJobRunner(self.db).run(
receipt_ids=receipt_ids,
current_user=current_user,
)
except ValueError as exc:
return self._blocked(
"associate_attachments",
str(exc),
blocked_reasons=["attachment_association_blocked"],
trace=[*trace, self._trace("blocked", reason="attachment_association_blocked")],
)
except Exception as exc:
return self._failed("associate_attachments", str(exc), trace)
return StewardActionExecuteResponse(
action_type="associate_attachments",
status="succeeded",
message=str(
result.get("message")
or f"已自动关联到 {result.get('claim_no') or '报销草稿'}"
).strip(),
result_payload={
**dict(result or {}),
"receipt_ids": receipt_ids,
},
trace=[*trace, self._trace("completed", service="AttachmentAssociationJobRunner")],
)
def _build_application_user_agent_request(
self,
request: StewardActionExecuteRequest,
current_user: CurrentUserContext,
*,
action_type: str,
force_submit_message: bool,
) -> UserAgentRequest:
run_id = self._build_run_id(request, action_type)
context_json = self._build_application_context_json(request, current_user, action_type)
message = self._resolve_message(request)
if force_submit_message and "确认提交" not in message and "直接提交" not in message:
message = "\n".join([message, "确认提交"]).strip()
ontology = OntologyParseResult(
scenario="expense",
intent="operate",
permission=OntologyPermission(
level="approval_required",
allowed=True,
reason="小财管家白名单动作执行申请操作。",
),
confidence=1.0,
run_id=run_id,
)
return UserAgentRequest(
run_id=run_id,
user_id=current_user.username,
message=message,
ontology=ontology,
context_json=context_json,
tool_payload={},
selected_capability_codes=[],
degraded=False,
requires_confirmation=False,
)
def _build_application_context_json(
self,
request: StewardActionExecuteRequest,
current_user: CurrentUserContext,
action_type: str,
) -> dict[str, Any]:
fields = self._resolve_ontology_fields(request.task)
preview_fields = {
"applicationType": self._resolve_application_type_label(fields.get("expense_type")),
"time": str(fields.get("time_range") or ""),
"location": str(fields.get("location") or ""),
"reason": str(fields.get("reason") or ""),
"days": self._resolve_days_text(fields.get("time_range")),
"transportMode": self._resolve_transport_label(fields.get("transport_mode")),
"amount": str(fields.get("amount") or ""),
"applicant": current_user.name,
"department": current_user.department_name,
"position": current_user.position,
"grade": current_user.grade,
"managerName": current_user.manager_name,
}
context_json = {
**dict(request.context_json or {}),
"session_type": "application",
"entry_source": "steward_action_executor",
"document_type": "expense_application",
"application_stage": "expense_application",
"role_codes": current_user.role_codes,
"is_admin": current_user.is_admin,
"username": current_user.username,
"name": current_user.name,
"department_name": current_user.department_name,
"position": current_user.position,
"grade": current_user.grade,
"employee_no": current_user.employee_no,
"manager_name": current_user.manager_name,
"application_preview": {"fields": preview_fields},
}
if action_type == "save_application_draft":
context_json["application_action"] = "save_draft"
context_json["application_save_mode"] = True
return context_json
def _build_reimbursement_context_json(
self,
request: StewardActionExecuteRequest,
current_user: CurrentUserContext,
) -> dict[str, Any]:
fields = self._resolve_ontology_fields(request.task)
review_form_values = {
"expense_type": self._resolve_expense_type_label(fields.get("expense_type")),
"time_range": str(fields.get("time_range") or ""),
"occurred_date": str(fields.get("time_range") or ""),
"location": str(fields.get("location") or ""),
"reason": str(fields.get("reason") or ""),
"amount": str(fields.get("amount") or ""),
"transport_mode": self._resolve_transport_label(fields.get("transport_mode")),
"attachments": str(fields.get("attachments") or ""),
}
return {
**dict(request.context_json or {}),
"session_type": "expense",
"entry_source": "steward_action_executor",
"review_action": "save_draft",
"review_form_values": review_form_values,
"user_input_text": self._resolve_message(request),
"role_codes": current_user.role_codes,
"is_admin": current_user.is_admin,
"username": current_user.username,
"name": current_user.name,
"department_name": current_user.department_name,
"position": current_user.position,
"grade": current_user.grade,
"employee_no": current_user.employee_no,
"manager_name": current_user.manager_name,
}
@staticmethod
def _resolve_receipt_ids(request: StewardActionExecuteRequest) -> list[str]:
context_json = dict(request.context_json or {})
raw_values = context_json.get("receipt_ids") or context_json.get("receiptIds") or []
if isinstance(raw_values, str):
raw_values = [item.strip() for item in raw_values.split(",")]
if not isinstance(raw_values, list):
raw_values = []
receipt_ids = [
str(item or "").strip()
for item in raw_values
if str(item or "").strip()
]
if receipt_ids:
return list(dict.fromkeys(receipt_ids))
fields = StewardActionExecutor._resolve_ontology_fields(request.task)
raw_receipt = str(fields.get("receipt_ids") or fields.get("receiptIds") or "").strip()
if not raw_receipt:
return []
return list(dict.fromkeys(item.strip() for item in raw_receipt.split(",") if item.strip()))
@staticmethod
def _resolve_submit_precheck_blocker(context_json: dict[str, Any]) -> str:
precheck = context_json.get("precheck_result") or context_json.get("precheckResult")
if not isinstance(precheck, dict):
return "提交申请前需要先完成重复/冲突预检查。"
if bool(precheck.get("blocking")):
return str(precheck.get("summary") or "重复/冲突预检查未通过,已停止提交。").strip()
status = str(precheck.get("status") or "").strip().lower()
if status not in {"ok", "passed", "succeeded"}:
return "重复/冲突预检查未通过,已停止提交。"
return ""
@staticmethod
def _resolve_task_blockers(task: StewardTask | None) -> list[str]:
if task is None:
return []
return [
str(field or "").strip()
for field in list(task.missing_fields or [])
if str(field or "").strip()
]
@staticmethod
def _resolve_ontology_fields(task: StewardTask | None) -> dict[str, str]:
if task is None or not isinstance(task.ontology_fields, dict):
return {}
return {
str(key or "").strip(): str(value or "").strip()
for key, value in task.ontology_fields.items()
if str(key or "").strip() and str(value or "").strip()
}
@staticmethod
def _resolve_message(request: StewardActionExecuteRequest) -> str:
message = str(request.message or "").strip()
if message:
return message
if request.task is not None:
return str(request.task.summary or request.task.title or "").strip()
return "小财管家动作执行"
@staticmethod
def _resolve_transport_label(value: Any) -> str:
text = str(value or "").strip()
return TRANSPORT_MODE_LABELS.get(text.lower(), text)
@staticmethod
def _resolve_application_type_label(value: Any) -> str:
text = str(value or "").strip()
return APPLICATION_TYPE_LABELS.get(text.lower(), text or "费用申请")
@staticmethod
def _resolve_expense_type_label(value: Any) -> str:
text = str(value or "").strip()
return EXPENSE_TYPE_LABELS.get(text.lower(), text or "其他费用")
@staticmethod
def _resolve_days_text(value: Any) -> str:
days = resolve_application_days_from_time_range(str(value or ""))
return f"{days}" if days else ""
@staticmethod
def _normalize_action_type(value: str) -> str:
return str(value or "").strip()
@staticmethod
def _build_run_id(request: StewardActionExecuteRequest, action_type: str) -> str:
trace_id = str(request.client_trace_id or "").strip()
if trace_id:
return f"steward-action:{trace_id}"
task_id = str(request.task.task_id if request.task is not None else "").strip()
suffix = task_id or datetime.now(UTC).strftime("%Y%m%d%H%M%S%f")
return f"steward-action:{action_type}:{suffix}"
@staticmethod
def _trace(stage: str, **extra: Any) -> dict[str, Any]:
return {
"stage": stage,
"at": datetime.now(UTC).isoformat(),
**extra,
}
@staticmethod
def _blocked(
action_type: str,
message: str,
*,
blocked_reasons: list[str] | None = None,
trace: list[dict[str, Any]] | None = None,
) -> StewardActionExecuteResponse:
return StewardActionExecuteResponse(
action_type=action_type,
status="blocked",
execution_source="rule_fallback",
fallback_used=True,
message=message,
blocked_reasons=blocked_reasons or [message],
trace=trace or [],
)
def _failed(
self,
action_type: str,
message: str,
trace: list[dict[str, Any]],
) -> StewardActionExecuteResponse:
return StewardActionExecuteResponse(
action_type=action_type,
status="failed",
message=message or "动作执行失败。",
blocked_reasons=[message] if message else [],
trace=[*trace, self._trace("failed", reason=message)],
)