Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions configs/risk_policy.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Context-Aware Adaptive Risk Scoring Policy
# ============================================
# Weights control how much each contextual signal contributes to the
# final risk score. They must sum to 1.0 for correct normalisation.
#
# To customise scoring behaviour, adjust the weight values below.
# The scorer will raise an error on startup if weights do not sum to 1.0.

risk_scoring:
restricted_zone:
weight: 0.30
description: "Subject is inside or entered a restricted zone"

repeated_approach:
weight: 0.25
description: "Subject has approached the same zone multiple times"

loitering:
weight: 0.15
description: "Subject has been dwelling/lingering beyond threshold"

after_hours:
weight: 0.20
description: "Activity detected outside normal operating hours"

reasoning_confidence:
weight: 0.10
description: "LLM reasoning confidence as a risk amplifier"

# Thresholds for risk level classification (applied to 0-100 scale)
risk_levels:
low_max: 40
medium_max: 70

# Normalization parameters for continuous signals
normalization:
loitering_max_seconds: 120.0
repeated_approach_max_count: 5
61 changes: 42 additions & 19 deletions services/reasoning/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import json

import asyncio
import datetime
import logging
import time
import uuid
Expand All @@ -27,9 +28,10 @@
from libs.schemas.memory import ActionHint, TrackSequence
from libs.schemas.reasoning import ReasoningResult, GroundingResult
from services.memory.ring_buffer import MemoryStore
from services.reasoning.dedup import AlertDeduplicator
from services.reasoning.vlm import BaseCaptioner, get_captioner
from services.reasoning.llm import BaseLLMReasoner, get_reasoner
from services.reasoning.dedup import AlertDeduplicator
from services.reasoning.vlm import BaseCaptioner, get_captioner
from services.reasoning.llm import BaseLLMReasoner, get_reasoner
from services.reasoning.risk_scoring import AdaptiveRiskScorer

logger = logging.getLogger(__name__)

Expand All @@ -39,13 +41,7 @@
# Global asyncio queue consumed by FastAPI SSE endpoint
alert_queue: asyncio.Queue = asyncio.Queue(maxsize=200)

# Severity weights
_W = dict(
confidence = 0.50,
long_dwell = 0.20,
repeated_approach = 0.20,
high_tier_bonus = 0.10,
)



class ReasoningPipeline:
Expand All @@ -63,13 +59,15 @@ def __init__(
reasoner: Optional[BaseLLMReasoner] = None,
store: Optional[MemoryStore] = None,
deduplicator: Optional[AlertDeduplicator] = None,
risk_scorer: Optional[AdaptiveRiskScorer] = None,
) -> None:
self._captioner = captioner or get_captioner()
self._reasoner = reasoner or get_reasoner()
self._store = store or MemoryStore()
self._deduplicator = deduplicator or AlertDeduplicator(
redis_client=self._store._r
)
self._risk_scorer = risk_scorer or AdaptiveRiskScorer()

# ── Public ───────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -215,21 +213,46 @@ def _ground(
)
return GroundingResult(grounded=True, checked_caption=caption)

# ── Severity scoring ──────────────────────────────────────────────────────
# ── Severity scoring (powered by AdaptiveRiskScorer) ─────────────────────

def _attach_severity(
self,
result: ReasoningResult,
seq: TrackSequence,
) -> ReasoningResult:
score = result.confidence * _W["confidence"]
if seq.total_dwell > 30:
score += _W["long_dwell"]
if "repeated_approach" in seq.action_summary:
score += _W["repeated_approach"]
if result.confidence_tier == "high":
score += _W["high_tier_bonus"]
result.severity_score = round(min(score, 1.0), 3)
"""Compute severity using the context-aware adaptive risk scorer.

Builds contextual signals from the reasoning result and track
sequence, delegates to :class:`AdaptiveRiskScorer`, and maps
the 0–100 risk score back to the 0.0–1.0 ``severity_score`` field.
"""
# Determine whether any visited zone is restricted
restricted_keywords = ("restricted", "danger")
in_restricted = any(
any(kw in z.lower() for kw in restricted_keywords)
for z in seq.zones_visited
)

