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