chore: initialize project repository
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.contracts import DomainEvent, RequestContext
|
||||
from app.core.errors import ConflictAgentError, ResourceNotFoundError
|
||||
from app.infrastructure.db import SessionFactory
|
||||
from app.model.audit import InteractionAudit
|
||||
from app.model.conversation import ConversationFeedback
|
||||
from app.model.platform import AgentRun, HandoverTicket, RequestIdempotency
|
||||
from app.model.session import ConversationSession
|
||||
from app.repository.conversation_repository import ConversationRepository
|
||||
from app.repository.outbox_repository import OutboxRepository
|
||||
from app.repository.platform_repository import PlatformRepository
|
||||
from app.repository.session_repository import SessionRepository
|
||||
from app.service.admin_service import public
|
||||
from app.service.agent.bootstrap import get_agent_factory
|
||||
from app.service.api_transaction_service import ApiTransactionService
|
||||
from app.service.authorization_service import AuthorizationService
|
||||
|
||||
|
||||
class PublicPlatformService:
|
||||
async def session(self, session_id: str, context: RequestContext) -> dict[str, Any]:
|
||||
async with SessionFactory() as session:
|
||||
row = await SessionRepository(session).owned(session_id, int(context.user_id))
|
||||
return {"data": self._session_view(row), "meta": {"trace_id": context.trace_id}}
|
||||
|
||||
async def handover(self, ticket_no: str, context: RequestContext) -> dict[str, Any]:
|
||||
async with SessionFactory() as session:
|
||||
row = await session.scalar(
|
||||
select(HandoverTicket).where(
|
||||
HandoverTicket.ticket_no == ticket_no,
|
||||
HandoverTicket.customer_id == int(context.user_id),
|
||||
)
|
||||
)
|
||||
if row is None:
|
||||
raise ResourceNotFoundError("转人工请求不存在")
|
||||
return {
|
||||
"data": {
|
||||
"handover_id": row.ticket_no,
|
||||
"status": row.status,
|
||||
"created_at": public(row.created_at),
|
||||
},
|
||||
"meta": {"trace_id": context.trace_id},
|
||||
}
|
||||
|
||||
async def write(
|
||||
self,
|
||||
operation: str,
|
||||
target: str,
|
||||
context: RequestContext,
|
||||
key: str | None,
|
||||
payload: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
permission = {
|
||||
"create": "conversation:create",
|
||||
"close": "conversation:close",
|
||||
"cancel": "agent:cancel",
|
||||
"feedback": "conversation:feedback",
|
||||
"handover": "handover:create",
|
||||
}[operation]
|
||||
await AuthorizationService.require(context, permission)
|
||||
|
||||
async def action(session: AsyncSession) -> dict[str, Any]:
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
user_id = int(context.user_id)
|
||||
session_id: str | None = None
|
||||
if operation == "create":
|
||||
get_agent_factory().authorize(payload["agent_type"], context)
|
||||
row = ConversationSession(
|
||||
session_id=str(uuid4()),
|
||||
user_id=user_id,
|
||||
agent_type=payload["agent_type"],
|
||||
portal=context.portal,
|
||||
)
|
||||
session.add(row)
|
||||
await session.flush()
|
||||
session_id = row.session_id
|
||||
data = self._session_view(row)
|
||||
elif operation == "cancel":
|
||||
data, session_id = await self._cancel(session, target, user_id, now)
|
||||
elif operation == "feedback":
|
||||
message = await ConversationRepository(session).message(int(target), user_id)
|
||||
if message is None:
|
||||
raise ResourceNotFoundError("消息不存在")
|
||||
if await ConversationRepository(session).feedback(int(target), user_id):
|
||||
raise ConflictAgentError("消息已经反馈")
|
||||
feedback = ConversationFeedback(
|
||||
feedback_no=f"fb-{uuid4().hex[:24]}",
|
||||
session_id=message.session_id,
|
||||
message_id=message.id,
|
||||
customer_id=user_id,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
**payload,
|
||||
)
|
||||
session.add(feedback)
|
||||
await session.flush()
|
||||
session_id = message.session_id
|
||||
data = {"feedback_no": feedback.feedback_no, "status": feedback.status}
|
||||
else:
|
||||
row = await SessionRepository(session).owned(target, user_id, lock=True)
|
||||
session_id = row.session_id
|
||||
if operation == "close":
|
||||
if row.status not in {"active", "ended"}:
|
||||
raise ConflictAgentError("当前会话状态不能关闭")
|
||||
if row.status == "active":
|
||||
row.status, row.ended_at = "ended", now
|
||||
data = self._session_view(row)
|
||||
else:
|
||||
if row.status != "active":
|
||||
raise ConflictAgentError("会话不在可转人工状态")
|
||||
messages = await ConversationRepository(session).messages(target, user_id, 1)
|
||||
ticket = HandoverTicket(
|
||||
ticket_no=f"ticket-{uuid4().hex[:24]}",
|
||||
session_id=target,
|
||||
customer_id=user_id,
|
||||
source_agent=row.agent_type or "customer_service",
|
||||
source_message_id=messages[0].id if messages else None,
|
||||
reason_code=payload["reason_code"],
|
||||
reason_detail=payload.get("reason_detail"),
|
||||
status="pending",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
session.add(ticket)
|
||||
await session.flush()
|
||||
await OutboxRepository(session).append(
|
||||
DomainEvent(
|
||||
event_id=str(uuid4()),
|
||||
event_type="conversation.transfer_requested",
|
||||
aggregate_type="conversation",
|
||||
aggregate_id=target,
|
||||
trace_id=context.trace_id,
|
||||
payload={"ticket_no": ticket.ticket_no},
|
||||
occurred_at=now,
|
||||
)
|
||||
)
|
||||
data = {
|
||||
"handover_id": ticket.ticket_no,
|
||||
"status": ticket.status,
|
||||
"session_id": target,
|
||||
"created_at": public(now),
|
||||
}
|
||||
session.add(
|
||||
InteractionAudit(
|
||||
actor_type="user",
|
||||
actor_id=user_id,
|
||||
session_id=session_id,
|
||||
portal=context.portal,
|
||||
action_type=f"platform.{operation}",
|
||||
detail={
|
||||
"trace_id": context.trace_id,
|
||||
"target": target,
|
||||
},
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
return {"data": data, "meta": {"trace_id": context.trace_id}}
|
||||
|
||||
return await ApiTransactionService().execute(
|
||||
context, f"public:{operation}:{target}", key, payload, action
|
||||
)
|
||||
|
||||
async def _cancel(
|
||||
self, session: AsyncSession, run_id: str, user_id: int, now: datetime
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
run = await session.scalar(
|
||||
select(AgentRun)
|
||||
.where(AgentRun.run_id == run_id, AgentRun.user_id == user_id)
|
||||
.with_for_update()
|
||||
)
|
||||
if run is None:
|
||||
raise ResourceNotFoundError("运行不存在")
|
||||
if run.status in {"succeeded", "failed"}:
|
||||
raise ConflictAgentError("RUN_NOT_CANCELLABLE")
|
||||
if run.status not in {"cancel_requested", "cancelled"}:
|
||||
run.status, run.cancel_requested_at = "cancel_requested", now
|
||||
await session.execute(
|
||||
update(RequestIdempotency)
|
||||
.where(RequestIdempotency.id == run.idempotency_id)
|
||||
.values(status="failed", error_code="RUN_CANCELLED", updated_at=now)
|
||||
)
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"status": run.status,
|
||||
"cancel_requested_at": public(run.cancel_requested_at),
|
||||
}, run.session_id
|
||||
|
||||
@staticmethod
|
||||
def _session_view(row: ConversationSession) -> dict[str, Any]:
|
||||
return {
|
||||
"session_id": row.session_id,
|
||||
"agent_type": row.agent_type,
|
||||
"portal": row.portal,
|
||||
"status": row.status,
|
||||
"clarification_round": row.clarification_round,
|
||||
"message_count": row.message_count,
|
||||
"last_active_at": public(row.last_active_at),
|
||||
"started_at": public(row.started_at),
|
||||
"ended_at": public(row.ended_at),
|
||||
"created_at": public(row.created_at),
|
||||
}
|
||||
|
||||
async def memory(self, customer_id: int, context: RequestContext) -> dict[str, Any]:
|
||||
own = str(customer_id) == context.user_id
|
||||
permission = "memory:read:self" if own else "memory:read:customer"
|
||||
await AuthorizationService.require(context, permission)
|
||||
scope = context.permission_scopes.get(permission, "self")
|
||||
if (
|
||||
not own
|
||||
and scope != "all"
|
||||
and (scope != "own_customers" or str(customer_id) not in context.customer_ids)
|
||||
):
|
||||
raise ResourceNotFoundError("客户不可访问")
|
||||
async with SessionFactory() as session, session.begin():
|
||||
rows = await PlatformRepository(session).rows(
|
||||
"profile_snapshots", {"customer_id": customer_id, "is_current": 1}, limit=1
|
||||
)
|
||||
session.add(
|
||||
InteractionAudit(
|
||||
actor_type="user",
|
||||
actor_id=int(context.user_id),
|
||||
target_customer_id=customer_id,
|
||||
portal=context.portal,
|
||||
action_type="memory.profile_read",
|
||||
detail={"trace_id": context.trace_id},
|
||||
created_at=datetime.now(UTC).replace(tzinfo=None),
|
||||
)
|
||||
)
|
||||
# Safe minimum projection; arbitrary snapshot JSON needs field-policy expansion.
|
||||
data: dict[str, Any] = {
|
||||
"customer_id": str(customer_id),
|
||||
"version": str(rows[0]["version"]) if rows else None,
|
||||
"generated_at": public(rows[0].get("generated_at")) if rows else None,
|
||||
"profile": {},
|
||||
}
|
||||
return {"data": data, "meta": {"trace_id": context.trace_id}}
|
||||
Reference in New Issue
Block a user