- 新增 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 测试
316 lines
11 KiB
Python
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,
|
|
}
|