Refactor store dwell alert management API and dwell engine
- Updated argument parsing in manage_api.py to include new threshold parameters. - Enhanced _config_payload to include thresholds and webhook configurations. - Modified _build_summary to track queue metrics and adjust alert reporting. - Refactored DwellEngine to utilize queue thresholds for alerting and reporting. - Added queue metrics calculations and status change tracking in dwell_engine.py. - Updated notifier.py to support posting JSON events to webhooks. - Adjusted example configuration to reflect new threshold parameters. - Enhanced Docker entrypoint script for better process management. - Updated tests to cover new queue metrics and thresholds. - Improved ManagedServiceDetail and ManagedServices Vue components to display queue metrics.
This commit is contained in:
@@ -8,16 +8,18 @@ def test_load_config_reads_thresholds(tmp_path: Path):
|
||||
cfg.write_text(
|
||||
"camera_id: store_cam_01\n"
|
||||
"thresholds:\n"
|
||||
" min_people: 5\n"
|
||||
" min_dwell_seconds: 600\n",
|
||||
" queue_time_threshold_seconds: 300\n"
|
||||
" crowded_count_threshold: 5\n"
|
||||
" normal_count_threshold: 2\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
data = load_config(cfg)
|
||||
|
||||
assert data.camera_id == "store_cam_01"
|
||||
assert data.thresholds.min_people == 5
|
||||
assert data.thresholds.min_dwell_seconds == 600
|
||||
assert data.thresholds.queue_time_threshold_seconds == 300
|
||||
assert data.thresholds.crowded_count_threshold == 5
|
||||
assert data.thresholds.normal_count_threshold == 2
|
||||
|
||||
|
||||
def test_load_config_uses_defaults_for_optional_sections(tmp_path: Path):
|
||||
@@ -29,4 +31,5 @@ def test_load_config_uses_defaults_for_optional_sections(tmp_path: Path):
|
||||
assert data.stream.sample_fps == 2.0
|
||||
assert data.staff.min_hits == 3
|
||||
assert data.event_sink.path == "logs/events.jsonl"
|
||||
assert data.webhook.url == ""
|
||||
assert data.webhook.timeout_seconds == 5.0
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.modules.dwell_engine import DwellEngine, DwellSession, long_stay_count
|
||||
|
||||
from app.modules.dwell_engine import DwellEngine, DwellSession
|
||||
|
||||
TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
@@ -12,34 +11,81 @@ def test_session_pauses_without_adding_absence_time():
|
||||
session = DwellSession(person_id="cust_1", session_id="cust_1-s1", entered_at=start)
|
||||
session.mark_seen(start.replace(minute=2))
|
||||
session.pause(start.replace(minute=2, second=10))
|
||||
session.close_if_expired(start.replace(minute=7, second=11), pause_timeout_seconds=300)
|
||||
session.close_if_expired(
|
||||
start.replace(minute=7, second=11), pause_timeout_seconds=300
|
||||
)
|
||||
assert session.state == "closed"
|
||||
assert session.dwell_seconds() == 130
|
||||
|
||||
|
||||
def test_engine_emits_alert_when_five_long_stays_are_active():
|
||||
def test_engine_emits_half_hour_report_with_queue_classification():
|
||||
engine = DwellEngine(
|
||||
camera_id="store_cam_01",
|
||||
min_people=5,
|
||||
min_dwell_seconds=600,
|
||||
queue_time_threshold_seconds=300,
|
||||
crowded_count_threshold=5,
|
||||
normal_count_threshold=2,
|
||||
pause_timeout_seconds=300,
|
||||
alert_cooldown_seconds=600,
|
||||
)
|
||||
now = datetime(2026, 4, 15, 11, 20, tzinfo=TZ)
|
||||
observations = [{"person_id": f"cust_{idx}", "role": "customer"} for idx in range(5)]
|
||||
start = datetime(2026, 4, 15, 11, 0, tzinfo=TZ)
|
||||
crowded_group = [
|
||||
{"person_id": f"cust_{idx}", "role": "customer"} for idx in range(6)
|
||||
]
|
||||
short_wait_group = [
|
||||
{"person_id": f"short_{idx}", "role": "customer"} for idx in range(2)
|
||||
]
|
||||
|
||||
engine.process_observations(observations, now.replace(minute=9, second=0))
|
||||
events = engine.process_observations(observations, now)
|
||||
engine.process_observations(crowded_group, start.replace(minute=0, second=0))
|
||||
engine.process_observations(crowded_group, start.replace(minute=6, second=0))
|
||||
engine.process_observations(
|
||||
crowded_group + short_wait_group, start.replace(minute=27, second=0)
|
||||
)
|
||||
events = engine.process_observations(
|
||||
short_wait_group, start.replace(minute=30, second=0)
|
||||
)
|
||||
|
||||
assert [event["event"] for event in events] == ["long_stay_alert"]
|
||||
assert events[0]["active_long_stay_count"] == 5
|
||||
report = next(event for event in events if event["event"] == "half_hour_report")
|
||||
assert report["queue_metrics"]["over_threshold_count"] == 6
|
||||
assert report["queue_metrics"]["under_threshold_count"] == 2
|
||||
assert report["queue_metrics"]["queue_level"] == "crowded"
|
||||
assert report["queue_metrics"]["status_change"] == "initial"
|
||||
|
||||
|
||||
def test_engine_tracks_queue_status_change_between_windows():
|
||||
engine = DwellEngine(
|
||||
camera_id="store_cam_01",
|
||||
queue_time_threshold_seconds=300,
|
||||
crowded_count_threshold=5,
|
||||
normal_count_threshold=2,
|
||||
pause_timeout_seconds=300,
|
||||
alert_cooldown_seconds=600,
|
||||
)
|
||||
start = datetime(2026, 4, 15, 11, 0, tzinfo=TZ)
|
||||
engine.process_observations(
|
||||
[{"person_id": f"crowded_{idx}", "role": "customer"} for idx in range(6)],
|
||||
start,
|
||||
)
|
||||
engine.process_observations([], start.replace(minute=30))
|
||||
engine.process_observations(
|
||||
[{"person_id": f"normal_{idx}", "role": "customer"} for idx in range(3)],
|
||||
start.replace(minute=31),
|
||||
)
|
||||
report_events = engine.process_observations([], start.replace(hour=12, minute=0))
|
||||
|
||||
report = next(
|
||||
event for event in report_events if event["event"] == "half_hour_report"
|
||||
)
|
||||
assert report["queue_metrics"]["queue_level"] == "normal"
|
||||
assert report["queue_metrics"]["status_change"] == "queue_normalized"
|
||||
assert report["queue_metrics"]["previous_queue_level"] == "crowded"
|
||||
|
||||
|
||||
def test_engine_emits_half_hour_report_with_closed_customers():
|
||||
engine = DwellEngine(
|
||||
camera_id="store_cam_01",
|
||||
min_people=5,
|
||||
min_dwell_seconds=600,
|
||||
queue_time_threshold_seconds=300,
|
||||
crowded_count_threshold=5,
|
||||
normal_count_threshold=2,
|
||||
pause_timeout_seconds=300,
|
||||
alert_cooldown_seconds=600,
|
||||
)
|
||||
@@ -53,11 +99,5 @@ def test_engine_emits_half_hour_report_with_closed_customers():
|
||||
report = next(event for event in events if event["event"] == "half_hour_report")
|
||||
assert report["window_end"] == "2026-04-15T11:30:00+08:00"
|
||||
assert report["closed_customers"][0]["person_id"] == "cust_1"
|
||||
|
||||
|
||||
def test_long_stay_count_excludes_staff():
|
||||
sessions = [
|
||||
{"role": "customer", "state": "active", "dwell_seconds": 700},
|
||||
{"role": "staff", "state": "active", "dwell_seconds": 40000},
|
||||
]
|
||||
assert long_stay_count(sessions, min_dwell_seconds=600) == 1
|
||||
assert report["queue_metrics"]["over_threshold_count"] == 0
|
||||
assert report["queue_metrics"]["under_threshold_count"] == 1
|
||||
|
||||
@@ -14,6 +14,10 @@ def build_client(project_root: Path):
|
||||
config_path.write_text(
|
||||
"camera_id: store_cam_01\n"
|
||||
"timezone: Asia/Shanghai\n"
|
||||
"thresholds:\n"
|
||||
" queue_time_threshold_seconds: 300\n"
|
||||
" crowded_count_threshold: 5\n"
|
||||
" normal_count_threshold: 2\n"
|
||||
"stream:\n"
|
||||
" rtsp_url: rtsp://before-update\n"
|
||||
" sample_fps: 2.0\n"
|
||||
@@ -28,13 +32,6 @@ def build_client(project_root: Path):
|
||||
(logs_dir / "events.jsonl").write_text(
|
||||
"\n".join(
|
||||
[
|
||||
json.dumps(
|
||||
{
|
||||
"event": "long_stay_alert",
|
||||
"camera_id": "store_cam_01",
|
||||
"ts": "2026-04-16T09:00:00+08:00",
|
||||
}
|
||||
),
|
||||
json.dumps(
|
||||
{
|
||||
"event": "half_hour_report",
|
||||
@@ -50,6 +47,14 @@ def build_client(project_root: Path):
|
||||
{"person_id": "cust_3", "final_dwell_seconds": 450}
|
||||
],
|
||||
"staff_seen_count": 1,
|
||||
"queue_metrics": {
|
||||
"queue_time_threshold_seconds": 300,
|
||||
"over_threshold_count": 2,
|
||||
"under_threshold_count": 1,
|
||||
"queue_level": "normal",
|
||||
"previous_queue_level": null,
|
||||
"status_change": "initial",
|
||||
},
|
||||
}
|
||||
),
|
||||
json.dumps(
|
||||
@@ -67,6 +72,14 @@ def build_client(project_root: Path):
|
||||
{"person_id": "cust_6", "final_dwell_seconds": 120},
|
||||
],
|
||||
"staff_seen_count": 0,
|
||||
"queue_metrics": {
|
||||
"queue_time_threshold_seconds": 300,
|
||||
"over_threshold_count": 6,
|
||||
"under_threshold_count": 2,
|
||||
"queue_level": "crowded",
|
||||
"previous_queue_level": "normal",
|
||||
"status_change": "queue_increased",
|
||||
},
|
||||
}
|
||||
),
|
||||
]
|
||||
@@ -126,10 +139,14 @@ def test_get_manage_summary(tmp_path: Path):
|
||||
assert response.status_code == 200
|
||||
assert response.json["result_type"] == "store_dwell_alert"
|
||||
assert response.json["last_result_time"] == "2026-04-16T10:00:00+08:00"
|
||||
assert response.json["metrics"]["alert_count"] == 1
|
||||
assert response.json["metrics"]["active_customer_count"] == 1
|
||||
assert response.json["metrics"]["longest_dwell_seconds"] == 900
|
||||
assert response.json["metrics"]["recent_window_stats"][0]["window_end"] == "2026-04-16T10:00:00+08:00"
|
||||
assert response.json["metrics"]["queue_level"] == "crowded"
|
||||
assert response.json["metrics"]["over_threshold_count"] == 6
|
||||
assert response.json["metrics"]["under_threshold_count"] == 2
|
||||
assert response.json["metrics"]["status_change"] == "queue_increased"
|
||||
assert (
|
||||
response.json["metrics"]["recent_window_stats"][0]["window_end"]
|
||||
== "2026-04-16T10:00:00+08:00"
|
||||
)
|
||||
|
||||
|
||||
def test_get_manage_windows(tmp_path: Path):
|
||||
|
||||
Reference in New Issue
Block a user