refactor(backend): update service layers

- services/ontology.py: update ontology service logic
- services/orchestrator.py: update orchestrator service logic
- services/user_agent.py: update user agent service logic
This commit is contained in:
caoxiaozhu
2026-05-12 07:19:21 +00:00
parent 3ad16405a1
commit a3d40ad9f5
3 changed files with 270 additions and 80 deletions

View File

@@ -155,13 +155,20 @@ OPERATE_KEYWORDS = (
EXPENSE_TYPE_KEYWORDS = {
"差旅": "travel",
"出差": "travel",
"住宿": "hotel",
"酒店": "hotel",
"交通": "transport",
"打车": "transport",
"网约车": "transport",
"出租车": "transport",
"停车费": "transport",
"餐费": "meal",
"用餐": "meal",
"会务": "meeting",
"招待费": "entertainment",
"招待": "entertainment",
"宴请": "entertainment",
}
EXPENSE_NARRATIVE_KEYWORDS = (
@@ -176,6 +183,10 @@ EXPENSE_NARRATIVE_KEYWORDS = (
"打车",
"车费",
"餐费",
"吃饭",
"用餐",
"宴请",
"请客",
"住宿",
"发票",
"票据",
@@ -416,6 +427,11 @@ class SemanticOntologyService:
permission=permission,
missing_slots=missing_slots,
ambiguity=ambiguity,
allow_incomplete_draft=self._allow_incomplete_draft(
context_json,
scenario=scenario,
intent=intent,
),
model_clarification_required=bool(
model_parse is not None and model_parse.clarification_required
),
@@ -778,6 +794,8 @@ class SemanticOntologyService:
"conversation_scenario": payload.context_json.get("conversation_scenario"),
"conversation_intent": payload.context_json.get("conversation_intent"),
"draft_claim_id": payload.context_json.get("draft_claim_id"),
"review_action": payload.context_json.get("review_action"),
"review_form_values": payload.context_json.get("review_form_values"),
"conversation_history": payload.context_json.get("conversation_history", []),
},
"rule_candidates": {
@@ -1004,6 +1022,10 @@ class SemanticOntologyService:
suffix = match.group(1).strip()
normalized = f"客户{suffix}".replace(" ", "")
upsert(self._make_entity("customer", match.group(0).strip(), normalized, role="filter"))
labeled_customer_match = re.search(r"客户名称[:]\s*(?P<name>[^\n]+)", query)
if labeled_customer_match:
customer_name = labeled_customer_match.group("name").strip()
upsert(self._make_entity("customer", customer_name, customer_name, role="filter"))
for match in re.finditer(r"供应商\s*([A-Za-z0-9一二三四五六七八九十]+)", query):
suffix = match.group(1).strip()
@@ -1062,6 +1084,35 @@ class SemanticOntologyService:
if label in query:
upsert(self._make_entity("expense_type", label, normalized, role="filter"))
has_customer_entertainment_signal = "客户" in query and any(
keyword in query for keyword in ("吃饭", "用餐", "餐饮", "宴请", "请客", "招待")
)
if has_customer_entertainment_signal:
upsert(
self._make_entity(
"expense_type",
"客户招待",
"entertainment",
role="filter",
confidence=0.96,
)
)
if any(keyword in query for keyword in ("打车", "网约车", "出租车", "车费", "停车费", "过路费")):
upsert(self._make_entity("expense_type", "交通", "transport", role="filter", confidence=0.9))
if any(keyword in query for keyword in ("出差", "机票", "火车", "高铁", "行程单")):
upsert(self._make_entity("expense_type", "差旅", "travel", role="filter", confidence=0.88))
if any(keyword in query for keyword in ("酒店", "住宿", "宾馆")):
upsert(self._make_entity("expense_type", "住宿", "hotel", role="filter", confidence=0.86))
if (
not has_customer_entertainment_signal
and any(keyword in query for keyword in ("餐费", "用餐", "午餐", "晚餐", "早餐", "餐饮"))
):
upsert(self._make_entity("expense_type", "餐费", "meal", role="filter", confidence=0.84))
for amount in self._extract_amount_entities(query):
upsert(amount)
@@ -1475,6 +1526,7 @@ class SemanticOntologyService:
permission: OntologyPermission,
missing_slots: list[str],
ambiguity: list[str],
allow_incomplete_draft: bool,
model_clarification_required: bool,
model_clarification_question: str | None,
) -> tuple[bool, str | None]:
@@ -1492,12 +1544,25 @@ class SemanticOntologyService:
return True, "请说明这是报销、应收、应付,还是制度知识问题?"
if intent == "compare" and len([item for item in entities if item.type != "amount"]) < 2:
return True, "请补充需要对比的两个对象,例如两个客户、两个供应商或两个员工。"
if allow_incomplete_draft and scenario == "expense" and intent == "draft":
return False, None
if missing_slots:
return True, self._build_missing_slot_question(missing_slots)
if ambiguity:
return True, f"当前问题存在歧义,请进一步说明:{''.join(ambiguity)}"
return False, None
@staticmethod
def _allow_incomplete_draft(
context_json: dict[str, Any],
*,
scenario: str,
intent: str,
) -> bool:
if scenario != "expense" or intent != "draft":
return False
return str(context_json.get("review_action") or "").strip() == "save_draft"
@staticmethod
def _display_slot_label(slot: str) -> str:
return MISSING_SLOT_LABELS.get(slot, slot)

View File

@@ -166,20 +166,54 @@ class OrchestratorService:
route_json["stage"] = "blocked"
route_json["route_reason"] = route_reason
elif ontology.clarification_required:
outcome = ExecutionOutcome(
status=AgentRunStatus.BLOCKED.value,
result={
"message": ontology.clarification_question or "需要补充更多上下文。",
"clarification_required": True,
"missing_slots": ontology.missing_slots,
"ambiguity": ontology.ambiguity,
"parse_strategy": ontology.parse_strategy,
"degraded": False,
},
degraded=False,
tool_count=0,
failed_tool_count=0,
)
if selected_agent == AgentName.USER_AGENT.value and ontology.scenario == "expense":
clarification_response = self.user_agent_service.respond(
UserAgentRequest(
run_id=run.run_id,
user_id=payload.user_id,
message=payload.message or "",
ontology=ontology,
context_json=context_json,
tool_payload={"clarification_required": True},
selected_capability_codes=selected_capability_codes,
degraded=False,
requires_confirmation=requires_confirmation,
)
)
clarification_result = self._build_user_agent_result(
clarification_response,
degraded=False,
)
clarification_result.update(
{
"clarification_required": True,
"missing_slots": ontology.missing_slots,
"ambiguity": ontology.ambiguity,
"parse_strategy": ontology.parse_strategy,
}
)
outcome = ExecutionOutcome(
status=AgentRunStatus.BLOCKED.value,
result=clarification_result,
degraded=False,
tool_count=0,
failed_tool_count=0,
)
else:
outcome = ExecutionOutcome(
status=AgentRunStatus.BLOCKED.value,
result={
"message": ontology.clarification_question or "需要补充更多上下文。",
"clarification_required": True,
"missing_slots": ontology.missing_slots,
"ambiguity": ontology.ambiguity,
"parse_strategy": ontology.parse_strategy,
"degraded": False,
},
degraded=False,
tool_count=0,
failed_tool_count=0,
)
route_reason = "clarification_required"
route_json["stage"] = "clarification"
route_json["route_reason"] = route_reason

View File

@@ -59,13 +59,13 @@ GENERIC_EXPENSE_PROMPTS = {
EXPLICIT_DRAFT_KEYWORDS = ("生成", "草稿", "起草", "创建", "发起", "准备")
EXPENSE_TYPE_LABELS = {
"travel": "差旅",
"hotel": "住宿",
"transport": "交通",
"travel": "差旅",
"hotel": "住宿",
"transport": "交通",
"meal": "餐费",
"meeting": "会务",
"entertainment": "招待",
"other": "其他",
"meeting": "会务",
"entertainment": "业务招待",
"other": "其他费用",
}
GROUP_SCENE_LABELS = {
@@ -84,6 +84,7 @@ SLOT_LABELS = {
"location": "地点",
"merchant_name": "酒店/商户",
"amount": "金额",
"reason": "事由说明",
"participants": "参与人员",
"attachments": "票据附件",
}
@@ -549,12 +550,20 @@ class UserAgentService:
if payload.ontology.intent not in {"draft", "operate"} and attachment_count <= 0 and not ocr_documents:
return None
slot_cards = self._build_review_slot_cards(payload, ocr_documents=ocr_documents)
document_cards = self._build_review_document_cards(payload, ocr_documents=ocr_documents)
claim_groups = self._build_review_claim_groups(
payload,
document_cards=document_cards,
)
slot_cards = self._build_review_slot_cards(
payload,
ocr_documents=ocr_documents,
claim_groups=claim_groups,
)
missing_slot_keys = self._resolve_review_missing_slot_keys(
payload,
slot_cards=slot_cards,
)
risk_briefs = self._build_review_risk_briefs(
payload,
citations=citations,
@@ -563,6 +572,7 @@ class UserAgentService:
)
can_proceed = self._can_proceed_review(
payload,
missing_slot_keys=missing_slot_keys,
claim_groups=claim_groups,
)
confirmation_actions = self._build_review_confirmation_actions(
@@ -593,7 +603,7 @@ class UserAgentService:
scenario=payload.ontology.scenario,
intent=payload.ontology.intent,
can_proceed=can_proceed,
missing_slots=list(payload.ontology.missing_slots),
missing_slots=[SLOT_LABELS.get(key, key) for key in missing_slot_keys],
risk_briefs=risk_briefs,
slot_cards=slot_cards,
document_cards=document_cards,
@@ -607,8 +617,8 @@ class UserAgentService:
payload: UserAgentRequest,
*,
ocr_documents: list[dict[str, object]],
claim_groups: list[UserAgentReviewClaimGroup],
) -> list[UserAgentReviewSlotCard]:
missing_slots = set(payload.ontology.missing_slots)
entity_map = self._collect_entity_values(payload)
time_slot = self._build_time_slot(payload)
location_slot = self._build_location_slot(payload)
@@ -621,7 +631,13 @@ class UserAgentService:
ocr_documents=ocr_documents,
)
merchant_slot = self._build_merchant_slot(payload, ocr_documents=ocr_documents)
reason_slot = self._build_reason_slot(payload)
attachment_slot = self._build_attachment_slot(payload)
required_keys = self._resolve_required_review_keys(
payload,
primary_expense_type=str(expense_type_slot["normalized_value"] or ""),
claim_groups=claim_groups,
)
cards = [
self._make_slot_card(
@@ -632,7 +648,7 @@ class UserAgentService:
source=expense_type_slot["source"],
confidence=expense_type_slot["confidence"],
evidence=expense_type_slot["evidence"],
missing_slots=missing_slots,
required="expense_type" in required_keys,
),
self._make_slot_card(
key="customer_name",
@@ -642,7 +658,7 @@ class UserAgentService:
source=customer_slot["source"],
confidence=customer_slot["confidence"],
evidence=customer_slot["evidence"],
missing_slots=missing_slots,
required="customer_name" in required_keys,
),
self._make_slot_card(
key="time_range",
@@ -652,7 +668,7 @@ class UserAgentService:
source=time_slot["source"],
confidence=time_slot["confidence"],
evidence=time_slot["evidence"],
missing_slots=missing_slots,
required="time_range" in required_keys,
),
self._make_slot_card(
key="location",
@@ -662,8 +678,7 @@ class UserAgentService:
source=location_slot["source"],
confidence=location_slot["confidence"],
evidence=location_slot["evidence"],
required=False,
missing_slots=missing_slots,
required="location" in required_keys,
),
self._make_slot_card(
key="merchant_name",
@@ -673,8 +688,7 @@ class UserAgentService:
source=merchant_slot["source"],
confidence=merchant_slot["confidence"],
evidence=merchant_slot["evidence"],
required=False,
missing_slots=missing_slots,
required="merchant_name" in required_keys,
),
self._make_slot_card(
key="amount",
@@ -684,7 +698,17 @@ class UserAgentService:
source=amount_slot["source"],
confidence=amount_slot["confidence"],
evidence=amount_slot["evidence"],
missing_slots=missing_slots,
required="amount" in required_keys,
),
self._make_slot_card(
key="reason",
value=reason_slot["value"],
raw_value=reason_slot["raw_value"],
normalized_value=reason_slot["normalized_value"],
source=reason_slot["source"],
confidence=reason_slot["confidence"],
evidence=reason_slot["evidence"],
required="reason" in required_keys,
),
self._make_slot_card(
key="participants",
@@ -694,7 +718,7 @@ class UserAgentService:
source=participants_slot["source"],
confidence=participants_slot["confidence"],
evidence=participants_slot["evidence"],
missing_slots=missing_slots,
required="participants" in required_keys,
),
self._make_slot_card(
key="attachments",
@@ -704,7 +728,7 @@ class UserAgentService:
source=attachment_slot["source"],
confidence=attachment_slot["confidence"],
evidence=attachment_slot["evidence"],
missing_slots=missing_slots,
required="attachments" in required_keys,
),
]
return cards
@@ -994,11 +1018,12 @@ class UserAgentService:
def _can_proceed_review(
payload: UserAgentRequest,
*,
missing_slot_keys: list[str],
claim_groups: list[UserAgentReviewClaimGroup],
) -> bool:
if payload.ontology.ambiguity:
return False
if payload.ontology.missing_slots:
if missing_slot_keys:
return False
if not claim_groups:
return False
@@ -1019,7 +1044,7 @@ class UserAgentService:
else str(payload.context_json.get("name") or "").strip()
)
manager_name = self._resolve_manager_name(employee)
reason = self._extract_message_reason(payload.message)
reason = slot_map.get("reason").value if slot_map.get("reason") else ""
attachments = "".join(self._resolve_attachment_names(payload))
fields = [
@@ -1161,6 +1186,38 @@ class UserAgentService:
return cleaned[:300]
return ""
@classmethod
def _resolve_reason_text(cls, message: str) -> str:
reason = cls._extract_message_reason(message)
if not reason:
return ""
compact = re.sub(r"\s+", "", reason)
if compact in GENERIC_EXPENSE_PROMPTS:
return ""
instruction_prefixes = (
"帮我生成",
"请帮我生成",
"生成",
"起草",
"创建",
"发起",
"准备",
"帮我报销",
"我要报销",
"我想报销",
)
if compact.startswith(instruction_prefixes):
for separator in ("", ",", "", "", ";", "", ":"):
if separator in reason:
trailing = reason.split(separator, 1)[1].strip()
if trailing:
return trailing[:300]
return ""
return reason
@staticmethod
def _should_skip_model_answer(
payload: UserAgentRequest,
@@ -1561,6 +1618,31 @@ class UserAgentService:
)
return self._build_slot_value()
def _build_reason_slot(self, payload: UserAgentRequest) -> dict[str, str | float]:
review_form_values = self._resolve_review_form_values(payload)
edited_value = str(review_form_values.get("reason") or "").strip()
if edited_value:
return self._build_slot_value(
value=edited_value,
raw_value=edited_value,
normalized_value=edited_value,
source="user_form",
confidence=1.0,
evidence="来源于用户修改后的结构化表单。",
)
reason_value = self._resolve_reason_text(payload.message)
if reason_value:
return self._build_slot_value(
value=reason_value,
raw_value=reason_value,
normalized_value=reason_value,
source="user_text",
confidence=0.76,
evidence="系统从用户原始描述中提取了本次费用事由,建议继续核对。",
)
return self._build_slot_value()
def _build_amount_slot(
self,
payload: UserAgentRequest,
@@ -1719,18 +1801,65 @@ class UserAgentService:
def _normalize_expense_type_input(value: str) -> tuple[str, str]:
compact = str(value or "").replace(" ", "")
if "招待" in compact or ("客户" in compact and any(keyword in compact for keyword in ("吃饭", "用餐", "宴请", "请客"))):
return "entertainment", "招待"
return "entertainment", "业务招待"
if any(keyword in compact for keyword in ("差旅", "出差", "机票", "行程")):
return "travel", "差旅"
return "travel", "差旅"
if any(keyword in compact for keyword in ("住宿", "酒店", "宾馆")):
return "hotel", "住宿"
return "hotel", "住宿"
if any(keyword in compact for keyword in ("交通", "打车", "网约车", "出租车", "车费", "停车")):
return "transport", "交通"
return "transport", "交通"
if any(keyword in compact for keyword in ("餐费", "用餐", "午餐", "晚餐", "早餐", "伙食")):
return "meal", "餐费"
if "会务" in compact:
return "meeting", "会务"
return "other", str(value or "").strip() or "其他"
return "meeting", "会务"
return "other", str(value or "").strip() or "其他费用"
def _resolve_required_review_keys(
self,
payload: UserAgentRequest,
*,
primary_expense_type: str,
claim_groups: list[UserAgentReviewClaimGroup],
) -> set[str]:
required = {"expense_type", "time_range", "amount", "reason", "attachments"}
scene_codes = {
str(item.group_code or "").strip()
for item in claim_groups
if str(item.group_code or "").strip()
}
if primary_expense_type:
scene_codes.add(primary_expense_type)
compact_message = re.sub(r"\s+", "", payload.message)
if "entertainment" in scene_codes or (
"客户" in compact_message and any(keyword in compact_message for keyword in ("招待", "吃饭", "用餐", "宴请", "请客"))
):
required.update({"customer_name", "participants"})
return required
@staticmethod
def _resolve_review_missing_slot_keys(
payload: UserAgentRequest,
*,
slot_cards: list[UserAgentReviewSlotCard],
) -> list[str]:
required_keys = {item.key for item in slot_cards if item.required}
missing_keys = {
item.key
for item in slot_cards
if item.required and (item.status == "missing" or not str(item.value).strip())
}
for key in payload.ontology.missing_slots:
normalized_key = str(key or "").strip()
if normalized_key and normalized_key in required_keys:
missing_keys.add(normalized_key)
ordered_keys: list[str] = []
for item in slot_cards:
if item.required and item.key in missing_keys and item.key not in ordered_keys:
ordered_keys.append(item.key)
return ordered_keys
def _make_slot_card(
self,
@@ -1742,10 +1871,9 @@ class UserAgentService:
source: str,
confidence: float,
evidence: str,
missing_slots: set[str],
required: bool = True,
) -> UserAgentReviewSlotCard:
is_missing = key in missing_slots or not str(value).strip()
is_missing = required and not str(value).strip()
source_key = source if source in SOURCE_LABELS else "system"
return UserAgentReviewSlotCard(
key=key,
@@ -1764,43 +1892,6 @@ class UserAgentService:
else ("该字段来自系统辅助上下文,建议你再核对一次。" if source in {"detail_context", "ocr"} else ""),
evidence=evidence,
)
request_context = payload.context_json.get("request_context")
if isinstance(request_context, dict):
for key in ("city", "location"):
value = str(request_context.get(key) or "").strip()
if value:
return value
city_match = re.search(r"去(?P<city>[\u4e00-\u9fa5]{2,8})(?:出差|拜访|参会|见客户|客户现场)", payload.message)
if city_match:
return city_match.group("city").strip()
if "客户现场" in payload.message.replace(" ", ""):
return "客户现场"
return ""
def _make_slot_card(
self,
*,
key: str,
value: str,
source: str,
confidence: float,
missing_slots: set[str],
required: bool = True,
) -> UserAgentReviewSlotCard:
is_missing = key in missing_slots or not str(value).strip()
return UserAgentReviewSlotCard(
key=key,
label=SLOT_LABELS.get(key, key),
value=str(value or "").strip(),
source=source,
confidence=confidence,
required=required,
confirmed=not is_missing and source in {"user_text", "page_context", "upload"},
status="missing" if is_missing else "identified" if source == "user_text" else "inferred",
hint=f"建议补充 {SLOT_LABELS.get(key, key)}"
if is_missing and required
else "",
)
def _classify_document(
self,