"""内容审核 DTO。 定义内容审核域的不可变值对象,包括审核结论枚举、严重级别枚举、来源枚举、 资源类型枚举,以及审核请求、命中片段、审核结论、完整审核记录、历史查询 过滤与命令等 DTO。所有 DTO 均为 ``dataclass(frozen=True)``,仅依赖标准库 与契约层内部类型,用于内容审核域(CR-01 预审核 / CR-02 历史列表 / CR-03 历史详情)的跨层传递。 设计要点: - 4 个 ``StrEnum`` 不得用 ``@dataclass(frozen=True)`` 装饰(``StrEnum`` 本身已是不可变枚举,``@dataclass`` 装饰枚举会引发 ``TypeError``)。 - 拆分 ``ContentModerationOutcome``(适配器输出)与 ``ContentReviewRecord`` (持久化记录),使 ``review_id`` 生成责任明确归属 ``dispatch_stage`` (符项目惯例:业务 ID 由编排层生成)。 - ``ContentReviewHit.position`` 为 ``[start, end)`` 字符偏移区间,基于 ``content`` 字段的 0-based 字符索引,左闭右开。 - ``ContentReviewRecord.content_preview`` 长度约束 ``<= 200``,由 ``dispatch_stage`` 在构造时截断(``content[:200]``),DTO 不做截断。 - ``reviewer`` 字段来源为 ``operator.user_id``,由 ``dispatch_stage`` 填充。 - ``ContentReviewRecord`` 与 ``ContentReviewDetail`` 为概念对应关系 (字段重叠),非继承关系:前者为持久化记录(含 ``outcome`` 嵌套), 后者为查询返回(将 ``outcome`` 展平为 ``verdict`` / ``confidence`` / ``categories`` / ``detail`` 四个字段)。 """ from __future__ import annotations from dataclasses import dataclass from datetime import datetime from enum import StrEnum from typing import Literal from yuxi.channels.contract.dtos.channel import ChannelType from yuxi.channels.contract.dtos.common import BatchOperationFailure, CategoryStat, Operator from yuxi.channels.contract.errors import ValidationError class ContentReviewVerdict(StrEnum): """审核结论枚举。 取值: PASS: 内容合规,可投递。 REVIEW: 存在疑似违规,需人工复核。 BLOCK: 明确违规,禁止投递。 """ PASS = "pass" REVIEW = "review" BLOCK = "block" class ContentReviewSeverity(StrEnum): """审核命中严重级别。 取值: LOW: 低风险(如擦边、隐喻)。 MEDIUM: 中风险(如辱骂、不当言论)。 HIGH: 高风险(如涉政、涉黄、涉暴)。 """ LOW = "low" MEDIUM = "medium" HIGH = "high" class ContentReviewSource(StrEnum): """审核来源(标识审核触发路径)。 取值: MANUAL_PREVIEW: 管理员手动预审核(本期使用)。 INBOUND_PIPELINE: 入站管道自动审核(预留,本期不使用)。 OUTBOUND_PIPELINE: 出站管道自动审核(预留,本期不使用)。 """ MANUAL_PREVIEW = "manual_preview" INBOUND_PIPELINE = "inbound_pipeline" OUTBOUND_PIPELINE = "outbound_pipeline" class ContentReviewResourceType(StrEnum): """审核资源类型。 取值: MESSAGE_TEXT: 消息文本。 MESSAGE_ATTACHMENT: 消息附件(图片 / 文件 / 音视频)。 USER_PROFILE: 用户资料(昵称 / 简介 / 头像)。 """ MESSAGE_TEXT = "message_text" MESSAGE_ATTACHMENT = "message_attachment" USER_PROFILE = "user_profile" @dataclass(frozen=True) class ContentReviewRequest: """审核请求 DTO(传入 ContentModerationAdapter.review)。 不含 ``review_id``:``review_id`` 由 ``dispatch_stage`` 统一生成, 适配器仅返回审核结论(``ContentModerationOutcome``)。 字段: channel_type: 渠道类型。 account_id: 账户 ID。 resource_type: 资源类型(ContentReviewResourceType 枚举)。 content: 待审核文本。 peer_id: 对端 ID(可选,用于上下文)。 trace_id: 链路追踪 ID(可选)。 """ channel_type: ChannelType account_id: str resource_type: ContentReviewResourceType content: str peer_id: str | None = None trace_id: str | None = None @dataclass(frozen=True) class ContentReviewHit: """审核命中片段。 字段: snippet: 命中的文本片段。 position: 字符偏移区间 ``[start, end)``,基于 ``content`` 字段 的 0-based 字符索引,左闭右开。 category: 命中分类(厂商自定义,如 ``"politics"`` / ``"violence"``)。 severity: 严重级别(ContentReviewSeverity 枚举)。 """ snippet: str position: tuple[int, int] category: str severity: ContentReviewSeverity @dataclass(frozen=True) class ContentModerationOutcome: """审核适配器输出 DTO(ContentModerationAdapter.review 返回)。 不含 ``review_id`` / ``reviewed_at`` / ``source`` 等持久化字段: 这些字段由 ``dispatch_stage`` 统一填充,避免 adapter 实现差异。 字段: verdict: 审核结论(ContentReviewVerdict 枚举)。 confidence: 置信度 ``[0.0, 1.0]``。 categories: 命中分类元组(无命中时为空元组)。 detail: 命中片段元组(无命中时为空元组)。 """ verdict: ContentReviewVerdict confidence: float categories: tuple[str, ...] detail: tuple[ContentReviewHit, ...] @dataclass(frozen=True) class ContentReviewRecord: """完整审核记录(持久化与查询返回)。 由 ``dispatch_stage`` 构造:``ContentModerationOutcome`` + 上下文元数据。 作为 ``ContentReviewRepositoryPort.saveReviewResult`` 的入参。与 ``ContentReviewDetail``(查询返回 DTO)为概念对应关系,字段重叠但 非继承:本类保留 ``outcome`` 嵌套结构,``ContentReviewDetail`` 将其 展平为 ``verdict`` / ``confidence`` / ``categories`` / ``detail``。 字段: review_id: 审核记录 ID(由 ``dispatch_stage`` 生成)。 channel_type: 渠道类型。 account_id: 账户 ID。 resource_type: 资源类型。 content_preview: 内容预览(前 200 字符,由 ``dispatch_stage`` 截断)。 outcome: 审核结论(ContentModerationOutcome)。 reviewed_at: 审核时间戳(UTC,由 ``dispatch_stage`` 生成)。 reviewer: 审核人(``operator.user_id``)。 source: 审核来源(ContentReviewSource 枚举)。 trace_id: 链路追踪 ID(可选)。 created_by: 记录创建人(``operator.user_id``,写入审计字段,由 ``dispatch_stage`` 填充;与 ``reviewer`` 区别在于 ``reviewer`` 会被人决定覆盖,``created_by`` 永不改变)。 decision_started_at: 审核决策开始时间(首次写入时与 ``reviewed_at`` 同值)。用于 CR-STATS-01 ``avg_decision_seconds`` 真实化计算; ``decision_completed_at`` 不入 DTO,由仓储层在 ``update_verdict`` 时写入。 """ review_id: str channel_type: ChannelType account_id: str resource_type: ContentReviewResourceType content_preview: str outcome: ContentModerationOutcome reviewed_at: datetime reviewer: str source: ContentReviewSource trace_id: str | None = None created_by: str | None = None decision_started_at: datetime | None = None @dataclass(frozen=True) class ContentReviewHistoryFilter: """审核历史查询过滤器。 字段: channel_type: 渠道类型过滤(可选)。 account_id: 账户 ID 过滤(可选)。 verdict: 审核结论过滤(可选)。 resource_type: 资源类型过滤(可选)。 trace_id: 链路追踪 ID 过滤(可选,用于跨链路排查)。 start_time: 起始时间过滤(可选,含)。 end_time: 结束时间过滤(可选,含)。 """ channel_type: ChannelType | None = None account_id: str | None = None verdict: ContentReviewVerdict | None = None resource_type: ContentReviewResourceType | None = None trace_id: str | None = None start_time: datetime | None = None end_time: datetime | None = None @dataclass(frozen=True) class ContentReviewHistoryQueryCmd: """历史列表查询命令(CR-02)。 数据面查询无副作用,不写审计日志,故不携带 ``operator`` 字段 (对比 ``ContentReviewDetailQueryCmd`` 需 ``operator.request_id`` 作 ``NotFoundError.trace_id``)。 字段: channel_type: 渠道类型过滤(可选)。 account_id: 账户 ID 过滤(可选)。 verdict: 审核结论过滤(可选)。 resource_type: 资源类型过滤(可选)。 trace_id: 链路追踪 ID 过滤(可选)。 start_time: 起始时间过滤(可选)。 end_time: 结束时间过滤(可选)。 limit: 每页数量(1-100)。 offset: 偏移量(>= 0)。 """ channel_type: ChannelType | None account_id: str | None verdict: ContentReviewVerdict | None resource_type: ContentReviewResourceType | None trace_id: str | None start_time: datetime | None end_time: datetime | None limit: int offset: int def __post_init__(self) -> None: """校验业务规则(CR-02)。 - ``limit`` 1-100、``offset`` >= 0。 - ``start_time`` / ``end_time`` 同时提供时需满足 ``start_time < end_time``(与 ``ContentReviewStatsQuery`` 同源, 避免非法范围静默返回空结果,INV-8)。 """ if self.limit < 1 or self.limit > 100: raise ValidationError("limit", f"limit must be 1-100, got {self.limit}") if self.offset < 0: raise ValidationError("offset", f"offset must be >= 0, got {self.offset}") if self.start_time is not None and self.end_time is not None and self.start_time >= self.end_time: raise ValidationError( "time_range", "start_time must be earlier than end_time", ) @dataclass(frozen=True) class ContentReviewHistoryItem: """历史列表条目。 字段: review_id: 审核记录 ID。 channel_type: 渠道类型。 account_id: 账户 ID。 resource_type: 资源类型。 verdict: 审核结论。 confidence: 置信度(``[0.0, 1.0]``)。 categories: 命中分类元组。 reviewed_at: 审核时间戳。 reviewer: 审核人。 source: 审核来源(``manual_preview`` / ``inbound_pipeline`` / ``outbound_pipeline``)。 trace_id: 链路追踪 ID(可选)。 created_at: 记录创建时间(审计追溯)。 updated_at: 记录最后修改时间(人工决定覆盖时刷新)。 """ review_id: str channel_type: ChannelType account_id: str resource_type: ContentReviewResourceType verdict: ContentReviewVerdict confidence: float categories: tuple[str, ...] reviewed_at: datetime reviewer: str source: ContentReviewSource trace_id: str | None = None created_at: datetime | None = None updated_at: datetime | None = None @dataclass(frozen=True) class ContentReviewHistoryList: """历史列表结果(CR-02)。 字段: total: 匹配条目总数。 limit: 每页数量。 offset: 偏移量。 items: 条目元组。 """ total: int limit: int offset: int items: tuple[ContentReviewHistoryItem, ...] @dataclass(frozen=True) class ContentReviewDetailQueryCmd: """详情查询命令(CR-03)。 字段: review_id: 审核记录 ID。 operator: 操作人。 """ review_id: str operator: Operator def __post_init__(self) -> None: if not self.review_id: raise ValidationError("review_id", "review_id must not be empty") @dataclass(frozen=True) class ContentReviewDetail: """审核详情(CR-03 返回)。 字段: review_id: 审核记录 ID。 channel_type: 渠道类型。 account_id: 账户 ID。 resource_type: 资源类型。 content_preview: 内容预览(前 200 字符)。 verdict: 审核结论。 confidence: 置信度。 categories: 命中分类元组。 detail: 命中片段元组。 reviewed_at: 审核时间戳。 reviewer: 审核人。 source: 审核来源。 trace_id: 链路追踪 ID(可选)。 created_at: 记录创建时间。 updated_at: 记录最后修改时间。 """ review_id: str channel_type: ChannelType account_id: str resource_type: ContentReviewResourceType content_preview: str verdict: ContentReviewVerdict confidence: float categories: tuple[str, ...] detail: tuple[ContentReviewHit, ...] reviewed_at: datetime reviewer: str source: ContentReviewSource trace_id: str | None = None created_at: datetime | None = None updated_at: datetime | None = None @dataclass(frozen=True) class ContentReviewStatsQuery: """审核统计查询(CR-STATS-01)。 描述审核统计的查询条件,由 ``ContentReviewRepositoryPort.getReviewStats`` 消费。``granularity`` 取值 ``hour`` / ``day`` / ``week``,默认 ``day``。 字段: channel_type: 渠道类型过滤(可选)。 account_id: 账户 ID 过滤(可选)。 start_time: 起始时间(可选,含)。 end_time: 结束时间(可选,含)。 granularity: 时间粒度(默认 ``day``)。 """ channel_type: ChannelType | None = None account_id: str | None = None start_time: datetime | None = None end_time: datetime | None = None granularity: str = "day" def __post_init__(self) -> None: """校验业务规则(CR-STATS-01)。 - ``granularity`` 必须为 ``hour`` / ``day`` / ``week``。 - ``start_time`` / ``end_time`` 同时提供时需满足 ``start_time < end_time``。 在构造时即抛出 ``ValidationError``,避免非法值传播到仓储层后才 暴露(INV-8)。 """ if self.granularity not in ("hour", "day", "week"): raise ValidationError( "granularity", f"granularity must be 'hour', 'day' or 'week', got {self.granularity!r}", ) if self.start_time is not None and self.end_time is not None and self.start_time >= self.end_time: raise ValidationError( "time_range", "start_time must be earlier than end_time", ) @dataclass(frozen=True) class ReviewTrendPoint: """审核趋势点(CR-STATS-01)。 描述按时间粒度切片的审核趋势数据点,含通过与拦截计数。 字段: timestamp: 时间桶起始时间。 pass_count: 通过数。 block_count: 拦截数。 """ timestamp: datetime pass_count: int block_count: int @dataclass(frozen=True) class ContentReviewStatsResult: """审核统计结果(CR-STATS-01)。 描述审核记录的聚合统计指标,由 ``getReviewStats`` 返回。 字段: total_reviews: 审核总数。 pass_count: 通过数。 review_count: 待复核数。 block_count: 拦截数。 pass_rate: 通过率(0-100)。 block_rate: 拦截率(0-100)。 manual_intervention_rate: 人工介入率(0-100)。 avg_decision_seconds: 平均决策时长(秒)。 by_category: 按分类分组的统计元组。 trend: 审核趋势数据点元组。 """ total_reviews: int pass_count: int review_count: int block_count: int pass_rate: float block_rate: float manual_intervention_rate: float avg_decision_seconds: float by_category: tuple[CategoryStat, ...] trend: tuple[ReviewTrendPoint, ...] @dataclass(frozen=True) class ReviewDecisionItem: """单条审核决定条目(CR-DECISION-BATCH)。 描述批量审核决定中单条决定的输入,``decision`` 取值 ``pass`` / ``block``。 字段: review_id: 审核记录 ID。 decision: 决定结果(``pass`` / ``block``)。 reason: 决定原因(可选)。 categories: 命中分类元组(可选,默认空元组)。 """ review_id: str decision: Literal["pass", "block"] reason: str | None = None categories: tuple[str, ...] = () def __post_init__(self) -> None: """校验 ``decision`` 取值(INV-8)。 ``decision`` 必须为 ``pass`` / ``block``,在构造时即抛出 ``ValidationError``,避免非法值传播到 dispatch handler 后才暴露。 注:``ContentReviewVerdict`` 枚举含 ``REVIEW``,但人工决定仅允许 ``pass`` / ``block``(人工覆盖要么放行要么拦截,不存在"待复核" 语义),故不复用该枚举。 """ if self.decision not in ("pass", "block"): raise ValidationError( "decision", f"decision must be 'pass' or 'block', got {self.decision!r}", ) @dataclass(frozen=True) class BatchReviewDecisionCmd: """批量审核决定命令(CR-DECISION-BATCH)。 由 ``ContentReviewPort.batchReviewDecision`` 引用,``decisions`` 数量 限制 1-100,由 dispatch handler 校验。 字段: decisions: 决定条目元组(1-100 项)。 operator: 操作人(审计用)。 apply_to_pending_messages: 是否关联处理待审消息(默认 False)。 """ decisions: tuple[ReviewDecisionItem, ...] operator: Operator apply_to_pending_messages: bool = False def __post_init__(self) -> None: """校验业务规则(CR-DECISION-BATCH)。 - ``decisions`` 非空。 - ``decisions`` 长度 ≤ 100。 在构造时即抛出 ``ValidationError``,避免非法值传播到 dispatch handler 后才暴露(INV-8)。单条 ``decision`` 取值 ``pass`` / ``block`` 的校验由 ``ReviewDecisionItem`` 约束,dispatch handler 二次校验。 """ if not self.decisions: raise ValidationError("decisions", "decisions must not be empty") if len(self.decisions) > 100: raise ValidationError( "decisions", f"decisions length must be <= 100, got {len(self.decisions)}", ) @dataclass(frozen=True) class BatchDecisionSuccessItem: """批量审核决定成功条目(CR-DECISION-BATCH)。 描述单条审核决定成功后的返回信息,含最终审核结论。 字段: review_id: 审核记录 ID。 current_verdict: 当前审核结论(``pass`` / ``block``)。 """ review_id: str current_verdict: Literal["pass", "block"] @dataclass(frozen=True) class BatchDecisionResult: """批量审核决定结果(CR-DECISION-BATCH)。 描述逐条独立事务审核决定的执行结果,``failed`` 使用通用 ``BatchOperationFailure``(``id`` 字段承载 review_id)。 字段: total: 决定条目总数。 succeeded: 成功条目元组。 failed: 失败条目元组。 """ total: int succeeded: tuple[BatchDecisionSuccessItem, ...] failed: tuple[BatchOperationFailure, ...]