2026-06-23 11:21:18 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import re
|
|
|
|
|
|
import uuid
|
|
|
|
|
|
from datetime import date
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
from app.schemas.steward import (
|
|
|
|
|
|
StewardCandidateFlow,
|
|
|
|
|
|
StewardConfirmationAction,
|
|
|
|
|
|
StewardPendingFlowConfirmation,
|
|
|
|
|
|
StewardPlanRequest,
|
|
|
|
|
|
StewardPlanResponse,
|
|
|
|
|
|
StewardTask,
|
|
|
|
|
|
StewardThinkingEvent,
|
|
|
|
|
|
)
|
|
|
|
|
|
from app.services.steward_planner_shared import (
|
|
|
|
|
|
APPLICATION_SPLIT_PATTERN,
|
|
|
|
|
|
BUSINESS_FIELD_LABELS,
|
|
|
|
|
|
PlannedTaskDraft,
|
|
|
|
|
|
REIMBURSEMENT_PATTERN,
|
|
|
|
|
|
STEWARD_BUSINESS_SIGNAL_KEYWORDS,
|
|
|
|
|
|
STEWARD_GREETING_KEYWORDS,
|
|
|
|
|
|
STEWARD_OFF_TOPIC_SCENARIO_GREETING,
|
|
|
|
|
|
STEWARD_OFF_TOPIC_SCENARIO_MEANINGLESS,
|
|
|
|
|
|
STEWARD_OFF_TOPIC_SCENARIO_OFF_BUSINESS,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class StewardPlannerFallbackMixin:
|
|
|
|
|
|
def _should_use_model_intent_recognition(
|
|
|
|
|
|
self,
|
|
|
|
|
|
message: str,
|
|
|
|
|
|
base_date: date,
|
|
|
|
|
|
request: StewardPlanRequest,
|
|
|
|
|
|
) -> bool:
|
2026-06-24 21:58:35 +08:00
|
|
|
|
return bool(message.strip())
|
|
|
|
|
|
|
2026-06-23 11:21:18 +08:00
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _is_business_irrelevant_input(message: str, request: StewardPlanRequest) -> bool:
|
|
|
|
|
|
"""判断输入是否与小财管家支持的财务事项完全无关(向后兼容包装)。
|
|
|
|
|
|
|
|
|
|
|
|
判定规则:消息去除所有空白后不含任何业务信号关键词,且没有上传附件。
|
|
|
|
|
|
实际判定逻辑由 _classify_irrelevant_input 负责,命中任何场景即视为业务无关。
|
|
|
|
|
|
"""
|
|
|
|
|
|
return StewardPlannerFallbackMixin._classify_irrelevant_input(message, request) is not None
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _classify_irrelevant_input(message: str, request: StewardPlanRequest) -> str | None:
|
|
|
|
|
|
"""把业务无关输入细分为三个场景,便于给出更贴切的引导。
|
|
|
|
|
|
|
|
|
|
|
|
返回值:
|
|
|
|
|
|
- "greeting":礼貌问候("你好"等),无业务关键词
|
|
|
|
|
|
- "meaningless":完全无意义内容(纯数字、纯标点、单字符重复、纯字母数字乱码)
|
|
|
|
|
|
- "off_business":有意义但与财务无关(问天气、聊生活等)
|
|
|
|
|
|
- None:消息与业务相关,无需走 off_topic 路径
|
|
|
|
|
|
"""
|
|
|
|
|
|
if request.attachments:
|
|
|
|
|
|
return None
|
|
|
|
|
|
compact = re.sub(r"\s+", "", message)
|
|
|
|
|
|
if not compact:
|
|
|
|
|
|
return None
|
|
|
|
|
|
if any(keyword in compact for keyword in STEWARD_BUSINESS_SIGNAL_KEYWORDS):
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
if StewardPlannerFallbackMixin._looks_like_greeting(compact):
|
|
|
|
|
|
return STEWARD_OFF_TOPIC_SCENARIO_GREETING
|
|
|
|
|
|
if StewardPlannerFallbackMixin._looks_like_meaningless(compact):
|
|
|
|
|
|
return STEWARD_OFF_TOPIC_SCENARIO_MEANINGLESS
|
|
|
|
|
|
return STEWARD_OFF_TOPIC_SCENARIO_OFF_BUSINESS
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _looks_like_greeting(compact_message: str) -> bool:
|
|
|
|
|
|
"""判断消息是否只是礼貌问候(无其他有意义内容)。"""
|
|
|
|
|
|
normalized = compact_message.lower()
|
|
|
|
|
|
for keyword in STEWARD_GREETING_KEYWORDS:
|
|
|
|
|
|
if normalized == keyword.lower() or normalized.startswith(keyword.lower()):
|
|
|
|
|
|
# 整句只是问候词(允许少量标点)
|
|
|
|
|
|
tail = normalized[len(keyword.lower()):]
|
|
|
|
|
|
if not tail or re.fullmatch(r"[!!。.??,,~\s]+", tail):
|
|
|
|
|
|
return True
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _looks_like_meaningless(compact_message: str) -> bool:
|
|
|
|
|
|
"""判断消息是否完全没有语义价值(纯数字、纯标点、单字符重复等)。"""
|
|
|
|
|
|
if re.fullmatch(r"\d+", compact_message):
|
|
|
|
|
|
return True
|
|
|
|
|
|
# 纯标点
|
|
|
|
|
|
if re.fullmatch(r"[\W_]+", compact_message):
|
|
|
|
|
|
return True
|
|
|
|
|
|
# 单字符重复(例如 "啊啊啊啊啊")
|
|
|
|
|
|
if len(compact_message) >= 2 and len(set(compact_message)) == 1:
|
|
|
|
|
|
return True
|
|
|
|
|
|
# 短字母数字组合但没有任何业务意义,例如 "abc"、"test123"
|
|
|
|
|
|
# 注意:必须排除已经被关键词命中的情况(前面的判定已保证不命中关键词)
|
|
|
|
|
|
if re.fullmatch(r"[a-zA-Z0-9]+", compact_message) and len(compact_message) <= 12:
|
|
|
|
|
|
return True
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
def _build_off_topic_plan(
|
|
|
|
|
|
self,
|
|
|
|
|
|
request: StewardPlanRequest,
|
|
|
|
|
|
*,
|
|
|
|
|
|
scenario: str,
|
2026-06-24 21:58:35 +08:00
|
|
|
|
model_call_traces: list[dict[str, Any]] | None = None,
|
|
|
|
|
|
fallback_reason: str = "",
|
2026-06-23 11:21:18 +08:00
|
|
|
|
) -> StewardPlanResponse:
|
|
|
|
|
|
"""业务无关输入的兜底计划:根据场景给出对应引导,off_business 场景可由 LLM 增强。"""
|
|
|
|
|
|
base_summary = self._default_off_topic_summary(scenario)
|
|
|
|
|
|
thinking_event = self._build_off_topic_thinking_event(scenario)
|
|
|
|
|
|
suggested_prompts = self._off_topic_suggested_prompts(scenario)
|
2026-06-24 21:58:35 +08:00
|
|
|
|
traces = list(model_call_traces or [])
|
2026-06-23 11:21:18 +08:00
|
|
|
|
|
|
|
|
|
|
# 仅对 off_business 场景尝试让 LLM 生成多样化引导;问候/无意义场景用规则模板即可。
|
|
|
|
|
|
if (
|
|
|
|
|
|
scenario == STEWARD_OFF_TOPIC_SCENARIO_OFF_BUSINESS
|
|
|
|
|
|
and self.off_topic_agent is not None
|
|
|
|
|
|
):
|
|
|
|
|
|
try:
|
|
|
|
|
|
llm_result = self.off_topic_agent.generate(request, scenario=scenario)
|
|
|
|
|
|
if llm_result is not None and llm_result.response_text:
|
|
|
|
|
|
base_summary = llm_result.response_text
|
2026-06-24 21:58:35 +08:00
|
|
|
|
traces = llm_result.model_call_traces
|
2026-06-23 11:21:18 +08:00
|
|
|
|
except Exception:
|
|
|
|
|
|
# 失败时静默回退到规则模板
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
2026-06-24 21:58:35 +08:00
|
|
|
|
thinking_events = [thinking_event]
|
|
|
|
|
|
if fallback_reason:
|
|
|
|
|
|
thinking_events.insert(
|
|
|
|
|
|
0,
|
|
|
|
|
|
StewardThinkingEvent(
|
|
|
|
|
|
event_id="intent_agent_rule_fallback",
|
|
|
|
|
|
stage="rule_fallback",
|
|
|
|
|
|
title="意图识别智能体进入兜底模式",
|
|
|
|
|
|
content=fallback_reason,
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-23 11:21:18 +08:00
|
|
|
|
return StewardPlanResponse(
|
|
|
|
|
|
plan_id=f"steward_plan_{uuid.uuid4().hex[:12]}",
|
|
|
|
|
|
plan_status="off_topic",
|
|
|
|
|
|
planning_source="rule_fallback",
|
|
|
|
|
|
next_action="none",
|
|
|
|
|
|
summary=base_summary,
|
2026-06-24 21:58:35 +08:00
|
|
|
|
thinking_events=thinking_events,
|
2026-06-23 11:21:18 +08:00
|
|
|
|
tasks=[],
|
|
|
|
|
|
attachment_groups=[],
|
|
|
|
|
|
confirmation_groups=[],
|
|
|
|
|
|
candidate_flows=[],
|
|
|
|
|
|
suggested_prompts=suggested_prompts,
|
2026-06-24 21:58:35 +08:00
|
|
|
|
model_call_traces=traces,
|
2026-06-23 11:21:18 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _default_off_topic_summary(scenario: str) -> str:
|
|
|
|
|
|
"""off_topic 场景的默认引导文案;LLM 不可用时使用。"""
|
|
|
|
|
|
if scenario == STEWARD_OFF_TOPIC_SCENARIO_GREETING:
|
|
|
|
|
|
return (
|
|
|
|
|
|
"### 您好主人,很高兴为您服务\n\n"
|
|
|
|
|
|
"请问您今天要办理什么业务?目前小财管家能帮您整理"
|
|
|
|
|
|
"**费用申请**和**费用报销**这两类事项。\n\n"
|
|
|
|
|
|
"要不您换种说法告诉我:"
|
|
|
|
|
|
)
|
|
|
|
|
|
if scenario == STEWARD_OFF_TOPIC_SCENARIO_OFF_BUSINESS:
|
|
|
|
|
|
return (
|
|
|
|
|
|
"### 抱歉主人,这句话我暂时帮不上忙\n\n"
|
|
|
|
|
|
"我看了您刚才说的这句话,里面聊的不是财务事项。"
|
|
|
|
|
|
"小财管家目前只能帮您整理**费用申请**和**费用报销**这两类业务。\n\n"
|
|
|
|
|
|
"要不您换种说法告诉我:"
|
|
|
|
|
|
)
|
|
|
|
|
|
# meaningless
|
|
|
|
|
|
return (
|
|
|
|
|
|
"### 这句话我暂时没识别到财务事项\n\n"
|
|
|
|
|
|
"很抱歉主人,目前小财管家只能帮您整理**费用申请**和**费用报销**这两类事项。\n\n"
|
|
|
|
|
|
"要不您换种说法告诉我:"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _build_off_topic_thinking_event(scenario: str) -> StewardThinkingEvent:
|
|
|
|
|
|
"""off_topic 场景下向用户展示的思考过程摘要。"""
|
|
|
|
|
|
if scenario == STEWARD_OFF_TOPIC_SCENARIO_GREETING:
|
|
|
|
|
|
return StewardThinkingEvent(
|
|
|
|
|
|
event_id="intent_agent_off_topic_greeting",
|
|
|
|
|
|
stage="off_topic",
|
|
|
|
|
|
title="先回应主人的问候",
|
|
|
|
|
|
content="主人向我打了个招呼,我先礼貌回应一下,再引导他/她说出具体想办什么业务。",
|
|
|
|
|
|
)
|
|
|
|
|
|
if scenario == STEWARD_OFF_TOPIC_SCENARIO_OFF_BUSINESS:
|
|
|
|
|
|
return StewardThinkingEvent(
|
|
|
|
|
|
event_id="intent_agent_off_topic_non_business",
|
|
|
|
|
|
stage="off_topic",
|
|
|
|
|
|
title="这句话不在服务范围内",
|
|
|
|
|
|
content="我看了您刚才说的这句话,里面聊的不是财务事项。小财管家目前只能帮您整理费用申请和费用报销。",
|
|
|
|
|
|
)
|
|
|
|
|
|
return StewardThinkingEvent(
|
|
|
|
|
|
event_id="intent_agent_off_topic_meaningless",
|
|
|
|
|
|
stage="off_topic",
|
|
|
|
|
|
title="未识别到财务事项",
|
|
|
|
|
|
content=(
|
|
|
|
|
|
"我仔细看了看您刚才说的这句话,里面好像没有出现"
|
|
|
|
|
|
"费用申请、报销、出差、交通、招待这些财务关键词。"
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _off_topic_suggested_prompts(scenario: str) -> list[str]:
|
|
|
|
|
|
"""off_topic 场景下展示给用户的推荐话术。"""
|
|
|
|
|
|
if scenario == STEWARD_OFF_TOPIC_SCENARIO_GREETING:
|
|
|
|
|
|
return [
|
|
|
|
|
|
"我想要申请明天去北京出差3天,支撑客户现场实施",
|
|
|
|
|
|
"我要报销昨天的交通费",
|
|
|
|
|
|
"我上周出差去上海的费用需要报销",
|
|
|
|
|
|
]
|
|
|
|
|
|
if scenario == STEWARD_OFF_TOPIC_SCENARIO_OFF_BUSINESS:
|
|
|
|
|
|
return [
|
|
|
|
|
|
"我想要申请明天去北京出差3天,支撑客户现场实施",
|
|
|
|
|
|
"我要报销昨天的交通费",
|
|
|
|
|
|
"我需要整理上周出差的发票",
|
|
|
|
|
|
]
|
|
|
|
|
|
# meaningless
|
|
|
|
|
|
return [
|
|
|
|
|
|
"我想要申请明天去北京出差3天,支撑客户现场实施",
|
|
|
|
|
|
"我要报销昨天的交通费",
|
|
|
|
|
|
"我上周出差去上海的费用需要报销",
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
def _build_rule_fallback_plan(
|
|
|
|
|
|
self,
|
|
|
|
|
|
request: StewardPlanRequest,
|
|
|
|
|
|
*,
|
|
|
|
|
|
base_date: date,
|
|
|
|
|
|
model_call_traces: list[dict[str, Any]] | None = None,
|
|
|
|
|
|
fallback_reason: str = "",
|
|
|
|
|
|
) -> StewardPlanResponse:
|
|
|
|
|
|
message = self._clean_text(request.message)
|
|
|
|
|
|
if self._looks_like_ambiguous_travel_flow(message, base_date, request):
|
|
|
|
|
|
return self._build_pending_flow_fallback_plan(
|
|
|
|
|
|
request,
|
|
|
|
|
|
base_date=base_date,
|
|
|
|
|
|
model_call_traces=model_call_traces,
|
|
|
|
|
|
fallback_reason=fallback_reason,
|
|
|
|
|
|
)
|
|
|
|
|
|
task_drafts = self._extract_task_drafts(message)
|
|
|
|
|
|
tasks = [self._build_task(draft, base_date, request) for draft in task_drafts]
|
|
|
|
|
|
if not tasks:
|
|
|
|
|
|
tasks = [self._build_fallback_task(message, base_date, request)]
|
|
|
|
|
|
|
|
|
|
|
|
attachment_groups = self._build_attachment_groups(request.attachments, tasks)
|
|
|
|
|
|
confirmation_groups = self._build_confirmation_actions(tasks, attachment_groups)
|
|
|
|
|
|
thinking_events = self._build_thinking_events(tasks, attachment_groups, request.attachments)
|
|
|
|
|
|
if fallback_reason:
|
|
|
|
|
|
thinking_events.insert(
|
|
|
|
|
|
0,
|
|
|
|
|
|
StewardThinkingEvent(
|
|
|
|
|
|
event_id="intent_agent_rule_fallback",
|
|
|
|
|
|
stage="rule_fallback",
|
|
|
|
|
|
title="意图识别智能体进入兜底模式",
|
|
|
|
|
|
content=fallback_reason,
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
plan_id = f"steward_plan_{uuid.uuid4().hex[:12]}"
|
|
|
|
|
|
|
|
|
|
|
|
return StewardPlanResponse(
|
|
|
|
|
|
plan_id=plan_id,
|
|
|
|
|
|
plan_status="needs_confirmation" if confirmation_groups else "ready_to_delegate",
|
|
|
|
|
|
planning_source="rule_fallback",
|
|
|
|
|
|
next_action="confirm_task" if confirmation_groups else "delegate_task",
|
|
|
|
|
|
summary=self._build_summary(tasks, attachment_groups),
|
|
|
|
|
|
thinking_events=thinking_events,
|
|
|
|
|
|
tasks=tasks,
|
|
|
|
|
|
attachment_groups=attachment_groups,
|
|
|
|
|
|
confirmation_groups=confirmation_groups,
|
|
|
|
|
|
model_call_traces=model_call_traces or [],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def _build_pending_flow_fallback_plan(
|
|
|
|
|
|
self,
|
|
|
|
|
|
request: StewardPlanRequest,
|
|
|
|
|
|
*,
|
|
|
|
|
|
base_date: date,
|
|
|
|
|
|
model_call_traces: list[dict[str, Any]] | None = None,
|
|
|
|
|
|
fallback_reason: str = "",
|
|
|
|
|
|
planning_source: str = "rule_fallback",
|
|
|
|
|
|
) -> StewardPlanResponse:
|
|
|
|
|
|
candidates = self._build_rule_candidate_flows(request, base_date)
|
|
|
|
|
|
gate = self._resolve_required_application_gate(request, "travel")
|
|
|
|
|
|
pending_reason = self._build_pending_flow_reason(gate)
|
|
|
|
|
|
pending = StewardPendingFlowConfirmation(
|
|
|
|
|
|
status="pending",
|
|
|
|
|
|
source_message=request.message,
|
|
|
|
|
|
reason=pending_reason,
|
|
|
|
|
|
candidate_flows=candidates,
|
|
|
|
|
|
)
|
|
|
|
|
|
thinking_events = []
|
|
|
|
|
|
if fallback_reason:
|
|
|
|
|
|
thinking_events.append(
|
|
|
|
|
|
StewardThinkingEvent(
|
|
|
|
|
|
event_id="intent_agent_rule_fallback",
|
|
|
|
|
|
stage="rule_fallback",
|
|
|
|
|
|
title="意图识别智能体进入兜底模式",
|
|
|
|
|
|
content=fallback_reason,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
thinking_events.append(
|
|
|
|
|
|
StewardThinkingEvent(
|
|
|
|
|
|
event_id="intent_pending_flow_confirmation",
|
|
|
|
|
|
stage="flow_confirmation",
|
|
|
|
|
|
title="需要确认流程方向",
|
|
|
|
|
|
content=pending_reason,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
return StewardPlanResponse(
|
|
|
|
|
|
plan_id=f"steward_plan_{uuid.uuid4().hex[:12]}",
|
|
|
|
|
|
plan_status="needs_flow_confirmation",
|
|
|
|
|
|
planning_source=planning_source, # type: ignore[arg-type]
|
|
|
|
|
|
next_action="confirm_flow",
|
|
|
|
|
|
summary=self._build_pending_flow_summary(gate),
|
|
|
|
|
|
thinking_events=thinking_events,
|
|
|
|
|
|
pending_flow_confirmation=pending,
|
|
|
|
|
|
candidate_flows=candidates,
|
|
|
|
|
|
model_call_traces=model_call_traces or [],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def _build_rule_candidate_flows(
|
|
|
|
|
|
self,
|
|
|
|
|
|
request: StewardPlanRequest,
|
|
|
|
|
|
base_date: date,
|
|
|
|
|
|
) -> list[StewardCandidateFlow]:
|
|
|
|
|
|
application_fields = self._extract_ontology_fields(
|
|
|
|
|
|
request.message,
|
|
|
|
|
|
"expense_application",
|
|
|
|
|
|
base_date,
|
|
|
|
|
|
request,
|
|
|
|
|
|
)
|
|
|
|
|
|
reimbursement_fields = self._extract_ontology_fields(
|
|
|
|
|
|
request.message,
|
|
|
|
|
|
"reimbursement",
|
|
|
|
|
|
base_date,
|
|
|
|
|
|
request,
|
|
|
|
|
|
)
|
|
|
|
|
|
gate = self._resolve_required_application_gate(request, "travel")
|
|
|
|
|
|
if gate.get("checked") and int(gate.get("candidate_count") or 0) <= 0:
|
|
|
|
|
|
return [
|
|
|
|
|
|
StewardCandidateFlow(
|
|
|
|
|
|
flow_id="travel_application",
|
|
|
|
|
|
label="先发起出差申请",
|
|
|
|
|
|
confidence=0.86,
|
2026-06-24 10:42:24 +08:00
|
|
|
|
reason="已先查询您名下可关联的差旅申请单,暂未查到可关联单据,因此应先申请单据。",
|
2026-06-23 11:21:18 +08:00
|
|
|
|
ontology_fields=application_fields,
|
|
|
|
|
|
missing_fields=self._resolve_missing_fields("expense_application", application_fields),
|
|
|
|
|
|
)
|
|
|
|
|
|
]
|
|
|
|
|
|
reimbursement_label = "发起费用报销"
|
|
|
|
|
|
reimbursement_reason = "用户描述的也可能是已发生出差事项,需要进入报销材料整理。"
|
|
|
|
|
|
if gate.get("checked"):
|
|
|
|
|
|
candidate_count = int(gate.get("candidate_count") or 0)
|
|
|
|
|
|
reimbursement_label = "关联已有申请单并发起报销"
|
2026-06-24 10:42:24 +08:00
|
|
|
|
reimbursement_reason = f"已先查到 {candidate_count} 个可关联申请单,选择后会先请您关联具体单据。"
|
2026-06-23 11:21:18 +08:00
|
|
|
|
return [
|
|
|
|
|
|
StewardCandidateFlow(
|
|
|
|
|
|
flow_id="travel_application",
|
|
|
|
|
|
label="补办出差申请",
|
|
|
|
|
|
confidence=0.52,
|
|
|
|
|
|
reason="用户描述了出差时间、地点和事由,但没有明确说要报销。",
|
|
|
|
|
|
ontology_fields=application_fields,
|
|
|
|
|
|
missing_fields=self._resolve_missing_fields("expense_application", application_fields),
|
|
|
|
|
|
),
|
|
|
|
|
|
StewardCandidateFlow(
|
|
|
|
|
|
flow_id="travel_reimbursement",
|
|
|
|
|
|
label=reimbursement_label,
|
|
|
|
|
|
confidence=0.48,
|
|
|
|
|
|
reason=reimbursement_reason,
|
|
|
|
|
|
ontology_fields=reimbursement_fields,
|
|
|
|
|
|
missing_fields=self._resolve_missing_fields("reimbursement", reimbursement_fields),
|
|
|
|
|
|
),
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _resolve_required_application_gate(
|
|
|
|
|
|
request: StewardPlanRequest,
|
|
|
|
|
|
expense_type: str,
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
context = request.context_json if isinstance(request.context_json, dict) else {}
|
|
|
|
|
|
gates = context.get("required_application_gate")
|
|
|
|
|
|
if not isinstance(gates, dict):
|
|
|
|
|
|
return {}
|
|
|
|
|
|
gate = gates.get(expense_type)
|
|
|
|
|
|
if not isinstance(gate, dict) or not gate.get("checked"):
|
|
|
|
|
|
return {}
|
|
|
|
|
|
try:
|
|
|
|
|
|
candidate_count = max(0, int(gate.get("candidate_count") or 0))
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
candidate_count = 0
|
|
|
|
|
|
return {
|
|
|
|
|
|
**gate,
|
|
|
|
|
|
"candidate_count": candidate_count,
|
|
|
|
|
|
"checked": True,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _build_pending_flow_reason(gate: dict[str, Any]) -> str:
|
|
|
|
|
|
if gate.get("checked") and int(gate.get("candidate_count") or 0) <= 0:
|
2026-06-24 10:42:24 +08:00
|
|
|
|
return "我已先查询您名下可关联的差旅申请单,未查到可关联单据,所以当前应先申请单据。"
|
2026-06-23 11:21:18 +08:00
|
|
|
|
if gate.get("checked"):
|
|
|
|
|
|
candidate_count = int(gate.get("candidate_count") or 0)
|
2026-06-24 10:42:24 +08:00
|
|
|
|
return f"我已先查询您名下的差旅申请单,查到 {candidate_count} 个可关联申请单,需要您确认是否关联单据后发起报销。"
|
2026-06-23 11:21:18 +08:00
|
|
|
|
return "当前话术描述了出差事项,但没有明确说明要补办申请还是发起报销。"
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _build_pending_flow_summary(gate: dict[str, Any]) -> str:
|
|
|
|
|
|
if gate.get("checked") and int(gate.get("candidate_count") or 0) <= 0:
|
|
|
|
|
|
return "我已先查询可关联申请单,暂未查到可关联单据;这次应先申请单据,再进入后续报销。"
|
|
|
|
|
|
if gate.get("checked"):
|
|
|
|
|
|
candidate_count = int(gate.get("candidate_count") or 0)
|
|
|
|
|
|
return (
|
|
|
|
|
|
f"我已先查询可关联申请单,查到 {candidate_count} 个可关联申请单;"
|
2026-06-24 10:42:24 +08:00
|
|
|
|
"您可以选择关联已有申请单发起报销,也可以改为补办新的出差申请。"
|
2026-06-23 11:21:18 +08:00
|
|
|
|
)
|
|
|
|
|
|
return (
|
2026-06-24 10:42:24 +08:00
|
|
|
|
"我识别到这是一次出差事项,但还不能确定您要做的是"
|
2026-06-23 11:21:18 +08:00
|
|
|
|
"**补办出差申请**还是**发起费用报销**。请先选择一个方向。"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_task_drafts(self, message: str) -> list[PlannedTaskDraft]:
|
|
|
|
|
|
drafts: list[PlannedTaskDraft] = []
|
|
|
|
|
|
first_reimbursement = self._find_first_reimbursement_index(message)
|
|
|
|
|
|
application_source = message[:first_reimbursement] if first_reimbursement >= 0 else message
|
|
|
|
|
|
if self._looks_like_application(application_source) or self._looks_like_future_travel_application(application_source):
|
|
|
|
|
|
drafts.append(
|
|
|
|
|
|
PlannedTaskDraft(
|
|
|
|
|
|
task_type="expense_application",
|
|
|
|
|
|
segment=application_source.strip(",,。;; "),
|
|
|
|
|
|
index=len(drafts) + 1,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
for match in REIMBURSEMENT_PATTERN.finditer(message):
|
|
|
|
|
|
segment = f"报销{match.group(1)}"
|
|
|
|
|
|
drafts.append(
|
|
|
|
|
|
PlannedTaskDraft(
|
|
|
|
|
|
task_type="reimbursement",
|
|
|
|
|
|
segment=segment.strip(",,。;; "),
|
|
|
|
|
|
index=len(drafts) + 1,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return drafts
|