奶龙基金智能助手
公开知识与客服分流联调页
访客:正在建立测试连接
+diff --git a/.env.example b/.env.example index e13338c..ba48de3 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,7 @@ MODEL_DEFAULT_ENDPOINT= MODEL_FALLBACK_ENDPOINT= KNOWLEDGE_EMBEDDING_ENDPOINT_CODE= KNOWLEDGE_EMBEDDING_TIMEOUT_MS=15000 +CUSTOMER_SERVICE_PHONE=15936583816 SSE_HEARTBEAT_SECONDS=15 SSE_MAX_CONNECTION_SECONDS=300 diff --git a/app/core/config.py b/app/core/config.py index d745d90..189015b 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -24,6 +24,7 @@ class Settings(BaseSettings): jwt_public_key_path: str = "config/jwt/jwt-public.pem" jwt_clock_skew_seconds: int = Field(default=30, ge=0) visitor_token_ttl_seconds: int = Field(default=900, ge=60, le=3600) + customer_service_phone: str = Field(default="15936583816", pattern=r"^1\d{10}$") mysql_dsn: str mysql_pool_size: int = Field(default=5, ge=1) mysql_max_overflow: int = Field(default=10, ge=0) diff --git a/app/core/contracts.py b/app/core/contracts.py index 72e1b18..add064b 100644 --- a/app/core/contracts.py +++ b/app/core/contracts.py @@ -11,6 +11,8 @@ class AgentRequestMetadata(BaseModel): locale: str | None = None client_version: str | None = None ui_entry: str | None = None + # 仅由受理服务写入 Outbox,客户端 API 不接收该字段。 + chitchat_streak: int = Field(default=0, ge=0, le=5) class AgentRequest(BaseModel): diff --git a/app/main.py b/app/main.py index 868b17c..3365473 100644 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,8 @@ +from pathlib import Path + from fastapi import FastAPI, Request from fastapi.responses import JSONResponse +from fastapi.staticfiles import StaticFiles from app.api.controllers.admin import router as admin_router from app.api.controllers.agent_runs import router as agent_runs_router @@ -52,6 +55,11 @@ def create_app() -> FastAPI: application.include_router(knowledge_router) application.include_router(health_router) application.include_router(admin_router) + application.mount( + "/customer-service-test", + StaticFiles(directory=Path(__file__).resolve().parent / "static", html=True), + name="customer-service-test", + ) return application diff --git a/app/service/agent/bootstrap.py b/app/service/agent/bootstrap.py index b2ad3bc..639f975 100644 --- a/app/service/agent/bootstrap.py +++ b/app/service/agent/bootstrap.py @@ -12,6 +12,7 @@ from app.core.nl2sql_contracts import FinancialNL2SQLInput from app.infrastructure.fund_quote_cache import FundQuoteCache from app.infrastructure.memory_cache import MemoryCacheAdapter from app.infrastructure.vector_memory import VectorMemoryAdapter +from app.service.agent.customer_service_agent import CustomerServiceAgent from app.service.agent.factory import AgentFactory from app.service.agent.governance import PlatformGovernance from app.service.agent.implementations.fund_query_demo import FundQueryDemoAgent @@ -198,3 +199,7 @@ def register_business_agents(factory: AgentFactory) -> None: OffsiteFundAgent.definition, lambda _context: OffsiteFundAgent(OffsiteFundAgent.definition), ) + factory.register( + CustomerServiceAgent.definition, + lambda _context: CustomerServiceAgent(), + ) diff --git a/app/service/agent/customer_service_agent.py b/app/service/agent/customer_service_agent.py new file mode 100644 index 0000000..22e6a30 --- /dev/null +++ b/app/service/agent/customer_service_agent.py @@ -0,0 +1,113 @@ +"""奶龙基金智能助手:只处理一期公开客服范围。""" + +from app.core.config import get_settings +from app.core.contracts import AgentDefinition, AgentRequest, CoreResult, RequestContext +from app.core.errors import RecoverableAgentError +from app.core.knowledge_contracts import KnowledgeSearchResult +from app.service.agent.base import BaseAgent +from app.service.agent.customer_service_routing import CustomerServiceIntentRouter + + +def _human_service_phone() -> str: + return get_settings().customer_service_phone + + +class CustomerServiceAgent(BaseAgent): + definition = AgentDefinition( + agent_type="customer_service", + version="1.0.0", + allowed_tools=("query_knowledge",), + allowed_roles=("visitor", "customer"), + allowed_portals=("api",), + requires_model_intent_classification=False, + supported_intents=( + "security_notice", + "account_entry", + "human_transfer", + "compliance_refusal", + "chitchat", + "public_knowledge", + "faq", + "product_inquiry", + "policy_explain", + ), + ) + + def __init__(self) -> None: + super().__init__(self.definition) + + async def handle(self, request: AgentRequest, context: RequestContext) -> CoreResult: + message = request.message.strip() + route = CustomerServiceIntentRouter.classify(message) + if route.intent == "security_notice": + return CoreResult( + text=( + "请立即停止操作,不要继续提供验证码、密码或身份证件信息,并尽快联系" + f"人工客服 {_human_service_phone()}。" + ), + transfer_required=True, + transfer_reason="security_notice", + ) + if route.intent == "compliance_refusal": + return CoreResult( + text=( + "我不能推荐具体产品、承诺收益或代您交易;如需进一步了解,请联系" + f"人工客服 {_human_service_phone()}。" + ), + transfer_required=True, + transfer_reason="compliance_refusal", + ) + if route.intent == "account_entry": + if "visitor" in context.roles: + return CoreResult(text="我无法查询账户数据,请先登录后前往“我的账户”查看相关状态。") + return CoreResult(text="我无法查询账户数据,请前往“我的账户”查看相关状态。") + if route.intent == "human_transfer": + return CoreResult( + text=( + f"您可以联系人工客服 {_human_service_phone()}(工作日 09:00-18:00)" + "进一步核实。" + ), + transfer_required=True, + transfer_reason="human_transfer", + ) + if route.is_chitchat: + if request.metadata.chitchat_streak == 4: + return CoreResult(text="和您聊天很开心呀。您是想了解相关的基金业务或公开信息吗?") + return CoreResult(text="您好呀,我是奶龙基金智能助手,很高兴和您聊天。") + try: + output = await self.call_tool( + "query_knowledge", + {"query": message, "intents": route.knowledge_intents, "top_k": 5}, + intent="public_knowledge", + context=context, + ) + result = ( + output + if isinstance(output, KnowledgeSearchResult) + else KnowledgeSearchResult.model_validate(output) + ) + except RecoverableAgentError: + return self._knowledge_transfer("knowledge_unavailable") + answers = [ + hit.answer.strip() + for hit in result.hits + if isinstance(hit.answer, str) and hit.answer.strip() + ] + if not answers: + return self._knowledge_transfer("knowledge_not_found") + return CoreResult(text=answers[0]) + + @staticmethod + def _knowledge_transfer(reason: str) -> CoreResult: + phone = _human_service_phone() + if reason == "knowledge_unavailable": + text = ( + "您好呀,我暂时无法从公开资料中确认这个问题,请联系" + f"人工客服 {phone} 进一步核实。" + ) + else: + text = ( + "您好呀,我暂时没有查到匹配的公开资料,请联系" + f"人工客服 {phone} 进一步核实。" + ) + return CoreResult(text=text, transfer_required=True, transfer_reason=reason) diff --git a/app/service/agent/customer_service_routing.py b/app/service/agent/customer_service_routing.py new file mode 100644 index 0000000..dc85d95 --- /dev/null +++ b/app/service/agent/customer_service_routing.py @@ -0,0 +1,74 @@ +"""一期客服的确定性路由,先处理安全和边界,再允许公开知识检索。""" + +from collections.abc import Sequence +from dataclasses import dataclass + + +@dataclass(frozen=True) +class CustomerServiceRoute: + intent: str + knowledge_intents: tuple[str, ...] = () + is_chitchat: bool = False + + +class CustomerServiceIntentRouter: + _SECURITY_KEYWORDS = ("验证码", "密码泄露", "被盗", "诈骗", "非本人交易") + _COMPLIANCE_KEYWORDS = ("推荐", "收益最高", "稳赚", "保本", "帮我买", "替我交易") + _ACCOUNT_KEYWORDS = ("持仓", "收益", "订单", "定投", "银行卡", "风险测评", "投诉进度") + _HUMAN_TRANSFER_KEYWORDS = ("转人工", "人工客服", "投诉", "赔偿", "法律", "纠纷") + _POLICY_KEYWORDS = ( + "申购", "赎回", "到账", "费率", "手续费", "确认份额", "交易日", "分红", "规则", "政策" + ) + _PRODUCT_KEYWORDS = ( + "产品", "基金代码", "基金经理", "份额类别", "a类", "c类", "净值", "风险等级" + ) + _CHITCHAT_MESSAGES = frozenset({ + "你好", "您好", "嗨", "哈喽", "在吗", "谢谢", "谢谢你", "再见", "拜拜", + "你是谁", "你叫什么", "你今天开心吗", + }) + _CHITCHAT_PHRASES = ("今天天气", "讲个笑话", "你几岁", "你开心吗", "你忙吗") + + @classmethod + def classify(cls, message: str) -> CustomerServiceRoute: + normalized = message.strip().lower() + if cls._contains(normalized, cls._SECURITY_KEYWORDS): + return CustomerServiceRoute(intent="security_notice") + 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",) + ) + if cls._contains(normalized, cls._PRODUCT_KEYWORDS): + return CustomerServiceRoute( + intent="public_knowledge", knowledge_intents=("product_inquiry",) + ) + 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) diff --git a/app/service/agent/governance.py b/app/service/agent/governance.py index 579bf4b..d8cad12 100644 --- a/app/service/agent/governance.py +++ b/app/service/agent/governance.py @@ -6,6 +6,7 @@ from typing import Protocol from sqlalchemy import select, text from sqlalchemy.ext.asyncio import AsyncSession +from app.core.config import get_settings from app.core.contracts import ( AgentDefinition, AgentResult, @@ -135,8 +136,13 @@ def review_output( else: # Apply to text and citation titles, not only to the displayed answer. def redact(value: str) -> str: + # 人工客服电话是唯一经过配置审核、可在对客文本中保留的号码。 + trusted_phone = get_settings().customer_service_phone + protected_phone = "__customer_service_phone__" + value = value.replace(trusted_phone, protected_phone) value = re.sub(r"(? dict[str, object]: + """构造 Worker 使用的内部元数据,不信任外部传入的闲聊计数。""" + metadata = request.metadata + if request.agent_type == "customer_service": + metadata = metadata.model_copy(update={ + "chitchat_streak": CustomerServiceIntentRouter.chitchat_streak( + prior_user_messages, request.message + ) + }) + return metadata.model_dump(mode="json") + + class AgentRunApplicationService: def __init__(self, session: AsyncSession, factory: AgentFactory | None = None) -> None: self.session = session @@ -89,6 +105,20 @@ class AgentRunApplicationService: raise RuntimeError("idempotency record has no run") return RunAccepted(run.run_id, run.trace_id, run.status) + # 仅读取当前会话最近三条用户消息;第四条闲聊即触发一次自然业务引导。 + prior_user_messages = list(await self.session.scalars( + select(ConversationMessage.content) + .where( + ConversationMessage.session_id == request.session_id, + ConversationMessage.role == "user", + ) + .order_by(ConversationMessage.id.desc()) + .limit(3) + )) + outbox_metadata = build_outbox_metadata( + request, tuple(reversed(prior_user_messages)) + ) + trace_id = context.trace_id message = ConversationMessage( session_id=request.session_id, customer_id=user_id, portal="api", @@ -121,7 +151,7 @@ class AgentRunApplicationService: payload={ "run_id": run_id, "actor_type": "visitor" if "visitor" in context.roles else "authenticated", - "metadata": request.metadata.model_dump(mode="json"), + "metadata": outbox_metadata, }, occurred_at=now, )) diff --git a/app/static/index.html b/app/static/index.html new file mode 100644 index 0000000..e2b6141 --- /dev/null +++ b/app/static/index.html @@ -0,0 +1,155 @@ + + +
+ + +公开知识与客服分流联调页
访客:正在建立测试连接
+