ForcePilot/backend/package/yuxi/channels/contract/dtos/conversation.py

310 lines
12 KiB
Python
Raw Normal View History

"""会话命令 DTO。
定义会话端口方法引用的命令与结果值对象包括会话 ID消息 ID保存消息
命令解析会话命令关联会话命令合并会话命令与合并结果所有 DTO 均为
``dataclass(frozen=True)``仅依赖标准库与契约层内部类型用于会话端口
的命令传递与结果返回
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Literal
from yuxi.channels.contract.dtos.channel import ChannelType
from yuxi.channels.contract.dtos.common import Operator
from yuxi.channels.contract.dtos.session import ChatType
from yuxi.channels.contract.errors import ValidationError
@dataclass(frozen=True)
class ConversationId:
"""会话 ID。
标识内部会话的唯一 ID用于会话定位消息持久化与路由绑定
字段
value: 会话 ID 字符串
"""
value: str
@dataclass(frozen=True)
class MessageId:
"""消息 ID。
标识一条消息的唯一 ID用于消息持久化状态同步与审计关联
字段
value: 消息 ID 字符串
"""
value: str
@dataclass(frozen=True)
class SaveMessageCmd:
"""保存消息命令FR-09
由端口方法 ``ConversationPort.saveMessage`` 引用用于将一条消息持久化
至指定会话携带角色内容渠道侧消息 ID追踪 ID 与渠道侧初始状态
字段渠道侧初始状态字段用于出站消息创建时一次性填充渠道扩展字段
FR-09后续状态变更走 ``ConversationPort.updateMessageChannelStatus``
字段
conversation_id: 目标会话 ID
role: 角色user | assistant | admin
content: 消息文本内容
channel_msg_id: 渠道侧消息 ID可选
trace_id: 追踪 ID可选
channel_status: 渠道侧初始状态可选 ``sent``
ref_channel_msg_id: 引用的渠道消息 ID可选编辑/回复场景
channel_status_history: 初始状态历史数组可选
"""
conversation_id: str
role: Literal["user", "assistant", "admin"]
content: str
channel_msg_id: str | None = None
trace_id: str | None = None
channel_status: Literal["sent", "delivered", "read", "recalled", "edited"] | None = None
ref_channel_msg_id: str | None = None
channel_status_history: tuple[dict, ...] | None = None
def __post_init__(self) -> None:
"""校验必填字段非空与角色取值。
``conversation_id`` ``content`` 必须非空``role`` 必须为
``user`` / ``assistant`` / ``admin`` 之一在构造时即抛出
``ValidationError``adapter 不再做该校验INV-8
"""
if not self.conversation_id:
raise ValidationError("conversation_id", "must not be empty")
if not self.content:
raise ValidationError("content", "must not be empty")
if self.role not in ("user", "assistant", "admin"):
raise ValidationError(
"role",
"must be one of: user, assistant, admin",
)
@dataclass(frozen=True)
class ResolveConversationCmd:
"""解析会话命令FR-06
由端口方法 ``ConversationPort.resolveConversation`` 引用根据对端 ID
渠道类型账户 ID 与会话类型解析或创建会话可选携带统一身份 ID
字段
peer_id: 对端 ID
channel_type: 渠道类型
account_id: 渠道账户 ID
chat_type: 会话类型
unified_identity_id: 统一身份 ID可选
非空且策略为"关联"适配器按此 ID 查询已有跨渠道会话
命中则复用未命中则创建新会话并写入该 IDFR-06
create_if_not_found: 未找到会话时是否创建新会话默认 False
True 时适配器在未找到已绑定会话的情况下创建新 Conversation
并返回其 ID不再抛出 NotFoundErrorFR-06
user_id: 真实用户 ID可选FR-06 语义变更
身份解析到真实用户时传入创建新会话时写入 Conversation.uid
为空时由 ``__post_init__`` 回退到 ``peer_id``构造完成后保证非空FR-06 §7.1.2
new_conversation_status: 新建会话状态可选默认 ``"active"``
由应用层调 ``Conversation.create()`` 聚合根工厂填充适配器
直接消费不再 import core.model§4.6 / INV-8
new_conversation_title: 新建会话标题可选默认 ``"New Conversation"``
new_conversation_agent_id: 新建会话绑定代理 ID可选默认空字符串
new_conversation_extra_metadata: 新建会话附加元数据可选默认
``{"attachments": []}``
"""
peer_id: str
channel_type: ChannelType
account_id: str
chat_type: ChatType
unified_identity_id: str | None = None
create_if_not_found: bool = False
user_id: str | None = None
new_conversation_status: str = "active"
new_conversation_title: str = "New Conversation"
new_conversation_agent_id: str = ""
new_conversation_extra_metadata: dict[str, Any] = field(default_factory=lambda: {"attachments": []})
def __post_init__(self) -> None:
"""应用 FR-06 §7.1.2 身份回退规则。
``user_id`` None 时回退到 ``peer_id``构造完成后保证非空
适配器直接消费 ``cmd.user_id``不再做条件分支INV-8
"""
if self.user_id is None:
object.__setattr__(self, "user_id", self.peer_id)
@dataclass(frozen=True)
class AssociateConversationCmd:
"""关联会话命令FR-06
由端口方法 ``ConversationPort.associateConversation`` 引用将统一身份
与路由绑定关联至指定渠道会话支持身份级路由匹配
字段
unified_identity_id: 统一身份 ID
route_binding: 路由绑定信息
channel_session_id: 渠道会话 ID可选
"""
unified_identity_id: str
route_binding: dict[str, Any]
channel_session_id: str | None = None
def __post_init__(self) -> None:
"""校验必填字段非空。
``unified_identity_id`` 必须非空在构造时即抛出
``ValidationError``adapter 不再做该校验INV-8
"""
if not self.unified_identity_id:
raise ValidationError("unified_identity_id", "must not be empty")
@dataclass(frozen=True)
class MergeConversationCmd:
"""合并会话命令FR-07
由端口方法 ``ConversationPort.mergeConversations`` 引用将源会话合并
至目标会话需记录操作人与合并原因以满足审计要求
字段
source_conversation_id: 源会话 ID
target_conversation_id: 目标会话 ID
operator: 操作人审计用
reason: 合并原因审计用
"""
source_conversation_id: str
target_conversation_id: str
operator: Operator
reason: str
def __post_init__(self) -> None:
"""校验源/目标会话不相同FR-07
源与目标相同将导致消息迁移至自身源会话被软删除的逻辑矛盾
在构造时即抛出 ``ValidationError``adapter 不再做该校验INV-8
"""
if self.source_conversation_id == self.target_conversation_id:
raise ValidationError(
"target_conversation_id",
"source conversation and target conversation must not be the same",
)
@dataclass(frozen=True)
class MergeResult:
"""合并结果FR-07
描述会话合并操作的执行结果包括迁移消息数量源会话软删除标记与
审计日志 ID用于结果聚合与审计追溯
字段
migrated_message_count: 迁移的消息数量
source_soft_deleted: 源会话是否已软删除
audit_log_id: 审计日志 ID可选
"""
migrated_message_count: int
source_soft_deleted: bool
audit_log_id: str | None = None
@dataclass(frozen=True)
class UpdateMessageChannelStatusCmd:
"""渠道侧消息状态回写命令FR-09
由端口方法 ``ConversationPort.updateMessageChannelStatus`` 引用用于
渠道回调或状态回写阶段更新消息的渠道侧状态命令携带目标消息 ID
状态追加事件条目与按状态填充的时间戳
状态变更语义
- ``channel_status`` 取值``sent`` / ``delivered`` / ``read`` /
``recalled`` / ``edited``
- ``event`` 追加到 ``channel_status_history`` 数组应包含 ``status`` /
``at`` 等字段
- 时间戳字段按状态填充``read`` ``read_at````recalled``
``recalled_at````edited`` ``edited_at``
- ``channel_msg_id`` 为可选字段用于"持久化记录创建消息"场景FR-09
增强当通过 OutboxEntry 反查到 Message Message
``channel_msg_id`` 未初始化时由状态处理器传入以补全字段
字段
message_id: 目标消息 ID
channel_status: 新的渠道状态
event: 追加到 ``channel_status_history`` 的事件条目
read_at: 已读时间可选``channel_status='read'`` 时填充
recalled_at: 撤回时间可选``channel_status='recalled'`` 时填充
edited_at: 编辑时间可选``channel_status='edited'`` 时填充
channel_msg_id: 渠道侧消息 ID可选用于补全 Message channel_msg_id 字段
trace_id: 追踪 ID可选DTO 层透传用于状态回写链路追踪6-P0-03
"""
message_id: str
channel_status: Literal["sent", "delivered", "read", "recalled", "edited"]
event: dict[str, Any]
read_at: datetime | None = None
recalled_at: datetime | None = None
edited_at: datetime | None = None
channel_msg_id: str | None = None
trace_id: str | None = None # 追踪 ID用于状态回写链路追踪DTO 层透传)
def __post_init__(self) -> None:
"""校验必填字段非空与渠道状态取值FR-09
``message_id`` 必须非空``channel_status`` 必须为 ``sent`` /
``delivered`` / ``read`` / ``recalled`` / ``edited`` 之一在构造
时即抛出 ``ValidationError``adapter 不再做该校验INV-8
"""
if not self.message_id:
raise ValidationError("message_id", "must not be empty")
if self.channel_status not in (
"sent",
"delivered",
"read",
"recalled",
"edited",
):
raise ValidationError(
"channel_status",
"must be one of: sent, delivered, read, recalled, edited",
)
@dataclass(frozen=True)
class AppendOperationHistoryCmd:
"""追加消息操作历史命令FR-12
由端口方法 ``ConversationPort.appendOperationHistory`` 引用用于消息
操作执行器在操作完成后将操作记录追加到目标消息的 ``operations_history``
JSON 数组满足 PRD §FR-12 业务规则第 5 "操作历史记录到消息元数据的
操作列表"
字段
message_id: 目标消息 ID
entry: 操作历史条目应包含 ``operation`` / ``at`` / ``success`` /
``sender_id`` 等字段
"""
message_id: str
entry: dict[str, Any]
def __post_init__(self) -> None:
"""校验必填字段非空FR-12
``message_id`` 必须非空在构造时即抛出 ``ValidationError``
adapter 不再做该校验INV-8
"""
if not self.message_id:
raise ValidationError("message_id", "must not be empty")