merge: integrate ZSY customer service and profile capabilities

This commit is contained in:
张胜宇
2026-09-11 22:31:51 +08:00
94 changed files with 7933 additions and 77 deletions
+135 -16
View File
@@ -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