geo/backend/app/api/scoring.py

867 lines
29 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.

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