merge: integrate ZSY customer service and profile capabilities

This commit is contained in:
张胜宇
2026-09-11 22:31:51 +08:00
94 changed files with 7933 additions and 77 deletions
+6
View File
@@ -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(
+9
View File
@@ -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)
+5 -1
View File
@@ -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:
-1
View File
@@ -21,4 +21,3 @@ class OffsiteFundAgent(BaseAgent):
f"本次请求摘要:{request.message[:120]}"
)
return CoreResult(text=text)