扩展知识库索引任务和 RAG 检索支持增量入库和文档去重,优 化本体检测和规则匹配精度,前端设置页面拆分为 LLM、邮件 和 Hermes 员工同步子面板并重构样式,新增日志详情组件和 知识入库日志模型,补充单元测试覆盖。
501 lines
19 KiB
Python
501 lines
19 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
from datetime import UTC, datetime, timedelta
|
||
from decimal import Decimal, InvalidOperation
|
||
from typing import Any
|
||
|
||
from sqlalchemy import or_, select
|
||
from sqlalchemy.orm import selectinload
|
||
|
||
from app.api.deps import CurrentUserContext
|
||
from app.core.agent_enums import AgentAssetStatus, AgentAssetType
|
||
from app.models.employee import Employee
|
||
from app.models.financial_record import ExpenseClaim
|
||
from app.schemas.agent_asset import AgentAssetListItem
|
||
from app.schemas.reimbursement import TravelReimbursementCalculatorRequest
|
||
from app.schemas.user_agent import (
|
||
UserAgentCitation,
|
||
UserAgentDraftPayload,
|
||
UserAgentExpenseQueryRecord,
|
||
UserAgentQueryPayload,
|
||
UserAgentQueryStatusGroup,
|
||
UserAgentReviewAction,
|
||
UserAgentReviewClaimGroup,
|
||
UserAgentReviewDocumentCard,
|
||
UserAgentReviewDocumentField,
|
||
UserAgentReviewEditField,
|
||
UserAgentReviewPayload,
|
||
UserAgentReviewRiskBrief,
|
||
UserAgentReviewSlotCard,
|
||
UserAgentRequest,
|
||
UserAgentSuggestedAction,
|
||
)
|
||
from app.services.agent_assets import AgentAssetService
|
||
from app.services.expense_claims import ExpenseClaimService
|
||
from app.services.expense_rule_runtime import ExpenseRuleRuntimeService, RuntimeTravelPolicy, resolve_document_type_label
|
||
from app.services.risk_ontology_bridge import resolve_rule_codes_for_risk_check
|
||
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
||
from app.services.user_agent_constants import *
|
||
|
||
|
||
class UserAgentReviewProfileMixin:
|
||
|
||
def _build_review_edit_fields(
|
||
self,
|
||
payload: UserAgentRequest,
|
||
*,
|
||
draft_payload: UserAgentDraftPayload | None,
|
||
slot_cards: list[UserAgentReviewSlotCard],
|
||
) -> list[UserAgentReviewEditField]:
|
||
slot_map = {item.key: item for item in slot_cards}
|
||
employee = self._resolve_employee_profile(payload)
|
||
reporter_name = (
|
||
slot_map.get("reporter_name").value
|
||
if slot_map.get("reporter_name")
|
||
else str(payload.context_json.get("name") or "").strip()
|
||
)
|
||
manager_name = self._resolve_manager_name(employee)
|
||
reason = slot_map.get("reason").value if slot_map.get("reason") else ""
|
||
attachments = "、".join(self._resolve_attachment_names(payload))
|
||
expense_type_code = str(slot_map.get("expense_type").normalized_value if slot_map.get("expense_type") else "").strip()
|
||
customer_name = str(slot_map.get("customer_name").value if slot_map.get("customer_name") else "").strip()
|
||
merchant_name = str(slot_map.get("merchant_name").value if slot_map.get("merchant_name") else "").strip()
|
||
participants = str(slot_map.get("participants").value if slot_map.get("participants") else "").strip()
|
||
customer_slot = slot_map.get("customer_name")
|
||
participants_slot = slot_map.get("participants")
|
||
customer_required = bool(
|
||
customer_slot
|
||
and (customer_slot.required or customer_slot.status == "missing")
|
||
)
|
||
participants_required = bool(
|
||
participants_slot
|
||
and (participants_slot.required or participants_slot.status == "missing")
|
||
)
|
||
|
||
fields = [
|
||
UserAgentReviewEditField(
|
||
key="claim_no",
|
||
label="报销单据编号",
|
||
value=str(draft_payload.claim_no if draft_payload is not None and draft_payload.claim_no else "待生成"),
|
||
placeholder="保存草稿后自动生成",
|
||
required=False,
|
||
group="basic",
|
||
),
|
||
UserAgentReviewEditField(
|
||
key="expense_type",
|
||
label="报销类型",
|
||
value=slot_map.get("expense_type").value if slot_map.get("expense_type") else "",
|
||
placeholder="例如:业务招待费 / 差旅费",
|
||
group="basic",
|
||
),
|
||
UserAgentReviewEditField(
|
||
key="occurred_date",
|
||
label="业务发生时间",
|
||
value=slot_map.get("time_range").normalized_value if slot_map.get("time_range") and slot_map.get("time_range").normalized_value else slot_map.get("time_range").value if slot_map.get("time_range") else "",
|
||
placeholder="例如:2026-05-11",
|
||
group="basic",
|
||
),
|
||
UserAgentReviewEditField(
|
||
key="reporter_name",
|
||
label="报销人",
|
||
value=reporter_name,
|
||
placeholder="请输入报销人姓名",
|
||
group="basic",
|
||
),
|
||
UserAgentReviewEditField(
|
||
key="manager_name",
|
||
label="直属上司姓名",
|
||
value=manager_name,
|
||
placeholder="请输入直属上司姓名",
|
||
required=False,
|
||
group="basic",
|
||
),
|
||
]
|
||
|
||
if expense_type_code == "entertainment" or customer_required or customer_name:
|
||
fields.append(
|
||
UserAgentReviewEditField(
|
||
key="customer_name",
|
||
label="客户名称",
|
||
value=customer_name,
|
||
placeholder="请输入客户名称",
|
||
group="business",
|
||
)
|
||
)
|
||
|
||
fields.append(
|
||
UserAgentReviewEditField(
|
||
key="business_location",
|
||
label="业务地点",
|
||
value=slot_map.get("location").normalized_value if slot_map.get("location") and slot_map.get("location").normalized_value else slot_map.get("location").value if slot_map.get("location") else "",
|
||
placeholder="例如:北京 / 客户现场",
|
||
required=False,
|
||
group="business",
|
||
)
|
||
)
|
||
|
||
if expense_type_code == "hotel" or merchant_name:
|
||
fields.append(
|
||
UserAgentReviewEditField(
|
||
key="merchant_name",
|
||
label="酒店/商户",
|
||
value=merchant_name,
|
||
placeholder="请输入酒店或商户名称",
|
||
required=False,
|
||
group="business",
|
||
)
|
||
)
|
||
|
||
fields.extend([
|
||
UserAgentReviewEditField(
|
||
key="amount",
|
||
label="金额",
|
||
value=slot_map.get("amount").normalized_value if slot_map.get("amount") and slot_map.get("amount").normalized_value else slot_map.get("amount").value if slot_map.get("amount") else "",
|
||
placeholder="例如:200.00元",
|
||
group="business",
|
||
),
|
||
])
|
||
|
||
if expense_type_code == "entertainment" or participants_required or participants:
|
||
fields.append(
|
||
UserAgentReviewEditField(
|
||
key="participants",
|
||
label="参与人员",
|
||
value=participants,
|
||
placeholder="例如:客户 2 人,我方 1 人",
|
||
group="business",
|
||
)
|
||
)
|
||
|
||
fields.extend([
|
||
UserAgentReviewEditField(
|
||
key="reason",
|
||
label="事由",
|
||
value=reason,
|
||
placeholder="请输入报销事由",
|
||
field_type="textarea",
|
||
group="business",
|
||
),
|
||
UserAgentReviewEditField(
|
||
key="attachment_names",
|
||
label="附件清单",
|
||
value=attachments,
|
||
placeholder="例如:发票.jpg、行程单.png",
|
||
required=False,
|
||
field_type="textarea",
|
||
group="attachments",
|
||
),
|
||
])
|
||
return fields
|
||
|
||
|
||
def _resolve_employee_profile(self, payload: UserAgentRequest) -> Employee | None:
|
||
candidates = [
|
||
str(payload.context_json.get("name") or "").strip(),
|
||
str(payload.user_id or "").strip(),
|
||
self._collect_entity_values(payload).get("employee_name", ""),
|
||
]
|
||
normalized = [item for item in dict.fromkeys(candidates) if item]
|
||
if not normalized:
|
||
return None
|
||
|
||
stmt = (
|
||
select(Employee)
|
||
.options(selectinload(Employee.organization_unit), selectinload(Employee.manager))
|
||
.where(
|
||
or_(
|
||
Employee.name.in_(normalized),
|
||
Employee.employee_no.in_(normalized),
|
||
Employee.email.in_(normalized),
|
||
)
|
||
)
|
||
.limit(1)
|
||
)
|
||
return self.db.scalar(stmt)
|
||
|
||
|
||
@staticmethod
|
||
def _resolve_manager_name(employee: Employee | None) -> str:
|
||
if employee is None:
|
||
return ""
|
||
if employee.manager is not None and employee.manager.name:
|
||
return employee.manager.name
|
||
if employee.organization_unit is not None and employee.organization_unit.manager_name:
|
||
return employee.organization_unit.manager_name
|
||
return ""
|
||
|
||
|
||
@staticmethod
|
||
def _extract_message_reason(message: str) -> str:
|
||
for line in str(message or "").splitlines():
|
||
cleaned = line.strip()
|
||
if not cleaned:
|
||
continue
|
||
if cleaned.startswith(("附件名称:", "OCR摘要:", "关联单号:")):
|
||
continue
|
||
return cleaned[:300]
|
||
return ""
|
||
|
||
|
||
@staticmethod
|
||
def _looks_like_system_generated_reason_message(message: str) -> bool:
|
||
cleaned = str(message or "").strip()
|
||
if not cleaned:
|
||
return False
|
||
compact = re.sub(r"\s+", "", cleaned)
|
||
return compact.startswith(SYSTEM_GENERATED_REASON_PREFIXES)
|
||
|
||
|
||
def _resolve_reason_source_text(self, payload: UserAgentRequest) -> str:
|
||
explicit_text = payload.context_json.get("user_input_text")
|
||
if isinstance(explicit_text, str):
|
||
return explicit_text.strip()
|
||
if self._looks_like_system_generated_reason_message(payload.message):
|
||
return ""
|
||
return str(payload.message or "").strip()
|
||
|
||
|
||
@classmethod
|
||
def _resolve_reason_text(cls, message: str) -> str:
|
||
reason = cls._strip_leading_time_from_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 _strip_leading_time_from_reason(value: str) -> str:
|
||
reason = str(value or "").strip()
|
||
for pattern in LEADING_REASON_TIME_PATTERNS:
|
||
next_reason = pattern.sub("", reason).strip()
|
||
if next_reason != reason:
|
||
return next_reason
|
||
return reason
|
||
|
||
|
||
@staticmethod
|
||
def _should_skip_model_answer(
|
||
payload: UserAgentRequest,
|
||
review_payload: UserAgentReviewPayload | None,
|
||
) -> bool:
|
||
if payload.ontology.scenario == "expense" and payload.ontology.intent in {"query", "compare"}:
|
||
return True
|
||
if review_payload is None:
|
||
return False
|
||
return payload.ontology.scenario == "expense" and (
|
||
payload.ontology.intent == "draft"
|
||
or int(payload.context_json.get("attachment_count") or 0) > 0
|
||
)
|
||
|
||
|
||
def _build_citations(self, payload: UserAgentRequest) -> list[UserAgentCitation]:
|
||
knowledge_citations = self._build_knowledge_citations(payload)
|
||
if payload.ontology.scenario == "knowledge":
|
||
return knowledge_citations[:3]
|
||
|
||
rule_citations = self._build_rule_asset_citations(payload)
|
||
if knowledge_citations:
|
||
return (knowledge_citations + rule_citations)[:3]
|
||
return rule_citations
|
||
|
||
|
||
@staticmethod
|
||
def _build_knowledge_citations(payload: UserAgentRequest) -> list[UserAgentCitation]:
|
||
citations: list[UserAgentCitation] = []
|
||
for item in list(payload.tool_payload.get("hits") or [])[:3]:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
title = str(item.get("title") or item.get("document_name") or "").strip()
|
||
code = str(item.get("code") or item.get("candidate_id") or "").strip()
|
||
if not title or not code:
|
||
continue
|
||
citations.append(
|
||
UserAgentCitation(
|
||
source_type="knowledge",
|
||
code=code,
|
||
title=title,
|
||
version=str(item.get("version") or "").strip() or None,
|
||
updated_at=str(item.get("updated_at") or "").strip() or None,
|
||
excerpt=(
|
||
str(item.get("excerpt") or "").strip()
|
||
or str(item.get("content") or "").strip()
|
||
or None
|
||
),
|
||
)
|
||
)
|
||
return citations
|
||
|
||
|
||
def _build_rule_asset_citations(self, payload: UserAgentRequest) -> list[UserAgentCitation]:
|
||
domain = self._resolve_domain(payload.ontology.scenario)
|
||
items = self.asset_service.list_assets(
|
||
asset_type=AgentAssetType.RULE.value,
|
||
status=AgentAssetStatus.ACTIVE.value,
|
||
domain=domain,
|
||
)
|
||
ranked = self._rank_rule_assets(items, payload)
|
||
citations: list[UserAgentCitation] = []
|
||
for item in ranked[:2]:
|
||
detail = self.asset_service.get_asset(item.id)
|
||
if detail is None:
|
||
continue
|
||
excerpt = self._extract_excerpt(str(detail.current_version_content or ""))
|
||
citations.append(
|
||
UserAgentCitation(
|
||
source_type="rule",
|
||
code=detail.code,
|
||
title=detail.name,
|
||
version=detail.current_version,
|
||
updated_at=detail.updated_at.date().isoformat(),
|
||
excerpt=excerpt,
|
||
)
|
||
)
|
||
return citations
|
||
|
||
|
||
@staticmethod
|
||
def _resolve_risk_flags(payload: UserAgentRequest) -> list[str]:
|
||
tool_flags = payload.tool_payload.get("risk_flags")
|
||
if isinstance(tool_flags, list) and tool_flags:
|
||
return [str(item) for item in tool_flags]
|
||
return [str(item) for item in payload.ontology.risk_flags]
|
||
|
||
|
||
@staticmethod
|
||
def _resolve_subject(payload: UserAgentRequest) -> str:
|
||
named_entities = [
|
||
item.value
|
||
for item in payload.ontology.entities
|
||
if item.type in {"employee", "customer", "vendor", "project"}
|
||
]
|
||
if named_entities:
|
||
return f"{'、'.join(named_entities)} 相关数据"
|
||
return f"{SCENARIO_LABELS.get(payload.ontology.scenario, '当前')}场景数据"
|
||
|
||
|
||
@staticmethod
|
||
def _is_generic_expense_prompt(payload: UserAgentRequest) -> bool:
|
||
if payload.ontology.scenario != "expense":
|
||
return False
|
||
normalized_message = re.sub(r"\s+", "", payload.message)
|
||
return normalized_message in GENERIC_EXPENSE_PROMPTS
|
||
|
||
|
||
@staticmethod
|
||
def _is_implicit_expense_draft_request(payload: UserAgentRequest) -> bool:
|
||
if payload.ontology.scenario != "expense" or payload.ontology.intent != "draft":
|
||
return False
|
||
|
||
compact_message = re.sub(r"\s+", "", payload.message)
|
||
if any(keyword in compact_message for keyword in EXPLICIT_DRAFT_KEYWORDS):
|
||
return False
|
||
|
||
return True
|
||
|
||
|
||
@staticmethod
|
||
def _resolve_attachment_names(payload: UserAgentRequest) -> list[str]:
|
||
names = payload.context_json.get("attachment_names")
|
||
if not isinstance(names, list):
|
||
return []
|
||
return [str(name) for name in names if str(name).strip()]
|
||
|
||
|
||
@staticmethod
|
||
def _resolve_attachment_count(payload: UserAgentRequest) -> int:
|
||
names = UserAgentReviewProfileMixin._resolve_attachment_names(payload)
|
||
if names:
|
||
return len(names)
|
||
try:
|
||
return max(0, int(payload.context_json.get("attachment_count") or 0))
|
||
except (TypeError, ValueError):
|
||
return 0
|
||
|
||
|
||
@staticmethod
|
||
def _resolve_ocr_documents(payload: UserAgentRequest) -> list[dict[str, object]]:
|
||
documents = payload.context_json.get("ocr_documents")
|
||
if not isinstance(documents, list):
|
||
return []
|
||
overrides = payload.context_json.get("review_document_form_values")
|
||
override_map: dict[tuple[int, str], dict[str, object]] = {}
|
||
if isinstance(overrides, list):
|
||
for item in overrides:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
filename = str(item.get("filename") or "").strip()
|
||
index = int(item.get("index") or 0)
|
||
if not filename and index <= 0:
|
||
continue
|
||
override_map[(index, filename)] = item
|
||
normalized: list[dict[str, object]] = []
|
||
for index, item in enumerate(documents[:8], start=1):
|
||
if not isinstance(item, dict):
|
||
continue
|
||
normalized_item = dict(item)
|
||
override = override_map.get((index, str(normalized_item.get("filename") or "").strip()))
|
||
if override is None:
|
||
override = override_map.get((index, ""))
|
||
if override is not None:
|
||
summary = str(override.get("summary") or "").strip()
|
||
scene_label = str(override.get("scene_label") or "").strip()
|
||
fields = override.get("fields")
|
||
if summary:
|
||
normalized_item["summary"] = summary
|
||
if scene_label:
|
||
normalized_item["scene_label"] = scene_label
|
||
if isinstance(fields, list):
|
||
normalized_item["document_fields"] = [
|
||
{
|
||
"key": str(field.get("key") or field.get("label") or "").strip(),
|
||
"label": str(field.get("label") or "").strip(),
|
||
"value": str(field.get("value") or "").strip(),
|
||
}
|
||
for field in fields
|
||
if isinstance(field, dict)
|
||
and str(field.get("label") or "").strip()
|
||
and str(field.get("value") or "").strip()
|
||
]
|
||
normalized.append(normalized_item)
|
||
return normalized
|
||
|
||
|
||
@staticmethod
|
||
def _is_review_association_choice_pending(payload: UserAgentRequest) -> bool:
|
||
return bool(payload.tool_payload.get("pending_association_decision"))
|
||
|
||
|
||
def _resolve_review_document_count(self, payload: UserAgentRequest) -> int:
|
||
return max(
|
||
len(self._resolve_ocr_documents(payload)),
|
||
self._resolve_attachment_count(payload),
|
||
)
|
||
|