diff --git a/server/src/app/services/ontology.py b/server/src/app/services/ontology.py index 2b54e40..7fdf411 100644 --- a/server/src/app/services/ontology.py +++ b/server/src/app/services/ontology.py @@ -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[^\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) diff --git a/server/src/app/services/orchestrator.py b/server/src/app/services/orchestrator.py index 71c4afa..6c48446 100644 --- a/server/src/app/services/orchestrator.py +++ b/server/src/app/services/orchestrator.py @@ -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 diff --git a/server/src/app/services/user_agent.py b/server/src/app/services/user_agent.py index 69ef198..97097d8 100644 --- a/server/src/app/services/user_agent.py +++ b/server/src/app/services/user_agent.py @@ -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[\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,