feat(event): 新增事件多帧累积与 LLM 触发决策模块
This commit is contained in:
@@ -0,0 +1,373 @@
|
||||
"""结果融合器 (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"]
|
||||
Reference in New Issue
Block a user