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
+103 -2
View File
@@ -9,8 +9,14 @@ 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
from app.model.risk import RiskUser
from app.model.session import ConversationSession
from app.service.customer_service_handover_context import (
MAX_SUMMARY_MESSAGES,
CustomerServiceHandoverContext,
build_customer_service_handover_context,
)
#: 治理层追加免责声明时使用的分隔形状(`app/service/agent/governance.py` 里定义)。
#: 这里只用于**审计留痕**,不参与任何判定:判据是"末尾是否出现这个形状"。
@@ -39,7 +45,7 @@ class AgentPersistenceService:
async def complete_run(
self, run_id: str, result: AgentResult, memory_extraction_requested: bool = True,
*, worker_id: str | None = None,
*, worker_id: str | None = None, profile_candidate_requested: bool = False,
) -> int:
now = datetime.now(UTC).replace(tzinfo=None)
async with self.session.begin():
@@ -59,6 +65,20 @@ class AgentPersistenceService:
raise RunLeaseLostError("运行租约失效或已取消")
if run.status not in {"queued", "running"}:
raise RunLeaseLostError("不能覆盖运行终态")
clarification_round = 0
if run.agent_type == "customer_service":
session_row = await self.session.scalar(select(ConversationSession).where(
ConversationSession.session_id == run.session_id,
ConversationSession.user_id == run.user_id,
).with_for_update())
if session_row is not None:
clarification_round = session_row.clarification_round
if result.result.clarification_required:
session_row.clarification_round = min(
session_row.clarification_round + 1, 2
)
else:
session_row.clarification_round = 0
message = ConversationMessage(
session_id=run.session_id, customer_id=run.user_id, portal="agent",
role="assistant", content=result.result.text,
@@ -73,6 +93,47 @@ class AgentPersistenceService:
)
self.session.add(message)
await self.session.flush()
handover_ticket: HandoverTicket | None = None
handover_context: CustomerServiceHandoverContext | None = None
if result.result.transfer_required:
# 访客 subject 不是正式用户主键,先按 RiskUser 查询,查不到则保留空归属。
ticket_customer_id = await self.session.scalar(
select(RiskUser.id).where(RiskUser.id == run.user_id)
)
recent_messages = list(await self.session.scalars(
select(ConversationMessage)
.where(ConversationMessage.session_id == run.session_id)
.order_by(ConversationMessage.id.desc())
.limit(MAX_SUMMARY_MESSAGES)
))
recent_messages.reverse()
confidence = (
Decimal(str(result.result.intent.confidence))
if result.result.intent else None
)
reason_code = result.result.transfer_reason or "agent_requested"
handover_context = build_customer_service_handover_context(
reason_code=reason_code,
clarification_round=clarification_round,
confidence=confidence,
source_references=result.result.source_references,
messages=recent_messages,
)
handover_ticket = HandoverTicket(
ticket_no=f"ticket-{uuid4().hex[:24]}",
session_id=run.session_id,
customer_id=ticket_customer_id,
source_agent=run.agent_type,
source_message_id=message.id,
intent=(result.result.intent.intent if result.result.intent else None),
confidence=confidence,
reason_code=reason_code,
reason_detail=handover_context.reason_detail,
conversation_summary=handover_context.conversation_summary,
source_references=handover_context.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
@@ -103,6 +164,21 @@ class AgentPersistenceService:
},
created_at=now,
))
if handover_ticket is not None:
assert handover_context is not None
self.session.add(InteractionAudit(
actor_type="agent", actor_id=run.user_id,
target_customer_id=handover_ticket.customer_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,
"clarification_round": clarification_round,
"source_reference_count": len(handover_context.source_references),
},
created_at=now,
))
await self.session.execute(
update(RequestIdempotency)
.where(RequestIdempotency.id == run.idempotency_id)
@@ -120,6 +196,31 @@ class AgentPersistenceService:
payload={"run_id": run_id, "message_id": message.id,
"customer_id": run.user_id}, occurred_at=now,
))
if profile_candidate_requested:
# 候选画像只允许由已登录客服会话触发;Worker 会再次校验身份标记。
events.append(DomainEvent(
event_id=str(uuid4()),
event_type="customer_profile.candidate_requested",
aggregate_type="agent_run", aggregate_id=run_id, trace_id=run.trace_id,
payload={
"run_id": run_id, "message_id": message.id,
"customer_id": run.user_id,
"actor_type": "authenticated_customer",
},
occurred_at=now,
))
if handover_ticket is not None:
assert handover_context is not None
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,
"handover_context": handover_context.event_metadata,
},
occurred_at=now,
))
for event in events:
self.session.add(DomainEventOutbox(
event_id=event.event_id, event_type=event.event_type,