Files
jc-video-recognize/apps/server/services/event/llm_trigger.py
T

245 lines
8.0 KiB
Python

"""LLM 触发决策器 (MVP-3 / D28)
基于 ``MultiFrameAccumulator`` 的累积统计,决定哪些目标值得调用 LLM 二次判断。
触发策略 (任一满足即触发):
1. ``severity_bypass``: 累积条目最大严重性命中白名单 (默认 critical) 立即触发,
无需累积窗口
2. ``连续命中帧数 >= min_consecutive_hits`` 且 ``avg_confidence >= min_avg_confidence``
冷却机制:
- 触发后记录该目标最近一次的触发时间,``cooldown_seconds`` 内不再重复触发,
避免对同一可疑目标短时间多次调用 LLM 造成成本浪费。
可观测性:
- ``stats`` 暴露 evaluated / triggered / cooled / bypassed 计数,便于监控
线程安全: 与 ``MultiFrameAccumulator`` 一致,单事件循环串行使用即可。
"""
from __future__ import annotations
import logging
import time
from collections import OrderedDict
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple, TypeAlias
from models.event_schemas import CandidateEvent, SeverityLevel
from .frame_accumulator import AccumulationEntry, MultiFrameAccumulator
logger = logging.getLogger(__name__)
_TriggerKey: TypeAlias = Tuple[Optional[str], str, str]
# ---------------------------------------------------------------------------
# 触发结果
# ---------------------------------------------------------------------------
@dataclass
class TriggerDecision:
"""LLM 触发决策结果。"""
candidate: CandidateEvent
entry: AccumulationEntry
reason: str
def to_dict(self) -> Dict[str, object]:
return {
"event_type": self.candidate.event_type.value,
"source_id": self.candidate.source_id,
"confidence": round(self.candidate.confidence, 4),
"consecutive_hits": self.entry.consecutive_hits,
"avg_confidence": round(self.entry.avg_confidence, 4),
"max_severity": self.entry.max_severity.value,
"duration": round(self.entry.duration, 3),
"reason": self.reason,
}
# ---------------------------------------------------------------------------
# LLMTrigger
# ---------------------------------------------------------------------------
class LLMTrigger:
"""LLM 触发器。
Args:
accumulator: 多帧累积分析器 (由调用方共享,便于状态一致)
min_consecutive_hits: 触发所需的最小连续命中帧数
min_avg_confidence: 累积平均置信度下限
cooldown_seconds: 同目标 LLM 冷却时间 (秒),0 表示不冷却
severity_bypass: 立即触发的严重性级别集合
max_cooldown_entries: 冷却记录最大容量 (LRU 淘汰)
"""
def __init__(
self,
accumulator: MultiFrameAccumulator,
min_consecutive_hits: int = 3,
min_avg_confidence: float = 0.55,
cooldown_seconds: float = 20.0,
severity_bypass: Optional[List[str]] = None,
max_cooldown_entries: int = 5000,
) -> None:
if min_consecutive_hits < 1:
raise ValueError("min_consecutive_hits 必须 >= 1")
if not 0.0 <= min_avg_confidence <= 1.0:
raise ValueError("min_avg_confidence 必须在 [0, 1]")
if cooldown_seconds < 0:
raise ValueError("cooldown_seconds 必须 >= 0")
if max_cooldown_entries < 1:
raise ValueError("max_cooldown_entries 必须 >= 1")
self.accumulator = accumulator
self.min_consecutive_hits = min_consecutive_hits
self.min_avg_confidence = min_avg_confidence
self.cooldown_seconds = cooldown_seconds
self.severity_bypass = {
SeverityLevel(s) for s in (severity_bypass or [])
} if severity_bypass else set()
self.max_cooldown_entries = max_cooldown_entries
self._cooldowns: "OrderedDict[_TriggerKey, float]" = OrderedDict()
# 统计
self._evaluated = 0
self._triggered = 0
self._cooled = 0
self._bypassed = 0
# ------------------------------------------------------------------
# 主入口
# ------------------------------------------------------------------
def evaluate(
self,
candidates: List[CandidateEvent],
now: Optional[float] = None,
) -> List[TriggerDecision]:
"""评估候选事件,返回需要触发 LLM 复审的决策列表。
Args:
candidates: 当前批次候选事件 (通常来自规则引擎之前的决策结果)
now: 当前时间戳 (供测试注入)
"""
if now is None:
now = time.time()
# 1. 先把候选事件喂给累积器 (统一时间戳,确保统计与触发判定基于相同 now)
self.accumulator.accumulate(candidates, now=now)
decisions: List[TriggerDecision] = []
for candidate in candidates:
self._evaluated += 1
entry = self.accumulator.get(candidate)
if entry is None:
continue
decision = self._make_decision(candidate, entry, now)
if decision is not None:
decisions.append(decision)
# 维护冷却表容量
self._evict_cooldowns(now)
return decisions
# ------------------------------------------------------------------
# 内部
# ------------------------------------------------------------------
def _make_decision(
self,
candidate: CandidateEvent,
entry: AccumulationEntry,
now: float,
) -> Optional[TriggerDecision]:
cooldown_key: _TriggerKey = entry.key
# 冷却检查
last_fire = self._cooldowns.get(cooldown_key)
if last_fire is not None and (now - last_fire) < self.cooldown_seconds:
self._cooled += 1
return None
# 严重性快速通道
if entry.max_severity in self.severity_bypass:
self._bypassed += 1
self._mark_cooldown(cooldown_key, now)
return TriggerDecision(
candidate=candidate,
entry=entry,
reason=f"severity_bypass:{entry.max_severity.value}",
)
# 多帧累积阈值
if (
entry.consecutive_hits >= self.min_consecutive_hits
and entry.avg_confidence >= self.min_avg_confidence
):
self._triggered += 1
self._mark_cooldown(cooldown_key, now)
return TriggerDecision(
candidate=candidate,
entry=entry,
reason=(
f"hits={entry.consecutive_hits}>={self.min_consecutive_hits},"
f"avg_conf={entry.avg_confidence:.3f}>="
f"{self.min_avg_confidence:.2f}"
),
)
return None
def _mark_cooldown(self, key: _TriggerKey, now: float) -> None:
self._cooldowns[key] = now
self._cooldowns.move_to_end(key)
while len(self._cooldowns) > self.max_cooldown_entries:
self._cooldowns.popitem(last=False)
def _evict_cooldowns(self, now: float) -> None:
if self.cooldown_seconds <= 0:
self._cooldowns.clear()
return
expired = [
key
for key, ts in self._cooldowns.items()
if (now - ts) >= self.cooldown_seconds
]
for key in expired:
self._cooldowns.pop(key, None)
# ------------------------------------------------------------------
# 自省
# ------------------------------------------------------------------
@property
def stats(self) -> Dict[str, int]:
return {
"evaluated": self._evaluated,
"triggered": self._triggered,
"cooled": self._cooled,
"bypassed": self._bypassed,
"active_cooldowns": len(self._cooldowns),
"active_accumulations": self.accumulator.active_count,
}
def reset(self) -> None:
self._cooldowns.clear()
self._evaluated = 0
self._triggered = 0
self._cooled = 0
self._bypassed = 0
__all__ = ["LLMTrigger", "TriggerDecision"]