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

559 lines
19 KiB
Python
Raw Normal View History

"""聚合视图域 analytics 子域契约 DTO。
定义聚合视图域 analytics 子域 9 个跨子域深度分析端点ANL-01/02/04/05/06/07
/ACCOUNTS/PEERS/CR返回的不可变值对象所有 DTO 均为 ``dataclass(frozen=True)``
仅依赖标准库用于按时间 / 类型 / 状态切片的细分聚合展示供管理后台运营审计与
运维排障使用
dashboard 子域的区分dashboard 提供总览计数analytics 提供按维度切片的
细分聚合与单子域 stats OBX-02 outbox stats的区分单子域统计归各子域
router本域仅承载跨子域深度分析视图
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Literal
from yuxi.channels.contract.dtos.channel import ChannelType
from yuxi.channels.contract.dtos.common import CategoryStat, TrendDataPoint
from yuxi.channels.contract.errors import ValidationError
from yuxi.utils.datetime_utils import format_utc_datetime
@dataclass(frozen=True)
class TimeSeriesPoint:
"""时间序列数据点。
描述按时间粒度切片后的单个时间桶计数供消息与会话分析的趋势展示使用
字段
timestamp: 时间桶起始时间ISO 8601 字符串
value: 计数值
"""
timestamp: str
value: int
def to_dict(self) -> dict[str, Any]:
return {"timestamp": self.timestamp, "value": self.value}
@dataclass(frozen=True)
class MessageAnalytics:
"""消息深度分析结果ANL-01
描述时间范围内消息量按时间 / 渠道 / 角色 / 投递状态切片的细分聚合
``getMessageAnalytics`` 返回供运营审计按多维切片定位消息量趋势
字段
total: 时间范围内消息总数
timeseries: 按时间粒度切片的消息量趋势
by_channel: 按渠道类型分组的计数 dictkey channel_type 字符串值
by_role: 按发送角色分组的计数 dictkey user / assistant / system / tool
by_delivery_status: 按投递状态分组的计数 dict
"""
total: int
timeseries: tuple[TimeSeriesPoint, ...]
by_channel: dict[str, int]
by_role: dict[str, int]
by_delivery_status: dict[str, int]
def to_dict(self) -> dict[str, Any]:
"""序列化为 dict递归处理嵌套 ``TimeSeriesPoint`` 为 dict 列表。"""
return {
"total": self.total,
"timeseries": [point.to_dict() for point in self.timeseries],
"by_channel": self.by_channel,
"by_role": self.by_role,
"by_delivery_status": self.by_delivery_status,
}
@dataclass(frozen=True)
class MessageDistribution:
"""消息类型分布结果ANL-02
描述时间范围内消息按类型分组的计数与占比 ``getMessageDistribution`` 返回
供内容运营按类型text / card / attachment / mixed定位消息构成
字段
total: 时间范围内消息总数
by_type: 按消息类型分组的计数 dict
by_type_percent: 按消息类型分组的占比 dict0-100
"""
total: int
by_type: dict[str, int]
by_type_percent: dict[str, float]
def to_dict(self) -> dict[str, Any]:
"""序列化为 dict供控制面结果返回与日志输出使用。"""
return {
"total": self.total,
"by_type": self.by_type,
"by_type_percent": self.by_type_percent,
}
@dataclass(frozen=True)
class SessionAnalytics:
"""会话分析结果ANL-04
描述时间范围内会话量按时间 / 渠道 / 临时性 / 消息数区间切片的细分聚合
``getSessionAnalytics`` 返回供运营审计按多维切片定位会话量趋势
字段
total: 时间范围内活跃会话总数
timeseries: 按时间粒度切片的会话量趋势
by_channel: 按渠道类型分组的计数 dictkey channel_type 字符串值
by_is_temporary: 按是否临时会话分组的计数 dictkey "true" / "false"
by_message_count_bucket: 按消息数区间分组的计数 dict
key "0-10" / "11-50" / "51-100" / "100+"
"""
total: int
timeseries: tuple[TimeSeriesPoint, ...]
by_channel: dict[str, int]
by_is_temporary: dict[str, int]
by_message_count_bucket: dict[str, int]
def to_dict(self) -> dict[str, Any]:
"""序列化为 dict递归处理嵌套 ``TimeSeriesPoint`` 为 dict 列表。"""
return {
"total": self.total,
"timeseries": [point.to_dict() for point in self.timeseries],
"by_channel": self.by_channel,
"by_is_temporary": self.by_is_temporary,
"by_message_count_bucket": self.by_message_count_bucket,
}
@dataclass(frozen=True)
class DeliveryAnalytics:
"""投递链路分析结果ANL-05
与现有 ``OutboxStats``按状态分组计数的关系``DeliveryAnalytics``
``OutboxStats`` 的扩展新增 success_rate / failure_rate /
retry_distribution / avg_latency_ms 四个聚合维度
``getDeliveryAnalytics`` 返回供运维排障定位投递成功率与重试分布
字段
total: 时间范围内投递条目总数
success_rate: 投递成功率0-100
failure_rate: 投递失败率0-100
retry_distribution: 按重试次数分组的计数 dict
key "0" / "1" / "2" / "3+" / "max_reached"
avg_latency_ms: 平均投递延迟毫秒 latency_ms 数据时为 None
"""
total: int
success_rate: float
failure_rate: float
retry_distribution: dict[str, int]
avg_latency_ms: float | None
def to_dict(self) -> dict[str, Any]:
"""序列化为 dict``avg_latency_ms`` 为 None 时返回 None。"""
return {
"total": self.total,
"success_rate": self.success_rate,
"failure_rate": self.failure_rate,
"retry_distribution": self.retry_distribution,
"avg_latency_ms": self.avg_latency_ms,
}
@dataclass(frozen=True)
class DeliveryLatencyDistribution:
"""投递延迟分布结果ANL-06
描述投递延迟的分位数与直方图分布 ``getDeliveryLatencyDistribution``
返回供运维排障定位长尾延迟所有分位数字段无 latency_ms 数据时为 None
字段
p50_ms: P50 延迟毫秒无数据时为 None
p90_ms: P90 延迟毫秒无数据时为 None
p99_ms: P99 延迟毫秒无数据时为 None
max_ms: 最大延迟毫秒无数据时为 None
histogram: 按延迟区间分组的计数 dict
key "0-100ms" / "100-500ms" / "500ms-1s" / "1-5s" / "5s+"
"""
p50_ms: float | None
p90_ms: float | None
p99_ms: float | None
max_ms: float | None
histogram: dict[str, int]
def to_dict(self) -> dict[str, Any]:
"""序列化为 dict分位数字段为 None 时返回 None。"""
return {
"p50_ms": self.p50_ms,
"p90_ms": self.p90_ms,
"p99_ms": self.p99_ms,
"max_ms": self.max_ms,
"histogram": self.histogram,
}
@dataclass(frozen=True)
class DeliveryFunnel:
"""投递漏斗分析结果ANL-07
描述投递管道各节点的条目数与相对 enter 的转化率 ``getDeliveryFunnel``
返回供运维排障定位投递链路损耗节点
字段
enter: 进入投递管道的条目数
sent: 投递成功数
suppressed: 被抑制数
failed: 失败数
dead: 死信数
conversion_rate: 各节点相对 enter 的转化率 dict
key "sent" / "suppressed" / "failed" / "dead"value 0-100
"""
enter: int
sent: int
suppressed: int
failed: int
dead: int
conversion_rate: dict[str, float]
def to_dict(self) -> dict[str, Any]:
"""序列化为 dict供控制面结果返回与日志输出使用。"""
return {
"enter": self.enter,
"sent": self.sent,
"suppressed": self.suppressed,
"failed": self.failed,
"dead": self.dead,
"conversion_rate": self.conversion_rate,
}
@dataclass(frozen=True)
class AccountAnalyticsQuery:
"""账户活跃度分析查询ANL-ACCOUNTS
描述账户活跃度分析查询条件按时间范围必填与渠道类型过滤
支持按粒度切片
字段
start_time: 起始时间必填
end_time: 截止时间必填
granularity: 时间粒度默认 day可选 hour/day/week
channel_type: 渠道类型过滤可选
"""
start_time: datetime
end_time: datetime
granularity: Literal["hour", "day", "week"] = "day"
channel_type: ChannelType | None = None
def __post_init__(self) -> None:
"""校验时间范围与粒度取值。
``start_time`` 必须早于 ``end_time````granularity`` 必须为
``hour`` / ``day`` / ``week`` 之一在构造时即抛出
``ValidationError``adapter 不再做该校验INV-8
"""
if self.start_time >= self.end_time:
raise ValidationError(
"time_range",
"start_time must be earlier than end_time",
)
if self.granularity not in ("hour", "day", "week"):
raise ValidationError(
"granularity",
"must be one of: hour, day, week",
)
@dataclass(frozen=True)
class AccountActivityStat:
"""账户活跃统计ANL-ACCOUNTS by_account 条目)。
描述单账户在时间范围内的活跃度指标 MessageRepositoryPort
ChannelSessionRepositoryPort 聚合计算
字段
account_id: 账户 ID
channel_type: 渠道类型字符串值
message_count: 消息总数
session_count: 会话总数
active_days: 活跃天数
avg_daily_messages: 日均消息数
"""
account_id: str
channel_type: str
message_count: int
session_count: int
active_days: int
avg_daily_messages: float
def to_dict(self) -> dict[str, Any]:
"""序列化为 dict供控制面结果返回与日志输出使用。"""
return {
"account_id": self.account_id,
"channel_type": self.channel_type,
"message_count": self.message_count,
"session_count": self.session_count,
"active_days": self.active_days,
"avg_daily_messages": self.avg_daily_messages,
}
@dataclass(frozen=True)
class AccountAnalyticsResult:
"""账户活跃度分析结果ANL-ACCOUNTS
描述账户活跃度分析聚合结果包含按账户切片的统计与活跃账户数趋势
``trend`` 复用通用 ``TrendDataPoint````value`` 承载该时间桶的活跃账户数
序列化时由 ``to_dict`` 映射为 ``active_accounts`` 字段名``timestamp``
``datetime``序列化为 ISO 8601 UTC 字符串
字段
by_account: 按账户切片的统计元组
trend: 活跃账户数趋势元组value=active_accounts
"""
by_account: tuple[AccountActivityStat, ...]
trend: tuple[TrendDataPoint, ...]
def to_dict(self) -> dict[str, Any]:
"""序列化为 dict。
- ``by_account`` 递归调用 ``AccountActivityStat.to_dict``
- ``trend`` ``TrendDataPoint.timestamp``datetime格式化为
ISO 8601 UTC 字符串并将 ``value`` 映射为 ``active_accounts`` 字段名
"""
return {
"by_account": [stat.to_dict() for stat in self.by_account],
"trend": [
{
"timestamp": format_utc_datetime(point.timestamp),
"active_accounts": point.value,
}
for point in self.trend
],
}
@dataclass(frozen=True)
class PeerAnalyticsQuery:
"""对端活跃度分析查询ANL-PEERS
描述对端活跃度分析查询条件按时间范围必填与渠道类型/账户过滤
返回按消息数降序的 Top N 对端
字段
start_time: 起始时间必填
end_time: 截止时间必填
channel_type: 渠道类型过滤可选
account_id: 账户 ID 过滤可选
limit: Top N 上限默认 1001-500
"""
start_time: datetime
end_time: datetime
channel_type: ChannelType | None = None
account_id: str | None = None
limit: int = 100
def __post_init__(self) -> None:
"""校验时间范围与 limit 取值。
``start_time`` 必须早于 ``end_time````limit`` 必须在 1-500 之间
在构造时即抛出 ``ValidationError``adapter 不再做该校验INV-8
"""
if self.start_time >= self.end_time:
raise ValidationError(
"time_range",
"start_time must be earlier than end_time",
)
if self.limit < 1 or self.limit > 500:
raise ValidationError(
"limit",
"limit must be in [1, 500]",
)
@dataclass(frozen=True)
class PeerActivityStat:
"""对端活跃统计ANL-PEERS top_active 条目)。
描述单对端在时间范围内的活跃度指标 MessageRepositoryPort 聚合计算
字段
peer_id: 对端 ID
channel_type: 渠道类型字符串值
message_count: 消息总数
session_count: 会话总数
first_seen: 首次出现时间可选无数据时为 None
last_seen: 最后出现时间可选无数据时为 None
"""
peer_id: str
channel_type: str
message_count: int
session_count: int
first_seen: datetime | None
last_seen: datetime | None
def to_dict(self) -> dict[str, Any]:
"""序列化为 dict。
``first_seen`` / ``last_seen`` ``datetime``序列化为 ISO 8601 UTC
字符串 ``None`` 时原样返回 ``None``
"""
return {
"peer_id": self.peer_id,
"channel_type": self.channel_type,
"message_count": self.message_count,
"session_count": self.session_count,
"first_seen": format_utc_datetime(self.first_seen),
"last_seen": format_utc_datetime(self.last_seen),
}
@dataclass(frozen=True)
class PeerAnalyticsResult:
"""对端活跃度分析结果ANL-PEERS
描述对端活跃度分析聚合结果 ``message_count`` 降序返回 Top N 对端
字段
top_active: 按消息数降序的对端统计元组
"""
top_active: tuple[PeerActivityStat, ...]
def to_dict(self) -> dict[str, Any]:
"""序列化为 dict递归调用 ``PeerActivityStat.to_dict``。"""
return {
"top_active": [stat.to_dict() for stat in self.top_active],
}
@dataclass(frozen=True)
class ContentReviewAnalyticsQuery:
"""内容审核分析查询ANL-CR
描述内容审核分析查询条件按时间范围必填与渠道类型过滤
支持按粒度切片
字段
start_time: 起始时间必填
end_time: 截止时间必填
granularity: 时间粒度默认 day可选 hour/day/week
channel_type: 渠道类型过滤可选
"""
start_time: datetime
end_time: datetime
granularity: Literal["hour", "day", "week"] = "day"
channel_type: ChannelType | None = None
def __post_init__(self) -> None:
"""校验时间范围与粒度取值。
``start_time`` 必须早于 ``end_time````granularity`` 必须为
``hour`` / ``day`` / ``week`` 之一在构造时即抛出
``ValidationError``adapter 不再做该校验INV-8
"""
if self.start_time >= self.end_time:
raise ValidationError(
"time_range",
"start_time must be earlier than end_time",
)
if self.granularity not in ("hour", "day", "week"):
raise ValidationError(
"granularity",
"must be one of: hour, day, week",
)
@dataclass(frozen=True)
class ContentReviewAnalyticsResult:
"""内容审核分析结果ANL-CR
描述内容审核分析聚合结果包含总审核/拦截计数拦截率按分类切片与
审核趋势``by_category`` 复用通用 ``CategoryStat``序列化时由
``to_dict`` ``count / total_reviews * 100`` 计算并补充 ``rate`` 字段
``trend`` 为多值时间序列``reviewed`` / ``blocked`` 双计数无法用单一
``value`` 字段承载故使用 ``dict[str, Any]`` 元组由适配器层按
``{timestamp, reviewed, blocked}`` 结构组装序列化时将 ``timestamp``
datetime格式化为 ISO 8601 UTC 字符串
字段命名与 ``ContentReviewStatsResult`` 对齐``total_reviews`` /
``block_count`` 共享同一命名约定``total_X`` 表示总数``X_count``
表示分类计数
字段
total_reviews: 审核总数
block_count: 拦截数
block_rate: 拦截率0-100
by_category: 按分类切片的统计元组rate to_dict 补充
trend: 审核趋势元组每项含 timestamp/reviewed/blocked
"""
total_reviews: int
block_count: int
block_rate: float
by_category: tuple[CategoryStat, ...]
trend: tuple[dict[str, Any], ...]
def to_dict(self) -> dict[str, Any]:
"""序列化为 dict。
- ``by_category`` 递归展开 ``CategoryStat``并按 ``count / total_reviews
* 100`` 计算补充 ``rate`` 字段``total_reviews`` 0 ``rate=0.0``
- ``trend`` 将每项的 ``timestamp``datetime格式化为 ISO 8601 UTC 字符串
保留 ``reviewed`` / ``blocked`` 双计数
"""
return {
"total_reviews": self.total_reviews,
"block_count": self.block_count,
"block_rate": self.block_rate,
"by_category": [
{
"category": stat.category,
"count": stat.count,
"rate": (stat.count / self.total_reviews * 100) if self.total_reviews > 0 else 0.0,
}
for stat in self.by_category
],
"trend": [
{
"timestamp": format_utc_datetime(item["timestamp"]),
"reviewed": item["reviewed"],
"blocked": item["blocked"],
}
for item in self.trend
],
}
__all__ = [
"AccountActivityStat",
"AccountAnalyticsQuery",
"AccountAnalyticsResult",
"ContentReviewAnalyticsQuery",
"ContentReviewAnalyticsResult",
"DeliveryAnalytics",
"DeliveryFunnel",
"DeliveryLatencyDistribution",
"MessageAnalytics",
"MessageDistribution",
"PeerActivityStat",
"PeerAnalyticsQuery",
"PeerAnalyticsResult",
"SessionAnalytics",
"TimeSeriesPoint",
]