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:
2026-05-09 11:35:55 +08:00
parent be5014c582
commit ea618fd674
26 changed files with 1605 additions and 117 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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):