ForcePilot/backend/package/yuxi/channels/contract/dtos/analytics.py
Kris 00092c818e chore: 批量代码优化与规范完善
本次提交包含多项代码优化与规范修正:
1. 文档与注释优化:修正注释术语、补充注解与FR编号
2. 代码格式调整:统一空格、换行与缩进规范
3. 类型与接口完善:补充__all__导出、修正返回类型注解
4. 错误处理增强:新增领域错误类与校验逻辑
5. 依赖与导入调整:修复路径引用、统一时区导入
6. 协议与契约更新:完善接口文档与一致性注解
2026-07-03 19:18:13 +08:00

559 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""聚合视图域 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",
]