feat: complete customer service safety and handover flow
This commit is contained in:
+72
-5
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user