# Count repeated approaches from structured event data
approach_count = sum(
1 for event in seq.events
if event.action_hint == ActionHint.REPEATED_APPROACH
)

# Determine after-hours status from current wall-clock hour
current_hour = datetime.datetime.now().hour
is_after_hours = current_hour >= 20 or current_hour < 6

signals = {
"restricted_zone": in_restricted,
"repeated_approach": approach_count,
"loitering": seq.total_dwell,
"after_hours": is_after_hours,
"reasoning_confidence": result.confidence,
}

risk_result = self._risk_scorer.score(signals)
result.severity_score = round(risk_result["risk_score"] / 100.0, 3)
return result

# ── Storage ───────────────────────────────────────────────────────────────
Expand Down
229 changes: 229 additions & 0 deletions services/reasoning/risk_scoring.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
"""
Adaptive Risk Scoring Engine for Eagle Surveillance.

Loads configurable weights from a YAML policy file and computes a normalised
0–100 risk score from multiple contextual signals. Designed as a drop-in
replacement for the hardcoded severity weights previously defined in
``services.reasoning.pipeline._W``.

Usage
-----
from services.reasoning.risk_scoring import AdaptiveRiskScorer

scorer = AdaptiveRiskScorer("configs/risk_policy.yaml")
result = scorer.score({
"restricted_zone": True,
"repeated_approach": 3,
"loitering": 45.0,
"after_hours": False,
"reasoning_confidence": 0.85,
})
# result == {"risk_score": 62, "risk_level": "Medium", "risk_factors": [...]}
"""
from __future__ import annotations

import logging
from pathlib import Path
from typing import Any, TypedDict

import yaml

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | πŸ”΄ Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Candidate dependency manifests =="
fd -HI 'pyproject.toml|requirements.*|Pipfile|setup.py|poetry.lock'

echo
echo "== Entries mentioning yaml/pyyaml =="
rg -n --hidden \
  --iglob 'pyproject.toml' \
  --iglob 'requirements*.txt' \
  --iglob 'Pipfile' \
  --iglob 'setup.py' \
  --iglob 'poetry.lock' \
  '(?i)\bpyyaml\b|\byaml\b'

Repository: Devnil434/Eagle

Length of output: 14533


🏁 Script executed:

cat services/reasoning/requirements.txt

Repository: Devnil434/Eagle

Length of output: 148


Add PyYAML to services/reasoning/requirements.txt.

