Files
group_fqcd_jr/app/service/agent_run_application_service.py
T
张胜宇 e239eb778b docs: 品牌全量口径统一为「南方基金」+ 作废文档清理
1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富
   统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」;
   同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。
2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本),
   新增《文档规整方案与开发前待决事项-2026-09-17》。
3) 客服agent 四份交付文档首次纳入本分支。
2026-09-17 15:15:22 +08:00

144 lines
6.4 KiB
Python

import hashlib
import json
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from uuid import uuid4
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.contracts import AgentRequest, DomainEvent, RequestContext
from app.core.errors import (
ForbiddenAgentError,
IdempotencyConflictError,
SessionNotAccessibleError,
)
from app.model.audit import InteractionAudit
from app.model.conversation import ConversationMessage
from app.model.platform import AgentRun, RequestIdempotency
from app.model.session import ConversationSession
from app.repository.outbox_repository import OutboxRepository
from app.service.agent.bootstrap import get_agent_factory
from app.service.agent.factory import AgentFactory
@dataclass(frozen=True)
class RunAccepted:
run_id: str
trace_id: str
status: str = "queued"
def build_outbox_metadata(request: AgentRequest) -> dict[str, object]:
"""构造 Worker 使用的内部元数据。
2026-09-16 客服模块删除后本函数只做一次序列化。此前它额外为
``agent_type == "customer_service"`` 覆写 ``chitchat_streak`` /
``clarification_round`` / ``session_context``(服务端覆盖客户端提交值,不采信外部传入);
其余 Agent 一直是把 ``request.metadata`` 原样透出,因此这里保持与其余 Agent 相同的行为。
"""
return request.metadata.model_dump(mode="json")
class AgentRunApplicationService:
def __init__(
self, session: AsyncSession, factory: AgentFactory | None = None,
) -> None:
self.session = session
self.factory = factory if factory is not None else get_agent_factory()
async def accept(self, request: AgentRequest, context: RequestContext) -> RunAccepted:
try:
self.factory.authorize(request.agent_type, context)
except ForbiddenAgentError:
async with self.session.begin():
self.session.add(InteractionAudit(
actor_type="user", actor_id=int(context.user_id), portal=context.portal,
action_type="agent.access_denied", session_id=request.session_id,
detail={"agent_type": request.agent_type, "trace_id": context.trace_id},
created_at=datetime.now(UTC).replace(tzinfo=None),
))
raise
user_id = int(context.user_id)
request_hash = hashlib.sha256(
json.dumps(request.model_dump(mode="json"), sort_keys=True).encode("utf-8")
).hexdigest()
now = datetime.now(UTC).replace(tzinfo=None)
async with self.session.begin():
session_row = await self.session.scalar(
select(ConversationSession).where(
ConversationSession.session_id == request.session_id,
ConversationSession.user_id == user_id,
).with_for_update()
)
if session_row is not None:
if session_row.status != "active" or session_row.agent_type != request.agent_type:
raise ForbiddenAgentError("会话不可用于当前 Agent")
session_row.message_count += 1
session_row.last_active_at = now
owner = await self.session.scalar(
select(ConversationMessage.customer_id)
.where(ConversationMessage.session_id == request.session_id)
.where(ConversationMessage.customer_id.is_not(None))
.limit(1)
)
if owner is not None and owner != user_id:
raise SessionNotAccessibleError("会话不属于当前用户")
existing = await self.session.scalar(
select(RequestIdempotency).where(
RequestIdempotency.user_id == user_id,
RequestIdempotency.agent_type == request.agent_type,
RequestIdempotency.idempotency_key == request.idempotency_key,
)
)
if existing is not None:
if existing.request_hash != request_hash:
raise IdempotencyConflictError("同一幂等键对应不同请求")
run = await self.session.scalar(
select(AgentRun).where(AgentRun.idempotency_id == existing.id)
)
if run is None:
raise RuntimeError("idempotency record has no run")
return RunAccepted(run.run_id, run.trace_id, run.status)
outbox_metadata = build_outbox_metadata(request)
trace_id = context.trace_id
message = ConversationMessage(
session_id=request.session_id, customer_id=user_id, portal="api",
role="user", content=request.message, trace_id=trace_id, created_at=now,
)
self.session.add(message)
await self.session.flush()
idem = RequestIdempotency(
user_id=user_id, session_id=request.session_id, agent_type=request.agent_type,
idempotency_key=request.idempotency_key, request_hash=request_hash,
trace_id=trace_id, expire_at=now + timedelta(hours=24),
created_at=now, updated_at=now,
)
self.session.add(idem)
try:
await self.session.flush()
except IntegrityError as exc:
raise IdempotencyConflictError("幂等键正在被并发请求占用") from exc
run_id = str(uuid4())
run = AgentRun(
run_id=run_id, idempotency_id=idem.id, session_id=request.session_id,
user_id=user_id, agent_type=request.agent_type, trace_id=trace_id,
request_message_id=message.id, created_at=now, updated_at=now,
)
self.session.add(run)
await self.session.flush()
await OutboxRepository(self.session).append(DomainEvent(
event_id=str(uuid4()), event_type="agent.run_requested", aggregate_type="agent_run",
aggregate_id=run_id, trace_id=trace_id,
payload={
"run_id": run_id,
"actor_type": "visitor" if "visitor" in context.roles else "authenticated",
"metadata": outbox_metadata,
},
occurred_at=now,
))
return RunAccepted(run_id, trace_id)