geo/backend/app/services/monitoring/monitor_service.py

363 lines
13 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.

import logging
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.monitoring import MonitoringRecord, ContentBaseline
from app.models.query import Query
from app.models.citation_record import CitationRecord
from app.models.brand import Brand
logger = logging.getLogger(__name__)
class MonitorService:
async def create_monitoring_record(
self,
db: AsyncSession,
brand_id: uuid.UUID,
content_id: str | None = None,
query_keywords: str | None = None,
platform: str | None = None,
check_interval_hours: int = 24,
) -> MonitoringRecord:
now = datetime.now(timezone.utc)
stmt = select(Brand).where(Brand.id == brand_id)
result = await db.execute(stmt)
brand = result.scalar_one_or_none()
brand_name = brand.name if brand else ""
baseline_data = await self._get_current_metrics(db, brand_id, query_keywords, platform)
record = MonitoringRecord(
brand_id=brand_id,
content_id=content_id,
query_keywords=query_keywords,
platform=platform,
baseline_citation_count=baseline_data.get("citation_count", 0),
baseline_sentiment=baseline_data.get("positive_ratio"),
baseline_rank=baseline_data.get("avg_rank"),
current_citation_count=baseline_data.get("citation_count", 0),
current_sentiment=baseline_data.get("positive_ratio"),
current_rank=baseline_data.get("avg_rank"),
change_type="neutral",
change_details=None,
check_interval_hours=check_interval_hours,
last_checked_at=now,
next_check_at=now + timedelta(hours=check_interval_hours),
status="active",
)
db.add(record)
await db.flush()
await self._create_baseline_snapshot(
db=db,
record_id=record.id,
brand_name=brand_name,
query_keywords=query_keywords,
platform=platform,
metrics=baseline_data,
)
await db.commit()
await db.refresh(record)
return record
async def _create_baseline_snapshot(
self,
db: AsyncSession,
record_id: uuid.UUID,
brand_name: str,
query_keywords: str | None,
platform: str | None,
metrics: dict,
) -> ContentBaseline:
baseline = ContentBaseline(
monitoring_record_id=record_id,
brand_name=brand_name,
keyword=query_keywords or "",
platform=platform or "",
citation_count=metrics.get("citation_count", 0),
sentiment_score=metrics.get("positive_ratio"),
rank_position=metrics.get("avg_rank"),
snapshot_data=metrics,
)
db.add(baseline)
await db.flush()
return baseline
async def get_brand_monitoring(
self,
db: AsyncSession,
brand_id: uuid.UUID,
skip: int = 0,
limit: int = 20,
) -> tuple[list[MonitoringRecord], int]:
count_stmt = select(func.count()).select_from(MonitoringRecord).where(
MonitoringRecord.brand_id == brand_id,
)
count_result = await db.execute(count_stmt)
total = count_result.scalar_one()
stmt = (
select(MonitoringRecord)
.where(MonitoringRecord.brand_id == brand_id)
.order_by(MonitoringRecord.created_at.desc())
.offset(skip)
.limit(limit)
)
result = await db.execute(stmt)
records = list(result.scalars().all())
return records, total
async def check_and_compare(
self,
db: AsyncSession,
record_id: uuid.UUID,
) -> MonitoringRecord | None:
stmt = select(MonitoringRecord).where(MonitoringRecord.id == record_id)
result = await db.execute(stmt)
record = result.scalar_one_or_none()
if not record:
return None
current_data = await self._get_current_metrics(
db, record.brand_id, record.query_keywords, record.platform,
)
record.current_citation_count = current_data.get("citation_count", 0)
record.current_sentiment = current_data.get("positive_ratio")
record.current_rank = current_data.get("avg_rank")
change_type = self.determine_change_type(
baseline_citation=record.baseline_citation_count,
current_citation=record.current_citation_count,
baseline_sentiment=record.baseline_sentiment,
current_sentiment=record.current_sentiment,
baseline_rank=record.baseline_rank,
current_rank=record.current_rank,
)
record.change_type = change_type
change_details = self._build_change_details(record, current_data)
record.change_details = change_details
now = datetime.now(timezone.utc)
record.last_checked_at = now
record.next_check_at = now + timedelta(hours=record.check_interval_hours)
await db.commit()
await db.refresh(record)
return record
def determine_change_type(
self,
baseline_citation: int,
current_citation: int,
baseline_sentiment: float | None = None,
current_sentiment: float | None = None,
baseline_rank: int | None = None,
current_rank: int | None = None,
) -> str:
positive_signals = 0
negative_signals = 0
if current_citation > baseline_citation:
positive_signals += 1
elif current_citation < baseline_citation:
negative_signals += 1
if baseline_sentiment is not None and current_sentiment is not None:
if current_sentiment > baseline_sentiment:
positive_signals += 1
elif current_sentiment < baseline_sentiment:
negative_signals += 1
if baseline_rank is not None and current_rank is not None:
if current_rank < baseline_rank:
positive_signals += 1
elif current_rank > baseline_rank:
negative_signals += 1
if positive_signals > negative_signals:
return "positive"
elif negative_signals > positive_signals:
return "negative"
return "neutral"
def _build_change_details(self, record: MonitoringRecord, current_data: dict) -> dict:
details = {
"citation_change": {
"baseline": record.baseline_citation_count,
"current": record.current_citation_count,
"delta": record.current_citation_count - record.baseline_citation_count,
},
}
if record.baseline_sentiment is not None and record.current_sentiment is not None:
details["sentiment_change"] = {
"baseline": record.baseline_sentiment,
"current": record.current_sentiment,
"delta": round(record.current_sentiment - record.baseline_sentiment, 4),
}
if record.baseline_rank is not None and record.current_rank is not None:
details["rank_change"] = {
"baseline": record.baseline_rank,
"current": record.current_rank,
"delta": record.current_rank - record.baseline_rank,
}
details["platform_data"] = current_data.get("platform_data", {})
details["checked_at"] = datetime.now(timezone.utc).isoformat()
return details
async def generate_change_report(
self,
db: AsyncSession,
record_id: uuid.UUID,
) -> dict | None:
stmt = select(MonitoringRecord).where(MonitoringRecord.id == record_id)
result = await db.execute(stmt)
record = result.scalar_one_or_none()
if not record:
return None
recommendations = self._generate_recommendations(record)
baseline = {
"citation_count": record.baseline_citation_count,
"sentiment": record.baseline_sentiment,
"rank": record.baseline_rank,
}
current = {
"citation_count": record.current_citation_count,
"sentiment": record.current_sentiment,
"rank": record.current_rank,
}
return {
"monitoring_record_id": str(record.id),
"brand_id": str(record.brand_id),
"change_type": record.change_type,
"change_details": record.change_details,
"baseline": baseline,
"current": current,
"recommendations": recommendations,
}
def _generate_recommendations(self, record: MonitoringRecord) -> list[str]:
recommendations = []
if record.change_type == "negative":
if record.current_citation_count < record.baseline_citation_count:
recommendations.append("引用量下降建议增加高质量内容发布频率提升品牌在AI搜索引擎中的曝光")
if record.current_sentiment is not None and record.baseline_sentiment is not None:
if record.current_sentiment < record.baseline_sentiment:
recommendations.append("情感倾向下降,建议关注负面评价并优化品牌形象内容")
if record.current_rank is not None and record.baseline_rank is not None:
if record.current_rank > record.baseline_rank:
recommendations.append("排名下降建议优化GEO策略提升内容在AI搜索中的引用优先级")
elif record.change_type == "positive":
if record.current_citation_count > record.baseline_citation_count:
recommendations.append("引用量上升,建议继续保持当前内容策略")
if record.current_sentiment is not None and record.baseline_sentiment is not None:
if record.current_sentiment > record.baseline_sentiment:
recommendations.append("情感倾向改善,当前品牌内容策略效果良好")
else:
recommendations.append("各项指标保持稳定,建议持续监测")
return recommendations
async def _get_current_metrics(
self,
db: AsyncSession,
brand_id: uuid.UUID,
query_keywords: str | None = None,
platform: str | None = None,
) -> dict:
stmt = select(Brand).where(Brand.id == brand_id)
result = await db.execute(stmt)
brand = result.scalar_one_or_none()
if not brand:
return {
"citation_count": 0,
"positive_ratio": 0.0,
"avg_rank": 0,
"platform_data": {},
}
conditions = [Query.target_brand == brand.name]
if query_keywords:
conditions.append(Query.keyword.contains(query_keywords))
queries_stmt = select(Query).where(*conditions)
queries_result = await db.execute(queries_stmt)
queries = list(queries_result.scalars().all())
if not queries:
return {
"citation_count": 0,
"positive_ratio": 0.0,
"avg_rank": 0,
"platform_data": {},
}
query_ids = [q.id for q in queries]
citation_conditions = [CitationRecord.query_id.in_(query_ids)]
if platform:
citation_conditions.append(CitationRecord.platform == platform)
citations_stmt = select(CitationRecord).where(*citation_conditions)
citations_result = await db.execute(citations_stmt)
all_citations = list(citations_result.scalars().all())
brand_citations = [c for c in all_citations if c.cited]
citation_count = len(brand_citations)
sentiment_counts = {"positive": 0, "neutral": 0, "negative": 0}
for citation in brand_citations:
if citation.sentiment and citation.sentiment in sentiment_counts:
sentiment_counts[citation.sentiment] += 1
else:
sentiment_counts["neutral"] += 1
total_with_sentiment = sum(sentiment_counts.values())
positive_ratio = (
sentiment_counts["positive"] / total_with_sentiment
if total_with_sentiment > 0
else 0.0
)
positions = [c.citation_position for c in brand_citations if c.citation_position is not None]
avg_rank = int(sum(positions) / len(positions)) if positions else 0
platform_data = {}
for citation in all_citations:
p = citation.platform
if p not in platform_data:
platform_data[p] = {"total": 0, "cited": 0}
platform_data[p]["total"] += 1
if citation.cited:
platform_data[p]["cited"] += 1
platform_scores = {}
for p, data in platform_data.items():
platform_scores[p] = round(
(data["cited"] / data["total"] * 100) if data["total"] > 0 else 0.0, 2
)
return {
"citation_count": citation_count,
"positive_ratio": round(positive_ratio, 4),
"avg_rank": avg_rank,
"platform_data": platform_scores,
}