Files
X-Financial/server/src/app/services/steward_query_executors.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

316 lines
11 KiB
Python

from __future__ import annotations
from typing import Any
from app.api.deps import CurrentUserContext
from app.schemas.steward import (
StewardActionExecuteRequest,
StewardActionExecuteResponse,
StewardActionStep,
StewardTask,
)
from app.services.expense_rule_runtime_defaults import DEFAULT_TRAVEL_POLICY_CONFIG
from app.services.runtime_chat import RuntimeChatService
# 城市分级标签:差旅政策 city_tier 到面向用户的城市档位名称
CITY_TIER_LABELS = {
"tier_1": "一类城市(北上广深)",
"tier_2": "二类城市(省会及重点城市)",
"tier_3": "三类城市(其他地区)",
}
# 交通等级编码到中文标签
TRANSPORT_LEVEL_LABELS = {
1: "经济舱/二等座(普通席别)",
2: "高端经济舱/一等座(中级席别)",
3: "公务舱/商务座(高级席别)",
4: "头等舱(最高席别)",
}
def build_travel_standard_query_steps(task: StewardTask) -> list[StewardActionStep]:
"""生成差旅标准查询任务的动作步骤。
查询不产生副作用,无需校验必填、保存或提交,只生成单步执行动作。
"""
fields = _resolve_task_fields(task)
return [
StewardActionStep(
step_id=f"{task.task_id}:01",
action_type="execute_travel_standard_query",
label="查询差旅标准",
target_task_id=task.task_id,
status="planned",
requires_confirmation=False,
payload={
"task_id": task.task_id,
"ontology_fields": fields,
},
)
]
def execute_travel_standard_query(
executor: Any,
request: StewardActionExecuteRequest,
current_user: CurrentUserContext,
trace: list[dict[str, Any]],
) -> StewardActionExecuteResponse:
"""执行差旅标准查询:检索业务数据 → 交给 LLM 整理成自然语言。
数据源为 DEFAULT_TRAVEL_POLICY_CONFIG(住宿标准 by 职级×城市分级、
交通等级 by 职级)。补助标准当前未纳入运行时配置,用制度说明兜底。
"""
action_type = "execute_travel_standard_query"
fields = _resolve_task_fields(request.task)
message = _resolve_message(request)
location = str(fields.get("location") or "").strip()
employee_grade = _resolve_employee_grade(fields, current_user)
standard_category = str(fields.get("standard_category") or "").strip().lower()
standards = resolve_travel_standard_snapshot(
location=location,
employee_grade=employee_grade,
standard_category=standard_category,
)
if not standards["matched_any"]:
answer = _build_no_match_answer(location, employee_grade, standard_category)
return StewardActionExecuteResponse(
action_type=action_type,
status="succeeded",
message=answer,
result_payload={
"answer_markdown": answer,
"standards": standards,
"matched": False,
},
trace=[*trace, _trace("completed", mode="query_no_match")],
)
answer = _compose_travel_standard_answer(
message=message,
standards=standards,
location=location,
employee_grade=employee_grade,
)
return StewardActionExecuteResponse(
action_type=action_type,
status="succeeded",
message=answer,
result_payload={
"answer_markdown": answer,
"standards": standards,
"matched": True,
},
trace=[*trace, _trace("completed", mode="query_travel_standard")],
)
def resolve_travel_standard_snapshot(
*,
location: str,
employee_grade: str,
standard_category: str = "",
) -> dict[str, Any]:
"""按地点、职级和关注标准类别,从差旅政策配置检索确定性标准数值。
standard_category 为空表示返回全部类别;非空时只返回指定类别。
支持的类别:lodging(住宿)、transport(交通)、allowance(补助)。
"""
config = DEFAULT_TRAVEL_POLICY_CONFIG
city_tiers = config.get("city_tiers", {})
hotel_limits = config.get("hotel_limits", {})
transport_limits = config.get("transport_limits", {})
band_labels = config.get("band_labels", {})
normalized_city = str(location or "").strip()
city_tier = city_tiers.get(normalized_city, "tier_3") if normalized_city else "tier_3"
normalized_grade = _normalize_grade(employee_grade)
snapshot: dict[str, Any] = {
"location": normalized_city or "",
"city_tier": city_tier,
"city_tier_label": CITY_TIER_LABELS.get(city_tier, city_tier),
"employee_grade": normalized_grade,
"employee_grade_label": band_labels.get(normalized_grade, normalized_grade or "未指定"),
"standard_category": standard_category or "",
"matched_any": False,
"lodging": None,
"transport": None,
"allowance": None,
}
want_all = not standard_category
if want_all or standard_category == "lodging":
lodging_cap = _resolve_lodging_cap(hotel_limits, normalized_grade, city_tier)
if lodging_cap is not None:
snapshot["lodging"] = {
"daily_cap": str(lodging_cap),
"unit": "元/晚",
}
snapshot["matched_any"] = True
if want_all or standard_category == "transport":
transport_band = _resolve_transport_band(transport_limits, normalized_grade)
if transport_band is not None:
snapshot["transport"] = {
"flight_level": transport_band.get("flight"),
"train_level": transport_band.get("train"),
"flight_label": TRANSPORT_LEVEL_LABELS.get(int(transport_band.get("flight", 0)), ""),
"train_label": TRANSPORT_LEVEL_LABELS.get(int(transport_band.get("train", 0)), ""),
}
snapshot["matched_any"] = True
if want_all or standard_category == "allowance":
# 补助标准当前未纳入运行时配置,用占位说明,等补助数据源接入后补全。
# 占位说明不计入 matched_any,避免无有效数据时仍标记为已匹配。
snapshot["allowance"] = {
"note": "出差补助标准按地区(直辖市/港澳台/境外等)分档,具体数值请参考《公司差旅费报销规则》或咨询财务。",
}
return snapshot
def _resolve_lodging_cap(
hotel_limits: dict[str, Any],
grade: str,
city_tier: str,
) -> str | None:
grade_entry = hotel_limits.get(grade)
if not isinstance(grade_entry, dict):
return None
cap = grade_entry.get(city_tier)
return str(cap).strip() if cap is not None else None
def _resolve_transport_band(
transport_limits: dict[str, Any],
grade: str,
) -> dict[str, Any] | None:
band = transport_limits.get(grade)
if not isinstance(band, dict):
return None
return {"flight": band.get("flight"), "train": band.get("train")}
def _normalize_grade(value: str) -> str:
normalized = str(value or "").strip().upper()
if normalized in {"", "未指定", "未知"}:
return ""
if normalized in DEFAULT_TRAVEL_POLICY_CONFIG.get("band_labels", {}):
return normalized
# 容忍 P05 / p5 等写法
compact = normalized.lstrip("Pp")
if compact.isdigit():
candidate = f"P{int(compact)}"
if candidate in DEFAULT_TRAVEL_POLICY_CONFIG.get("band_labels", {}):
return candidate
return normalized
def _resolve_employee_grade(
fields: dict[str, str],
current_user: CurrentUserContext,
) -> str:
grade = str(fields.get("employee_grade") or "").strip()
if grade:
return grade
return str(getattr(current_user, "grade", "") or "").strip()
def _resolve_task_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()
}
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 "差旅标准查询"
def _build_no_match_answer(location: str, employee_grade: str, standard_category: str) -> str:
parts = ["### 未能匹配到具体差旅标准"]
details = []
if location:
details.append(f"目的地:**{location}**")
if employee_grade:
details.append(f"职级:**{employee_grade}**")
if standard_category:
details.append(f"关注类别:**{standard_category}**")
if details:
parts.append("")
parts.append("当前识别到:" + "".join(details) + "")
parts.append("")
parts.append(
"请补充更明确的信息,例如\"P5 去武汉出差的住宿标准是多少\","
"或直接说\"查武汉的住宿标准\""
)
return "\n".join(parts)
def _compose_travel_standard_answer(
*,
message: str,
standards: dict[str, Any],
location: str,
employee_grade: str,
) -> str:
"""把结构化标准整理成面向用户的 Markdown 回复。
优先用确定性数据拼装;如需更自然的表述,可在此处接入 LLM,
当前阶段确定性拼装已足够清晰,避免额外模型调用开销。
"""
lines = ["### 差旅标准查询结果"]
context_parts = []
if location:
context_parts.append(f"目的地 **{location}**({standards.get('city_tier_label', '')})")
if employee_grade:
context_parts.append(f"职级 **{standards.get('employee_grade_label', employee_grade)}**")
if context_parts:
lines.append("")
lines.append("查询条件:" + "".join(context_parts) + "")
lodging = standards.get("lodging")
transport = standards.get("transport")
allowance = standards.get("allowance")
if lodging:
lines.append("")
lines.append(f"- **住宿标准**:{lodging['daily_cap']} {lodging['unit']}")
if transport:
lines.append(
f"- **交通工具等级**:飞机 {transport.get('flight_label', '')}"
f"火车 {transport.get('train_label', '')}"
)
if allowance:
lines.append(f"- **出差补助**:{allowance.get('note', '')}")
lines.append("")
lines.append(
"> 标准依据公司差旅政策运行时配置。如需了解超标说明、多城市行程等例外口径,"
"请进一步描述您的场景。"
)
return "\n".join(lines)
def _trace(stage: str, **extra: Any) -> dict[str, Any]:
from datetime import UTC, datetime
return {
"stage": stage,
"at": datetime.now(UTC).isoformat(),
**extra,
}