"""Dashboard API endpoints.""" import uuid from datetime import datetime, timedelta from fastapi import APIRouter, Depends, Query from sqlalchemy import select, func, Integer from sqlalchemy.ext.asyncio import AsyncSession 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.dashboard import ( DashboardStatsResponse, DimensionScoreItem, PlatformScoreItem, RecentQueryItem, ) from app.services.scoring_service import ScoringService, get_health_level from app.services.sentiment_service import get_sentiment_service from app.schemas.scoring import CitationResult from app.services.cache import get_cache_service, TTL_DASHBOARD router = APIRouter() # Required platforms for the dashboard REQUIRED_PLATFORMS = [ "wenxin", # 文心一言 "kimi", # Kimi "tongyi", # 通义千问 "doubao", # 豆包 "xinghuo", # 讯飞星火 "tiangong", # 天工AI "qingyan", # 智谱清言 ] async def _get_brand_score_by_platform( db: AsyncSession, user_id: uuid.UUID, brand_id: uuid.UUID, ) -> dict[str, float]: """ Calculate brand score by platform. Returns a dict mapping platform key to score (0-100). """ 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 {platform: 0.0 for platform in REQUIRED_PLATFORMS} 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 {platform: 0.0 for platform in REQUIRED_PLATFORMS} query_ids = [q.id for q in queries] platform_scores = {platform: 0.0 for platform in REQUIRED_PLATFORMS} for platform in REQUIRED_PLATFORMS: citation_stmt = select( func.count().label("total"), func.sum( func.cast( func.case((CitationRecord.cited == True, 1), else_=0), Integer ) ).label("cited") ).where( CitationRecord.query_id.in_(query_ids), CitationRecord.platform == platform, ) result = await db.execute(citation_stmt) row = result.one() total = row.total or 0 cited = row.cited or 0 if total > 0: citation_rate = cited / total platform_scores[platform] = round(citation_rate * 100, 2) else: platform_scores[platform] = 0.0 return platform_scores async def _get_competitor_scores_by_platform( db: AsyncSession, user_id: uuid.UUID, brand_id: uuid.UUID, ) -> dict[str, float]: """ Calculate average competitor score by platform. Returns a dict mapping platform key to average competitor score (0-100). """ competitor_stmt = select(Competitor).where(Competitor.brand_id == brand_id) competitor_result = await db.execute(competitor_stmt) competitors = list(competitor_result.scalars().all()) if not competitors: return {platform: 0.0 for platform in REQUIRED_PLATFORMS} competitor_names = [c.name for c in competitors] competitor_queries_stmt = select(QueryModel).where( QueryModel.user_id == 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()) if not competitor_queries: return {platform: 0.0 for platform in REQUIRED_PLATFORMS} competitor_query_ids = [q.id for q in competitor_queries] platform_scores = {platform: 0.0 for platform in REQUIRED_PLATFORMS} for platform in REQUIRED_PLATFORMS: citation_stmt = select( func.count().label("total"), func.sum( func.cast( func.case((CitationRecord.cited == True, 1), else_=0), Integer ) ).label("cited") ).where( CitationRecord.query_id.in_(competitor_query_ids), CitationRecord.platform == platform, ) result = await db.execute(citation_stmt) row = result.one() total = row.total or 0 cited = row.cited or 0 if total > 0: citation_rate = cited / total platform_scores[platform] = round(citation_rate * 100, 2) else: platform_scores[platform] = 0.0 return platform_scores async def _calculate_overall_score_v2( db: AsyncSession, user_id: uuid.UUID, brand_id: uuid.UUID, ) -> tuple[float, float, list[DimensionScoreItem], int, int]: """ Calculate V2 overall score, change from yesterday, dimension scores, and competitor position. Returns: (overall_score, change_from_yesterday, dimensions, ahead_count, behind_count) """ 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, 0.0, [], 0, 0 # 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 0.0, 0.0, [], 0, 0 query_ids = [q.id for q in queries] # Get all citations citations_stmt = select(CitationRecord).where( CitationRecord.query_id.in_(query_ids), ) citations_result = await db.execute(citations_stmt) all_citations = list(citations_result.scalars().all()) total_queries = len(all_citations) brand_citations = [c for c in all_citations if c.cited] # Get competitor data 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] # Calculate competitor mentions competitor_mentions: dict[str, int] = {} for comp_name in competitor_names: count = sum( 1 for c in all_citations if c.cited and c.competitor_brands and comp_name in c.competitor_brands ) if count > 0: competitor_mentions[comp_name] = count # Sentiment analysis - prefer persisted sentiment field sentiment_service = get_sentiment_service() sentiment_counts = {"positive": 0, "neutral": 0, "negative": 0} for citation in brand_citations: if citation.sentiment and citation.sentiment in ("positive", "neutral", "negative"): sentiment_counts[citation.sentiment] += 1 else: 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 # Build citation results citation_results = [ CitationResult( cited=c.cited, position=c.citation_position, citation_text=c.citation_text, sentiment="neutral", # simplified for dashboard confidence=c.confidence or 0.0, ) for c in brand_citations ] # Extract positions positions = [c.citation_position for c in brand_citations if c.cited] # Calculate V2 score 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, ) # Build dimension items dimensions = [ DimensionScoreItem( name=v2_result.mention_rate.name, score=round(v2_result.mention_rate.score, 2), max_score=v2_result.mention_rate.max_score, percentage=round(v2_result.mention_rate.percentage, 2), ), DimensionScoreItem( name=v2_result.recommendation_rank.name, score=round(v2_result.recommendation_rank.score, 2), max_score=v2_result.recommendation_rank.max_score, percentage=round(v2_result.recommendation_rank.percentage, 2), ), DimensionScoreItem( name=v2_result.sentiment_score.name, score=round(v2_result.sentiment_score.score, 2), max_score=v2_result.sentiment_score.max_score, percentage=round(v2_result.sentiment_score.percentage, 2), ), DimensionScoreItem( name=v2_result.citation_quality.name, score=round(v2_result.citation_quality.score, 2), max_score=v2_result.citation_quality.max_score, percentage=round(v2_result.citation_quality.percentage, 2), ), DimensionScoreItem( name=v2_result.competitive_position.name, score=round(v2_result.competitive_position.score, 2), max_score=v2_result.competitive_position.max_score, percentage=round(v2_result.competitive_position.percentage, 2), ), ] # Calculate competitor ahead/behind ahead_count = sum( 1 for count in competitor_mentions.values() if len(brand_citations) > count ) behind_count = sum( 1 for count in competitor_mentions.values() if len(brand_citations) <= count ) # Calculate change from yesterday today = datetime.now().date() yesterday = today - timedelta(days=1) today_citations = [ c for c in all_citations if c.queried_at.date() == today ] yesterday_citations = [ c for c in all_citations if c.queried_at.date() == yesterday ] today_cited = sum(1 for c in today_citations if c.cited) today_total = len(today_citations) today_score = (today_cited / today_total * 100) if today_total > 0 else 0.0 yesterday_cited = sum(1 for c in yesterday_citations if c.cited) yesterday_total = len(yesterday_citations) yesterday_score = (yesterday_cited / yesterday_total * 100) if yesterday_total > 0 else 0.0 change = round(today_score - yesterday_score, 2) return v2_result.overall_score, change, dimensions, ahead_count, behind_count @router.get("/stats", response_model=DashboardStatsResponse) async def get_dashboard_stats( brand_id: uuid.UUID | None = Query(None), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """ 获取看板统计数据 包括: - 综合评分(V2)和较昨日变化 - 健康等级 - 五维度评分详情 - 各平台评分列表(含竞品对比) - 竞品地位(领先/落后数量) - 最近查询记录 """ cache = get_cache_service() # 如果 brand_id 尚未确定,先查库取第一个品牌 if brand_id is None: brand_stmt = select(Brand).where(Brand.user_id == current_user.id).limit(1) brand_result = await db.execute(brand_stmt) brand = brand_result.scalar_one_or_none() if brand: brand_id = brand.id else: return DashboardStatsResponse( overall_score=0.0, health_level="danger", score_change=0.0, platform_scores=[], recent_queries=[], dimensions=[], competitors_ahead=0, competitors_behind=0, monitored_platforms=0, total_platforms=7, ) # 尝试从缓存读取(TTL: 2 分钟) cache_key = f"dashboard:stats:{current_user.id}:{brand_id}" cached = await cache.get_json(cache_key) if cached is not None: return cached # Get brand name brand_stmt = select(Brand).where(Brand.id == brand_id) brand_result = await db.execute(brand_stmt) brand = brand_result.scalar_one_or_none() brand_name = brand.name if brand else None # Calculate V2 overall score and dimensions overall_score, score_change, dimensions, ahead_count, behind_count = ( await _calculate_overall_score_v2(db, current_user.id, brand_id) ) # Get platform scores platform_scores_dict = await _get_brand_score_by_platform( db, current_user.id, brand_id ) # Get competitor platform scores competitor_scores_dict = await _get_competitor_scores_by_platform( db, current_user.id, brand_id ) # Get first competitor name for display competitor_stmt = select(Competitor).where( Competitor.brand_id == brand_id ).limit(1) competitor_result = await db.execute(competitor_stmt) first_competitor = competitor_result.scalar_one_or_none() competitor_name = first_competitor.name if first_competitor else None platform_scores = [ PlatformScoreItem( platform=platform, score=score, competitor_score=competitor_scores_dict.get(platform), competitor_name=competitor_name if competitor_scores_dict.get(platform, 0) > 0 else None, ) for platform, score in platform_scores_dict.items() ] # Count monitored platforms monitored = sum(1 for s in platform_scores_dict.values() if s > 0) # Get recent queries (last 10) recent_queries_stmt = ( select(QueryModel) .where(QueryModel.user_id == current_user.id) .order_by(QueryModel.created_at.desc()) .limit(10) ) recent_queries_result = await db.execute(recent_queries_stmt) recent_queries_list = list(recent_queries_result.scalars().all()) # Get citation count for each query recent_queries = [] for query in recent_queries_list: citation_count_stmt = select( func.count().label("total"), func.sum( func.cast( func.case((CitationRecord.cited == True, 1), else_=0), Integer ) ).label("cited") ).where(CitationRecord.query_id == query.id) count_result = await db.execute(citation_count_stmt) count_row = count_result.one() recent_queries.append(RecentQueryItem( id=str(query.id), keyword=query.keyword, target_brand=query.target_brand, citation_count=count_row.cited or 0, queried_at=query.last_queried_at or query.created_at, )) # Health level health_level = get_health_level(overall_score) response = DashboardStatsResponse( overall_score=round(overall_score, 2), health_level=health_level, score_change=score_change, platform_scores=platform_scores, recent_queries=recent_queries, dimensions=dimensions, competitors_ahead=ahead_count, competitors_behind=behind_count, monitored_platforms=monitored, total_platforms=7, brand_name=brand_name, ) # 将结果写入缓存(TTL: 2 分钟) await cache.set_json( cache_key, response.model_dump(mode="json"), expire=TTL_DASHBOARD, ) return response