geo/backend/app/services/alert_engine.py

645 lines
21 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.

"""
告警检测引擎 - 检测品牌可见性变化并生成告警
告警类型:
- score_drop: 评分下降超过阈值默认5分
- score_rise: 评分上升超过阈值默认5分
- negative_sentiment: 出现负面情感
- competitor_overtake: 竞品超越(竞品评分超过我方)
- new_platform_mention: 新平台出现提及
严重程度:
- critical: 需要立即关注(如评分大幅下降、负面情感)
- warning: 需要留意(如评分小幅下降、竞品接近)
- info: 一般信息(如评分上升、新平台提及)
"""
from __future__ import annotations
import logging
import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Optional
from sqlalchemy import select, and_, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.alert import Alert
from app.models.alert_setting import AlertSetting
from app.models.brand import Brand
from app.models.citation_record import CitationRecord
from app.models.competitor import Competitor
from app.models.query import Query
logger = logging.getLogger(__name__)
# ============================================================
# 默认告警配置
# ============================================================
DEFAULT_ALERT_CONFIGS = {
"score_drop": {
"enabled": True,
"threshold": 5.0,
"severity": "warning",
},
"score_rise": {
"enabled": True,
"threshold": 5.0,
"severity": "info",
},
"negative_sentiment": {
"enabled": True,
"threshold": 1.0, # 出现1次负面即触发
"severity": "critical",
},
"competitor_overtake": {
"enabled": True,
"threshold": 0.0, # 只要超越即触发
"severity": "warning",
},
"new_platform_mention": {
"enabled": True,
"threshold": 1.0, # 新平台出现1次提及即触发
"severity": "info",
},
}
# ============================================================
# 数据结构
# ============================================================
@dataclass
class AlertContext:
"""告警检测上下文,包含检测所需的所有数据"""
brand_id: uuid.UUID
brand_name: str
user_id: uuid.UUID
current_score: float
previous_score: Optional[float]
sentiment_counts: dict[str, int] # {"positive": int, "neutral": int, "negative": int}
brand_mentions: int
competitor_mentions: dict[str, int] # {competitor_name: mention_count}
competitor_scores: dict[str, float] # {competitor_name: score}
current_platforms: set[str] # 当前已有提及的平台集合
new_platforms: set[str] # 新出现提及的平台集合
# ============================================================
# 告警检测引擎
# ============================================================
class AlertEngine:
"""告警检测引擎"""
def __init__(self, db: AsyncSession):
self.db = db
async def get_alert_setting(
self,
brand_id: uuid.UUID,
alert_type: str,
) -> AlertSetting | None:
"""获取指定品牌和告警类型的设置"""
stmt = select(AlertSetting).where(
and_(
AlertSetting.brand_id == brand_id,
AlertSetting.alert_type == alert_type,
)
)
result = await self.db.execute(stmt)
return result.scalar_one_or_none()
async def is_alert_enabled(
self,
brand_id: uuid.UUID,
alert_type: str,
) -> bool:
"""检查告警是否启用"""
setting = await self.get_alert_setting(brand_id, alert_type)
if setting is None:
# 没有设置记录时使用默认值
return DEFAULT_ALERT_CONFIGS.get(alert_type, {}).get("enabled", True)
return setting.enabled
async def get_threshold(
self,
brand_id: uuid.UUID,
alert_type: str,
) -> float:
"""获取告警阈值"""
setting = await self.get_alert_setting(brand_id, alert_type)
if setting is not None and setting.threshold is not None:
return setting.threshold
return DEFAULT_ALERT_CONFIGS.get(alert_type, {}).get("threshold", 5.0)
async def _create_alert(
self,
brand_id: uuid.UUID,
user_id: uuid.UUID,
alert_type: str,
severity: str,
title: str,
message: str,
data: dict | None = None,
) -> Alert | None:
"""创建告警记录(内部方法)"""
# 检查是否启用
if not await self.is_alert_enabled(brand_id, alert_type):
logger.debug(f"告警类型 {alert_type} 对品牌 {brand_id} 已禁用,跳过")
return None
# 防止重复告警检查最近1小时内是否已有相同类型的告警
one_hour_ago = datetime.now(timezone.utc) - timedelta(hours=1)
existing_stmt = select(Alert).where(
and_(
Alert.brand_id == brand_id,
Alert.alert_type == alert_type,
Alert.created_at >= one_hour_ago,
)
)
existing_result = await self.db.execute(existing_stmt)
existing = existing_result.scalar_one_or_none()
if existing:
logger.debug(f"品牌 {brand_id} 最近1小时已有 {alert_type} 告警,跳过重复创建")
return None
alert = Alert(
brand_id=brand_id,
user_id=user_id,
alert_type=alert_type,
severity=severity,
title=title,
message=message,
data=data,
is_read=False,
)
self.db.add(alert)
await self.db.flush()
logger.info(
f"创建告警: brand={brand_id}, type={alert_type}, "
f"severity={severity}, title={title}"
)
return alert
# ============================================================
# 五种告警检测方法
# ============================================================
async def check_score_drop(self, ctx: AlertContext) -> Alert | None:
"""
检测评分下降
当评分下降超过阈值时触发,严重程度根据下降幅度判断:
- 下降超过20分: critical
- 下降超过阈值: warning
"""
if ctx.previous_score is None:
return None
threshold = await self.get_threshold(ctx.brand_id, "score_drop")
drop = ctx.previous_score - ctx.current_score
if drop < threshold:
return None
severity = "critical" if drop >= 20 else "warning"
return await self._create_alert(
brand_id=ctx.brand_id,
user_id=ctx.user_id,
alert_type="score_drop",
severity=severity,
title=f"{ctx.brand_name} 评分下降 {drop:.1f}",
message=(
f"品牌「{ctx.brand_name}」的可见性评分从 {ctx.previous_score:.1f} "
f"下降至 {ctx.current_score:.1f},下降了 {drop:.1f} 分。"
f"请关注近期品牌在各AI平台的表现变化。"
),
data={
"previous_score": ctx.previous_score,
"current_score": ctx.current_score,
"drop": round(drop, 2),
},
)
async def check_score_rise(self, ctx: AlertContext) -> Alert | None:
"""
检测评分上升
当评分上升超过阈值时触发severity 固定为 info
"""
if ctx.previous_score is None:
return None
threshold = await self.get_threshold(ctx.brand_id, "score_rise")
rise = ctx.current_score - ctx.previous_score
if rise < threshold:
return None
return await self._create_alert(
brand_id=ctx.brand_id,
user_id=ctx.user_id,
alert_type="score_rise",
severity="info",
title=f"{ctx.brand_name} 评分上升 {rise:.1f}",
message=(
f"品牌「{ctx.brand_name}」的可见性评分从 {ctx.previous_score:.1f} "
f"上升至 {ctx.current_score:.1f},上升了 {rise:.1f} 分。"
f"品牌在AI平台的表现有所提升。"
),
data={
"previous_score": ctx.previous_score,
"current_score": ctx.current_score,
"rise": round(rise, 2),
},
)
async def check_negative_sentiment(self, ctx: AlertContext) -> Alert | None:
"""
检测负面情感
当出现负面情感时触发,严重程度根据负面数量判断:
- 负面数量 >= 3: critical
- 负面数量 >= 1: warning
"""
negative_count = ctx.sentiment_counts.get("negative", 0)
threshold = await self.get_threshold(ctx.brand_id, "negative_sentiment")
if negative_count < threshold:
return None
severity = "critical" if negative_count >= 3 else "warning"
total = sum(ctx.sentiment_counts.values())
negative_rate = (negative_count / total * 100) if total > 0 else 0
return await self._create_alert(
brand_id=ctx.brand_id,
user_id=ctx.user_id,
alert_type="negative_sentiment",
severity=severity,
title=f"{ctx.brand_name} 检测到负面情感",
message=(
f"品牌「{ctx.brand_name}」在AI回答中检测到 {negative_count} 条负面提及"
f"(占比 {negative_rate:.1f}%)。"
f"请关注AI平台对品牌的负面评价内容及时采取应对措施。"
),
data={
"negative_count": negative_count,
"total_count": total,
"negative_rate": round(negative_rate, 2),
"sentiment_counts": ctx.sentiment_counts,
},
)
async def check_competitor_overtake(self, ctx: AlertContext) -> Alert | None:
"""
检测竞品超越
当竞品评分超过我方时触发,严重程度根据差距判断:
- 竞品领先超过10分: critical
- 竞品领先: warning
"""
if not ctx.competitor_scores:
return None
overtake_competitors = []
for comp_name, comp_score in ctx.competitor_scores.items():
if comp_score > ctx.current_score:
overtake_competitors.append({
"name": comp_name,
"score": comp_score,
"gap": round(comp_score - ctx.current_score, 2),
})
if not overtake_competitors:
return None
# 找出差距最大的竞品
max_gap_competitor = max(overtake_competitors, key=lambda x: x["gap"])
severity = "critical" if max_gap_competitor["gap"] >= 10 else "warning"
comp_names = ", ".join(c["name"] for c in overtake_competitors)
return await self._create_alert(
brand_id=ctx.brand_id,
user_id=ctx.user_id,
alert_type="competitor_overtake",
severity=severity,
title=f"竞品 {comp_names} 评分已超越 {ctx.brand_name}",
message=(
f"品牌「{ctx.brand_name}」当前评分 {ctx.current_score:.1f}"
f"已被竞品「{comp_names}」超越。"
f"最大差距为 {max_gap_competitor['gap']:.1f}"
f"{max_gap_competitor['name']}{max_gap_competitor['score']:.1f})。"
f"建议关注竞品策略优化品牌在AI平台的表现。"
),
data={
"brand_score": ctx.current_score,
"overtake_competitors": overtake_competitors,
},
)
async def check_new_platform_mention(self, ctx: AlertContext) -> Alert | None:
"""
检测新平台出现提及
当品牌在之前未出现的平台上被提及时触发severity 固定为 info
"""
if not ctx.new_platforms:
return None
platform_names = ", ".join(ctx.new_platforms)
# 平台名称映射
platform_labels = {
"wenxin": "文心一言",
"kimi": "Kimi",
"tongyi": "通义千问",
"doubao": "豆包",
"xinghuo": "讯飞星火",
"tiangong": "天工AI",
"qingyan": "智谱清言",
"search_engine": "搜索引擎",
}
display_names = [
platform_labels.get(p, p) for p in ctx.new_platforms
]
display_text = ", ".join(display_names)
return await self._create_alert(
brand_id=ctx.brand_id,
user_id=ctx.user_id,
alert_type="new_platform_mention",
severity="info",
title=f"{ctx.brand_name} 在新平台 {display_text} 被提及",
message=(
f"品牌「{ctx.brand_name}」在新的AI平台「{display_text}」中首次被提及。"
f"这是一个积极的信号,表明品牌的影响力正在扩展到更多平台。"
),
data={
"new_platforms": list(ctx.new_platforms),
"existing_platforms": list(ctx.current_platforms - ctx.new_platforms),
},
)
# ============================================================
# 综合检测入口
# ============================================================
async def detect_all(self, ctx: AlertContext) -> list[Alert]:
"""
执行所有告警检测
Args:
ctx: 告警检测上下文
Returns:
生成的告警列表
"""
alerts: list[Alert] = []
check_methods = [
self.check_score_drop,
self.check_score_rise,
self.check_negative_sentiment,
self.check_competitor_overtake,
self.check_new_platform_mention,
]
for check_method in check_methods:
try:
alert = await check_method(ctx)
if alert is not None:
alerts.append(alert)
except Exception as e:
logger.error(
f"告警检测失败: method={check_method.__name__}, "
f"brand={ctx.brand_id}, error={e}"
)
return alerts
# ============================================================
# 便捷方法:从评分结果构建上下文并检测
# ============================================================
async def detect_after_scoring(
self,
brand_id: uuid.UUID,
brand_name: str,
user_id: uuid.UUID,
current_score: float,
sentiment_counts: dict[str, int],
brand_mentions: int,
competitor_mentions: dict[str, int],
competitor_scores: dict[str, float] | None = None,
current_platforms: set[str] | None = None,
new_platforms: set[str] | None = None,
) -> list[Alert]:
"""
评分计算后执行告警检测
自动获取上一次评分进行对比,构建告警上下文后执行所有检测。
Args:
brand_id: 品牌ID
brand_name: 品牌名称
user_id: 用户ID
current_score: 当前评分
sentiment_counts: 情感分布统计
brand_mentions: 品牌提及次数
competitor_mentions: 竞品提及次数
competitor_scores: 竞品评分
current_platforms: 当前已有提及的平台集合
new_platforms: 新出现提及的平台集合
Returns:
生成的告警列表
"""
# 获取上一次评分(从最近的历史告警数据中获取,或从评分历史获取)
previous_score = await self._get_previous_score(brand_id, user_id)
ctx = AlertContext(
brand_id=brand_id,
brand_name=brand_name,
user_id=user_id,
current_score=current_score,
previous_score=previous_score,
sentiment_counts=sentiment_counts,
brand_mentions=brand_mentions,
competitor_mentions=competitor_mentions,
competitor_scores=competitor_scores or {},
current_platforms=current_platforms or set(),
new_platforms=new_platforms or set(),
)
return await self.detect_all(ctx)
async def _get_previous_score(
self,
brand_id: uuid.UUID,
user_id: uuid.UUID,
) -> float | None:
"""
获取品牌上一次评分
从 score_drop 或 score_rise 类型的告警数据中获取,
如果没有则从引用记录中估算。
"""
# 尝试从最近的评分变化告警中获取
recent_alert_stmt = (
select(Alert)
.where(
and_(
Alert.brand_id == brand_id,
Alert.alert_type.in_(["score_drop", "score_rise"]),
)
)
.order_by(Alert.created_at.desc())
.limit(1)
)
result = await self.db.execute(recent_alert_stmt)
recent_alert = result.scalar_one_or_none()
if recent_alert and recent_alert.data:
return recent_alert.data.get("current_score")
# 没有历史告警,尝试从引用记录估算历史评分
return await self._estimate_previous_score(brand_id, user_id)
async def _estimate_previous_score(
self,
brand_id: uuid.UUID,
user_id: uuid.UUID,
) -> float | None:
"""
从历史引用记录估算上一次评分
使用昨天的引用数据计算一个简化的评分
"""
brand_stmt = select(Brand).where(Brand.id == brand_id)
brand_result = await self.db.execute(brand_stmt)
brand = brand_result.scalar_one_or_none()
if not brand:
return None
# 获取昨天的引用数据
yesterday = datetime.now(timezone.utc).date() - timedelta(days=1)
today_start = datetime.combine(yesterday, datetime.min.time()).replace(
tzinfo=timezone.utc
)
today_end = datetime.combine(
yesterday + timedelta(days=1), datetime.min.time()
).replace(tzinfo=timezone.utc)
queries_stmt = select(Query).where(
and_(
Query.user_id == user_id,
Query.target_brand == brand.name,
)
)
queries_result = await self.db.execute(queries_stmt)
queries = list(queries_result.scalars().all())
if not queries:
return None
query_ids = [q.id for q in queries]
citations_stmt = select(CitationRecord).where(
and_(
CitationRecord.query_id.in_(query_ids),
CitationRecord.queried_at >= today_start,
CitationRecord.queried_at < today_end,
)
)
citations_result = await self.db.execute(citations_stmt)
citations = list(citations_result.scalars().all())
if not citations:
return None
# 简化评分:提及率 * 100
cited_count = sum(1 for c in citations if c.cited)
total_count = len(citations)
if total_count > 0:
return round((cited_count / total_count) * 100, 2)
return None
# ============================================================
# 告警清理
# ============================================================
async def cleanup_old_alerts(self, days: int = 90) -> int:
"""
清理超过指定天数的旧告警
Args:
days: 保留天数默认90天
Returns:
删除的告警数量
"""
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
stmt = select(Alert).where(Alert.created_at < cutoff)
result = await self.db.execute(stmt)
old_alerts = list(result.scalars().all())
count = len(old_alerts)
for alert in old_alerts:
await self.db.delete(alert)
await self.db.flush()
if count > 0:
logger.info(f"已清理 {count} 条超过 {days} 天的旧告警")
return count
# ============================================================
# 初始化默认告警设置
# ============================================================
async def ensure_default_settings(
self,
brand_id: uuid.UUID,
user_id: uuid.UUID,
) -> list[AlertSetting]:
"""
确保品牌有默认的告警设置
如果某种告警类型没有设置记录,则创建默认设置。
Returns:
所有告警设置列表
"""
settings: list[AlertSetting] = []
for alert_type, config in DEFAULT_ALERT_CONFIGS.items():
existing = await self.get_alert_setting(brand_id, alert_type)
if existing is None:
setting = AlertSetting(
brand_id=brand_id,
user_id=user_id,
alert_type=alert_type,
enabled=config["enabled"],
threshold=config["threshold"],
)
self.db.add(setting)
settings.append(setting)
else:
settings.append(existing)
await self.db.flush()
return settings