""" LectureLens — Alert Engine Reads thresholds.yaml once at startup and exposes generate_alerts(). """ from __future__ import annotations import math from pathlib import Path from typing import Any, Dict, List import yaml from app.schemas import Alert, AlertCategory, AlertSeverity, AudioMetrics, VideoMetrics # ── Loader ──────────────────────────────────────────────────────────────────── _thresholds_cache: Dict[str, Any] | None = None def load_thresholds(path: str = "thresholds.yaml") -> Dict[str, Any]: global _thresholds_cache if _thresholds_cache is None: config_path = Path(path) if not config_path.exists(): raise FileNotFoundError(f"Thresholds config not found at: {config_path}") with config_path.open("r", encoding="utf-8") as f: _thresholds_cache = yaml.safe_load(f) return _thresholds_cache def reload_thresholds(path: str = "thresholds.yaml") -> None: """Force a reload (useful for tests or live config updates).""" global _thresholds_cache _thresholds_cache = None load_thresholds(path) # ── Internal helpers ────────────────────────────────────────────────────────── def _severity_from_ratio(ratio: float) -> AlertSeverity: """ ratio = how far beyond the threshold, relative to the threshold magnitude. ratio > 0.5 → critical else → warning """ if ratio > 0.5: return AlertSeverity.critical return AlertSeverity.warning def _make_alert( severity: AlertSeverity, category: AlertCategory, kpi: str, message: str, suggested_fix: str, timestamp_range=None, ) -> Alert: return Alert( severity=severity, category=category, kpi=kpi, message=message, suggested_fix=suggested_fix, timestamp_range=timestamp_range, ) # ── Audio alerts ────────────────────────────────────────────────────────────── def _audio_alerts(metrics: AudioMetrics, cfg: Dict[str, Any]) -> List[Alert]: alerts: List[Alert] = [] ac = cfg.get("audio", {}) # integrated_loudness_lufs if metrics.integrated_loudness_lufs is not None: lufs_cfg = ac.get("integrated_loudness_lufs", {}) target = lufs_cfg.get("target", -14) tolerance = lufs_cfg.get("tolerance", 2) val = metrics.integrated_loudness_lufs diff = abs(val - target) if diff > tolerance: ratio = (diff - tolerance) / max(abs(target), 1) sev = _severity_from_ratio(ratio) alerts.append(_make_alert( sev, AlertCategory.audio, "integrated_loudness_lufs", f"Integrated loudness is {val:.1f} LUFS — {diff - tolerance:.1f} LU " f"{'below' if val < target else 'above'} the target of {target} LUFS.", "Adjust microphone gain or apply loudness normalisation before uploading." )) # true_peak_dbtp if metrics.true_peak_dbtp is not None: tp_max = ac.get("true_peak_dbtp", {}).get("max", -1.0) val = metrics.true_peak_dbtp if val > tp_max: alerts.append(_make_alert( AlertSeverity.critical, AlertCategory.audio, "true_peak_dbtp", f"True peak is {val:.1f} dBTP — exceeds the {tp_max} dBTP ceiling.", "Lower recording level or apply a true-peak limiter." )) # clipped_samples_count if metrics.clipped_samples_count > 0: max_clips = ac.get("clipped_samples_count", {}).get("max", 0) alerts.append(_make_alert( AlertSeverity.critical, AlertCategory.audio, "clipped_samples_count", f"{metrics.clipped_samples_count:,} clipped sample(s) detected — audio is distorted.", "Reduce microphone input level to prevent clipping." )) # snr_db if metrics.snr_db is not None: snr_min = ac.get("snr_db", {}).get("min", 20) val = metrics.snr_db if val < snr_min: diff = snr_min - val sev = AlertSeverity.critical if diff > 10 else AlertSeverity.warning alerts.append(_make_alert( sev, AlertCategory.audio, "snr_db", f"SNR is {val:.1f} dB — below the {snr_min} dB minimum.", "Use a directional microphone, reduce background noise, or move to a quieter room." )) # loudness_range_lu if metrics.loudness_range_lu is not None: lra_cfg = ac.get("loudness_range_lu", {}) lra_min = lra_cfg.get("min", 4) lra_max = lra_cfg.get("max", 15) val = metrics.loudness_range_lu if val < lra_min: alerts.append(_make_alert( AlertSeverity.info, AlertCategory.audio, "loudness_range_lu", f"Loudness range is very low ({val:.1f} LU) — audio may sound over-compressed.", "Avoid applying heavy dynamic compression to the recording." )) elif val > lra_max: alerts.append(_make_alert( AlertSeverity.warning, AlertCategory.audio, "loudness_range_lu", f"Loudness range is {val:.1f} LU — high variation indicates inconsistent mic distance.", "Keep a consistent distance from the microphone throughout the lecture." )) # silence_segments silence_max = ac.get("silence_max_duration_sec", 20) if metrics.longest_silence_seconds > silence_max: count = sum(1 for s in metrics.silence_segments if (s.end - s.start) > silence_max) alerts.append(_make_alert( AlertSeverity.warning, AlertCategory.audio, "silence_segments", f"Detected {count} long silence segment(s). Total silence: {metrics.total_silence_seconds:.1f}s, Longest: {metrics.longest_silence_seconds:.1f}s.", "Check for accidental muting or long recording gaps.", )) # dnsmos_ovrl if metrics.dnsmos_ovrl is not None: dnsmos_min = ac.get("dnsmos_ovrl", {}).get("min", 3.0) val = metrics.dnsmos_ovrl if val < dnsmos_min: diff = dnsmos_min - val sev = AlertSeverity.critical if diff > 1.0 else AlertSeverity.warning alerts.append(_make_alert( sev, AlertCategory.audio, "dnsmos_ovrl", f"DNSMOS overall speech quality score is {val:.2f}/5 — below the {dnsmos_min} threshold.", "Improve acoustic environment, use a better microphone, or apply noise suppression." )) return alerts # ── Video alerts ────────────────────────────────────────────────────────────── def _video_alerts(metrics: VideoMetrics, cfg: Dict[str, Any]) -> List[Alert]: alerts: List[Alert] = [] vc = cfg.get("video", {}) # brightness if metrics.avg_brightness is not None: br_cfg = vc.get("brightness", {}) br_min = br_cfg.get("min", 80) br_max = br_cfg.get("max", 180) val = metrics.avg_brightness if val < br_min: alerts.append(_make_alert( AlertSeverity.warning, AlertCategory.video, "avg_brightness", f"Average brightness is {val:.1f}/255 — video is too dark.", "Increase room lighting or adjust camera exposure settings." )) elif val > br_max: alerts.append(_make_alert( AlertSeverity.warning, AlertCategory.video, "avg_brightness", f"Average brightness is {val:.1f}/255 — video is overexposed.", "Reduce direct lighting on the speaker or lower camera exposure." )) # sharpness if metrics.avg_sharpness_laplacian is not None: sharp_min = vc.get("sharpness_laplacian", {}).get("min", 100) val = metrics.avg_sharpness_laplacian if val < sharp_min: sev = AlertSeverity.critical if val < sharp_min * 0.5 else AlertSeverity.warning alerts.append(_make_alert( sev, AlertCategory.video, "avg_sharpness_laplacian", f"Image sharpness (Laplacian variance) is {val:.1f} — video appears blurry or out-of-focus.", "Clean the camera lens, ensure correct focus, and avoid camera movement." )) # dropped frames if metrics.dropped_frames_ratio is not None: df_max = vc.get("dropped_frames_ratio", {}).get("max", 0.01) val = metrics.dropped_frames_ratio if val > df_max: alerts.append(_make_alert( AlertSeverity.warning, AlertCategory.video, "dropped_frames_ratio", f"Dropped frame ratio is {val * 100:.2f}% — exceeds the {df_max * 100:.1f}% limit.", "Check recording hardware performance and storage write speed." )) # frozen segments freeze_max = vc.get("freeze_max_duration_sec", 5) if metrics.longest_frozen_seconds > freeze_max: count = sum(1 for s in metrics.frozen_segments if (s.end - s.start) > freeze_max) alerts.append(_make_alert( AlertSeverity.critical, AlertCategory.video, "frozen_segments", f"Detected {count} frozen video segment(s). Total frozen: {metrics.total_frozen_seconds:.1f}s, Longest: {metrics.longest_frozen_seconds:.1f}s.", "Check network stability and recording settings.", )) # black segments black_max = vc.get("black_max_duration_sec", 5) if metrics.longest_black_seconds > black_max: count = sum(1 for s in metrics.black_segments if (s.end - s.start) > black_max) alerts.append(_make_alert( AlertSeverity.warning, AlertCategory.video, "black_segments", f"Detected {count} black screen segment(s). Total black: {metrics.total_black_seconds:.1f}s, Longest: {metrics.longest_black_seconds:.1f}s.", "Check for accidental screen-sharing stops or camera disconnections.", )) # compression artifacts if metrics.compression_artifact_score is not None: ca_max = vc.get("compression_artifact_score", {}).get("max", 0.3) val = metrics.compression_artifact_score if val > ca_max: alerts.append(_make_alert( AlertSeverity.info, AlertCategory.video, "compression_artifact_score", f"Compression artefact score is {val:.2f} — noticeable blocking/banding.", "Use a higher video bitrate in Zoom recording settings." )) return alerts # ── Public API ──────────────────────────────────────────────────────────────── def generate_alerts( metrics: AudioMetrics | VideoMetrics, media_type: str, thresholds_path: str = "thresholds.yaml", ) -> List[Alert]: """ Compare metrics against thresholds and return a list of Alert objects. """ cfg = load_thresholds(thresholds_path) if media_type == "audio": return _audio_alerts(metrics, cfg) # type: ignore[arg-type] return _video_alerts(metrics, cfg) # type: ignore[arg-type] # ── Composite score helpers ─────────────────────────────────────────────────── def compute_audio_score(metrics: AudioMetrics) -> float: """ Weighted composite score (0–1) for audio quality. Higher = better. """ scores = [] # DNSMOS: weight 0.35 if metrics.dnsmos_ovrl is not None: scores.append((metrics.dnsmos_ovrl / 5.0, 0.35)) # SNR: weight 0.25 if metrics.snr_db is not None: snr_score = min(metrics.snr_db / 40.0, 1.0) # 40 dB = perfect scores.append((snr_score, 0.25)) # Loudness: weight 0.20 — penalty for distance from -14 LUFS if metrics.integrated_loudness_lufs is not None: dist = abs(metrics.integrated_loudness_lufs - (-14)) loudness_score = max(0.0, 1.0 - dist / 14.0) scores.append((loudness_score, 0.20)) # Clipping penalty: weight 0.10 clip_score = 1.0 if metrics.clipped_samples_count == 0 else 0.0 scores.append((clip_score, 0.10)) # True peak: weight 0.10 if metrics.true_peak_dbtp is not None: tp_score = 1.0 if metrics.true_peak_dbtp <= -1.0 else 0.0 scores.append((tp_score, 0.10)) if not scores: return 0.5 # default when no data total_weight = sum(w for _, w in scores) weighted_sum = sum(s * w for s, w in scores) return round(weighted_sum / total_weight, 3) def compute_video_score(metrics: VideoMetrics) -> float: """ Weighted composite score (0–1) for video quality. Higher = better. """ scores = [] # Sharpness: weight 0.45 if metrics.avg_sharpness_laplacian is not None: sharp_score = min(metrics.avg_sharpness_laplacian / 300.0, 1.0) scores.append((sharp_score, 0.45)) # Brightness: weight 0.25 if metrics.avg_brightness is not None: br = metrics.avg_brightness # Gaussian-like penalty centred on 130 br_score = max(0.0, 1.0 - abs(br - 130) / 80.0) scores.append((br_score, 0.25)) # Dropped frames: weight 0.20 if metrics.dropped_frames_ratio is not None: df_score = max(0.0, 1.0 - metrics.dropped_frames_ratio * 50) scores.append((df_score, 0.20)) # Frozen segments penalty: weight 0.10 freeze_score = 1.0 if not metrics.frozen_segments else 0.3 scores.append((freeze_score, 0.10)) if not scores: return 0.5 total_weight = sum(w for _, w in scores) weighted_sum = sum(s * w for s, w in scores) return round(weighted_sum / total_weight, 3)