feat: add governed customer service agent
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"(?<!\d)1[3-9]\d{9}(?!\d)", "[手机号已脱敏]", value)
|
||||
return re.sub(r"(?<!\d)\d{15,19}[Xx]?(?!\d)", "[敏感号码已脱敏]", value)
|
||||
value = re.sub(r"(?<!\d)\d{15,19}[Xx]?(?!\d)", "[敏感号码已脱敏]", value)
|
||||
return value.replace(protected_phone, trusted_phone)
|
||||
|
||||
content = content.model_copy(update={
|
||||
"text": redact(output),
|
||||
|
||||
@@ -9,7 +9,7 @@ from app.core.contracts import AgentResult, DomainEvent
|
||||
from app.core.errors import RunLeaseLostError
|
||||
from app.model.audit import InteractionAudit
|
||||
from app.model.conversation import ConversationMessage
|
||||
from app.model.platform import AgentRun, DomainEventOutbox, RequestIdempotency
|
||||
from app.model.platform import AgentRun, DomainEventOutbox, HandoverTicket, RequestIdempotency
|
||||
|
||||
|
||||
class AgentPersistenceService:
|
||||
@@ -52,6 +52,31 @@ class AgentPersistenceService:
|
||||
)
|
||||
self.session.add(message)
|
||||
await self.session.flush()
|
||||
# 转人工只接受已完成治理决策的结果;工单与回复绑定,便于管理员回看上下文。
|
||||
handover_ticket: HandoverTicket | None = None
|
||||
if result.result.transfer_required:
|
||||
handover_ticket = HandoverTicket(
|
||||
ticket_no=f"ticket-{uuid4().hex[:24]}",
|
||||
session_id=run.session_id,
|
||||
customer_id=run.user_id,
|
||||
source_agent=run.agent_type,
|
||||
source_message_id=message.id,
|
||||
intent=(result.result.intent.intent if result.result.intent else None),
|
||||
confidence=(
|
||||
Decimal(str(result.result.intent.confidence))
|
||||
if result.result.intent else None
|
||||
),
|
||||
reason_code=result.result.transfer_reason or "agent_requested",
|
||||
conversation_summary=result.result.text,
|
||||
source_references=[
|
||||
reference.model_dump(mode="json")
|
||||
for reference in result.result.source_references
|
||||
],
|
||||
status="pending",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
self.session.add(handover_ticket)
|
||||
run.result_message_id = message.id
|
||||
run.status = "succeeded"
|
||||
run.result_version = 1
|
||||
@@ -63,6 +88,19 @@ class AgentPersistenceService:
|
||||
session_id=run.session_id, portal="agent", action_type="agent.run_completed",
|
||||
detail={"run_id": run_id, "result_message_id": message.id}, created_at=now,
|
||||
))
|
||||
if handover_ticket is not None:
|
||||
# 管理员工单列表之外还需留一条不可变审计,记录 Agent 自动转接来源。
|
||||
self.session.add(InteractionAudit(
|
||||
actor_type="agent", actor_id=run.user_id, target_customer_id=run.user_id,
|
||||
session_id=run.session_id, portal="agent",
|
||||
action_type="agent.handover_requested",
|
||||
detail={
|
||||
"run_id": run_id,
|
||||
"ticket_no": handover_ticket.ticket_no,
|
||||
"reason_code": handover_ticket.reason_code,
|
||||
},
|
||||
created_at=now,
|
||||
))
|
||||
await self.session.execute(
|
||||
update(RequestIdempotency)
|
||||
.where(RequestIdempotency.id == run.idempotency_id)
|
||||
@@ -80,6 +118,14 @@ class AgentPersistenceService:
|
||||
payload={"run_id": run_id, "message_id": message.id,
|
||||
"customer_id": run.user_id}, occurred_at=now,
|
||||
))
|
||||
if handover_ticket is not None:
|
||||
# Outbox 事件由后续管理员通知/工单消费方可靠投递,服务层不直接通知外部系统。
|
||||
events.append(DomainEvent(
|
||||
event_id=str(uuid4()), event_type="conversation.transfer_requested",
|
||||
aggregate_type="conversation", aggregate_id=run.session_id,
|
||||
trace_id=run.trace_id,
|
||||
payload={"ticket_no": handover_ticket.ticket_no}, occurred_at=now,
|
||||
))
|
||||
for event in events:
|
||||
self.session.add(DomainEventOutbox(
|
||||
event_id=event.event_id, event_type=event.event_type,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
@@ -20,6 +21,7 @@ from app.model.platform import AgentRun, RequestIdempotency
|
||||
from app.model.session import ConversationSession
|
||||
from app.repository.outbox_repository import OutboxRepository
|
||||
from app.service.agent.bootstrap import get_agent_factory
|
||||
from app.service.agent.customer_service_routing import CustomerServiceIntentRouter
|
||||
from app.service.agent.factory import AgentFactory
|
||||
|
||||
|
||||
@@ -30,6 +32,20 @@ class RunAccepted:
|
||||
status: str = "queued"
|
||||
|
||||
|
||||
def build_outbox_metadata(
|
||||
request: AgentRequest, prior_user_messages: Sequence[str]
|
||||
) -> 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,
|
||||
))
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>奶龙基金智能助手联调</title>
|
||||
<style>
|
||||
:root { --ink: #17212b; --muted: #607080; --line: #d9e1e7; --canvas: #f4f7f8; --surface: #ffffff; --brand: #008a7a; --brand-dark: #00695f; --accent: #e96f45; --visitor: #e8f6f1; --agent: #f2f5f7; --danger: #b43b2c; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-height: 100vh; background: var(--canvas); color: var(--ink); font-family: "Microsoft YaHei", "PingFang SC", sans-serif; }
|
||||
main { width: min(100%, 980px); min-height: 100vh; margin: 0 auto; padding: 24px; display: grid; grid-template-rows: auto auto minmax(360px, 1fr) auto; gap: 14px; }
|
||||
header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-bottom: 14px; border-bottom: 1px solid var(--line); }
|
||||
.brand { display: flex; align-items: center; gap: 12px; }
|
||||
.brand-mark { width: 38px; height: 38px; display: grid; place-items: center; background: var(--brand); color: #ffffff; border-radius: 8px; font-weight: 700; }
|
||||
h1 { margin: 0; font-size: 20px; font-weight: 700; letter-spacing: 0; }
|
||||
.status { margin: 2px 0 0; color: var(--muted); font-size: 13px; }
|
||||
.role { color: var(--brand-dark); font-weight: 700; }
|
||||
.samples { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.sample { min-height: 34px; border: 1px solid var(--line); background: var(--surface); color: var(--ink); padding: 7px 10px; border-radius: 6px; cursor: pointer; font: inherit; font-size: 13px; }
|
||||
.sample:hover, .sample:focus-visible { border-color: var(--brand); color: var(--brand-dark); outline: none; }
|
||||
.chat { overflow-y: auto; display: flex; flex-direction: column; gap: 12px; padding: 18px; background: var(--surface); border: 1px solid var(--line); border-radius: 8px; }
|
||||
.message { display: flex; gap: 10px; max-width: min(82%, 620px); }
|
||||
.message.user { align-self: flex-end; flex-direction: row-reverse; }
|
||||
.avatar { flex: 0 0 30px; height: 30px; display: grid; place-items: center; border-radius: 50%; background: var(--brand); color: #ffffff; font-size: 12px; font-weight: 700; }
|
||||
.message.user .avatar { background: var(--accent); }
|
||||
.bubble { margin: 0; padding: 10px 12px; background: var(--agent); border-radius: 6px; font-size: 14px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.message.user .bubble { background: var(--visitor); }
|
||||
.pending .bubble { color: var(--muted); }
|
||||
.error .bubble { background: #fff0ed; color: var(--danger); }
|
||||
form { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; }
|
||||
input { min-width: 0; height: 44px; border: 1px solid var(--line); border-radius: 6px; padding: 0 12px; background: var(--surface); color: var(--ink); font: inherit; }
|
||||
input:focus { border-color: var(--brand); outline: 2px solid #bde6df; }
|
||||
button[type="submit"] { min-width: 76px; height: 44px; border: 0; border-radius: 6px; background: var(--brand); color: #ffffff; cursor: pointer; font: inherit; }
|
||||
button[type="submit"]:disabled { cursor: wait; opacity: 0.6; }
|
||||
@media (max-width: 620px) { main { padding: 14px; } header { align-items: flex-start; flex-direction: column; } .message { max-width: 92%; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<div class="brand">
|
||||
<div class="brand-mark" aria-hidden="true">龙</div>
|
||||
<div><h1>奶龙基金智能助手</h1><p class="status">公开知识与客服分流联调页</p></div>
|
||||
</div>
|
||||
<p id="connection-status" class="status"><span class="role">访客</span>:正在建立测试连接</p>
|
||||
</header>
|
||||
<section class="samples" aria-label="测试场景">
|
||||
<button class="sample" type="button" data-message="你好">闲聊</button>
|
||||
<button class="sample" type="button" data-message="基金赎回到账规则是什么">政策</button>
|
||||
<button class="sample" type="button" data-message="我的持仓收益是多少">账户入口</button>
|
||||
<button class="sample" type="button" data-message="验证码已经发给别人了">安全风险</button>
|
||||
<button class="sample" type="button" data-message="帮我推荐一只收益最高的基金">合规拒答</button>
|
||||
<button class="sample" type="button" data-message="我要转人工客服">人工服务</button>
|
||||
</section>
|
||||
<section id="chat" class="chat" aria-live="polite"></section>
|
||||
<form id="composer">
|
||||
<input id="message" type="text" maxlength="2000" autocomplete="off" placeholder="输入要测试的问题" aria-label="客服测试消息">
|
||||
<button id="send" type="submit">发送</button>
|
||||
</form>
|
||||
</main>
|
||||
<script>
|
||||
let accessToken = "";
|
||||
const sessionId = `visitor-${crypto.randomUUID()}`;
|
||||
const chat = document.querySelector("#chat");
|
||||
const input = document.querySelector("#message");
|
||||
const composer = document.querySelector("#composer");
|
||||
const sendButton = document.querySelector("#send");
|
||||
const connectionStatus = document.querySelector("#connection-status");
|
||||
|
||||
function appendMessage(role, text, state = "") {
|
||||
const wrapper = document.createElement("article");
|
||||
wrapper.className = `message ${role} ${state}`;
|
||||
const avatar = document.createElement("span");
|
||||
avatar.className = "avatar";
|
||||
avatar.textContent = role === "user" ? "我" : "龙";
|
||||
const bubble = document.createElement("p");
|
||||
bubble.className = "bubble";
|
||||
bubble.textContent = text;
|
||||
wrapper.append(avatar, bubble);
|
||||
chat.append(wrapper);
|
||||
chat.scrollTop = chat.scrollHeight;
|
||||
return { wrapper, bubble };
|
||||
}
|
||||
|
||||
async function ensureVisitorToken() {
|
||||
if (accessToken) return;
|
||||
const response = await fetch("/api/v1/visitor-tokens", { method: "POST" });
|
||||
if (!response.ok) throw new Error("访客令牌获取失败");
|
||||
accessToken = (await response.json()).access_token;
|
||||
connectionStatus.innerHTML = '<span class="role">访客</span>:测试连接已建立';
|
||||
}
|
||||
|
||||
function delay(milliseconds) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
async function pollRun(statusUrl, messageView) {
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
const response = await fetch(statusUrl, { headers: { Authorization: `Bearer ${accessToken}` } });
|
||||
if (!response.ok) throw new Error("客服运行状态读取失败");
|
||||
const run = (await response.json()).data;
|
||||
if (run.status === "succeeded") {
|
||||
messageView.bubble.textContent = run.result?.content || "客服未返回可显示内容。";
|
||||
messageView.wrapper.classList.remove("pending");
|
||||
chat.scrollTop = chat.scrollHeight;
|
||||
return;
|
||||
}
|
||||
if (run.status === "failed" || run.status === "cancelled") {
|
||||
messageView.bubble.textContent = `本次客服运行未完成:${run.error_code || run.status}`;
|
||||
messageView.wrapper.className = "message error";
|
||||
return;
|
||||
}
|
||||
await delay(500);
|
||||
}
|
||||
messageView.bubble.textContent = "客服请求仍在处理中,请确认 Worker 已启动后重试。";
|
||||
messageView.wrapper.className = "message error";
|
||||
}
|
||||
|
||||
async function sendMessage(message) {
|
||||
const normalized = message.trim();
|
||||
if (!normalized) return;
|
||||
appendMessage("user", normalized);
|
||||
input.value = "";
|
||||
sendButton.disabled = true;
|
||||
const pending = appendMessage("agent", "奶龙基金智能助手正在处理您的问题…", "pending");
|
||||
try {
|
||||
await ensureVisitorToken();
|
||||
const response = await fetch("/api/v1/agent-runs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}` },
|
||||
body: JSON.stringify({ agent_type: "customer_service", message: normalized, session_id: sessionId, idempotency_key: crypto.randomUUID().replaceAll("-", "") }),
|
||||
});
|
||||
if (!response.ok) throw new Error("客服请求创建失败");
|
||||
await pollRun((await response.json()).data.status_url, pending);
|
||||
} catch (error) {
|
||||
pending.bubble.textContent = error instanceof Error ? error.message : "客服测试请求失败";
|
||||
pending.wrapper.className = "message error";
|
||||
} finally {
|
||||
sendButton.disabled = false;
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
composer.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
void sendMessage(input.value);
|
||||
});
|
||||
document.querySelectorAll(".sample").forEach((button) => {
|
||||
button.addEventListener("click", () => void sendMessage(button.dataset.message || ""));
|
||||
});
|
||||
appendMessage("agent", "您好呀,我是奶龙基金智能助手。这里可测试公开问题、账户入口、安全提示和人工服务路径。");
|
||||
void ensureVisitorToken().catch(() => { connectionStatus.textContent = "访客:连接未建立,发送消息时将自动重试"; });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
def test_customer_service_test_page_exposes_visitor_agent_flow() -> None:
|
||||
with TestClient(create_app()) as client:
|
||||
response = client.get("/customer-service-test/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "奶龙基金智能助手" in response.text
|
||||
assert "/api/v1/visitor-tokens" in response.text
|
||||
assert "/api/v1/agent-runs" in response.text
|
||||
@@ -0,0 +1,121 @@
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.contracts import AgentResult, CoreResult, IntentResult
|
||||
from app.model.conversation import ConversationMessage
|
||||
from app.model.platform import AgentRun, DomainEventOutbox, HandoverTicket
|
||||
from app.service.agent_persistence_service import AgentPersistenceService
|
||||
|
||||
|
||||
class FakeTransaction:
|
||||
async def __aenter__(self) -> None:
|
||||
return None
|
||||
|
||||
async def __aexit__(
|
||||
self, exc_type: object, exc_value: object, traceback: object
|
||||
) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class FakeSession:
|
||||
"""仅收集持久化服务在同一事务内计划写入的 ORM 实体。"""
|
||||
|
||||
def __init__(self, run: AgentRun) -> None:
|
||||
self.run = run
|
||||
self.added: list[Any] = []
|
||||
self.executed: list[Any] = []
|
||||
|
||||
def begin(self) -> FakeTransaction:
|
||||
return FakeTransaction()
|
||||
|
||||
async def scalar(self, statement: object) -> AgentRun:
|
||||
return self.run
|
||||
|
||||
def add(self, item: Any) -> None:
|
||||
self.added.append(item)
|
||||
|
||||
async def flush(self) -> None:
|
||||
for item in self.added:
|
||||
if isinstance(item, ConversationMessage) and item.id is None:
|
||||
item.id = 901
|
||||
|
||||
async def execute(self, statement: object) -> None:
|
||||
self.executed.append(statement)
|
||||
|
||||
|
||||
def queued_run() -> AgentRun:
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
return AgentRun(
|
||||
id=1,
|
||||
run_id="run-transfer-1",
|
||||
idempotency_id=2,
|
||||
session_id="session-transfer-1",
|
||||
user_id=7,
|
||||
agent_type="customer_service",
|
||||
trace_id="trace-transfer-1",
|
||||
request_message_id=800,
|
||||
status="queued",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
|
||||
def result(*, transfer_required: bool) -> AgentResult:
|
||||
return AgentResult(
|
||||
run_id="run-transfer-1",
|
||||
result=CoreResult(
|
||||
text="已为您转接人工客服。",
|
||||
intent=IntentResult(intent="human_handover", confidence=1),
|
||||
transfer_required=transfer_required,
|
||||
transfer_reason="user_requested" if transfer_required else None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def added_of(items: list[Any], model: type[Any]) -> list[Any]:
|
||||
return [item for item in items if isinstance(item, model)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transfer_required_result_creates_pending_ticket_and_outbox_event() -> None:
|
||||
"""Agent 发起的转人工必须在完成运行的事务内留待处理工单和通知事件。"""
|
||||
session = FakeSession(queued_run())
|
||||
|
||||
await AgentPersistenceService(session).complete_run(
|
||||
"run-transfer-1", result(transfer_required=True), memory_extraction_requested=False
|
||||
)
|
||||
|
||||
tickets = added_of(session.added, HandoverTicket)
|
||||
events = added_of(session.added, DomainEventOutbox)
|
||||
assert len(tickets) == 1
|
||||
assert tickets[0].status == "pending"
|
||||
assert tickets[0].session_id == "session-transfer-1"
|
||||
assert tickets[0].customer_id == 7
|
||||
assert tickets[0].source_agent == "customer_service"
|
||||
assert tickets[0].source_message_id == 901
|
||||
assert tickets[0].reason_code == "user_requested"
|
||||
assert len(events) == 2 # agent.run_completed + conversation.transfer_requested
|
||||
transfer_event = next(
|
||||
event for event in events if event.event_type == "conversation.transfer_requested"
|
||||
)
|
||||
assert transfer_event.aggregate_type == "conversation"
|
||||
assert transfer_event.aggregate_id == "session-transfer-1"
|
||||
assert transfer_event.payload["ticket_no"] == tickets[0].ticket_no
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normal_result_does_not_create_handover_ticket_or_event() -> None:
|
||||
"""非转人工回答不得污染管理员待处理队列。"""
|
||||
session = FakeSession(queued_run())
|
||||
|
||||
await AgentPersistenceService(session).complete_run(
|
||||
"run-transfer-1", result(transfer_required=False), memory_extraction_requested=False
|
||||
)
|
||||
|
||||
assert added_of(session.added, HandoverTicket) == []
|
||||
assert all(
|
||||
event.event_type != "conversation.transfer_requested"
|
||||
for event in added_of(session.added, DomainEventOutbox)
|
||||
)
|
||||
@@ -0,0 +1,171 @@
|
||||
import pytest
|
||||
|
||||
from app.core.contracts import AgentRequest, AgentRequestMetadata, RequestContext
|
||||
from app.core.errors import RecoverableAgentError
|
||||
from app.core.knowledge_contracts import KnowledgeHit, KnowledgeSearchResult
|
||||
from app.service.agent.base import BaseAgent
|
||||
from app.service.agent.bootstrap import get_agent_factory
|
||||
from app.service.agent.customer_service_agent import CustomerServiceAgent
|
||||
from app.service.agent.customer_service_routing import CustomerServiceIntentRouter
|
||||
|
||||
|
||||
def request(message: str, *, chitchat_streak: int = 0) -> AgentRequest:
|
||||
return AgentRequest(
|
||||
agent_type="customer_service",
|
||||
message=message,
|
||||
session_id="customer-service-session",
|
||||
idempotency_key="customer-service-idempotency-key",
|
||||
metadata=AgentRequestMetadata(chitchat_streak=chitchat_streak),
|
||||
)
|
||||
|
||||
|
||||
def context(role: str) -> RequestContext:
|
||||
return RequestContext(
|
||||
user_id="1",
|
||||
trace_id="customer-service-trace",
|
||||
roles=(role,),
|
||||
permissions=("agent:run", "knowledge:query"),
|
||||
data_scope="public" if role == "visitor" else "self",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_visitor_account_question_only_returns_login_entry() -> None:
|
||||
result = await CustomerServiceAgent().handle(
|
||||
request("我的持仓收益是多少"), context("visitor")
|
||||
)
|
||||
|
||||
assert result.text == "我无法查询账户数据,请先登录后前往“我的账户”查看相关状态。"
|
||||
assert result.transfer_required is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticated_account_question_only_returns_account_entry() -> None:
|
||||
result = await CustomerServiceAgent().handle(request("查一下我的订单"), context("customer"))
|
||||
|
||||
assert result.text == "我无法查询账户数据,请前往“我的账户”查看相关状态。"
|
||||
assert result.transfer_required is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_security_question_requires_human_transfer() -> None:
|
||||
result = await CustomerServiceAgent().handle(
|
||||
request("验证码已经发给别人了"), context("visitor")
|
||||
)
|
||||
|
||||
assert result.transfer_required is True
|
||||
assert result.transfer_reason == "security_notice"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_personalized_investment_advice_is_refused_and_transferred() -> None:
|
||||
result = await CustomerServiceAgent().handle(
|
||||
request("帮我推荐一只收益最高的基金"), context("customer")
|
||||
)
|
||||
|
||||
assert result.transfer_required is True
|
||||
assert result.transfer_reason == "compliance_refusal"
|
||||
|
||||
|
||||
def test_customer_service_agent_is_registered_for_visitor_role() -> None:
|
||||
agent = get_agent_factory().create("customer_service", context("visitor"))
|
||||
|
||||
assert isinstance(agent, CustomerServiceAgent)
|
||||
|
||||
|
||||
def test_chitchat_streak_is_derived_from_continuous_prior_messages() -> None:
|
||||
assert CustomerServiceIntentRouter.chitchat_streak(
|
||||
("你好", "讲个笑话", "你开心吗"), "在吗"
|
||||
) == 4
|
||||
assert CustomerServiceIntentRouter.chitchat_streak(
|
||||
("你好", "基金怎么开户"), "在吗"
|
||||
) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_policy_question_uses_only_policy_knowledge(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_call_tool(
|
||||
self: BaseAgent,
|
||||
name: str,
|
||||
arguments: dict[str, object],
|
||||
*,
|
||||
intent: str,
|
||||
context: RequestContext,
|
||||
) -> KnowledgeSearchResult:
|
||||
captured.update(name=name, arguments=arguments, intent=intent)
|
||||
return KnowledgeSearchResult(
|
||||
hits=(
|
||||
KnowledgeHit(
|
||||
knowledge_id="101",
|
||||
collection="fin_policy_collection",
|
||||
snippet="赎回规则摘要",
|
||||
answer="这是已审核的赎回公开规则。",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
monkeypatch.setattr(BaseAgent, "call_tool", fake_call_tool)
|
||||
|
||||
result = await CustomerServiceAgent().handle(
|
||||
request("基金赎回到账规则是什么"), context("customer")
|
||||
)
|
||||
|
||||
assert result.text == "这是已审核的赎回公开规则。"
|
||||
assert captured == {
|
||||
"name": "query_knowledge",
|
||||
"arguments": {
|
||||
"query": "基金赎回到账规则是什么",
|
||||
"intents": ("policy_explain",),
|
||||
"top_k": 5,
|
||||
},
|
||||
"intent": "public_knowledge",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_knowledge_failure_requires_human_transfer(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def unavailable_tool(
|
||||
self: BaseAgent,
|
||||
name: str,
|
||||
arguments: dict[str, object],
|
||||
*,
|
||||
intent: str,
|
||||
context: RequestContext,
|
||||
) -> KnowledgeSearchResult:
|
||||
raise RecoverableAgentError("知识检索不可用")
|
||||
|
||||
monkeypatch.setattr(BaseAgent, "call_tool", unavailable_tool)
|
||||
|
||||
result = await CustomerServiceAgent().handle(request("基金怎么开户"), context("visitor"))
|
||||
|
||||
assert result.transfer_required is True
|
||||
assert result.transfer_reason == "knowledge_unavailable"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fourth_chitchat_message_is_guided_once_without_knowledge_lookup(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
async def unexpected_tool(
|
||||
self: BaseAgent,
|
||||
name: str,
|
||||
arguments: dict[str, object],
|
||||
*,
|
||||
intent: str,
|
||||
context: RequestContext,
|
||||
) -> KnowledgeSearchResult:
|
||||
raise AssertionError("闲聊不应调用公开知识工具")
|
||||
|
||||
monkeypatch.setattr(BaseAgent, "call_tool", unexpected_tool)
|
||||
|
||||
guided = await CustomerServiceAgent().handle(
|
||||
request("你今天开心吗", chitchat_streak=4), context("visitor")
|
||||
)
|
||||
ordinary = await CustomerServiceAgent().handle(
|
||||
request("你今天开心吗", chitchat_streak=5), context("visitor")
|
||||
)
|
||||
|
||||
assert "基金业务" in guided.text
|
||||
assert "基金业务" not in ordinary.text
|
||||
@@ -0,0 +1,16 @@
|
||||
from app.core.contracts import AgentRequest, AgentRequestMetadata
|
||||
from app.service.agent_run_application_service import build_outbox_metadata
|
||||
|
||||
|
||||
def test_customer_service_outbox_metadata_overrides_supplied_chitchat_streak() -> None:
|
||||
request = AgentRequest(
|
||||
agent_type="customer_service",
|
||||
message="在吗",
|
||||
session_id="customer-service-session",
|
||||
idempotency_key="customer-service-idempotency-key",
|
||||
metadata=AgentRequestMetadata(chitchat_streak=5),
|
||||
)
|
||||
|
||||
metadata = build_outbox_metadata(request, ("你好", "基金怎么开户"))
|
||||
|
||||
assert metadata["chitchat_streak"] == 1
|
||||
Reference in New Issue
Block a user