507 lines
18 KiB
Python
507 lines
18 KiB
Python
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()
|