feat: add queue level and status change labels in metrics for better readability

This commit is contained in:
2026-05-12 18:55:42 +08:00
parent e2409d4ebe
commit 330373b8f1
5 changed files with 155 additions and 19 deletions

View File

@@ -217,7 +217,7 @@ def _build_summary(ctx: ManageContext) -> dict:
"result_type": PROJECT_TYPE, "result_type": PROJECT_TYPE,
"headline": ( "headline": (
"Latest report shows " "Latest report shows "
f"{_string_value(queue_metrics.get('queue_level')) or 'few'} queue, " f"{_queue_metric_label(queue_metrics, 'queue_level_label') or _string_value(queue_metrics.get('queue_level')) or 'few'} queue, "
f"{_int_value(queue_metrics.get('over_threshold_count'))} over 5 min and " f"{_int_value(queue_metrics.get('over_threshold_count'))} over 5 min and "
f"{_int_value(queue_metrics.get('under_threshold_count'))} under 5 min" f"{_int_value(queue_metrics.get('under_threshold_count'))} under 5 min"
), ),
@@ -228,6 +228,19 @@ def _build_summary(ctx: ManageContext) -> dict:
"window_end": window_end, "window_end": window_end,
"total_people": total_people, "total_people": total_people,
"queue_level": _string_value(queue_metrics.get("queue_level")), "queue_level": _string_value(queue_metrics.get("queue_level")),
"queue_level_label": _queue_metric_label(
queue_metrics,
"queue_level_label",
fallback_level=_string_value(queue_metrics.get("queue_level")),
),
"previous_queue_level": _string_value(
queue_metrics.get("previous_queue_level")
),
"previous_queue_level_label": _queue_metric_label(
queue_metrics,
"previous_queue_level_label",
fallback_level=_string_value(queue_metrics.get("previous_queue_level")),
),
"over_threshold_count": _int_value( "over_threshold_count": _int_value(
queue_metrics.get("over_threshold_count") queue_metrics.get("over_threshold_count")
), ),
@@ -235,6 +248,11 @@ def _build_summary(ctx: ManageContext) -> dict:
queue_metrics.get("under_threshold_count") queue_metrics.get("under_threshold_count")
), ),
"status_change": _string_value(queue_metrics.get("status_change")), "status_change": _string_value(queue_metrics.get("status_change")),
"status_change_label": _queue_metric_label(
queue_metrics,
"status_change_label",
fallback_status=_string_value(queue_metrics.get("status_change")),
),
"direction_counts": direction_counts, "direction_counts": direction_counts,
"age_counts": _map_string_int(payload.get("age_counts")), "age_counts": _map_string_int(payload.get("age_counts")),
"gender_counts": _map_string_int(payload.get("gender_counts")), "gender_counts": _map_string_int(payload.get("gender_counts")),
@@ -282,6 +300,19 @@ def _load_window_stats(ctx: ManageContext) -> list[dict]:
"window_end": _string_value(payload.get("window_end")), "window_end": _string_value(payload.get("window_end")),
"total_people": _int_value(payload.get("total_people")), "total_people": _int_value(payload.get("total_people")),
"queue_level": _queue_metric_value(payload, "queue_level"), "queue_level": _queue_metric_value(payload, "queue_level"),
"queue_level_label": _queue_metric_label(
payload.get("queue_metrics"),
"queue_level_label",
fallback_level=_queue_metric_value(payload, "queue_level"),
),
"previous_queue_level": _queue_metric_value(
payload, "previous_queue_level"
),
"previous_queue_level_label": _queue_metric_label(
payload.get("queue_metrics"),
"previous_queue_level_label",
fallback_level=_queue_metric_value(payload, "previous_queue_level"),
),
"over_threshold_count": _queue_metric_int( "over_threshold_count": _queue_metric_int(
payload, "over_threshold_count" payload, "over_threshold_count"
), ),
@@ -289,6 +320,11 @@ def _load_window_stats(ctx: ManageContext) -> list[dict]:
payload, "under_threshold_count" payload, "under_threshold_count"
), ),
"status_change": _queue_metric_value(payload, "status_change"), "status_change": _queue_metric_value(payload, "status_change"),
"status_change_label": _queue_metric_label(
payload.get("queue_metrics"),
"status_change_label",
fallback_status=_queue_metric_value(payload, "status_change"),
),
"age_counts": _map_string_int(payload.get("age_counts")), "age_counts": _map_string_int(payload.get("age_counts")),
"gender_counts": _map_string_int(payload.get("gender_counts")), "gender_counts": _map_string_int(payload.get("gender_counts")),
"unknown_attributes": _int_value(payload.get("unknown_attributes")), "unknown_attributes": _int_value(payload.get("unknown_attributes")),
@@ -448,5 +484,46 @@ def _queue_metric_int(payload: dict, field: str) -> int:
return _int_value(queue_metrics.get(field)) return _int_value(queue_metrics.get(field))
def _queue_metric_label(
queue_metrics,
field: str,
fallback_level: str = "",
fallback_status: str = "",
) -> str:
if isinstance(queue_metrics, dict):
direct = _string_value(queue_metrics.get(field))
if direct:
return direct
if fallback_level:
return _queue_level_label(fallback_level)
if fallback_status:
return _queue_status_change_label(fallback_status)
return ""
def _queue_level_label(level: str) -> str:
if level == "crowded":
return "人多"
if level == "normal":
return "人数正常"
if level == "few":
return "人少"
return ""
def _queue_status_change_label(status_change: str) -> str:
if status_change == "queue_increased":
return "人数变多"
if status_change == "queue_decreased":
return "人数变少"
if status_change == "queue_normalized":
return "人数变正常"
if status_change == "unchanged":
return "没变化"
if status_change == "initial":
return "初始"
return "其他变化"
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main())

