import difflib import logging import re import uuid from datetime import datetime, timedelta from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select def _sanitize_raw_response(text: str | None) -> str: """清理原始响应中的无效控制字符,避免 PostgreSQL UTF-8 插入失败""" if not text: return "" # 移除 NULL 字节及其他非法控制字符,保留 \n \t \r return re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", text) from app.models.citation_record import CitationRecord from app.models.query import Query from app.models.query_task import QueryTask from app.workers.platforms.kimi import KimiAdapter from app.workers.platforms.wenxin import WenxinAdapter from app.workers.platforms.tongyi import TongyiAdapter from app.workers.platforms.doubao import DoubaoAdapter from app.workers.platforms.qingyan import QingyanAdapter from app.workers.platforms.tiangong import TiangongAdapter from app.workers.platforms.xinghuo import XinghuoAdapter from app.workers.citation_extractor import analyze_citations logger = logging.getLogger(__name__) class BrandMatcher: """品牌匹配器:检测文本中是否引用了目标品牌""" def __init__(self, target_brand: str, brand_aliases: list[str] | None = None): self.target_brand = target_brand self.brand_aliases = brand_aliases or [] def match(self, text: str) -> dict: """ 返回: { "cited": bool, "confidence": float, # 0.0-1.0 "match_type": str, # "exact"/"alias"/"fuzzy"/None "position": int|None, # 在文本段落中的位置(第几段提到,1-based) "citation_text": str|None, # 被引用的上下文片段 } """ if not text: return { "cited": False, "confidence": 0.0, "match_type": None, "position": None, "citation_text": None, } # 1. 精确匹配 if self.target_brand in text: position, citation_text = self._extract_position_and_context(text, self.target_brand) return { "cited": True, "confidence": 1.0, "match_type": "exact", "position": position, "citation_text": citation_text, } # 2. 别名匹配 for alias in self.brand_aliases: if alias in text: position, citation_text = self._extract_position_and_context(text, alias) return { "cited": True, "confidence": 0.9, "match_type": "alias", "position": position, "citation_text": citation_text, } # 3. 模糊匹配 best_ratio = 0.0 best_match = None for word in self._extract_candidates(text): ratio = difflib.SequenceMatcher(None, self.target_brand, word).ratio() if ratio > best_ratio: best_ratio = ratio best_match = word for alias in self.brand_aliases: for word in self._extract_candidates(text): ratio = difflib.SequenceMatcher(None, alias, word).ratio() if ratio > best_ratio: best_ratio = ratio best_match = word if best_ratio > 0.4 and best_match: position, citation_text = self._extract_position_and_context(text, best_match) return { "cited": True, "confidence": round(best_ratio, 2), "match_type": "fuzzy", "position": position, "citation_text": citation_text, } return { "cited": False, "confidence": 0.0, "match_type": None, "position": None, "citation_text": None, } def _extract_candidates(self, text: str) -> list[str]: """从文本中提取候选词(按非文字字符分割)""" # 匹配中文词组、英文单词等 return [w for w in re.split(r'[^\w\u4e00-\u9fff]+', text) if len(w) >= 2] def _extract_position_and_context(self, text: str, keyword: str) -> tuple[int | None, str | None]: """提取品牌首次出现的段落位置(1-based)和上下文片段""" paragraphs = [p.strip() for p in text.split('\n') if p.strip()] if not paragraphs: paragraphs = [text] for idx, paragraph in enumerate(paragraphs, start=1): if keyword in paragraph: # 截取前200字符 snippet = paragraph[:200] return idx, snippet return None, None class CompetitorDetector: """竞争品牌检测器""" # 预定义一些常见行业品牌列表 KNOWN_BRANDS = { "保险": ["中国平安", "中国人寿", "太平洋保险", "新华保险", "泰康保险", "中国人保", "友邦保险"], "金融": ["工商银行", "建设银行", "农业银行", "中国银行", "招商银行", "交通银行"], "科技": ["华为", "腾讯", "阿里巴巴", "百度", "字节跳动", "小米", "京东"], } def detect(self, text: str, target_brand: str) -> list[str]: """检测文本中出现的其他品牌(排除 target_brand)""" if not text: return [] competitors = set() for category, brands in self.KNOWN_BRANDS.items(): for brand in brands: if brand == target_brand: continue if brand in text: competitors.add(brand) return sorted(list(competitors)) class CitationEngine: """引用检测引擎核心""" def __init__(self): self.platforms = { "wenxin": WenxinAdapter(), "kimi": KimiAdapter(), "tongyi": TongyiAdapter(), "doubao": DoubaoAdapter(), "qingyan": QingyanAdapter(), "tiangong": TiangongAdapter(), "xinghuo": XinghuoAdapter(), } self.matcher = None self.competitor_detector = CompetitorDetector() async def execute_query(self, query: Query, db: AsyncSession) -> list[CitationRecord]: """ 执行一个查询任务: 1. 创建 BrandMatcher 2. 遍历 query.platforms 3. 对每个 platform 执行查询和检测 4. 更新 query.last_queried_at 和 query.next_query_at """ self.matcher = BrandMatcher( target_brand=query.target_brand, brand_aliases=query.brand_aliases or [], ) records: list[CitationRecord] = [] platforms = query.platforms or ["wenxin", "kimi"] for platform_name in platforms: # 查找或创建 QueryTask task = await self._get_or_create_task(db, query.id, platform_name) # 更新状态为 running task.status = "running" task.started_at = datetime.utcnow() task.error_message = None await db.commit() try: result = await self.execute_single_platform( keyword=query.keyword, platform=platform_name, target_brand=query.target_brand, brand_aliases=query.brand_aliases or [], ) # 创建 CitationRecord record = CitationRecord( query_id=query.id, platform=platform_name, cited=result["cited"], citation_position=result.get("position"), citation_text=result.get("citation_text"), competitor_brands=result.get("competitor_brands", []), raw_response=_sanitize_raw_response(result.get("raw_response", "")), confidence=result.get("confidence"), match_type=result.get("match_type"), # 引用源分析字段 data_source=result.get("data_source"), source_urls=result.get("source_urls"), source_titles=result.get("source_titles"), citation_contexts=result.get("citation_contexts"), ai_response_text=_sanitize_raw_response(result.get("ai_response_text", "")), ) db.add(record) records.append(record) # 更新 QueryTask 状态为 success task.status = "success" task.completed_at = datetime.utcnow() await db.commit() except Exception as e: logger.error(f"平台 {platform_name} 查询失败: {e}") error_msg = str(e) task.status = "failed" task.error_message = error_msg task.completed_at = datetime.utcnow() # 创建一条 cited=False 的记录作为占位 record = CitationRecord( query_id=query.id, platform=platform_name, cited=False, raw_response=_sanitize_raw_response(error_msg), ) db.add(record) records.append(record) await db.commit() # 更新 Query 时间字段 query.last_queried_at = datetime.utcnow() query.next_query_at = self._calculate_next_query_at(query.frequency) await db.commit() return records async def execute_single_platform( self, keyword: str, platform: str, target_brand: str, brand_aliases: list, ) -> dict: """执行单个平台的查询和检测""" adapter = self.platforms.get(platform) if not adapter: raise ValueError(f"不支持的平台: {platform}") # 获取平台内容(将关键词与目标品牌组合,确保结果包含品牌信息) search_keyword = f"{keyword} {target_brand}" raw_response = await adapter.query(search_keyword) # 引用源分析 citation_analysis = analyze_citations(raw_response) # 品牌匹配(使用清理后的纯文本进行匹配) matcher = BrandMatcher(target_brand=target_brand, brand_aliases=brand_aliases) match_result = matcher.match(citation_analysis.clean_response) # 竞争品牌检测 competitor_brands = self.competitor_detector.detect( citation_analysis.clean_response, target_brand ) # 提取引用源信息 source_urls = [ c.source_url for c in citation_analysis.citations if c.source_url ] source_titles = [ c.source_title for c in citation_analysis.citations if c.source_title ] citation_contexts = [ c.citation_context for c in citation_analysis.citations if c.citation_context ] return { "cited": match_result["cited"], "confidence": match_result["confidence"], "match_type": match_result["match_type"], "position": match_result["position"], "citation_text": match_result["citation_text"], "competitor_brands": competitor_brands, "raw_response": raw_response, # 引用源分析新增字段 "data_source": citation_analysis.data_source, "source_urls": source_urls, "source_titles": source_titles, "citation_contexts": citation_contexts, "ai_response_text": citation_analysis.clean_response, } async def _get_or_create_task( self, db: AsyncSession, query_id: uuid.UUID, platform: str ) -> QueryTask: """获取或创建 QueryTask""" stmt = select(QueryTask).where( QueryTask.query_id == query_id, QueryTask.platform == platform, ) result = await db.execute(stmt) task = result.scalar_one_or_none() if not task: task = QueryTask( query_id=query_id, platform=platform, status="pending", ) db.add(task) await db.commit() await db.refresh(task) return task def _calculate_next_query_at(self, frequency: str | None) -> datetime: """根据频率计算下次查询时间""" now = datetime.utcnow() freq_map = { "daily": timedelta(days=1), "weekly": timedelta(days=7), "monthly": timedelta(days=30), } delta = freq_map.get(frequency or "weekly", timedelta(days=7)) return now + delta async def close(self): """关闭所有平台适配器""" for adapter in self.platforms.values(): try: await adapter.close() except Exception as e: logger.warning(f"关闭适配器 {adapter.platform_name} 时出错: {e}")