867 lines
29 KiB
Python
867 lines
29 KiB
Python
"""Brand Scoring API endpoints."""
|
||
import logging
|
||
import uuid
|
||
from datetime import datetime, timedelta
|
||
from typing import Annotated
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||
from sqlalchemy import select, func, and_, case
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy.orm import selectinload
|
||
|
||
from app.api.deps import get_current_user
|
||
from app.database import get_db
|
||
from app.models.user import User
|
||
from app.models.brand import Brand
|
||
from app.models.competitor import Competitor
|
||
from app.models.query import Query as QueryModel
|
||
from app.models.citation_record import CitationRecord
|
||
from app.schemas.scoring import (
|
||
BrandScoreResponse,
|
||
BrandScoreV2Response,
|
||
BrandScoreHistoryItem,
|
||
BrandScoreHistoryResponse,
|
||
CompareResponse,
|
||
CompareItem,
|
||
DimensionScoreResponse,
|
||
DimensionCompareItem,
|
||
CitationResult,
|
||
)
|
||
from app.services.scoring.scoring_service import ScoringService, get_health_level
|
||
from app.services.analysis.sentiment_service import get_sentiment_service
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter()
|
||
def _to_uuid(value: str | uuid.UUID) -> uuid.UUID:
|
||
if isinstance(value, uuid.UUID):
|
||
return value
|
||
return uuid.UUID(str(value))
|
||
|
||
|
||
async def _get_brand_with_access(
|
||
brand_id: uuid.UUID,
|
||
db: AsyncSession,
|
||
current_user: User,
|
||
) -> Brand:
|
||
"""Verify brand exists and user has access."""
|
||
stmt = select(Brand).where(
|
||
Brand.id == brand_id,
|
||
Brand.user_id == _to_uuid(current_user.id),
|
||
)
|
||
result = await db.execute(stmt)
|
||
brand = result.scalar_one_or_none()
|
||
|
||
if not brand:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="品牌不存在",
|
||
)
|
||
return brand
|
||
|
||
|
||
async def _get_citations_for_brand(
|
||
db: AsyncSession,
|
||
user_id: uuid.UUID,
|
||
brand_id: uuid.UUID,
|
||
) -> tuple[int, list[CitationRecord], int, dict[str, int]]:
|
||
"""
|
||
Get citations for a brand.
|
||
|
||
Returns:
|
||
tuple: (total_queries, brand_citations, competitor_citations_count, competitor_mentions)
|
||
"""
|
||
# Get all queries for this brand (by brand name matching)
|
||
brand_stmt = select(Brand).where(Brand.id == brand_id, Brand.user_id == user_id)
|
||
brand_result = await db.execute(brand_stmt)
|
||
brand = brand_result.scalar_one_or_none()
|
||
|
||
if not brand:
|
||
return 0, [], 0, {}
|
||
|
||
# Find queries that match this brand
|
||
queries_stmt = select(QueryModel).where(
|
||
QueryModel.user_id == user_id,
|
||
QueryModel.target_brand == brand.name,
|
||
)
|
||
queries_result = await db.execute(queries_stmt)
|
||
queries = list(queries_result.scalars().all())
|
||
|
||
if not queries:
|
||
return 0, [], 0, {}
|
||
|
||
query_ids = [q.id for q in queries]
|
||
|
||
# Get citations for these queries
|
||
citations_stmt = select(CitationRecord).where(
|
||
CitationRecord.query_id.in_(query_ids)
|
||
)
|
||
citations_result = await db.execute(citations_stmt)
|
||
citations = list(citations_result.scalars().all())
|
||
|
||
total_queries = len(citations)
|
||
brand_citations = [c for c in citations if c.cited]
|
||
|
||
# Get competitor citations
|
||
competitor_stmt = select(Competitor).where(Competitor.brand_id == brand_id)
|
||
competitor_result = await db.execute(competitor_stmt)
|
||
competitors = list(competitor_result.scalars().all())
|
||
competitor_names = [c.name for c in competitors]
|
||
|
||
competitor_citations = [
|
||
c for c in citations
|
||
if c.cited and c.competitor_brands
|
||
and any(comp in competitor_names for comp in c.competitor_brands)
|
||
]
|
||
|
||
# Calculate competitor mentions: {competitor_name: mention_count}
|
||
competitor_mentions: dict[str, int] = {}
|
||
for comp_name in competitor_names:
|
||
count = sum(
|
||
1 for c in citations
|
||
if c.cited and c.competitor_brands
|
||
and comp_name in c.competitor_brands
|
||
)
|
||
if count > 0:
|
||
competitor_mentions[comp_name] = count
|
||
|
||
return total_queries, brand_citations, len(competitor_citations), competitor_mentions
|
||
|
||
|
||
async def _analyze_sentiments_for_citations(
|
||
brand_name: str,
|
||
brand_citations: list[CitationRecord],
|
||
) -> dict[str, int]:
|
||
"""
|
||
对引用记录进行情感分析,返回情感分布统计。
|
||
|
||
优先使用已持久化的sentiment字段;
|
||
如果没有,则调用情感分析服务。
|
||
|
||
Returns:
|
||
{"positive": int, "neutral": int, "negative": int}
|
||
"""
|
||
sentiment_counts = {"positive": 0, "neutral": 0, "negative": 0}
|
||
sentiment_service = get_sentiment_service()
|
||
|
||
for citation in brand_citations:
|
||
# 优先使用已持久化的sentiment字段
|
||
if citation.sentiment and citation.sentiment in ("positive", "neutral", "negative"):
|
||
sentiment_counts[citation.sentiment] += 1
|
||
continue
|
||
|
||
# 没有持久化数据,调用情感分析服务
|
||
content = citation.raw_response or citation.citation_text or ""
|
||
if content.strip():
|
||
try:
|
||
result = await sentiment_service.analyze(
|
||
brand_name=brand_name,
|
||
content=content,
|
||
)
|
||
sentiment_counts[result.sentiment] += 1
|
||
except Exception:
|
||
sentiment_counts["neutral"] += 1
|
||
else:
|
||
sentiment_counts["neutral"] += 1
|
||
|
||
return sentiment_counts
|
||
|
||
|
||
def _dimension_to_response(dim) -> DimensionScoreResponse:
|
||
"""将DimensionScore转换为DimensionScoreResponse"""
|
||
return DimensionScoreResponse(
|
||
name=dim.name,
|
||
score=round(dim.score, 2),
|
||
max_score=dim.max_score,
|
||
percentage=round(dim.percentage, 2),
|
||
detail=dim.detail,
|
||
)
|
||
|
||
|
||
def _determine_trend(current: float, previous: float, threshold: float = 0.5) -> tuple[str, float]:
|
||
"""
|
||
判断趋势方向。
|
||
|
||
Args:
|
||
current: 当前值
|
||
previous: 之前值
|
||
threshold: 变化阈值,低于此值视为持平
|
||
|
||
Returns:
|
||
(trend, trend_value): trend为"up"/"down"/"stable", trend_value为变化绝对值
|
||
"""
|
||
diff = current - previous
|
||
if diff > threshold:
|
||
return "up", round(diff, 2)
|
||
elif diff < -threshold:
|
||
return "down", round(diff, 2)
|
||
return "stable", round(diff, 2)
|
||
|
||
|
||
async def _calculate_trend(
|
||
db: AsyncSession,
|
||
user_id: uuid.UUID,
|
||
brand: Brand,
|
||
current_score: float,
|
||
) -> tuple[str, float]:
|
||
"""
|
||
计算品牌评分趋势(与昨天对比)。
|
||
|
||
Returns:
|
||
(trend, trend_value)
|
||
"""
|
||
today = datetime.now().date()
|
||
yesterday = today - timedelta(days=1)
|
||
|
||
# Get queries for this brand
|
||
queries_stmt = select(QueryModel).where(
|
||
QueryModel.user_id == user_id,
|
||
QueryModel.target_brand == brand.name,
|
||
)
|
||
queries_result = await db.execute(queries_stmt)
|
||
queries = list(queries_result.scalars().all())
|
||
|
||
if not queries:
|
||
return "stable", 0.0
|
||
|
||
query_ids = [q.id for q in queries]
|
||
|
||
# Get yesterday's citations
|
||
yesterday_citations_stmt = select(CitationRecord).where(
|
||
CitationRecord.query_id.in_(query_ids),
|
||
)
|
||
yesterday_result = await db.execute(yesterday_citations_stmt)
|
||
yesterday_citations = list(yesterday_result.scalars().all())
|
||
|
||
# Filter by date
|
||
yesterday_filtered = [
|
||
c for c in yesterday_citations
|
||
if c.queried_at and c.queried_at.date() == yesterday
|
||
]
|
||
|
||
if not yesterday_filtered:
|
||
return "stable", 0.0
|
||
|
||
total = len(yesterday_filtered)
|
||
cited = sum(1 for c in yesterday_filtered if c.cited)
|
||
yesterday_score = (cited / total * 100) if total > 0 else 0.0
|
||
|
||
return _determine_trend(current_score, yesterday_score)
|
||
|
||
|
||
async def _calculate_trend_for_competitor(
|
||
db: AsyncSession,
|
||
user_id: uuid.UUID,
|
||
competitor_name: str,
|
||
current_score: float,
|
||
) -> tuple[str, float]:
|
||
"""
|
||
计算竞品评分趋势(与昨天对比)。
|
||
|
||
Returns:
|
||
(trend, trend_value)
|
||
"""
|
||
today = datetime.now().date()
|
||
yesterday = today - timedelta(days=1)
|
||
|
||
# Get queries for this competitor
|
||
queries_stmt = select(QueryModel).where(
|
||
QueryModel.user_id == user_id,
|
||
QueryModel.target_brand == competitor_name,
|
||
)
|
||
queries_result = await db.execute(queries_stmt)
|
||
queries = list(queries_result.scalars().all())
|
||
|
||
if not queries:
|
||
return "stable", 0.0
|
||
|
||
query_ids = [q.id for q in queries]
|
||
|
||
# Get yesterday's citations
|
||
citations_stmt = select(CitationRecord).where(
|
||
CitationRecord.query_id.in_(query_ids),
|
||
)
|
||
citations_result = await db.execute(citations_stmt)
|
||
citations = list(citations_result.scalars().all())
|
||
|
||
# Filter by date
|
||
yesterday_filtered = [
|
||
c for c in citations
|
||
if c.queried_at and c.queried_at.date() == yesterday
|
||
]
|
||
|
||
if not yesterday_filtered:
|
||
return "stable", 0.0
|
||
|
||
total = len(yesterday_filtered)
|
||
cited = sum(1 for c in yesterday_filtered if c.cited)
|
||
yesterday_score = (cited / total * 100) if total > 0 else 0.0
|
||
|
||
return _determine_trend(current_score, yesterday_score)
|
||
|
||
|
||
def _build_dimension_compare_items(
|
||
v2_result,
|
||
overall_trend: str = "stable",
|
||
) -> list[DimensionCompareItem]:
|
||
"""
|
||
从V2评分结果构建维度对比项列表。
|
||
|
||
Args:
|
||
v2_result: ScoringResultV2 实例
|
||
overall_trend: 整体趋势方向
|
||
|
||
Returns:
|
||
五维度对比项列表
|
||
"""
|
||
dimensions = [
|
||
v2_result.mention_rate,
|
||
v2_result.recommendation_rank,
|
||
v2_result.sentiment_score,
|
||
v2_result.citation_quality,
|
||
v2_result.competitive_position,
|
||
]
|
||
|
||
items = []
|
||
for dim in dimensions:
|
||
# 维度趋势基于得分率变化简化处理
|
||
trend = "stable"
|
||
trend_value = 0.0
|
||
if overall_trend == "up":
|
||
trend = "up"
|
||
trend_value = round(dim.percentage * 0.02, 2)
|
||
elif overall_trend == "down":
|
||
trend = "down"
|
||
trend_value = round(-dim.percentage * 0.02, 2)
|
||
|
||
items.append(DimensionCompareItem(
|
||
name=dim.name,
|
||
score=round(dim.score, 2),
|
||
max_score=dim.max_score,
|
||
percentage=round(dim.percentage, 2),
|
||
trend=trend,
|
||
trend_value=trend_value,
|
||
))
|
||
|
||
return items
|
||
|
||
|
||
def _build_radar_data(
|
||
all_v2_results: dict,
|
||
) -> list[dict]:
|
||
"""
|
||
从所有V2评分结果构建雷达图数据。
|
||
|
||
Args:
|
||
all_v2_results: {品牌/竞品名称: ScoringResultV2}
|
||
|
||
Returns:
|
||
雷达图数据列表,每项包含维度名称和各品牌得分率
|
||
"""
|
||
dimension_keys = [
|
||
("提及率", "mention_rate"),
|
||
("推荐排名", "recommendation_rank"),
|
||
("情感倾向", "sentiment_score"),
|
||
("引用质量", "citation_quality"),
|
||
("竞品对比", "competitive_position"),
|
||
]
|
||
|
||
radar_data = []
|
||
for dim_label, dim_key in dimension_keys:
|
||
item = {"dimension": dim_label, "label": dim_label}
|
||
for name, v2_result in all_v2_results.items():
|
||
dim = getattr(v2_result, dim_key, None)
|
||
if dim:
|
||
item[name] = round(dim.percentage, 2)
|
||
else:
|
||
item[name] = 0.0
|
||
radar_data.append(item)
|
||
|
||
return radar_data
|
||
|
||
|
||
@router.get("/{brand_id}/score/", response_model=BrandScoreV2Response)
|
||
async def get_brand_score(
|
||
brand_id: uuid.UUID,
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""
|
||
获取品牌评分V2
|
||
|
||
计算品牌在AI搜索中的综合评分,包括五个维度:
|
||
- 提及率 (25分): 品牌在AI回答中被提及的频率
|
||
- 推荐排名 (25分): 品牌在推荐列表中的位置
|
||
- 情感倾向 (20分): AI对品牌的情感倾向
|
||
- 引用质量 (15分): 引用内容的深度和正面性
|
||
- 竞品对比 (15分): 相对于竞品的表现
|
||
|
||
同时返回V1兼容字段,方便前端平滑迁移。
|
||
"""
|
||
brand = await _get_brand_with_access(brand_id, db, current_user)
|
||
|
||
# Get citations data
|
||
total_queries, brand_citations, competitor_citations, competitor_mentions = (
|
||
await _get_citations_for_brand(db, _to_uuid(current_user.id), brand_id)
|
||
)
|
||
|
||
# 情感分析
|
||
sentiment_counts = await _analyze_sentiments_for_citations(
|
||
brand_name=brand.name,
|
||
brand_citations=brand_citations,
|
||
)
|
||
|
||
# 构建CitationResult列表
|
||
citation_results = [
|
||
CitationResult(
|
||
cited=c.cited,
|
||
position=c.citation_position,
|
||
citation_text=c.citation_text,
|
||
sentiment=_get_sentiment_from_counts(c, sentiment_counts),
|
||
confidence=c.confidence or 0.0,
|
||
)
|
||
for c in brand_citations
|
||
]
|
||
|
||
# 提取位置列表
|
||
positions = [c.citation_position for c in brand_citations if c.cited]
|
||
|
||
# 计算V2评分
|
||
scoring_service = ScoringService()
|
||
v2_result = scoring_service.calculate_v2(
|
||
mentioned_count=len(brand_citations),
|
||
total_queries=total_queries,
|
||
positions=positions,
|
||
sentiment_counts=sentiment_counts,
|
||
citations=citation_results,
|
||
brand_mentions=len(brand_citations),
|
||
competitor_mentions=competitor_mentions,
|
||
)
|
||
|
||
# V1兼容字段计算
|
||
mention_rate_score = scoring_service.calculate_mention_rate_score(
|
||
len(brand_citations), total_queries
|
||
)
|
||
sov_score = scoring_service.calculate_sov_score(
|
||
len(brand_citations),
|
||
len(brand_citations) + competitor_citations,
|
||
)
|
||
quality_score = scoring_service.calculate_quality_score(citation_results)
|
||
|
||
# 异步触发告警检测(不影响主流程)
|
||
try:
|
||
from app.services.alert.alert_engine import AlertEngine
|
||
alert_engine = AlertEngine(db)
|
||
|
||
# 获取当前已有提及的平台集合
|
||
current_platforms = set()
|
||
new_platforms = set()
|
||
for c in brand_citations:
|
||
if c.cited and c.platform:
|
||
current_platforms.add(c.platform)
|
||
|
||
# 计算竞品评分用于告警
|
||
competitor_scores: dict[str, float] = {}
|
||
if competitor_mentions:
|
||
competitor_stmt = select(Competitor).where(Competitor.brand_id == brand_id)
|
||
competitor_result = await db.execute(competitor_stmt)
|
||
competitors = list(competitor_result.scalars().all())
|
||
for comp in competitors:
|
||
if comp.name in competitor_mentions:
|
||
# 简化:使用提及次数估算竞品评分
|
||
comp_score = (competitor_mentions[comp.name] / total_queries * 100) if total_queries > 0 else 0.0
|
||
competitor_scores[comp.name] = round(comp_score, 2)
|
||
|
||
await alert_engine.detect_after_scoring(
|
||
brand_id=brand_id,
|
||
brand_name=brand.name,
|
||
user_id=_to_uuid(current_user.id),
|
||
current_score=v2_result.overall_score,
|
||
sentiment_counts=sentiment_counts,
|
||
brand_mentions=len(brand_citations),
|
||
competitor_mentions=competitor_mentions,
|
||
competitor_scores=competitor_scores,
|
||
current_platforms=current_platforms,
|
||
new_platforms=new_platforms,
|
||
)
|
||
except Exception as e:
|
||
# 告警检测失败不影响主流程
|
||
logger.warning(f"告警检测失败: brand={brand_id}, error={e}")
|
||
|
||
return BrandScoreV2Response(
|
||
overall_score=v2_result.overall_score,
|
||
health_level=v2_result.health_level,
|
||
mention_rate=_dimension_to_response(v2_result.mention_rate),
|
||
recommendation_rank=_dimension_to_response(v2_result.recommendation_rank),
|
||
sentiment_score=_dimension_to_response(v2_result.sentiment_score),
|
||
citation_quality=_dimension_to_response(v2_result.citation_quality),
|
||
competitive_position=_dimension_to_response(v2_result.competitive_position),
|
||
# V1兼容字段
|
||
mention_rate_score=round(mention_rate_score, 2),
|
||
sov_score=round(sov_score, 2),
|
||
quality_score=round(quality_score, 2),
|
||
)
|
||
|
||
|
||
def _get_sentiment_from_counts(
|
||
citation: CitationRecord,
|
||
sentiment_counts: dict[str, int],
|
||
) -> str:
|
||
"""
|
||
从情感分析结果中获取单条引用的情感。
|
||
|
||
由于情感分析是批量统计的,这里对单条引用使用规则分析作为近似。
|
||
后续可以优化为逐条分析并缓存。
|
||
"""
|
||
# 简单规则:基于citation_text的关键词判断
|
||
text = citation.citation_text or ""
|
||
positive_keywords = ["推荐", "领先", "优秀", "首选", "最佳", "出色", "卓越", "优势"]
|
||
negative_keywords = ["不足", "缺陷", "问题", "较差", "落后", "劣势", "不推荐"]
|
||
|
||
pos_count = sum(1 for kw in positive_keywords if kw in text)
|
||
neg_count = sum(1 for kw in negative_keywords if kw in text)
|
||
|
||
if pos_count > neg_count:
|
||
return "positive"
|
||
elif neg_count > pos_count:
|
||
return "negative"
|
||
return "neutral"
|
||
|
||
|
||
@router.get("/{brand_id}/score/v1/", response_model=BrandScoreResponse)
|
||
async def get_brand_score_v1(
|
||
brand_id: uuid.UUID,
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""
|
||
获取品牌评分V1(兼容接口)
|
||
|
||
保留旧版评分接口,使用原来的3维度算法。
|
||
"""
|
||
brand = await _get_brand_with_access(brand_id, db, current_user)
|
||
|
||
# Get citations data
|
||
total_queries, brand_citations, competitor_citations, _ = (
|
||
await _get_citations_for_brand(db, _to_uuid(current_user.id), brand_id)
|
||
)
|
||
|
||
# Calculate scores using scoring service
|
||
scoring_service = ScoringService()
|
||
|
||
# Mention rate score
|
||
mention_rate_score = scoring_service.calculate_mention_rate_score(
|
||
len(brand_citations), total_queries
|
||
)
|
||
|
||
# SOV score
|
||
sov_score = scoring_service.calculate_sov_score(
|
||
len(brand_citations),
|
||
len(brand_citations) + competitor_citations
|
||
)
|
||
|
||
# Quality score
|
||
citation_results = [
|
||
CitationResult(
|
||
cited=c.cited,
|
||
position=c.citation_position,
|
||
citation_text=c.citation_text,
|
||
sentiment=_get_sentiment_from_counts(c, {}),
|
||
confidence=c.confidence or 0.0,
|
||
)
|
||
for c in brand_citations
|
||
]
|
||
|
||
quality_score = scoring_service.calculate_quality_score(citation_results)
|
||
|
||
# Overall score
|
||
overall_score = scoring_service.calculate_overall_score(
|
||
mention_rate_score, sov_score, quality_score
|
||
)
|
||
|
||
return BrandScoreResponse(
|
||
mention_rate_score=round(mention_rate_score, 2),
|
||
sov_score=round(sov_score, 2),
|
||
quality_score=round(quality_score, 2),
|
||
overall_score=round(overall_score, 2),
|
||
)
|
||
|
||
|
||
@router.get("/{brand_id}/score/history/", response_model=BrandScoreHistoryResponse)
|
||
async def get_brand_score_history(
|
||
brand_id: uuid.UUID,
|
||
skip: int = Query(0, ge=0),
|
||
limit: int = Query(20, ge=1, le=100),
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""
|
||
获取品牌评分历史
|
||
|
||
返回品牌的历史评分记录,支持分页。
|
||
"""
|
||
brand = await _get_brand_with_access(brand_id, db, current_user)
|
||
|
||
# Get queries for this brand
|
||
queries_stmt = select(QueryModel).where(
|
||
QueryModel.user_id == current_user.id,
|
||
QueryModel.target_brand == brand.name,
|
||
)
|
||
queries_result = await db.execute(queries_stmt)
|
||
queries = list(queries_result.scalars().all())
|
||
|
||
if not queries:
|
||
return BrandScoreHistoryResponse(history=[], total=0)
|
||
|
||
query_ids = [q.id for q in queries]
|
||
|
||
# Count total
|
||
count_stmt = select(func.count()).select_from(CitationRecord).where(
|
||
CitationRecord.query_id.in_(query_ids)
|
||
)
|
||
count_result = await db.execute(count_stmt)
|
||
total = count_result.scalar_one()
|
||
|
||
# Get citations grouped by date for trend
|
||
history_stmt = (
|
||
select(
|
||
func.date(CitationRecord.queried_at).label("date"),
|
||
func.count().label("total_queries"),
|
||
func.sum(
|
||
case((CitationRecord.cited == True, 1), else_=0)
|
||
).label("cited_count"),
|
||
)
|
||
.where(CitationRecord.query_id.in_(query_ids))
|
||
.group_by(func.date(CitationRecord.queried_at))
|
||
.order_by(func.date(CitationRecord.queried_at).desc())
|
||
.offset(skip)
|
||
.limit(limit)
|
||
)
|
||
|
||
history_result = await db.execute(history_stmt)
|
||
rows = history_result.all()
|
||
|
||
history = []
|
||
for row in rows:
|
||
date_str = row.date.isoformat() if hasattr(row.date, 'isoformat') else str(row.date)
|
||
total_q = row.total_queries or 0
|
||
cited_c = row.cited_count or 0
|
||
|
||
# Calculate daily score using V2 formula
|
||
mention_rate = (cited_c / total_q * 100) if total_q > 0 else 0.0
|
||
# V2 mention rate score (out of 25)
|
||
v2_mention = (cited_c / total_q * 25) if total_q > 0 else 0.0
|
||
# Approximate overall as mention_rate * 0.3 (simplified for history)
|
||
overall = round(v2_mention, 2)
|
||
|
||
history.append(BrandScoreHistoryItem(
|
||
date=date_str,
|
||
mention_rate_score=round(mention_rate, 2),
|
||
sov_score=0.0,
|
||
quality_score=0.0,
|
||
overall_score=round(mention_rate * 0.3, 2),
|
||
total_queries=total_q,
|
||
cited_count=cited_c,
|
||
))
|
||
|
||
return BrandScoreHistoryResponse(history=history, total=total)
|
||
|
||
|
||
@router.get("/{brand_id}/compare/", response_model=CompareResponse)
|
||
async def get_brand_comparison(
|
||
brand_id: uuid.UUID,
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""
|
||
获取品牌与竞品对比数据(增强版)
|
||
|
||
返回品牌及其所有竞品的评分对比,包括:
|
||
- 综合评分 + 五维度评分(提及率、推荐排名、情感倾向、引用质量、竞品对比)
|
||
- 各项评分趋势变化(与上期对比)
|
||
- 雷达图数据
|
||
- 引用次数
|
||
"""
|
||
brand = await _get_brand_with_access(brand_id, db, current_user)
|
||
|
||
# Get brand's own score
|
||
total_queries, brand_citations, competitor_citations, competitor_mentions = (
|
||
await _get_citations_for_brand(db, _to_uuid(current_user.id), brand_id)
|
||
)
|
||
|
||
scoring_service = ScoringService()
|
||
|
||
# Calculate brand's scores
|
||
mention_rate_score = scoring_service.calculate_mention_rate_score(
|
||
len(brand_citations), total_queries
|
||
)
|
||
sov_score = scoring_service.calculate_sov_score(
|
||
len(brand_citations),
|
||
len(brand_citations) + competitor_citations
|
||
)
|
||
citation_results = [
|
||
CitationResult(
|
||
cited=c.cited,
|
||
position=c.citation_position,
|
||
citation_text=c.citation_text,
|
||
sentiment=_get_sentiment_from_counts(c, {}),
|
||
confidence=c.confidence or 0.0,
|
||
)
|
||
for c in brand_citations
|
||
]
|
||
quality_score = scoring_service.calculate_quality_score(citation_results)
|
||
|
||
# Use V2 overall score
|
||
sentiment_counts = await _analyze_sentiments_for_citations(
|
||
brand_name=brand.name,
|
||
brand_citations=brand_citations,
|
||
)
|
||
positions = [c.citation_position for c in brand_citations if c.cited]
|
||
v2_result = scoring_service.calculate_v2(
|
||
mentioned_count=len(brand_citations),
|
||
total_queries=total_queries,
|
||
positions=positions,
|
||
sentiment_counts=sentiment_counts,
|
||
citations=citation_results,
|
||
brand_mentions=len(brand_citations),
|
||
competitor_mentions=competitor_mentions,
|
||
)
|
||
|
||
# Calculate brand trend (compare with yesterday)
|
||
brand_trend, brand_trend_value = await _calculate_trend(
|
||
db, current_user.id, brand, v2_result.overall_score
|
||
)
|
||
|
||
# Build brand dimension compare items
|
||
brand_dimensions = _build_dimension_compare_items(v2_result, brand_trend)
|
||
|
||
# Build compare items list with brand first
|
||
items: list[CompareItem] = [
|
||
CompareItem(
|
||
entity_id=str(brand.id),
|
||
entity_name=brand.name,
|
||
entity_type="brand",
|
||
mention_rate_score=round(mention_rate_score, 2),
|
||
sov_score=round(sov_score, 2),
|
||
quality_score=round(quality_score, 2),
|
||
overall_score=round(v2_result.overall_score, 2),
|
||
citation_count=len(brand_citations),
|
||
dimensions=brand_dimensions,
|
||
overall_trend=brand_trend,
|
||
overall_trend_value=brand_trend_value,
|
||
)
|
||
]
|
||
|
||
# Collect all V2 results for radar data
|
||
all_v2_results = {brand.name: v2_result}
|
||
|
||
# Get competitors and calculate their scores
|
||
competitor_stmt = select(Competitor).where(Competitor.brand_id == brand_id)
|
||
competitor_result = await db.execute(competitor_stmt)
|
||
competitors = list(competitor_result.scalars().all())
|
||
|
||
competitor_names = [c.name for c in competitors]
|
||
|
||
# Find queries that match competitors
|
||
if competitor_names:
|
||
competitor_queries_stmt = select(QueryModel).where(
|
||
QueryModel.user_id == current_user.id,
|
||
QueryModel.target_brand.in_(competitor_names),
|
||
)
|
||
competitor_queries_result = await db.execute(competitor_queries_stmt)
|
||
competitor_queries = list(competitor_queries_result.scalars().all())
|
||
|
||
competitor_query_ids = [q.id for q in competitor_queries]
|
||
|
||
if competitor_query_ids:
|
||
# Get citations for competitor queries
|
||
competitor_citations_stmt = select(CitationRecord).where(
|
||
CitationRecord.query_id.in_(competitor_query_ids)
|
||
)
|
||
competitor_citations_result = await db.execute(competitor_citations_stmt)
|
||
all_competitor_citations = list(competitor_citations_result.scalars().all())
|
||
|
||
# Group citations by competitor
|
||
for competitor in competitors:
|
||
comp_citations = [
|
||
c for c in all_competitor_citations
|
||
if c.cited and c.competitor_brands
|
||
and any(comp in competitor.name for comp in c.competitor_brands)
|
||
]
|
||
|
||
comp_total = len([q for q in competitor_queries if q.target_brand == competitor.name])
|
||
comp_mentions = len(comp_citations)
|
||
|
||
comp_mention_rate = scoring_service.calculate_mention_rate_score(
|
||
comp_mentions, comp_total if comp_total > 0 else 1
|
||
)
|
||
|
||
comp_sov = scoring_service.calculate_sov_score(
|
||
comp_mentions,
|
||
comp_mentions + 1
|
||
)
|
||
|
||
comp_citation_results = [
|
||
CitationResult(
|
||
cited=c.cited,
|
||
position=c.citation_position,
|
||
citation_text=c.citation_text,
|
||
sentiment=_get_sentiment_from_counts(c, {}),
|
||
confidence=c.confidence or 0.0,
|
||
)
|
||
for c in comp_citations
|
||
]
|
||
comp_quality = scoring_service.calculate_quality_score(
|
||
comp_citation_results
|
||
) if comp_citations else 0.0
|
||
|
||
# V2 score for competitor
|
||
comp_sentiment_counts = await _analyze_sentiments_for_citations(
|
||
brand_name=competitor.name,
|
||
brand_citations=comp_citations,
|
||
)
|
||
comp_positions = [c.citation_position for c in comp_citations if c.cited]
|
||
comp_v2 = scoring_service.calculate_v2(
|
||
mentioned_count=comp_mentions,
|
||
total_queries=comp_total if comp_total > 0 else 1,
|
||
positions=comp_positions,
|
||
sentiment_counts=comp_sentiment_counts,
|
||
citations=comp_citation_results,
|
||
brand_mentions=comp_mentions,
|
||
competitor_mentions={},
|
||
)
|
||
|
||
# Calculate competitor trend
|
||
comp_trend, comp_trend_value = await _calculate_trend_for_competitor(
|
||
db, current_user.id, competitor.name, comp_v2.overall_score
|
||
)
|
||
|
||
# Build competitor dimension compare items
|
||
comp_dimensions = _build_dimension_compare_items(comp_v2, comp_trend)
|
||
|
||
all_v2_results[competitor.name] = comp_v2
|
||
|
||
items.append(CompareItem(
|
||
entity_id=str(competitor.id),
|
||
entity_name=competitor.name,
|
||
entity_type="competitor",
|
||
mention_rate_score=round(comp_mention_rate, 2),
|
||
sov_score=round(comp_sov, 2),
|
||
quality_score=round(comp_quality, 2),
|
||
overall_score=round(comp_v2.overall_score, 2),
|
||
citation_count=comp_mentions,
|
||
dimensions=comp_dimensions,
|
||
overall_trend=comp_trend,
|
||
overall_trend_value=comp_trend_value,
|
||
))
|
||
|
||
# Build radar data for chart
|
||
radar_data = _build_radar_data(all_v2_results)
|
||
|
||
return CompareResponse(
|
||
brand_id=str(brand.id),
|
||
brand_name=brand.name,
|
||
items=items,
|
||
radar_data=radar_data,
|
||
)
|