View File

@@ -126,8 +126,11 @@ class QueueWindowTracker:
"over_threshold_count": over_threshold_count, "over_threshold_count": over_threshold_count,
"under_threshold_count": under_threshold_count, "under_threshold_count": under_threshold_count,
"queue_level": queue_level, "queue_level": queue_level,
"queue_level_label": _queue_level_label(queue_level),
"previous_queue_level": previous_queue_level, "previous_queue_level": previous_queue_level,
"previous_queue_level_label": _queue_level_label(previous_queue_level),
"status_change": status_change, "status_change": status_change,
"status_change_label": _queue_status_change_label(status_change),
"people": [ "people": [
{ {
"person_id": f"track_{track_id}", "person_id": f"track_{track_id}",
@@ -199,3 +202,27 @@ def _queue_status_change(previous_level: str | None, current_level: str) -> str:
if current_level == "normal" and previous_level in {"crowded", "few"}: if current_level == "normal" and previous_level in {"crowded", "few"}:
return "queue_normalized" return "queue_normalized"
return "changed" return "changed"
def _queue_level_label(level: str | None) -> str:
if level == "crowded":
return "人多"
if level == "normal":
return "人数正常"
if level == "few":
return "人少"
return ""
def _queue_status_change_label(status_change: str) -> str:
if status_change == "queue_increased":
return "人数变多"
if status_change == "queue_decreased":
return "人数变少"
if status_change == "queue_normalized":
return "人数变正常"
if status_change == "unchanged":
return "没变化"
if status_change == "initial":
return "初始"
return "其他变化"

View File

@@ -46,8 +46,11 @@ def build_client(project_root: Path):
"over_threshold_count": 6, "over_threshold_count": 6,
"under_threshold_count": 2, "under_threshold_count": 2,
"queue_level": "crowded", "queue_level": "crowded",
"queue_level_label": "人多",
"previous_queue_level": "normal", "previous_queue_level": "normal",
"previous_queue_level_label": "人数正常",
"status_change": "queue_increased", "status_change": "queue_increased",
"status_change_label": "人数变多",
}, },
"tracks": [ "tracks": [
{"track_id": 1, "direction": "in"}, {"track_id": 1, "direction": "in"},
@@ -70,8 +73,11 @@ def build_client(project_root: Path):
"over_threshold_count": 2, "over_threshold_count": 2,
"under_threshold_count": 1, "under_threshold_count": 1,
"queue_level": "normal", "queue_level": "normal",
"queue_level_label": "人数正常",
"previous_queue_level": None, "previous_queue_level": None,
"previous_queue_level_label": "",
"status_change": "initial", "status_change": "initial",
"status_change_label": "初始",
}, },
"age_counts": {"minor": 0, "adult": 4, "senior": 1}, "age_counts": {"minor": 0, "adult": 4, "senior": 1},
"gender_counts": {"male": 2, "female": 3}, "gender_counts": {"male": 2, "female": 3},
@@ -145,9 +151,13 @@ def test_get_manage_summary(tmp_path: Path):
assert response.json["last_result_time"] == "2026-04-16T10:00:00+08:00" assert response.json["last_result_time"] == "2026-04-16T10:00:00+08:00"
assert response.json["metrics"]["total_people"] == 7 assert response.json["metrics"]["total_people"] == 7
assert response.json["metrics"]["queue_level"] == "crowded" assert response.json["metrics"]["queue_level"] == "crowded"
assert response.json["metrics"]["queue_level_label"] == "人多"
assert response.json["metrics"]["previous_queue_level"] == "normal"
assert response.json["metrics"]["previous_queue_level_label"] == "人数正常"
assert response.json["metrics"]["over_threshold_count"] == 6 assert response.json["metrics"]["over_threshold_count"] == 6
assert response.json["metrics"]["under_threshold_count"] == 2 assert response.json["metrics"]["under_threshold_count"] == 2
assert response.json["metrics"]["status_change"] == "queue_increased" assert response.json["metrics"]["status_change"] == "queue_increased"
assert response.json["metrics"]["status_change_label"] == "人数变多"
assert response.json["metrics"]["direction_counts"] == {"in": 2, "out": 1} assert response.json["metrics"]["direction_counts"] == {"in": 2, "out": 1}
assert ( assert (
response.json["metrics"]["recent_window_stats"][0]["window_end"] response.json["metrics"]["recent_window_stats"][0]["window_end"]
@@ -167,6 +177,11 @@ def test_get_manage_windows(tmp_path: Path):
assert response.json["items"][0]["window_end"] == "2026-04-16T10:00:00+08:00" assert response.json["items"][0]["window_end"] == "2026-04-16T10:00:00+08:00"
assert response.json["items"][0]["total_people"] == 7 assert response.json["items"][0]["total_people"] == 7
assert response.json["items"][0]["queue_level"] == "crowded" assert response.json["items"][0]["queue_level"] == "crowded"
assert response.json["items"][0]["queue_level_label"] == "人多"
assert response.json["items"][0]["previous_queue_level"] == "normal"
assert response.json["items"][0]["previous_queue_level_label"] == "人数正常"
assert response.json["items"][0]["status_change"] == "queue_increased"
assert response.json["items"][0]["status_change_label"] == "人数变多"
def test_get_manage_files(tmp_path: Path): def test_get_manage_files(tmp_path: Path):

View File

@@ -41,3 +41,8 @@ def test_queue_window_tracker_builds_crowded_report():
assert queue_metrics["over_threshold_count"] == 6 assert queue_metrics["over_threshold_count"] == 6
assert queue_metrics["under_threshold_count"] == 2 assert queue_metrics["under_threshold_count"] == 2
assert queue_metrics["queue_level"] == "crowded" assert queue_metrics["queue_level"] == "crowded"
assert queue_metrics["queue_level_label"] == "人多"
assert queue_metrics["previous_queue_level"] is None
assert queue_metrics["previous_queue_level_label"] == ""
assert queue_metrics["status_change"] == "initial"
assert queue_metrics["status_change_label"] == "初始"

View File

@@ -2,32 +2,44 @@
## Checklist ## Checklist
- [x] Re-read the current `people_flow_project` same-person dedupe implementation and existing tests. - [x] Confirm the changed `people_flow_project` slice is locally validated before deploy.
- [x] Verify the plan covers both code-path inspection and executable validation of actual output. - [x] Verify the plan covers remote sync, service rebuild, health verification, and post-deploy output inspection.
- [x] Run focused tests covering window identity and counting dedupe. - [x] Sync the updated `people_flow_project` runtime files to `10.8.0.11` and verify remote hashes.
- [x] Reproduce a same-person reentry scenario through the runtime counting path and inspect the resulting output values. - [x] Rebuild and restart only the `people-flow-project` service on the remote host.
- [x] If available, compare the synthetic output with remote runtime artifacts or logs for consistency. - [x] Verify the remote container is healthy after deployment.
- [x] Record the validation result and any remaining evidence gap in the Review section. - [x] Print the actual new output structure from the deployed remote code path and note any limitation versus waiting for the next live half-hour webhook.
- [x] Record deployment and verification evidence in the Review section.
## Scope And Risks ## Scope And Risks
- Scope: validate whether the previously changed `people_flow_project` logic really counts the same person only once when that person exits and re-enters multiple times within the same half-hour window. - Scope: deploy the `people_flow_project` output-label changes to `10.8.0.11` and inspect the newly available output structure from the remote deployed code.
- Expected touch points: read-only inspection of `managed/people_flow_project/src/people_flow/counting.py`, `managed/people_flow_project/src/people_flow/window_identity.py`, `managed/people_flow_project/src/people_flow/pipeline.py`, focused tests, and possibly remote output artifacts or logs. - Expected touch points: `managed/people_flow_project/src/people_flow/queue_analytics.py`, `managed/people_flow_project/src/people_flow/manage_api.py`, remote deployment under `/home/xiaozheng/managed-portal`, and the `people-flow-project` docker compose service.
- Risk: remote runtime payloads may not expose enough identity detail to prove dedupe for a specific real person, so synthetic execution may be the strongest evidence. - Risk: the currently saved live webhook/window JSON files on the remote host will not gain the new label fields until the next real half-hour window is emitted after restart, so immediate inspection may need to use a direct code-path sample or manage API response rather than a freshly emitted live webhook file.
- Risk: the local environment may lack heavy runtime dependencies for a full pipeline run; if so, validation should use the narrowest dependency-light path that still exercises the production counting logic. - Risk: restarting `people-flow-project` resets the current rolling half-hour window boundary; that is acceptable for deployment but should be stated explicitly.
## Validation Intent ## Validation Intent
- First confirm the current code path still routes `person_keys` from `WindowIdentityResolver` into `LineCrossCounter` and ultimately into `total_people` in the half-hour payload. - Verify remote file parity before rebuilding.
- Run the focused tests that directly cover reentry dedupe. - Check container health and startup logs after deployment.
- Execute one synthetic scenario through the real resolver and counter classes and inspect the actual emitted values such as `events`, `crossings`, and `total_people`. - Print an actual structure from the deployed remote code path immediately, and distinguish it from the next live webhook file that will only appear after the next rollover.
## Review ## Review
- Status: completed. - Status: completed.
- Result: the current `people_flow_project` same-person dedupe logic behaves correctly for the intended case: within one half-hour window, the same visual person can disappear, reappear under a new track id, cross the counting line again, and still contribute only `1` to the final `total_people` output. - Result: the updated `people_flow_project` code is deployed on `10.8.0.11`, the rebuilt `people-flow-project` container is healthy, and the deployed remote code path now exposes the new human-readable queue level and change labels. The currently saved live window/webhook files were generated before the next post-restart half-hour rollover, so the most immediate proof comes from the deployed manage API response and a direct runtime-code simulation inside the container.
- Verification: - Verification:
- re-read the active code path and confirmed `managed/people_flow_project/src/people_flow/pipeline.py` passes `person_keys = identity_resolver.resolve(...)` into `counter.update(...)`, and the emitted half-hour payload uses `counter.total_people` as `total_people`; - synced `managed/people_flow_project/src/people_flow/queue_analytics.py` and `managed/people_flow_project/src/people_flow/manage_api.py` to `/home/xiaozheng/managed-portal/managed/people_flow_project/src/people_flow/` on `10.8.0.11` and verified SHA256 parity with local files:
- ran `pytest tests/test_counting.py` under `managed/people_flow_project` and got `2 passed` for the focused dedupe tests; - `queue_analytics.py`: `dd12c0a7af2d7c1bf68e3496560fe2ea0fb5c1d582bea7c4dada0caf105711c8`
- executed a local synthetic scenario with the real `WindowIdentityResolver` and `LineCrossCounter` classes: track `1` crossed once, then the same constant-signature person disappeared and re-entered as track `2` and crossed again; observed `first_keys = {1: 'person:00001'}`, `second_keys = {2: 'person:00001'}`, `first_events = [{'track_id': 1, 'direction': 'negative_to_positive'}]`, `second_events = []`, `total_people = 1`, and payload-like output `{'total_people': 1, 'tracks': [{'track_id': 1, 'direction': 'negative_to_positive'}]}`; - `manage_api.py`: `c723fd570a29b43cd055dfaca4a5fc9ce1459b55754d2dbd0b8edcdef7da4cf1`
- inspected remote runtime artifacts on `10.8.0.11` and confirmed the latest `people_flow_project` window artifact and webhook event are still emitted through the same `half_hour_report` shape with `total_people` and `tracks` fields; the most recent remote window ended at `2026-05-12T16:27:58+08:00` with `total_people = 48`. - rebuilt and restarted only `people-flow-project` with `docker compose --env-file managed-portal.10.8.0.11.env up -d --build people-flow-project` on the remote host;
- confirmed remote status after deploy: `people-flow-project` is `Up` and `healthy`;
- queried the deployed manage API summary endpoint inside the container and observed these actual metrics keys/values from the live response: `{ "queue_level": "normal", "queue_level_label": "人数正常", "previous_queue_level": "few", "previous_queue_level_label": "人少", "status_change": "queue_normalized", "status_change_label": "人数变正常" }`;
- executed a direct simulation inside the deployed container using the updated `QueueWindowTracker` code path and printed the actual new `queue_metrics` JSON:
- `queue_level`: `crowded`
- `queue_level_label`: `人多`
- `previous_queue_level`: `null`
- `previous_queue_level_label`: `""`
- `status_change`: `initial`
- `status_change_label`: `初始`
- plus the existing `queue_time_threshold_seconds`, `over_threshold_count`, `under_threshold_count`, and `people[]` fields;
- noted deployment side effect: restarting `people-flow-project` resets the current rolling 1800-second window, so the next real live `half_hour_report` file/webhook emitted after this restart will be the first persisted artifact that contains the new label fields.