"""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() 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 == 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, 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=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, 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, 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, )