merge: integrate ZSY customer service and profile capabilities
This commit is contained in:
@@ -134,11 +134,17 @@ class BaseAgent(ABC):
|
||||
async def recall_memory(self, request: AgentRequest, context: RequestContext) -> None:
|
||||
if self._governance is None:
|
||||
raise RecoverableAgentError("缺少记忆治理依赖")
|
||||
# 公共召回是长期/画像记忆,不是客服二期的会话短期上下文;定义未授权时不得读取。
|
||||
if not self.definition.recalls_customer_memory or "visitor" in context.roles:
|
||||
self.memories = ()
|
||||
return
|
||||
self.memories = await self._governance.recall(context)
|
||||
if any(memory.customer_id != context.user_id for memory in self.memories):
|
||||
raise RecoverableAgentError("记忆召回越过客户范围")
|
||||
|
||||
async def classify_intent(self, request: AgentRequest) -> IntentResult | None:
|
||||
if not self.definition.requires_model_intent_classification:
|
||||
return None
|
||||
if self._intent_classifier is None or self._intent_endpoint_resolver is None:
|
||||
return None
|
||||
endpoints = await self._intent_endpoint_resolver.resolve(
|
||||
|
||||
@@ -296,6 +296,15 @@ def get_agent_factory() -> AgentFactory:
|
||||
# 10s 覆盖冷启动与 Milvus 抖动,又不至于让客户等太久
|
||||
timeout_seconds=10,
|
||||
))
|
||||
# 兼容一期发布配置和旧客户端的工具名;实现仍复用同一个只读检索处理器。
|
||||
registry.register(ToolDefinition(
|
||||
name="query_knowledge",
|
||||
input_model=KnowledgeSearchInput,
|
||||
handler=cast(Any, knowledge_search_tool),
|
||||
required_permission="knowledge:query",
|
||||
allowed_roles=("visitor", "customer"),
|
||||
timeout_seconds=10,
|
||||
))
|
||||
registry.register(ToolDefinition(
|
||||
name="search_risk_alerts",
|
||||
input_model=RiskAlertQuery,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Backward-compatible import path for the consolidated customer-service Agent."""
|
||||
|
||||
from app.service.agent.implementations.customer_service import CustomerServiceAgent
|
||||
|
||||
__all__ = ["CustomerServiceAgent"]
|
||||
@@ -0,0 +1,87 @@
|
||||
"""一期客服的确定性路由,先处理安全和边界,再允许公开知识检索。"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CustomerServiceRoute:
|
||||
intent: str
|
||||
knowledge_intents: tuple[str, ...] = ()
|
||||
is_chitchat: bool = False
|
||||
requires_context: bool = False
|
||||
|
||||
|
||||
class CustomerServiceIntentRouter:
|
||||
_SECURITY_KEYWORDS = ("验证码", "密码泄露", "被盗", "诈骗", "非本人交易")
|
||||
# 明确的凭据披露和提示词注入必须在知识检索前拦截,避免把攻击内容当作普通 FAQ。
|
||||
_SECURITY_DISCLOSURE_PHRASES = ("密码是", "密码为", "我的密码", "验证码是", "验证码为")
|
||||
_PROMPT_INJECTION_KEYWORDS = (
|
||||
"忽略之前", "忽略所有规则", "系统提示词", "开发者消息", "泄露提示词", "越过限制",
|
||||
"不要遵守规则", "显示内部指令",
|
||||
)
|
||||
_COMPLIANCE_KEYWORDS = ("推荐", "收益最高", "稳赚", "保本", "帮我买", "替我交易")
|
||||
_ACCOUNT_KEYWORDS = ("持仓", "收益", "订单", "定投", "银行卡", "风险测评", "投诉进度")
|
||||
_HUMAN_TRANSFER_KEYWORDS = ("转人工", "人工客服", "投诉", "赔偿", "法律", "纠纷")
|
||||
_POLICY_KEYWORDS = (
|
||||
"申购", "赎回", "到账", "费率", "手续费", "确认份额", "交易日", "分红", "规则", "政策"
|
||||
)
|
||||
_PRODUCT_KEYWORDS = (
|
||||
"产品", "基金代码", "基金经理", "份额类别", "a类", "c类", "净值", "风险等级"
|
||||
)
|
||||
_CHITCHAT_MESSAGES = frozenset({
|
||||
"你好", "您好", "嗨", "哈喽", "在吗", "谢谢", "谢谢你", "再见", "拜拜",
|
||||
"你是谁", "你叫什么", "你今天开心吗",
|
||||
})
|
||||
_CHITCHAT_PHRASES = ("今天天气", "讲个笑话", "你几岁", "你开心吗", "你忙吗")
|
||||
_REFERENCE_PHRASES = ("这个", "那个", "它的", "刚才", "上面", "前面", "这只", "那只")
|
||||
|
||||
@classmethod
|
||||
def classify(cls, message: str) -> CustomerServiceRoute:
|
||||
normalized = message.strip().lower()
|
||||
if (cls._contains(normalized, cls._SECURITY_KEYWORDS)
|
||||
or cls._contains(normalized, cls._SECURITY_DISCLOSURE_PHRASES)):
|
||||
return CustomerServiceRoute(intent="security_notice")
|
||||
if cls._contains(normalized, cls._PROMPT_INJECTION_KEYWORDS):
|
||||
return CustomerServiceRoute(intent="compliance_refusal")
|
||||
if cls._contains(normalized, cls._COMPLIANCE_KEYWORDS):
|
||||
return CustomerServiceRoute(intent="compliance_refusal")
|
||||
if cls._contains(normalized, cls._ACCOUNT_KEYWORDS):
|
||||
return CustomerServiceRoute(intent="account_entry")
|
||||
if cls._contains(normalized, cls._HUMAN_TRANSFER_KEYWORDS):
|
||||
return CustomerServiceRoute(intent="human_transfer")
|
||||
if cls._is_chitchat(normalized):
|
||||
return CustomerServiceRoute(intent="chitchat", is_chitchat=True)
|
||||
if cls._contains(normalized, cls._POLICY_KEYWORDS):
|
||||
return CustomerServiceRoute(
|
||||
intent="public_knowledge", knowledge_intents=("policy_explain",),
|
||||
requires_context=cls._contains(normalized, cls._REFERENCE_PHRASES),
|
||||
)
|
||||
if cls._contains(normalized, cls._PRODUCT_KEYWORDS):
|
||||
return CustomerServiceRoute(
|
||||
intent="public_knowledge", knowledge_intents=("product_inquiry",),
|
||||
requires_context=cls._contains(normalized, cls._REFERENCE_PHRASES),
|
||||
)
|
||||
return CustomerServiceRoute(intent="public_knowledge", knowledge_intents=("faq",))
|
||||
|
||||
@classmethod
|
||||
def chitchat_streak(cls, prior_messages: Sequence[str], message: str) -> int:
|
||||
"""返回当前消息在同一会话中连续闲聊的次数,最大只需记录到第五句。"""
|
||||
if not cls._is_chitchat(message.strip().lower()):
|
||||
return 0
|
||||
streak = 1
|
||||
for prior_message in reversed(prior_messages):
|
||||
if not cls._is_chitchat(prior_message.strip().lower()):
|
||||
break
|
||||
streak += 1
|
||||
if streak == 5:
|
||||
break
|
||||
return streak
|
||||
|
||||
@staticmethod
|
||||
def _contains(message: str, keywords: tuple[str, ...]) -> bool:
|
||||
return any(keyword in message for keyword in keywords)
|
||||
|
||||
@classmethod
|
||||
def _is_chitchat(cls, message: str) -> bool:
|
||||
return message in cls._CHITCHAT_MESSAGES or cls._contains(message, cls._CHITCHAT_PHRASES)
|
||||
@@ -59,6 +59,10 @@ class AgentFactory:
|
||||
agent.bind_model_service(self._model_service)
|
||||
if self._tool_executor is not None:
|
||||
agent.bind_tool_executor(self._tool_executor)
|
||||
if self._intent_classifier is not None and self._intent_endpoint_resolver is not None:
|
||||
if (
|
||||
agent.definition.requires_model_intent_classification
|
||||
and self._intent_classifier is not None
|
||||
and self._intent_endpoint_resolver is not None
|
||||
):
|
||||
agent.bind_intent_classifier(self._intent_classifier, self._intent_endpoint_resolver)
|
||||
return agent
|
||||
|
||||
@@ -27,6 +27,7 @@ from app.core.contracts import (
|
||||
RequestContext,
|
||||
SourceReference,
|
||||
)
|
||||
from app.core.customer_service_rules import route_message
|
||||
from app.core.errors import ForbiddenAgentError
|
||||
from app.service.agent.base import BaseAgent
|
||||
from app.service.model_gateway import DatabaseModelEndpointResolver
|
||||
@@ -150,7 +151,7 @@ TOP_K = 5
|
||||
MAX_ANSWER_CHARS = 1200
|
||||
REFERENCE_LIMIT = 3
|
||||
|
||||
COMPANY = "南方科技"
|
||||
COMPANY = "奶龙基金责任有限公司"
|
||||
# 客服热线:正式号码确定后改这里(或改为读配置项,避免改代码)
|
||||
HOTLINE = "400-XXX-XXXX"
|
||||
SERVICE_HOURS = "每日 7:00-22:00"
|
||||
@@ -184,7 +185,7 @@ class CustomerServiceAgent(BaseAgent):
|
||||
definition = AgentDefinition(
|
||||
agent_type=AGENT_TYPE,
|
||||
version="1.0.0",
|
||||
allowed_roles=("customer",),
|
||||
allowed_roles=("visitor", "customer"),
|
||||
allowed_portals=("api",),
|
||||
# 代码上限:实际可用范围由发布配置的意图白名单收窄(两者取交集)
|
||||
allowed_tools=(TOOL_NAME, SUITABILITY_TOOL, PROFILE_TOOL_NAME),
|
||||
@@ -192,9 +193,25 @@ class CustomerServiceAgent(BaseAgent):
|
||||
INTENT_FAQ, INTENT_PRODUCT, INTENT_POLICY, INTENT_SUITABILITY,
|
||||
INTENT_CHITCHAT, INTENT_TRANSFER,
|
||||
),
|
||||
# 客服不隐式召回长期画像;已登录用户的画像查询必须显式调用受控工具。
|
||||
recalls_customer_memory=False,
|
||||
)
|
||||
|
||||
def __init__(self, definition: AgentDefinition | None = None) -> None:
|
||||
"""Use the class definition for direct tests and factory-created instances alike."""
|
||||
super().__init__(definition or self.definition)
|
||||
|
||||
async def handle(self, request: AgentRequest, context: RequestContext) -> CoreResult:
|
||||
# 先执行确定性的安全与权限边界路由;这些分支不查知识库、不调用模型,
|
||||
# 从根上阻断账户敏感数据、凭据泄露、诈骗和代办交易等越界请求。
|
||||
safety = route_message(request.message)
|
||||
if safety is not None:
|
||||
return CoreResult(
|
||||
text=safety.reply,
|
||||
intent=IntentResult(intent=safety.intent, confidence=1.0),
|
||||
transfer_required=safety.transfer_required,
|
||||
transfer_reason=safety.transfer_reason,
|
||||
)
|
||||
# 画像问题优先处理(确定性关键词,不走意图分类):知识库答不了"我的风险等级是多少",
|
||||
# 那需要读该客户的画像数据,必须走 `query_customer_profile` 工具取权威字段。
|
||||
# 放在意图分发**之前**是有意的:让画像能力不依赖意图分类是否恰好给出 faq。
|
||||
@@ -508,6 +525,12 @@ class CustomerServiceAgent(BaseAgent):
|
||||
# ---- 出口二:闲聊(提示词走发布配置) ----
|
||||
|
||||
async def _chitchat(self, request: AgentRequest) -> CoreResult:
|
||||
# 连续闲聊超过三轮后只做一次自然的业务引导,避免模型无限延续闲聊。
|
||||
if request.metadata.chitchat_streak == 4:
|
||||
return CoreResult(
|
||||
text="您好呀,您是想了解基金产品、申赎规则或其他公开业务信息吗?",
|
||||
intent=self._classified_intent,
|
||||
)
|
||||
system, template = await self._chitchat_prompt()
|
||||
message = request.message[:500]
|
||||
try:
|
||||
|
||||
@@ -21,4 +21,3 @@ class OffsiteFundAgent(BaseAgent):
|
||||
f"本次请求摘要:{request.message[:120]}"
|
||||
)
|
||||
return CoreResult(text=text)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user