The module imports yaml but PyYAML is not listed in the service's dependency manifest. If services/reasoning/ is deployed independently, startup will fail with ModuleNotFoundError. Add pyyaml>=6.0 to services/reasoning/requirements.txt.

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/reasoning/risk_scoring.py` at line 29, The risk_scoring.py module
imports the yaml module, but PyYAML is not declared as a dependency in
services/reasoning/requirements.txt. This will cause a ModuleNotFoundError when
the service is deployed independently. Add the line pyyaml>=6.0 to the
services/reasoning/requirements.txt file to ensure the required dependency is
installed.


logger = logging.getLogger(__name__)

# Default policy path relative to the project root
_DEFAULT_POLICY_PATH = Path(__file__).resolve().parents[2] / "configs" / "risk_policy.yaml"

# Human-readable descriptions for each signal
_FACTOR_LABELS: dict[str, str] = {
"restricted_zone": "Restricted zone detected",
"repeated_approach": "Repeated approach behavior",
"loitering": "Loitering / extended dwell time",
"after_hours": "After-hours activity",
"reasoning_confidence": "High reasoning confidence",
}


class RiskScoringResult(TypedDict):
"""Typed result returned by :meth:`AdaptiveRiskScorer.score`."""

risk_score: int
risk_level: str
risk_factors: list[str]


class AdaptiveRiskScorer:
"""Context-aware risk scoring engine.

Loads configurable weights from a YAML policy file and computes a
normalised 0–100 risk score from multiple contextual signals.

Parameters
----------
policy_path:
Path to the YAML policy file. If ``None``, uses the default
``configs/risk_policy.yaml`` relative to the project root.

Raises
------
FileNotFoundError
If the policy YAML file does not exist.
ValueError
If the YAML file is malformed or has an invalid structure.
"""

def __init__(self, policy_path: str | Path | None = None) -> None:
resolved = Path(policy_path) if policy_path else _DEFAULT_POLICY_PATH
self._policy = self._load_policy(resolved)
self._weights: dict[str, float] = {
key: cfg["weight"]
for key, cfg in self._policy["risk_scoring"].items()
}
self._risk_levels: dict[str, int] = self._policy["risk_levels"]
self._normalization: dict[str, float] = self._policy["normalization"]

# ── Public API ────────────────────────────────────────────────────────

def score(self, signals: dict[str, Any]) -> RiskScoringResult:
"""Compute a context-aware risk score from raw contextual signals.

Parameters
----------
signals:
Dictionary of raw signal values. Expected keys:

- ``restricted_zone`` – ``bool``
- ``repeated_approach`` – ``int`` (entry count)
- ``loitering`` – ``float`` (dwell seconds)
- ``after_hours`` – ``bool``
- ``reasoning_confidence`` – ``float`` in [0, 1]

Missing keys are treated as zero / inactive.

Returns
-------
RiskScoringResult
A dict with ``risk_score`` (0–100), ``risk_level``
(``"Low"`` / ``"Medium"`` / ``"High"``), and
``risk_factors`` (list of human-readable strings).
"""
normalized = self._normalize_signals(signals)
raw_score = self._compute_weighted_score(normalized)
score_100 = round(raw_score * 100)
score_100 = max(0, min(score_100, 100))

return RiskScoringResult(
risk_score=score_100,
risk_level=self._classify_risk_level(score_100),
risk_factors=self._identify_contributing_factors(normalized),
)

# ── Internal helpers ──────────────────────────────────────────────────

def _normalize_signals(self, signals: dict[str, Any]) -> dict[str, float]:
"""Normalise each raw signal to a value in [0.0, 1.0].

Boolean signals map to 1.0 / 0.0. Continuous signals are clamped
against the normalization parameters defined in the policy YAML.
"""
loitering_max = self._normalization["loitering_max_seconds"]
approach_max = self._normalization["repeated_approach_max_count"]

restricted = signals.get("restricted_zone", False)
approach_count = signals.get("repeated_approach", 0)
dwell = signals.get("loitering", 0.0)
after_hours = signals.get("after_hours", False)
confidence = signals.get("reasoning_confidence", 0.0)

return {
"restricted_zone": 1.0 if restricted else 0.0,
"repeated_approach": max(0.0, min(approach_count / approach_max, 1.0)) if approach_max > 0 else 0.0,
"loitering": max(0.0, min(dwell / loitering_max, 1.0)) if loitering_max > 0 else 0.0,
"after_hours": 1.0 if after_hours else 0.0,
"reasoning_confidence": max(0.0, min(float(confidence), 1.0)),
}

def _compute_weighted_score(self, normalized: dict[str, float]) -> float:
"""Apply YAML weights to normalised signals and return a 0–1 float."""
total = 0.0
for key, weight in self._weights.items():
total += weight * normalized.get(key, 0.0)
return min(total, 1.0)

def _classify_risk_level(self, score_100: int) -> str:
"""Classify a 0–100 score into Low / Medium / High."""
if score_100 <= self._risk_levels["low_max"]:
return "Low"
if score_100 <= self._risk_levels["medium_max"]:
return "Medium"
return "High"

def _identify_contributing_factors(
self, normalized: dict[str, float]
) -> list[str]:
"""Return human-readable labels for signals that contributed."""
# Confidence threshold for reporting β€” only flag genuinely high values
_CONFIDENCE_HIGH_THRESHOLD = 0.7

factors: list[str] = []
for key, value in normalized.items():
if value > 0.0 and key in _FACTOR_LABELS:
# "High reasoning confidence" should only appear when
# confidence is genuinely high, not for any nonzero value.
if key == "reasoning_confidence" and value < _CONFIDENCE_HIGH_THRESHOLD:
continue
factors.append(_FACTOR_LABELS[key])
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return factors

# ── YAML loading ──────────────────────────────────────────────────────

@staticmethod
def _load_policy(path: Path) -> dict[str, Any]:
"""Load and validate the YAML policy file.

Raises
------
FileNotFoundError
If *path* does not exist.
ValueError
If the YAML is empty, unparseable, missing required sections,
or contains invalid weight / threshold values.
"""
if not path.exists():
raise FileNotFoundError(f"Risk policy file not found: {path}")

try:
with open(path, "r", encoding="utf-8") as fh:
data = yaml.safe_load(fh)
except yaml.YAMLError as exc:
raise ValueError(f"Invalid YAML in risk policy: {exc}") from exc

if data is None:
raise ValueError(f"Risk policy file is empty: {path}")

for section in ("risk_scoring", "risk_levels", "normalization"):
if section not in data:
raise ValueError(
f"Risk policy missing required section: '{section}'"
)

# Lightweight weight validation
for signal, cfg in data["risk_scoring"].items():
w = cfg.get("weight", 0)
if not isinstance(w, (int, float)) or w < 0:
raise ValueError(
f"Weight for '{signal}' must be a non-negative number, got {w!r}"
)
if w > 1.0:
raise ValueError(
f"Weight for '{signal}' must not exceed 1.0, got {w}"
)

# Risk-level threshold ordering
levels = data["risk_levels"]
if levels["low_max"] >= levels["medium_max"]:
raise ValueError(
f"risk_levels.low_max ({levels['low_max']}) must be less than "
f"medium_max ({levels['medium_max']})"
)

return data
Comment on lines +203 to +229

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚑ Quick win

Enforce full policy schema constraints at load-time.

_load_policy currently checks section presence only. Invalid weights/thresholds/normalization values pass startup and produce silently wrong risk scores.

Proposed hard validation in loader
         for section in ("risk_scoring", "risk_levels", "normalization"):
             if section not in data:
                 raise ValueError(
                     f"Risk policy missing required section: '{section}'"
                 )
+
+        weights = []
+        for signal, cfg in data["risk_scoring"].items():
+            weight = cfg.get("weight")
+            if not isinstance(weight, (int, float)) or weight < 0:
+                raise ValueError(f"Invalid weight for '{signal}': {weight}")
+            weights.append(float(weight))
+        if abs(sum(weights) - 1.0) > 1e-6:
+            raise ValueError("Risk policy weights must sum to 1.0")
+
+        low_max = data["risk_levels"].get("low_max")
+        medium_max = data["risk_levels"].get("medium_max")
+        if not (isinstance(low_max, (int, float)) and isinstance(medium_max, (int, float))):
+            raise ValueError("risk_levels.low_max and medium_max must be numeric")
+        if not (0 <= low_max <= medium_max <= 100):
+            raise ValueError("Risk thresholds must satisfy 0 <= low_max <= medium_max <= 100")
+
+        loitering_max = data["normalization"].get("loitering_max_seconds")
+        approach_max = data["normalization"].get("repeated_approach_max_count")
+        if not (isinstance(loitering_max, (int, float)) and loitering_max > 0):
+            raise ValueError("normalization.loitering_max_seconds must be > 0")
+        if not (isinstance(approach_max, (int, float)) and approach_max > 0):
+            raise ValueError("normalization.repeated_approach_max_count must be > 0")
 
         return data
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/reasoning/risk_scoring.py` around lines 195 - 201, The _load_policy
method currently only validates that required sections exist in the policy data
but does not enforce constraints on the actual values within those sections
(weights, thresholds, normalization values). Add comprehensive schema validation
after the section presence checks to verify that weights and thresholds are
valid numeric values within acceptable ranges, and that normalization parameters
meet expected constraints. This ensures invalid configurations are caught at
load-time rather than causing silent failures during risk score calculations.

Loading
Loading