"""结果融合器 (MVP-3 / D30) 将 YOLO 候选事件 (含规则引擎产出的 ``AlertEvent``) 与 LLM 分析结果 按指定策略融合,产出最终对外发出的 ``AlertEvent``。 策略说明 -------- 1. ``weighted`` (加权平均): final_conf = yolo_weight * yolo_conf + llm_weight * llm_conf - LLM 明确否决时 (confirmed=False) 同样适用,但置信度向 0 收敛 - LLM 不确定 (confirmed=None) 时,若 ``fallback_to_yolo`` 为 True 直接保留 YOLO 结果 2. ``conservative`` (保守): - LLM 否决 + ``suppress_on_llm_negative`` -> 抑制预警 (返回 None) - LLM 确认 -> 取 ``min(yolo_conf, llm_conf)`` 与原值的最小值 - LLM 不确定 -> 抑制 (除非 ``fallback_to_yolo``) 3. ``llm_priority`` (LLM 优先): - LLM 确认 -> 直接使用 LLM 置信度 - LLM 否决 -> 抑制 - LLM 不确定 -> 回退到 YOLO 置信度 (若 ``fallback_to_yolo``) 融合后会在 ``AlertEvent.metadata`` 中写入 ``llm`` 子对象,便于审计。 """ from __future__ import annotations import logging import time from dataclasses import dataclass, field from typing import Any, Dict, Optional from models.event_schemas import AlertEvent, SeverityLevel from services.llm_analysis_service import LLMAnalysisResult logger = logging.getLogger(__name__) SUPPORTED_STRATEGIES = ("weighted", "conservative", "llm_priority") # --------------------------------------------------------------------------- # 融合输出 # --------------------------------------------------------------------------- @dataclass class FusionOutcome: """融合结果。""" alert: Optional[AlertEvent] suppressed: bool = False reason: str = "" final_confidence: float = 0.0 yolo_confidence: float = 0.0 llm_confidence: float = 0.0 strategy: str = "" metadata: Dict[str, Any] = field(default_factory=dict) # --------------------------------------------------------------------------- # ResultFusion # --------------------------------------------------------------------------- class ResultFusion: """YOLO + LLM 结果融合器。 Args: strategy: 融合策略,见模块 docstring yolo_weight: weighted 策略下 YOLO 权重 llm_weight: weighted 策略下 LLM 权重 suppress_on_llm_negative: LLM 明确否决时是否抑制预警 fallback_to_yolo: LLM 未确认 / 不可用时是否回退到 YOLO 结果 promote_severity_on_high_confidence: 高融合置信度是否提升严重性 """ def __init__( self, strategy: str = "weighted", yolo_weight: float = 0.4, llm_weight: float = 0.6, suppress_on_llm_negative: bool = True, fallback_to_yolo: bool = True, promote_severity_on_high_confidence: bool = False, high_confidence_threshold: float = 0.85, ) -> None: if strategy not in SUPPORTED_STRATEGIES: raise ValueError( f"不支持的融合策略: {strategy}, 支持: {SUPPORTED_STRATEGIES}" ) if not 0.0 <= yolo_weight <= 1.0: raise ValueError("yolo_weight 必须在 [0, 1]") if not 0.0 <= llm_weight <= 1.0: raise ValueError("llm_weight 必须在 [0, 1]") total = yolo_weight + llm_weight if total <= 0: raise ValueError("yolo_weight + llm_weight 必须 > 0") self.strategy = strategy self.yolo_weight = yolo_weight / total self.llm_weight = llm_weight / total self.suppress_on_llm_negative = suppress_on_llm_negative self.fallback_to_yolo = fallback_to_yolo self.promote_severity_on_high_confidence = promote_severity_on_high_confidence self.high_confidence_threshold = high_confidence_threshold # ------------------------------------------------------------------ # 主入口 # ------------------------------------------------------------------ def fuse( self, alert: AlertEvent, llm_result: Optional[LLMAnalysisResult], ) -> FusionOutcome: """融合单条预警事件与 LLM 结果。 Args: alert: 规则引擎/聚合器产出的预警事件 (尚未发布) llm_result: LLM 分析结果,None 表示未触发 LLM """ yolo_conf = float(alert.confidence) # 未调用 LLM 或 LLM 不可用 if llm_result is None: return self._fallback_outcome( alert, yolo_conf=yolo_conf, reason="llm_skipped", ) if llm_result.error or llm_result.confirmed is None: return self._fallback_outcome( alert, yolo_conf=yolo_conf, llm_conf=llm_result.confidence, reason=f"llm_unavailable:{llm_result.error or 'unknown'}", llm_metadata=self._llm_metadata(llm_result), ) # 至此 LLM 给出明确判定 if self.strategy == "weighted": outcome = self._fuse_weighted(alert, yolo_conf, llm_result) elif self.strategy == "conservative": outcome = self._fuse_conservative(alert, yolo_conf, llm_result) else: # llm_priority outcome = self._fuse_llm_priority(alert, yolo_conf, llm_result) if outcome.alert is not None: self._apply_metadata(outcome.alert, llm_result, outcome) if self.promote_severity_on_high_confidence: self._maybe_promote_severity(outcome.alert, outcome.final_confidence) return outcome # ------------------------------------------------------------------ # 策略实现 # ------------------------------------------------------------------ def _fuse_weighted( self, alert: AlertEvent, yolo_conf: float, llm_result: LLMAnalysisResult, ) -> FusionOutcome: # 否决降权: confirmed=False 时 LLM 置信度按 1-conf 反转 llm_conf = ( float(llm_result.confidence) if llm_result.confirmed else max(0.0, 1.0 - float(llm_result.confidence)) ) # confirmed=False 直接将 LLM 端贡献当作"反对票" if not llm_result.confirmed: # 反转后 llm 端置信度越高代表越反对,因此对最终值取 (yolo*w_y) - llm*w_l final = self.yolo_weight * yolo_conf - self.llm_weight * llm_conf final = max(0.0, min(1.0, final)) else: final = self.yolo_weight * yolo_conf + self.llm_weight * llm_conf final = max(0.0, min(1.0, final)) # 反对足够强且策略允许时抑制 if ( not llm_result.confirmed and self.suppress_on_llm_negative and final < 0.2 ): return FusionOutcome( alert=None, suppressed=True, reason="weighted_suppressed_by_llm_negative", final_confidence=final, yolo_confidence=yolo_conf, llm_confidence=float(llm_result.confidence), strategy=self.strategy, metadata=self._llm_metadata(llm_result), ) alert.confidence = round(final, 4) return FusionOutcome( alert=alert, suppressed=False, reason="weighted", final_confidence=final, yolo_confidence=yolo_conf, llm_confidence=float(llm_result.confidence), strategy=self.strategy, metadata=self._llm_metadata(llm_result), ) def _fuse_conservative( self, alert: AlertEvent, yolo_conf: float, llm_result: LLMAnalysisResult, ) -> FusionOutcome: if not llm_result.confirmed: if self.suppress_on_llm_negative: return FusionOutcome( alert=None, suppressed=True, reason="conservative_llm_negative", final_confidence=0.0, yolo_confidence=yolo_conf, llm_confidence=float(llm_result.confidence), strategy=self.strategy, metadata=self._llm_metadata(llm_result), ) # 不抑制时也大幅降权 final = min(yolo_conf, 1.0 - float(llm_result.confidence)) else: # 双方都确认: 取较小者,体现保守 final = min(yolo_conf, float(llm_result.confidence)) final = max(0.0, min(1.0, final)) alert.confidence = round(final, 4) return FusionOutcome( alert=alert, suppressed=False, reason="conservative", final_confidence=final, yolo_confidence=yolo_conf, llm_confidence=float(llm_result.confidence), strategy=self.strategy, metadata=self._llm_metadata(llm_result), ) def _fuse_llm_priority( self, alert: AlertEvent, yolo_conf: float, llm_result: LLMAnalysisResult, ) -> FusionOutcome: if not llm_result.confirmed: if self.suppress_on_llm_negative: return FusionOutcome( alert=None, suppressed=True, reason="llm_priority_negative", final_confidence=0.0, yolo_confidence=yolo_conf, llm_confidence=float(llm_result.confidence), strategy=self.strategy, metadata=self._llm_metadata(llm_result), ) final = max(0.0, 1.0 - float(llm_result.confidence)) else: final = float(llm_result.confidence) final = max(0.0, min(1.0, final)) alert.confidence = round(final, 4) return FusionOutcome( alert=alert, suppressed=False, reason="llm_priority", final_confidence=final, yolo_confidence=yolo_conf, llm_confidence=float(llm_result.confidence), strategy=self.strategy, metadata=self._llm_metadata(llm_result), ) # ------------------------------------------------------------------ # 降级 # ------------------------------------------------------------------ def _fallback_outcome( self, alert: AlertEvent, yolo_conf: float, llm_conf: float = 0.0, reason: str = "fallback", llm_metadata: Optional[Dict[str, Any]] = None, ) -> FusionOutcome: if not self.fallback_to_yolo: return FusionOutcome( alert=None, suppressed=True, reason=f"{reason}:fallback_disabled", final_confidence=0.0, yolo_confidence=yolo_conf, llm_confidence=llm_conf, strategy=self.strategy, metadata=llm_metadata or {}, ) alert.metadata.setdefault("llm", {}) if llm_metadata: alert.metadata["llm"].update(llm_metadata) alert.metadata["llm"]["fusion_reason"] = reason return FusionOutcome( alert=alert, suppressed=False, reason=reason, final_confidence=yolo_conf, yolo_confidence=yolo_conf, llm_confidence=llm_conf, strategy=self.strategy, metadata=llm_metadata or {}, ) # ------------------------------------------------------------------ # 元数据 / 严重性 # ------------------------------------------------------------------ @staticmethod def _llm_metadata(llm_result: LLMAnalysisResult) -> Dict[str, Any]: return { "provider": llm_result.provider, "model": llm_result.model, "confirmed": llm_result.confirmed, "confidence": round(float(llm_result.confidence), 4), "reasoning": llm_result.reasoning, "latency_ms": round(llm_result.latency_ms, 2), "error": llm_result.error, "evaluated_at": time.time(), } def _apply_metadata( self, alert: AlertEvent, llm_result: LLMAnalysisResult, outcome: FusionOutcome, ) -> None: alert.metadata.setdefault("llm", {}) alert.metadata["llm"].update(self._llm_metadata(llm_result)) alert.metadata["llm"]["fusion_strategy"] = self.strategy alert.metadata["llm"]["fusion_reason"] = outcome.reason alert.metadata["llm"]["final_confidence"] = round( outcome.final_confidence, 4 ) @staticmethod def _maybe_promote_severity(alert: AlertEvent, final_confidence: float) -> None: order = [ SeverityLevel.INFO, SeverityLevel.LOW, SeverityLevel.MEDIUM, SeverityLevel.HIGH, SeverityLevel.CRITICAL, ] try: idx = order.index(alert.severity) except ValueError: return if final_confidence >= 0.95 and idx < len(order) - 1: alert.severity = order[idx + 1] __all__ = ["ResultFusion", "FusionOutcome", "SUPPORTED_STRATEGIES"]