merge: integrate ZSY customer service and profile capabilities
This commit is contained in:
+135
-16
@@ -10,12 +10,17 @@ from uuid import uuid4
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.contracts import AgentRequest, AgentResult, RequestContext
|
||||
from app.core.contracts import AgentRequest, AgentRequestMetadata, AgentResult, RequestContext
|
||||
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,
|
||||
@@ -27,6 +32,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,
|
||||
@@ -37,6 +47,7 @@ from app.service.memory_recall_service import MemoryRecallService
|
||||
from app.service.memory_service import CacheDeleteAdapter, MemoryService
|
||||
from app.service.memory_taxonomy import BUSINESS_EVENT_TYPES
|
||||
from app.service.model_gateway import DatabaseModelEndpointResolver, ModelGenerationService
|
||||
from app.worker.customer_profile_candidate_worker import CustomerProfileCandidateWorker
|
||||
from app.worker.episode_worker import (
|
||||
EpisodeConsumptionResult,
|
||||
EpisodeExtractionConsumer,
|
||||
@@ -91,6 +102,7 @@ class WorkerRuntime:
|
||||
knowledge_writer: Any = _UNSET,
|
||||
knowledge_embedder: Any = _UNSET,
|
||||
knowledge_endpoint_resolver: Any = _UNSET,
|
||||
session_memory: CustomerServiceSessionMemory | None = None,
|
||||
) -> None:
|
||||
self.factory = factory if factory is not None else get_agent_factory()
|
||||
self.settings = settings or get_settings()
|
||||
@@ -112,6 +124,11 @@ class WorkerRuntime:
|
||||
# 这里不兜底。组件内部给默认实现会把"尚未装配"这一事实悄悄盖住——而"未注入即显式
|
||||
# 降级并留痕"是本模块刻意保留的语义(有单测守着),因此默认值保持 None。
|
||||
self.projection_cleaner = projection_cleaner
|
||||
# 客服短期会话 Redis 仅用于当前会话上下文,不参与长期画像召回。
|
||||
self.session_memory = (
|
||||
session_memory if session_memory is not None
|
||||
else build_customer_service_session_memory()
|
||||
)
|
||||
# 图关系服务:画像投影用它写入节点与关系(投顾的多跳推荐、风控的关系网络都读它)。
|
||||
# 默认取生产装配;图库不可用时该值为 None,投影如实降级而不是失败。
|
||||
if relationships is not None:
|
||||
@@ -141,6 +158,48 @@ class WorkerRuntime:
|
||||
# episode 聚合是低频批处理,按轮次节流而不是每轮都查。
|
||||
self._episode_rounds = 0
|
||||
|
||||
async def restore_context(
|
||||
self, *, actor_type: str, actor_id: str, trace_id: str
|
||||
) -> RequestContext:
|
||||
"""按受理事件中的可信身份恢复最小执行权限。"""
|
||||
identity = RequestContext(user_id=actor_id, trace_id=trace_id)
|
||||
if actor_type == "visitor":
|
||||
return identity.model_copy(update={
|
||||
"roles": ("visitor",),
|
||||
"permissions": ("agent:run", "knowledge:query"),
|
||||
"data_scope": "public",
|
||||
})
|
||||
return await self.resolve_identity(identity)
|
||||
|
||||
@staticmethod
|
||||
def should_request_memory_extraction(
|
||||
*, agent_type: str, context: RequestContext, message: str,
|
||||
result: AgentResult, business_events: tuple[str, ...] | list[str],
|
||||
) -> bool:
|
||||
"""长期记忆抽取只接收非客服、非访客的明确业务事实。"""
|
||||
if agent_type == "customer_service" or "visitor" in context.roles:
|
||||
return False
|
||||
return MemoryService.should_extract_memory(
|
||||
conversation_content=message,
|
||||
role="user",
|
||||
tool_result=any(call.status == "succeeded" for call in result.result.tool_calls),
|
||||
event_type=business_events[0] if business_events else None,
|
||||
signals=MemoryService.detect_memory_signals(message),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def should_request_profile_candidate(
|
||||
*, agent_type: str, context: RequestContext, message: str,
|
||||
) -> bool:
|
||||
"""客服仅为已登录且 self 范围内的用户生成待确认画像候选。"""
|
||||
if agent_type != "customer_service" or "visitor" in context.roles:
|
||||
return False
|
||||
if not {"customer", "authenticated_user"}.intersection(context.roles):
|
||||
return False
|
||||
if context.data_scope != "self":
|
||||
return False
|
||||
return bool(MemoryService.detect_memory_signals(message))
|
||||
|
||||
async def dispatch_one(self, *, run_id: str | None = None) -> bool:
|
||||
# Outbox acknowledges a durable SQL queue entry, not an in-memory task.
|
||||
async with SessionFactory() as session:
|
||||
@@ -158,6 +217,13 @@ class WorkerRuntime:
|
||||
session, extractor=self.memory_extraction, cache=self.memory_cache
|
||||
).handle(payload)
|
||||
|
||||
async def dispatch_profile_candidate(payload: dict[str, Any]) -> None:
|
||||
if "message_id" not in payload or "customer_id" not in payload:
|
||||
raise OutboxHandlerError("profile candidate payload is incomplete")
|
||||
await CustomerProfileCandidateWorker(
|
||||
session, extractor=self.memory_extraction, cache=self.memory_cache
|
||||
).handle(payload)
|
||||
|
||||
async def dispatch_run_completed(payload: dict[str, Any]) -> None:
|
||||
# 结果消息与审计已由 complete_run 同事务落库,此事件只承担
|
||||
# "运行已完成"的对外通知职责。当前没有独立外部消费者,
|
||||
@@ -196,6 +262,33 @@ class WorkerRuntime:
|
||||
logger.warning("graph projection degraded customer_id=%s reason=%s",
|
||||
customer_id, result.reason)
|
||||
|
||||
async def dispatch_handover_queue_ready(payload: dict[str, Any]) -> None:
|
||||
"""记录转人工队列已就绪;不向客户承诺已接单或处理时限。"""
|
||||
ticket_no = str(payload.get("ticket_no", "")).strip()
|
||||
if not ticket_no:
|
||||
raise OutboxHandlerError(
|
||||
"conversation.transfer_requested payload is incomplete"
|
||||
)
|
||||
ticket = await session.scalar(
|
||||
select(HandoverTicket).where(HandoverTicket.ticket_no == ticket_no)
|
||||
)
|
||||
if ticket is None:
|
||||
raise OutboxHandlerError("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()
|
||||
|
||||
async def dispatch_projection_cleanup(payload: dict[str, Any]) -> None:
|
||||
# memory.invalidated / memory.deleted 由 MemoryLifecycleService 按
|
||||
# memory_uuid 写入,这里做幂等的投影清理(Milvus 向量、Neo4j 关系)。
|
||||
@@ -220,6 +313,7 @@ class WorkerRuntime:
|
||||
handlers: dict[str, Callable[[dict[str, Any]], Awaitable[None]]] = {
|
||||
"agent.run_requested": dispatch,
|
||||
"memory.extraction_requested": dispatch_memory_extraction,
|
||||
"customer_profile.candidate_requested": dispatch_profile_candidate,
|
||||
"agent.run_completed": dispatch_run_completed,
|
||||
"config.cache_invalidate_requested": dispatch_cache_invalidate,
|
||||
"memory.deletion_requested": dispatch_memory_deletion,
|
||||
@@ -228,6 +322,7 @@ class WorkerRuntime:
|
||||
"memory.deleted": dispatch_projection_cleanup,
|
||||
# 画像重建:记忆写入后自动触发,使「记忆 → 画像 → 图」全链路无需手工介入
|
||||
"profile.rebuild_requested": dispatch_profile_rebuild,
|
||||
"conversation.transfer_requested": dispatch_handover_queue_ready,
|
||||
}
|
||||
# 知识向量同步/删除:Task 5 交付了 handler 与写适配器,但先前没有任何生产装配
|
||||
# 调用它们 —— 事件类型不在上面的白名单里,`OutboxWorker.publish_one` 的
|
||||
@@ -558,12 +653,19 @@ class WorkerRuntime:
|
||||
request = AgentRequest(
|
||||
agent_type=run.agent_type, message=message.content, session_id=run.session_id,
|
||||
idempotency_key=idem.idempotency_key,
|
||||
metadata=event.payload.get("metadata", {}) if event else {},
|
||||
metadata=AgentRequestMetadata.model_validate(
|
||||
event.payload.get("metadata", {}) if event else {}
|
||||
),
|
||||
history=history,
|
||||
)
|
||||
identity = RequestContext(user_id=str(run.user_id), trace_id=run.trace_id)
|
||||
actor_type = (
|
||||
str(event.payload.get("actor_type", "authenticated"))
|
||||
if event else "authenticated"
|
||||
)
|
||||
# Re-check account and permissions at execution time, including delayed jobs.
|
||||
context = await self.resolve_identity(identity)
|
||||
context = await self.restore_context(
|
||||
actor_type=actor_type, actor_id=str(run.user_id), trace_id=run.trace_id
|
||||
)
|
||||
result: AgentResult | None = None
|
||||
async for event_data in AgentExecutor(self.factory).execute(
|
||||
request.agent_type, request, context, run_id
|
||||
@@ -585,19 +687,36 @@ class WorkerRuntime:
|
||||
async with SessionFactory() as session:
|
||||
await AgentPersistenceService(session).complete_run(
|
||||
run_id, result, worker_id=worker_id,
|
||||
memory_extraction_requested=MemoryService.should_extract_memory(
|
||||
conversation_content=request.message,
|
||||
role="user",
|
||||
# 工具产出的权威事实同样构成持久记忆(工具调用记录来自终态结果)。
|
||||
tool_result=any(
|
||||
call.status == "succeeded" for call in result.result.tool_calls
|
||||
),
|
||||
# 本 run 落库的业务事件(风险评估完成、交易完成等)。
|
||||
event_type=business_events[0] if business_events else None,
|
||||
# 用户明确陈述的偏好/约束/身份/目标,命中才触发抽取。
|
||||
signals=MemoryService.detect_memory_signals(request.message),
|
||||
memory_extraction_requested=self.should_request_memory_extraction(
|
||||
agent_type=run.agent_type, context=context, message=request.message,
|
||||
result=result, business_events=business_events,
|
||||
),
|
||||
profile_candidate_requested=self.should_request_profile_candidate(
|
||||
agent_type=run.agent_type, context=context, message=request.message,
|
||||
),
|
||||
)
|
||||
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:
|
||||
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