geo/backend/app/services/trend/trend_analyzer_service.py

507 lines
18 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.

import json
import logging
import uuid
from datetime import datetime, timedelta, timezone
from collections import defaultdict
from sqlalchemy import func, select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.citation_record import CitationRecord
from app.models.query import Query
from app.models.trend_insight import TrendInsight
from app.models.brand import Brand
from app.services.llm import LLMFactory, LLMError
from app.utils.json_extractor import extract_json
logger = logging.getLogger(__name__)
class TrendAnalyzerService:
def __init__(self, db: AsyncSession):
self.db = db
async def analyze_trends(
self,
brand_id: uuid.UUID,
days: int = 30,
platforms: list[str] | None = None,
keywords: list[str] | None = None,
) -> dict:
brand = await self._get_brand(brand_id)
if brand is None:
raise ValueError("品牌不存在")
citations = await self.get_time_series_data(brand.name, days, platforms, keywords)
if len(citations) < 7:
now = datetime.now(timezone.utc)
return {
"status": "insufficient_data",
"message": f"数据不足7天当前{len(citations)}天),无法进行趋势分析",
"brand_id": str(brand_id),
"days": days,
}
aggregated = self._aggregate_time_series(citations, days)
change_rate = self.calculate_change_rate(aggregated)
classified_type = self.detect_trends(change_rate)
absolute_change = self._calculate_absolute_change(aggregated)
sentiment_trend = self._analyze_sentiment_trend(citations)
platform_comparison = await self.compare_platforms(citations)
cause_analysis = await self.infer_causes(
brand_name=brand.name,
keyword="",
trend_type=classified_type,
change_rate=change_rate,
data_points=aggregated,
)
recommendations = self.generate_recommendations(
classified_type, change_rate, sentiment_trend
)
confidence = self._calculate_confidence(len(citations), change_rate)
severity = self._determine_severity(classified_type, change_rate)
now = datetime.now(timezone.utc)
period_start = now - timedelta(days=days)
insight = TrendInsight(
brand_id=str(brand_id),
trend_type=classified_type,
keyword=brand.name,
platform="all",
period_start=period_start,
period_end=now,
data_points=aggregated,
change_rate=change_rate,
absolute_change=absolute_change,
sentiment_trend=sentiment_trend,
cause_analysis=cause_analysis,
recommendations=recommendations,
confidence=confidence,
severity=severity,
)
self.db.add(insight)
await self.db.commit()
await self.db.refresh(insight)
return {
"status": "success",
"insight_id": str(insight.id),
"trend_type": insight.trend_type,
"change_rate": insight.change_rate,
"absolute_change": insight.absolute_change,
"sentiment_trend": insight.sentiment_trend,
"cause_analysis": insight.cause_analysis,
"recommendations": insight.recommendations,
"confidence": insight.confidence,
"severity": insight.severity,
"data_points": insight.data_points,
}
async def get_time_series_data(
self,
brand_name: str,
days: int,
platforms: list[str] | None = None,
keywords: list[str] | None = None,
) -> list[dict]:
now = datetime.now(timezone.utc)
start_date = now - timedelta(days=days)
conditions = [
Query.target_brand == brand_name,
CitationRecord.queried_at >= start_date,
]
if platforms:
conditions.append(CitationRecord.platform.in_(platforms))
if keywords:
conditions.append(Query.keyword.in_(keywords))
stmt = (
select(
CitationRecord.platform,
CitationRecord.cited,
CitationRecord.sentiment,
CitationRecord.queried_at,
Query.keyword,
)
.join(Query, CitationRecord.query_id == Query.id)
.where(and_(*conditions))
.order_by(CitationRecord.queried_at.asc())
)
result = await self.db.execute(stmt)
citations = []
for row in result.all():
citations.append({
"platform": row.platform,
"cited": row.cited,
"sentiment": row.sentiment or "neutral",
"queried_at": row.queried_at,
"keyword": row.keyword,
})
return citations
def detect_trends(self, change_rate: float) -> str:
if change_rate > 20:
return "rising"
elif change_rate < -20:
return "declining"
else:
return "stable"
def detect_hotspots(
self,
citations: list[dict],
days: int = 30,
) -> list[dict]:
if not citations:
return []
daily_counts = defaultdict(lambda: defaultdict(int))
for c in citations:
date_str = c.get("queried_at", datetime.now(timezone.utc)).strftime("%Y-%m-%d")
keyword = c.get("keyword", "")
daily_counts[keyword][date_str] += 1
hotspots = []
for keyword, daily in daily_counts.items():
counts = list(daily.values())
if len(counts) < 7:
continue
recent_7 = counts[-7:]
mean_7 = sum(recent_7) / len(recent_7)
latest = recent_7[-1]
if mean_7 > 0 and latest > mean_7 * 2:
hotspots.append({
"keyword": keyword,
"latest_count": latest,
"mean_7d": round(mean_7, 2),
"surge_ratio": round(latest / mean_7, 2),
"trend_type": "hotspot",
})
hotspots.sort(key=lambda x: x["surge_ratio"], reverse=True)
return hotspots
async def compare_platforms(self, citations: list[dict]) -> dict:
platform_data = defaultdict(lambda: {"cited": 0, "total": 0, "positive": 0})
for c in citations:
platform = c.get("platform", "unknown")
entry = platform_data[platform]
entry["total"] += 1
if c.get("cited"):
entry["cited"] += 1
if c.get("sentiment") == "positive":
entry["positive"] += 1
result = {}
for platform, data in platform_data.items():
citation_rate = (data["cited"] / data["total"] * 100) if data["total"] > 0 else 0.0
positive_rate = (data["positive"] / data["total"] * 100) if data["total"] > 0 else 0.0
result[platform] = {
"citation_rate": round(citation_rate, 2),
"positive_rate": round(positive_rate, 2),
"total_mentions": data["total"],
}
return result
async def infer_causes(
self,
brand_name: str,
keyword: str,
trend_type: str,
change_rate: float,
data_points: list[dict],
) -> str:
trend_desc = {
"rising": "上升",
"declining": "下降",
"stable": "平稳",
"hotspot": "热点",
"platform_shift": "平台迁移",
}.get(trend_type, trend_type)
messages = [
{
"role": "system",
"content": "你是一个品牌趋势分析专家,请根据提供的数据推断趋势变化的可能原因。返回一段简洁的分析文本。",
},
{
"role": "user",
"content": (
f"品牌: {brand_name}\n"
f"关键词: {keyword or brand_name}\n"
f"趋势类型: {trend_desc}\n"
f"变化率: {change_rate}%\n"
f"时间序列数据(最近5点): {json.dumps(data_points[-5:], ensure_ascii=False)}\n\n"
f"请推断导致该趋势变化的可能原因,返回简洁的分析文本。"
),
},
]
try:
provider = LLMFactory.get_default()
response = await provider.chat(messages, temperature=0.3, max_tokens=1024)
return response.content.strip()
except (LLMError, Exception) as e:
logger.warning(f"LLM推断原因失败: {e}")
return f"品牌{brand_name}的引用趋势{trend_desc},变化率{change_rate}%"
def generate_recommendations(
self,
trend_type: str,
change_rate: float,
sentiment_trend: dict,
) -> list[str]:
recommendations = []
sentiment_direction = sentiment_trend.get("direction", "neutral") if sentiment_trend else "neutral"
if trend_type == "rising":
recommendations.append("引用趋势上升,建议加大内容投入以巩固优势")
if sentiment_direction == "positive":
recommendations.append("情感倾向正向提升,可强化正面内容传播")
elif sentiment_direction == "negative":
recommendations.append("虽然引用量上升但情感转负,需关注负面评价并积极回应")
elif trend_type == "declining":
recommendations.append("引用趋势下降,建议审查内容策略并优化关键词覆盖")
recommendations.append("考虑增加高质量内容产出提升AI引擎引用概率")
if sentiment_direction == "negative":
recommendations.append("情感倾向同步恶化,建议优先处理负面舆情")
elif trend_type == "stable":
recommendations.append("引用趋势平稳,建议探索新的关键词和内容方向以突破现状")
elif trend_type == "hotspot":
recommendations.append("检测到热点趋势,建议快速响应并产出相关内容以获取流量红利")
elif trend_type == "platform_shift":
recommendations.append("检测到平台迁移趋势,建议调整各平台内容策略以适应变化")
if abs(change_rate) > 50:
recommendations.append("变化幅度较大,建议密切关注后续走势并准备应对方案")
return recommendations
def calculate_change_rate(self, data_points: list[dict]) -> float:
if len(data_points) < 2:
return 0.0
half = len(data_points) // 2
previous = sum(p.get("citation_count", 0) for p in data_points[:half])
current = sum(p.get("citation_count", 0) for p in data_points[half:])
if previous == 0:
return 100.0 if current > 0 else 0.0
return round(((current - previous) / previous) * 100, 2)
async def get_insights(
self,
brand_id: uuid.UUID,
skip: int = 0,
limit: int = 20,
) -> tuple[list[TrendInsight], int]:
conditions = [TrendInsight.brand_id == str(brand_id)]
stmt = (
select(TrendInsight)
.where(and_(*conditions))
.order_by(TrendInsight.created_at.desc())
.offset(skip)
.limit(limit)
)
result = await self.db.execute(stmt)
items = result.scalars().all()
count_stmt = (
select(func.count())
.select_from(TrendInsight)
.where(and_(*conditions))
)
count_result = await self.db.execute(count_stmt)
total = count_result.scalar_one()
return list(items), total
async def get_insight_by_id(self, insight_id: uuid.UUID) -> TrendInsight | None:
stmt = select(TrendInsight).where(TrendInsight.id == insight_id)
result = await self.db.execute(stmt)
return result.scalar_one_or_none()
async def get_summary(
self,
brand_id: uuid.UUID,
days: int = 30,
) -> dict:
conditions = [TrendInsight.brand_id == str(brand_id)]
stmt = (
select(TrendInsight.trend_type, func.count().label("count"))
.where(and_(*conditions))
.group_by(TrendInsight.trend_type)
)
result = await self.db.execute(stmt)
summary = {
"brand_id": brand_id,
"period_days": days,
"rising_count": 0,
"declining_count": 0,
"hotspot_count": 0,
"top_keywords": [],
"platform_highlights": {},
}
for row in result.all():
trend_type = row.trend_type
count = row.count
if trend_type == "rising":
summary["rising_count"] = count
elif trend_type == "declining":
summary["declining_count"] = count
elif trend_type == "hotspot":
summary["hotspot_count"] = count
keyword_stmt = (
select(TrendInsight.keyword, func.count().label("count"))
.where(and_(*conditions), TrendInsight.keyword.isnot(None))
.group_by(TrendInsight.keyword)
.order_by(func.count().desc())
.limit(10)
)
keyword_result = await self.db.execute(keyword_stmt)
summary["top_keywords"] = [row.keyword for row in keyword_result.all()]
platform_stmt = (
select(TrendInsight.platform, TrendInsight.trend_type, func.count().label("count"))
.where(and_(*conditions), TrendInsight.platform.isnot(None))
.group_by(TrendInsight.platform, TrendInsight.trend_type)
)
platform_result = await self.db.execute(platform_stmt)
for row in platform_result.all():
platform = row.platform
if platform not in summary["platform_highlights"]:
summary["platform_highlights"][platform] = {}
summary["platform_highlights"][platform][row.trend_type] = row.count
return summary
async def get_hotspots(
self,
brand_id: uuid.UUID,
days: int = 30,
) -> dict:
brand = await self._get_brand(brand_id)
if brand is None:
raise ValueError("品牌不存在")
citations = await self.get_time_series_data(brand.name, days)
if len(citations) < 7:
return {
"status": "insufficient_data",
"message": f"数据不足7天当前{len(citations)}天),无法进行热点分析",
"hotspots": [],
}
hotspots = self.detect_hotspots(citations, days)
return {
"status": "success",
"hotspots": hotspots,
}
def _aggregate_time_series(self, citations: list[dict], days: int) -> list[dict]:
daily = defaultdict(lambda: {"citation_count": 0, "positive_count": 0})
for c in citations:
date_str = c.get("queried_at", datetime.now(timezone.utc)).strftime("%Y-%m-%d")
entry = daily[date_str]
entry["citation_count"] += 1
sentiment = c.get("sentiment", "neutral")
if sentiment == "positive":
entry["positive_count"] += 1
result = []
for date_str in sorted(daily.keys()):
entry = daily[date_str]
positive_ratio = entry["positive_count"] / entry["citation_count"] if entry["citation_count"] > 0 else 0.0
result.append({
"date": date_str,
"citation_count": entry["citation_count"],
"positive_ratio": round(positive_ratio, 4),
})
return result
def _calculate_absolute_change(self, data_points: list[dict]) -> int:
if len(data_points) < 2:
return 0
half = len(data_points) // 2
previous = sum(p.get("citation_count", 0) for p in data_points[:half])
current = sum(p.get("citation_count", 0) for p in data_points[half:])
return current - previous
def _analyze_sentiment_trend(self, citations: list[dict]) -> dict:
if not citations:
return {"direction": "neutral", "positive_ratio": 0.0, "negative_ratio": 0.0}
half = len(citations) // 2
first_half = citations[:half]
second_half = citations[half:]
def sentiment_ratios(records: list[dict]) -> dict:
if not records:
return {"positive": 0.0, "negative": 0.0}
positive = sum(1 for r in records if r.get("sentiment") == "positive")
negative = sum(1 for r in records if r.get("sentiment") == "negative")
total = len(records)
return {
"positive": round(positive / total, 4),
"negative": round(negative / total, 4),
}
prev = sentiment_ratios(first_half)
curr = sentiment_ratios(second_half)
diff = curr["positive"] - prev["positive"]
if diff > 0.1:
direction = "positive"
elif diff < -0.1:
direction = "negative"
else:
direction = "neutral"
return {
"direction": direction,
"previous_positive_ratio": prev["positive"],
"current_positive_ratio": curr["positive"],
"previous_negative_ratio": prev["negative"],
"current_negative_ratio": curr["negative"],
}
def _calculate_confidence(self, data_days: int, change_rate: float) -> float:
base = 0.3
if data_days >= 21:
base += 0.3
elif data_days >= 14:
base += 0.2
elif data_days >= 7:
base += 0.1
if abs(change_rate) > 30:
base += 0.2
elif abs(change_rate) > 10:
base += 0.1
return round(min(base, 1.0), 2)
def _determine_severity(self, trend_type: str, change_rate: float) -> str:
if trend_type == "hotspot" or abs(change_rate) > 50:
return "critical"
elif trend_type in ("rising", "declining") and abs(change_rate) > 20:
return "warning"
return "info"
async def _get_brand(self, brand_id: uuid.UUID) -> Brand | None:
stmt = select(Brand).where(Brand.id == brand_id)
result = await self.db.execute(stmt)
return result.scalar_one_or_none()