feat: complete customer service safety and handover flow

This commit is contained in:
张胜宇
2026-09-11 16:11:30 +08:00
parent 0059701509
commit ef098e6a4b
30 changed files with 1868 additions and 50 deletions
+72 -5
View File
@@ -15,7 +15,7 @@ from app.core.errors import AgentError, RecoverableAgentError, RunLeaseLostError
from app.infrastructure.db import SessionFactory
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
from app.repository.agent_run_repository import AgentRunRepository
from app.service.agent.bootstrap import (
get_agent_factory,
@@ -25,6 +25,11 @@ from app.service.agent.bootstrap import (
from app.service.agent.executor import AgentExecutor
from app.service.agent.factory import AgentFactory
from app.service.agent_persistence_service import AgentPersistenceService
from app.service.customer_service_session_memory_service import (
CustomerServiceSessionMemory,
CustomerServiceSessionTurn,
build_customer_service_session_memory,
)
from app.service.identity_service import IdentityService
from app.service.memory_extraction_service import (
ExtractionEndpointResolver,
@@ -79,6 +84,7 @@ class WorkerRuntime:
endpoint_resolver: ExtractionEndpointResolver | None = None,
memory_cache: CacheDeleteAdapter | None = None,
projection_cleaner: ProjectionCleaner | None = None,
session_memory: CustomerServiceSessionMemory | None = None,
) -> None:
self.factory = factory if factory is not None else get_agent_factory()
self.settings = settings or get_settings()
@@ -99,6 +105,12 @@ class WorkerRuntime:
# Milvus/Neo4j 删除客户端:当前组装层没有提供(bootstrap 只装配召回用的读适配器),
# 因此默认 None = 投影清理显式降级并留痕,绝不写成"删除成功"。
self.projection_cleaner = projection_cleaner
# 该组件是客服当前会话 Redis List,不是长期记忆召回缓存或客户画像。
self.session_memory = (
session_memory
if session_memory is not None
else build_customer_service_session_memory()
)
# episode 聚合是低频批处理,按轮次节流而不是每轮都查。
self._episode_rounds = 0
@@ -118,11 +130,12 @@ class WorkerRuntime:
@staticmethod
def should_request_memory_extraction(
*, context: RequestContext, message: str, result: AgentResult,
*, agent_type: str, context: RequestContext, message: str, result: AgentResult,
business_events: tuple[str, ...] | list[str],
) -> bool:
"""只允许已登录用户的明确业务事实进入客户记忆抽取队列。"""
if "visitor" in context.roles:
"""只允许非客服 Agent 的已登录用户业务事实进入客户记忆抽取队列。"""
# 客服只使用未来受控的会话短期上下文,绝不沉淀为长期画像或客户偏好。
if agent_type == "customer_service" or "visitor" in context.roles:
return False
return MemoryService.should_extract_memory(
conversation_content=message,
@@ -180,6 +193,34 @@ class WorkerRuntime:
trace_id=str(payload.get("trace_id", "")),
)
async def dispatch_handover_queue_ready(payload: dict[str, Any]) -> None:
# 事件成功消费只表示管理员队列可查看,不能修改 pending 状态,更不能向用户
# 表述人工已经接单、处理或承诺时效。
ticket_no = str(payload.get("ticket_no", "")).strip()
if not ticket_no:
raise ValueError("conversation.transfer_requested payload is incomplete")
ticket = await session.scalar(
select(HandoverTicket).where(HandoverTicket.ticket_no == ticket_no)
)
if ticket is None:
raise ValueError("handover ticket not found")
session.add(InteractionAudit(
actor_type="system",
actor_id=None,
target_customer_id=ticket.customer_id,
session_id=ticket.session_id,
portal="worker",
action_type="handover.queue_ready",
detail={
"ticket_no": ticket.ticket_no,
"source_agent": ticket.source_agent,
"reason_code": ticket.reason_code,
"ticket_status": ticket.status,
},
created_at=datetime.now(UTC).replace(tzinfo=None),
))
await session.flush()
handlers: dict[str, Callable[[dict[str, Any]], Awaitable[None]]] = {
"agent.run_requested": dispatch,
"memory.extraction_requested": dispatch_memory_extraction,
@@ -189,6 +230,7 @@ class WorkerRuntime:
# 投影清理:这两类事件此前没有消费者,永久 pending。
"memory.invalidated": dispatch_projection_cleanup,
"memory.deleted": dispatch_projection_cleanup,
"conversation.transfer_requested": dispatch_handover_queue_ready,
}
return await OutboxWorker(session, handlers).publish_one(aggregate_id=run_id)
@@ -489,12 +531,37 @@ class WorkerRuntime:
await AgentPersistenceService(session).complete_run(
run_id, result, worker_id=worker_id,
memory_extraction_requested=self.should_request_memory_extraction(
context=context,
agent_type=run.agent_type, context=context,
message=request.message,
result=result,
business_events=business_events,
),
)
# 只有数据库成功保存完整用户/助手轮次后才写短期 Redis;Redis 故障不应让已完成
# 的客服回答回滚或重试。短期组件会再次脱敏,形成持久化链路的第二道保护。
await self._append_customer_service_session_memory(
agent_type=run.agent_type, actor_id=str(run.user_id), session_id=run.session_id,
request_message=request.message, response_message=result.result.text,
)
async def _append_customer_service_session_memory(
self, *, agent_type: str, actor_id: str, session_id: str,
request_message: str, response_message: str,
) -> None:
"""将一轮成功客服会话写入 Redis;此派生写入永不影响已提交的主事务。"""
if agent_type != "customer_service":
return
try:
await self.session_memory.append(
actor_id=actor_id, session_id=session_id,
turns=(
CustomerServiceSessionTurn(role="user", content=request_message),
CustomerServiceSessionTurn(role="assistant", content=response_message),
),
)
except Exception:
# 注入的第三方 Redis 适配器也可能违反“不抛异常”约定,Worker 仍必须隔离故障。
logger.warning("客服短期会话写入降级,不影响已完成的客服运行", exc_info=True)
async def _failure(
self, run_id: str, worker_id: str, error_code: str, *, retryable: bool