feat(event): 新增事件多帧累积与 LLM 触发决策模块
This commit is contained in:
@@ -0,0 +1,524 @@
|
||||
"""LLM 分析服务 (MVP-3 / D29)
|
||||
|
||||
调用多模态大模型 (GPT-4V / Qwen-VL / GLM-4V) 对可疑事件进行二次判断。
|
||||
|
||||
设计要点:
|
||||
|
||||
1. **provider 抽象**: ``BaseLLMProvider`` 定义统一接口,
|
||||
``OpenAICompatibleProvider`` 覆盖所有 OpenAI 协议兼容的 vendor,
|
||||
``MockLLMProvider`` 提供完全离线的可预测行为,便于测试与本地开发。
|
||||
2. **并发控制**: 使用 ``asyncio.Semaphore`` 限制同时进行的 LLM 调用数量,
|
||||
避免大量并发请求拖垮 API 配额。
|
||||
3. **降级策略**: 上层调用方可通过 ``analyze_with_fallback`` 在 LLM 失败时
|
||||
返回 ``confirmed=None`` 的"未确认"结果,由 ``ResultFusion`` 决定如何处理。
|
||||
4. **图像编码**: 自动按最大边长缩放后编码为 base64 JPEG,控制 token 成本。
|
||||
5. **结构化输出**: prompt 强制 LLM 输出 JSON,解析失败时降级为
|
||||
``confirmed=None`` 而不是抛异常。
|
||||
|
||||
依赖说明:
|
||||
|
||||
- HTTP 客户端使用 ``httpx`` (已在 requirements 中)
|
||||
- 图像编码使用 ``opencv-python`` 已存在的 cv2.imencode
|
||||
|
||||
无需新增 Python 依赖即可运行 (mock provider),仅当 ``provider != mock`` 时
|
||||
才会真正发起 HTTP 请求。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
from models.event_schemas import CandidateEvent, EventType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 数据结构
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMAnalysisRequest:
|
||||
"""LLM 分析请求。
|
||||
|
||||
Attributes:
|
||||
candidate: 触发分析的候选事件 (含事件类型 / bbox)
|
||||
frames: 关键帧序列 (BGR ``np.ndarray``),
|
||||
通常取累积窗口内的 1-3 张代表帧
|
||||
prompt_extra: 业务侧附加提示词 (可选)
|
||||
request_id: 请求标识 (用于日志关联)
|
||||
"""
|
||||
|
||||
candidate: CandidateEvent
|
||||
frames: Sequence[np.ndarray]
|
||||
prompt_extra: Optional[str] = None
|
||||
request_id: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMAnalysisResult:
|
||||
"""LLM 分析结果。
|
||||
|
||||
Attributes:
|
||||
confirmed: True=LLM 确认事件成立,False=否决,None=未知/调用失败
|
||||
confidence: LLM 给出的置信度 [0, 1]
|
||||
reasoning: LLM 的简短推理说明
|
||||
provider: 实际调用的 provider 名称
|
||||
model: 实际调用的模型名
|
||||
latency_ms: 调用耗时 (毫秒)
|
||||
error: 失败时的错误信息
|
||||
raw: 原始返回 (调试用)
|
||||
"""
|
||||
|
||||
confirmed: Optional[bool]
|
||||
confidence: float = 0.0
|
||||
reasoning: str = ""
|
||||
provider: str = ""
|
||||
model: str = ""
|
||||
latency_ms: float = 0.0
|
||||
error: Optional[str] = None
|
||||
raw: Optional[str] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider 抽象
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BaseLLMProvider:
|
||||
"""LLM provider 抽象。"""
|
||||
|
||||
name: str = "base"
|
||||
|
||||
async def analyze(self, request: LLMAnalysisRequest) -> LLMAnalysisResult:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class MockLLMProvider(BaseLLMProvider):
|
||||
"""离线 Mock provider。
|
||||
|
||||
根据候选事件置信度生成可预测的判定结果,便于测试与无 API Key 环境。
|
||||
|
||||
规则:
|
||||
- confidence >= 0.7 -> confirmed=True
|
||||
- confidence < 0.3 -> confirmed=False
|
||||
- 其余 -> confirmed=None
|
||||
"""
|
||||
|
||||
name = "mock"
|
||||
|
||||
def __init__(self, model: str = "mock-vlm") -> None:
|
||||
self.model = model
|
||||
|
||||
async def analyze(self, request: LLMAnalysisRequest) -> LLMAnalysisResult:
|
||||
start = time.time()
|
||||
await asyncio.sleep(0) # 模拟异步
|
||||
conf = float(request.candidate.confidence)
|
||||
if conf >= 0.7:
|
||||
confirmed: Optional[bool] = True
|
||||
elif conf < 0.3:
|
||||
confirmed = False
|
||||
else:
|
||||
confirmed = None
|
||||
|
||||
reasoning = (
|
||||
f"mock provider: candidate confidence={conf:.3f}, "
|
||||
f"event_type={request.candidate.event_type.value}"
|
||||
)
|
||||
return LLMAnalysisResult(
|
||||
confirmed=confirmed,
|
||||
confidence=conf,
|
||||
reasoning=reasoning,
|
||||
provider=self.name,
|
||||
model=self.model,
|
||||
latency_ms=(time.time() - start) * 1000.0,
|
||||
)
|
||||
|
||||
|
||||
class OpenAICompatibleProvider(BaseLLMProvider):
|
||||
"""OpenAI 协议兼容 provider (覆盖 GPT-4V / Qwen-VL / GLM-4V 等)。"""
|
||||
|
||||
name = "openai"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_base: str,
|
||||
api_key: str,
|
||||
model: str,
|
||||
timeout: float = 15.0,
|
||||
max_retries: int = 2,
|
||||
max_tokens: int = 512,
|
||||
temperature: float = 0.0,
|
||||
provider_name: Optional[str] = None,
|
||||
) -> None:
|
||||
if not api_base:
|
||||
raise ValueError("api_base 不能为空")
|
||||
if not api_key:
|
||||
raise ValueError("api_key 不能为空")
|
||||
self.api_base = api_base.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self.timeout = timeout
|
||||
self.max_retries = max_retries
|
||||
self.max_tokens = max_tokens
|
||||
self.temperature = temperature
|
||||
if provider_name:
|
||||
self.name = provider_name
|
||||
|
||||
async def analyze(self, request: LLMAnalysisRequest) -> LLMAnalysisResult:
|
||||
try:
|
||||
import httpx # noqa: WPS433
|
||||
except ImportError as exc: # pragma: no cover - 依赖在 requirements 已声明
|
||||
return LLMAnalysisResult(
|
||||
confirmed=None,
|
||||
provider=self.name,
|
||||
model=self.model,
|
||||
error=f"httpx 未安装: {exc}",
|
||||
)
|
||||
|
||||
url = f"{self.api_base}/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"messages": _build_messages(request),
|
||||
"max_tokens": self.max_tokens,
|
||||
"temperature": self.temperature,
|
||||
}
|
||||
|
||||
attempt = 0
|
||||
last_error: Optional[str] = None
|
||||
start = time.time()
|
||||
while attempt <= self.max_retries:
|
||||
attempt += 1
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
resp = await client.post(url, headers=headers, json=payload)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
content = (
|
||||
data.get("choices", [{}])[0]
|
||||
.get("message", {})
|
||||
.get("content", "")
|
||||
)
|
||||
parsed = _parse_llm_json(content)
|
||||
return LLMAnalysisResult(
|
||||
confirmed=parsed.get("confirmed"),
|
||||
confidence=float(parsed.get("confidence", 0.0) or 0.0),
|
||||
reasoning=str(parsed.get("reasoning", "")),
|
||||
provider=self.name,
|
||||
model=self.model,
|
||||
latency_ms=(time.time() - start) * 1000.0,
|
||||
raw=content,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_error = f"{type(exc).__name__}: {exc}"
|
||||
logger.warning(
|
||||
"LLM 调用失败 attempt=%d/%d err=%s",
|
||||
attempt,
|
||||
self.max_retries + 1,
|
||||
last_error,
|
||||
)
|
||||
if attempt > self.max_retries:
|
||||
break
|
||||
await asyncio.sleep(min(2 ** (attempt - 1), 5))
|
||||
|
||||
return LLMAnalysisResult(
|
||||
confirmed=None,
|
||||
provider=self.name,
|
||||
model=self.model,
|
||||
latency_ms=(time.time() - start) * 1000.0,
|
||||
error=last_error,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class LLMAnalysisService:
|
||||
"""LLM 二次判断服务。
|
||||
|
||||
Args:
|
||||
provider: LLM provider 实例 (Mock / OpenAI 兼容)
|
||||
max_concurrency: 同时进行的最大调用数
|
||||
image_max_side: 编码前图像最大边长 (像素)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider: BaseLLMProvider,
|
||||
max_concurrency: int = 2,
|
||||
image_max_side: int = 768,
|
||||
) -> None:
|
||||
if max_concurrency < 1:
|
||||
raise ValueError("max_concurrency 必须 >= 1")
|
||||
if image_max_side < 64:
|
||||
raise ValueError("image_max_side 必须 >= 64")
|
||||
self.provider = provider
|
||||
self._semaphore = asyncio.Semaphore(max_concurrency)
|
||||
self.image_max_side = image_max_side
|
||||
|
||||
# 统计
|
||||
self._call_count = 0
|
||||
self._success_count = 0
|
||||
self._failure_count = 0
|
||||
self._total_latency_ms = 0.0
|
||||
|
||||
async def analyze(self, request: LLMAnalysisRequest) -> LLMAnalysisResult:
|
||||
"""调用 LLM 分析单个事件 (并发受信号量限制)。"""
|
||||
|
||||
# 图像缩放预处理 (mock provider 也走一遍,保持行为一致)
|
||||
prepared_frames = [
|
||||
_resize_frame(f, self.image_max_side) for f in request.frames
|
||||
]
|
||||
if any(p is not o for p, o in zip(prepared_frames, request.frames)):
|
||||
request = LLMAnalysisRequest(
|
||||
candidate=request.candidate,
|
||||
frames=prepared_frames,
|
||||
prompt_extra=request.prompt_extra,
|
||||
request_id=request.request_id,
|
||||
)
|
||||
|
||||
async with self._semaphore:
|
||||
self._call_count += 1
|
||||
try:
|
||||
result = await self.provider.analyze(request)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("LLM provider 异常: %s", exc)
|
||||
result = LLMAnalysisResult(
|
||||
confirmed=None,
|
||||
provider=getattr(self.provider, "name", "unknown"),
|
||||
model=getattr(self.provider, "model", ""),
|
||||
error=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
|
||||
if result.error or result.confirmed is None:
|
||||
self._failure_count += 1
|
||||
else:
|
||||
self._success_count += 1
|
||||
self._total_latency_ms += result.latency_ms
|
||||
return result
|
||||
|
||||
async def analyze_with_fallback(
|
||||
self,
|
||||
request: LLMAnalysisRequest,
|
||||
) -> LLMAnalysisResult:
|
||||
"""带降级的分析: 任意异常都不会向上抛出。"""
|
||||
|
||||
try:
|
||||
return await self.analyze(request)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("LLM 分析降级: %s", exc)
|
||||
return LLMAnalysisResult(
|
||||
confirmed=None,
|
||||
provider=getattr(self.provider, "name", "unknown"),
|
||||
model=getattr(self.provider, "model", ""),
|
||||
error=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
|
||||
@property
|
||||
def stats(self) -> Dict[str, Any]:
|
||||
avg_latency = (
|
||||
self._total_latency_ms / self._call_count
|
||||
if self._call_count > 0
|
||||
else 0.0
|
||||
)
|
||||
return {
|
||||
"provider": getattr(self.provider, "name", "unknown"),
|
||||
"model": getattr(self.provider, "model", ""),
|
||||
"call_count": self._call_count,
|
||||
"success_count": self._success_count,
|
||||
"failure_count": self._failure_count,
|
||||
"avg_latency_ms": round(avg_latency, 2),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_llm_service_from_settings(settings: Any) -> Optional[LLMAnalysisService]:
|
||||
"""根据 ``Settings.llm`` 配置构造 LLMAnalysisService。
|
||||
|
||||
返回 None 表示未启用,调用方应跳过 LLM 分析。
|
||||
"""
|
||||
|
||||
llm_cfg = getattr(settings, "llm", None)
|
||||
if llm_cfg is None or not getattr(llm_cfg, "enabled", False):
|
||||
return None
|
||||
|
||||
provider_name = (llm_cfg.provider or "mock").lower()
|
||||
if provider_name == "mock":
|
||||
provider: BaseLLMProvider = MockLLMProvider(model=llm_cfg.model or "mock-vlm")
|
||||
else:
|
||||
api_base = llm_cfg.api_base
|
||||
api_key = llm_cfg.api_key
|
||||
if not api_base or not api_key:
|
||||
logger.warning(
|
||||
"LLM provider=%s 缺少 api_base/api_key,回退到 mock", provider_name
|
||||
)
|
||||
provider = MockLLMProvider(model=llm_cfg.model or "mock-vlm")
|
||||
else:
|
||||
provider = OpenAICompatibleProvider(
|
||||
api_base=api_base,
|
||||
api_key=api_key,
|
||||
model=llm_cfg.model,
|
||||
timeout=llm_cfg.timeout,
|
||||
max_retries=llm_cfg.max_retries,
|
||||
max_tokens=llm_cfg.max_tokens,
|
||||
temperature=llm_cfg.temperature,
|
||||
provider_name=provider_name,
|
||||
)
|
||||
|
||||
return LLMAnalysisService(
|
||||
provider=provider,
|
||||
max_concurrency=llm_cfg.max_concurrency,
|
||||
image_max_side=llm_cfg.image_max_side,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_EVENT_TYPE_DESCRIPTIONS: Dict[EventType, str] = {
|
||||
EventType.FIRE: "fire / open flame",
|
||||
EventType.SMOKE: "smoke / haze",
|
||||
EventType.SMOKING: "a person smoking a cigarette",
|
||||
EventType.FIGHT: "a fight or physical altercation",
|
||||
EventType.LOITERING: "a person loitering / lingering suspiciously",
|
||||
EventType.STATIONARY: "a stationary person (possibly fallen or unconscious)",
|
||||
EventType.INTRUSION: "an intrusion into a restricted area",
|
||||
EventType.ILLEGAL_PARKING: "a vehicle illegally parked",
|
||||
EventType.VEHICLE: "a vehicle of interest",
|
||||
EventType.PERSON: "a person of interest",
|
||||
EventType.UNKNOWN: "an unspecified suspicious event",
|
||||
}
|
||||
|
||||
|
||||
def _build_messages(request: LLMAnalysisRequest) -> List[Dict[str, Any]]:
|
||||
"""构造 OpenAI ChatCompletion messages。"""
|
||||
|
||||
candidate = request.candidate
|
||||
event_desc = _EVENT_TYPE_DESCRIPTIONS.get(candidate.event_type, "an event")
|
||||
bbox = candidate.detection.bbox.to_list()
|
||||
|
||||
system_prompt = (
|
||||
"You are a strict video surveillance auditor. "
|
||||
"Given one or more frames and a candidate event reported by an AI detector, "
|
||||
"decide whether the event is truly present. "
|
||||
"Respond with a single JSON object: "
|
||||
'{"confirmed": true|false, "confidence": 0.0-1.0, "reasoning": "..."} '
|
||||
"and nothing else."
|
||||
)
|
||||
|
||||
user_text_parts: List[str] = [
|
||||
f"Candidate event: {event_desc}.",
|
||||
f"Detector confidence: {candidate.confidence:.3f}.",
|
||||
f"Bounding box (x1,y1,x2,y2): {bbox}.",
|
||||
]
|
||||
if request.prompt_extra:
|
||||
user_text_parts.append(request.prompt_extra)
|
||||
user_text_parts.append(
|
||||
"Please answer strictly in JSON: "
|
||||
'{"confirmed": <bool>, "confidence": <float>, "reasoning": <str>}'
|
||||
)
|
||||
|
||||
content: List[Dict[str, Any]] = [
|
||||
{"type": "text", "text": "\n".join(user_text_parts)},
|
||||
]
|
||||
for frame in request.frames:
|
||||
b64 = _encode_frame_base64(frame)
|
||||
if b64:
|
||||
content.append(
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:image/jpeg;base64,{b64}"},
|
||||
}
|
||||
)
|
||||
|
||||
return [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": content},
|
||||
]
|
||||
|
||||
|
||||
def _encode_frame_base64(frame: np.ndarray) -> Optional[str]:
|
||||
"""将 BGR 帧编码为 base64 JPEG 字符串。"""
|
||||
|
||||
try:
|
||||
import cv2 # noqa: WPS433
|
||||
except ImportError: # pragma: no cover
|
||||
return None
|
||||
if frame is None or frame.size == 0:
|
||||
return None
|
||||
ok, buf = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
|
||||
if not ok:
|
||||
return None
|
||||
return base64.b64encode(buf.tobytes()).decode("ascii")
|
||||
|
||||
|
||||
def _resize_frame(frame: np.ndarray, max_side: int) -> np.ndarray:
|
||||
"""按最大边长等比缩放 (节省 token)。"""
|
||||
|
||||
if frame is None or frame.size == 0:
|
||||
return frame
|
||||
try:
|
||||
import cv2 # noqa: WPS433
|
||||
except ImportError: # pragma: no cover
|
||||
return frame
|
||||
h, w = frame.shape[:2]
|
||||
longest = max(h, w)
|
||||
if longest <= max_side:
|
||||
return frame
|
||||
scale = max_side / float(longest)
|
||||
new_size = (max(1, int(w * scale)), max(1, int(h * scale)))
|
||||
return cv2.resize(frame, new_size, interpolation=cv2.INTER_AREA)
|
||||
|
||||
|
||||
_JSON_PATTERN = re.compile(r"\{.*\}", re.DOTALL)
|
||||
|
||||
|
||||
def _parse_llm_json(content: str) -> Dict[str, Any]:
|
||||
"""从 LLM 文本输出中尽力解析 JSON。"""
|
||||
|
||||
if not content:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
match = _JSON_PATTERN.search(content)
|
||||
if match:
|
||||
try:
|
||||
return json.loads(match.group(0))
|
||||
except json.JSONDecodeError:
|
||||
logger.debug("LLM JSON 解析失败: %s", content[:200])
|
||||
return {}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"LLMAnalysisService",
|
||||
"LLMAnalysisRequest",
|
||||
"LLMAnalysisResult",
|
||||
"BaseLLMProvider",
|
||||
"MockLLMProvider",
|
||||
"OpenAICompatibleProvider",
|
||||
"create_llm_service_from_settings",
|
||||
]
|
||||
@@ -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