feat: improve ota packaging and people-flow runtime

This commit is contained in:
2026-05-19 15:44:00 +08:00
parent 6783be8db0
commit f3f40b5167
8 changed files with 452 additions and 29 deletions

View File

@@ -82,6 +82,41 @@ docker compose --env-file managed-portal.env up -d --build
http://<服务器IP>:13000/
```
## OTA 发布包
OTA 安装脚本默认从发布目录下载一个主 ZIP再用 `deploy/docker-compose.ota-release.yml` 启动服务。主 ZIP 现在只需要包含安装端真正依赖的内容:
- `deploy/docker-compose.ota-release.yml`
- `deploy/Dockerfile.runtime-overlay`
- `deploy/managed-portal.release.env`
- `release-manifest.env`
- `managed/store_dwell_alert/config/`
- `managed/people_flow_project/config/`
仓库提供了一个最小打包脚本:`deploy/package-managed-portal-ota.sh`
示例:
```bash
RELEASE_VERSION=20260518-7b32b21-11 \
RELEASE_MANIFEST_SOURCE=/path/to/release-manifest.env \
RELEASE_ENV_SOURCE=deploy/managed-portal.10.8.0.12.env \
sh deploy/package-managed-portal-ota.sh
```
默认情况下,主 ZIP 不包含 `managed/people_flow_project/weights/`。OTA installer 会优先复用主机上的共享权重目录,避免每次只改安装脚本或配置时都重复打包、上传大体积权重。
只有两种场景才建议把权重重新打进 ZIP
- 首次在一台没有预置权重的新主机上安装
- `people_flow_project` 的权重文件本身发生变更
这两种场景可以临时打开:
```bash
INCLUDE_WEIGHTS=1 sh deploy/package-managed-portal-ota.sh
```
## 模型权重
子服务镜像构建前需要以下权重文件:

View File

@@ -1,11 +1,13 @@
#!/usr/bin/env sh
set -eu
RELEASE_VERSION="${RELEASE_VERSION:-20260513-330373b-11}"
RELEASE_VERSION="${RELEASE_VERSION:-20260518-7b32b21-11}"
BASE_URL="${BASE_URL:-http://10.8.0.1/ai_deploy}"
BUNDLE_NAME="${BUNDLE_NAME:-managed-portal-${RELEASE_VERSION}.zip}"
INSTALL_ROOT="${INSTALL_ROOT:-/opt/managed-portal-releases}"
TARGET_DIR="${TARGET_DIR:-${INSTALL_ROOT}/managed-portal-${RELEASE_VERSION}}"
SHARED_ROOT="${SHARED_ROOT:-${INSTALL_ROOT}/shared}"
DEFAULT_PEOPLE_FLOW_WEIGHTS_DIR="${SHARED_ROOT}/people_flow_project/weights"
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
@@ -14,6 +16,23 @@ require_command() {
fi
}
pull_or_use_local() {
image="$1"
if docker pull "$image"; then
return 0
fi
echo "docker pull failed for $image, checking local image cache" >&2
if docker image inspect "$image" >/dev/null 2>&1; then
echo "using existing local image $image" >&2
return 0
fi
echo "image unavailable locally after pull failure: $image" >&2
exit 1
}
run_compose() {
if command -v docker-compose >/dev/null 2>&1; then
docker-compose "$@"
@@ -22,6 +41,19 @@ run_compose() {
docker compose "$@"
}
dir_has_files() {
directory="$1"
[ -d "$directory" ] && [ -n "$(find "$directory" -mindepth 1 -print -quit 2>/dev/null)" ]
}
copy_dir_contents() {
source_dir="$1"
target_dir="$2"
mkdir -p "$target_dir"
cp -R "$source_dir"/. "$target_dir"/
}
download_bundle() {
tmp_dir="$1"
bundle_zip="$tmp_dir/$BUNDLE_NAME"
@@ -58,6 +90,66 @@ build_overlay_image() {
printf '%s\n' "$overlay_image"
}
clear_stale_runtime_state() {
people_flow_status="$TARGET_DIR/managed/people_flow_project/outputs/rtsp_stream/worker_status.json"
if [ -f "$people_flow_status" ]; then
rm -f "$people_flow_status"
fi
}
ensure_runtime_directories() {
mkdir -p \
"$TARGET_DIR/managed/store_dwell_alert/config" \
"$TARGET_DIR/managed/store_dwell_alert/data" \
"$TARGET_DIR/managed/people_flow_project/config" \
"$TARGET_DIR/managed/people_flow_project/outputs/rtsp_stream"
}
find_existing_people_flow_weights() {
if dir_has_files "$MANAGED_PEOPLE_FLOW_WEIGHTS_DIR"; then
printf '%s\n' "$MANAGED_PEOPLE_FLOW_WEIGHTS_DIR"
return 0
fi
for candidate in "$INSTALL_ROOT"/managed-portal-*/managed/people_flow_project/weights; do
if [ "$candidate" = "$TARGET_DIR/managed/people_flow_project/weights" ]; then
continue
fi
if dir_has_files "$candidate"; then
printf '%s\n' "$candidate"
return 0
fi
done
return 1
}
prepare_people_flow_weights() {
bundle_weights_dir="$TARGET_DIR/managed/people_flow_project/weights"
source_weights_dir=""
if dir_has_files "$bundle_weights_dir"; then
source_weights_dir="$bundle_weights_dir"
echo "seeding shared people-flow weights from bundle" >&2
elif source_weights_dir="$(find_existing_people_flow_weights 2>/dev/null)"; then
echo "reusing existing people-flow weights from $source_weights_dir" >&2
else
echo "people-flow weights not found; seed $MANAGED_PEOPLE_FLOW_WEIGHTS_DIR or include managed/people_flow_project/weights in the release zip" >&2
exit 1
fi
if [ "$source_weights_dir" != "$MANAGED_PEOPLE_FLOW_WEIGHTS_DIR" ]; then
rm -rf "$MANAGED_PEOPLE_FLOW_WEIGHTS_DIR"
copy_dir_contents "$source_weights_dir" "$MANAGED_PEOPLE_FLOW_WEIGHTS_DIR"
fi
rm -rf "$bundle_weights_dir"
mkdir -p "$(dirname "$bundle_weights_dir")"
ln -s "$MANAGED_PEOPLE_FLOW_WEIGHTS_DIR" "$bundle_weights_dir"
}
require_command curl
require_command unzip
require_command docker
@@ -79,11 +171,18 @@ set -a
. "$TARGET_DIR/release-manifest.env"
set +a
MANAGED_PEOPLE_FLOW_WEIGHTS_DIR="${MANAGED_PEOPLE_FLOW_WEIGHTS_DIR:-$DEFAULT_PEOPLE_FLOW_WEIGHTS_DIR}"
ensure_runtime_directories
prepare_people_flow_weights
clear_stale_runtime_state
echo "pulling release images"
docker pull "$MANAGED_PORTAL_IMAGE"
docker pull "$MANAGED_PORTAL_WEB_IMAGE"
docker pull "$PEOPLE_FLOW_PROJECT_IMAGE"
docker pull "$STORE_DWELL_ALERT_IMAGE"
pull_or_use_local "$MANAGED_PORTAL_IMAGE"
pull_or_use_local "$MANAGED_PORTAL_WEB_IMAGE"
pull_or_use_local "$PEOPLE_FLOW_PROJECT_IMAGE"
pull_or_use_local "$STORE_DWELL_ALERT_IMAGE"
PEOPLE_FLOW_PROJECT_IMAGE="$(build_overlay_image \
people-flow-project \
@@ -101,6 +200,7 @@ export MANAGED_PORTAL_IMAGE
export MANAGED_PORTAL_WEB_IMAGE
export PEOPLE_FLOW_PROJECT_IMAGE
export STORE_DWELL_ALERT_IMAGE
export MANAGED_PEOPLE_FLOW_WEIGHTS_DIR
cd "$TARGET_DIR/deploy"
run_compose \

View File

@@ -0,0 +1,98 @@
#!/usr/bin/env sh
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)"
REPO_ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)"
RELEASE_VERSION="${RELEASE_VERSION:?RELEASE_VERSION is required}"
RELEASE_MANIFEST_SOURCE="${RELEASE_MANIFEST_SOURCE:?RELEASE_MANIFEST_SOURCE is required}"
RELEASE_ENV_SOURCE="${RELEASE_ENV_SOURCE:-$SCRIPT_DIR/managed-portal.env}"
OUTPUT_DIR="${OUTPUT_DIR:-$REPO_ROOT/dist/ota}"
STAGE_DIR="${OUTPUT_DIR}/managed-portal-${RELEASE_VERSION}"
BUNDLE_PATH="${OUTPUT_DIR}/managed-portal-${RELEASE_VERSION}.zip"
INSTALLER_PATH="${OUTPUT_DIR}/install-managed-portal-${RELEASE_VERSION}.sh"
INCLUDE_WEIGHTS="${INCLUDE_WEIGHTS:-0}"
PEOPLE_FLOW_WEIGHTS_SOURCE="${PEOPLE_FLOW_WEIGHTS_SOURCE:-$REPO_ROOT/managed/people_flow_project/weights}"
require_path() {
target="$1"
if [ ! -e "$target" ]; then
echo "missing required path: $target" >&2
exit 1
fi
}
dir_has_files() {
directory="$1"
[ -d "$directory" ] && [ -n "$(find "$directory" -mindepth 1 -print -quit 2>/dev/null)" ]
}
copy_dir() {
source_dir="$1"
target_dir="$2"
mkdir -p "$(dirname "$target_dir")"
cp -R "$source_dir" "$target_dir"
}
require_path "$RELEASE_MANIFEST_SOURCE"
require_path "$RELEASE_ENV_SOURCE"
require_path "$SCRIPT_DIR/docker-compose.ota-release.yml"
require_path "$SCRIPT_DIR/Dockerfile.runtime-overlay"
require_path "$SCRIPT_DIR/install-managed-portal-ota.sh"
require_path "$REPO_ROOT/managed/store_dwell_alert/config"
require_path "$REPO_ROOT/managed/people_flow_project/config"
rm -rf "$STAGE_DIR"
mkdir -p "$STAGE_DIR/deploy" "$OUTPUT_DIR"
cp "$SCRIPT_DIR/docker-compose.ota-release.yml" "$STAGE_DIR/deploy/docker-compose.ota-release.yml"
cp "$SCRIPT_DIR/Dockerfile.runtime-overlay" "$STAGE_DIR/deploy/Dockerfile.runtime-overlay"
cp "$RELEASE_ENV_SOURCE" "$STAGE_DIR/deploy/managed-portal.release.env"
cp "$RELEASE_MANIFEST_SOURCE" "$STAGE_DIR/release-manifest.env"
copy_dir "$REPO_ROOT/managed/store_dwell_alert/config" "$STAGE_DIR/managed/store_dwell_alert/config"
copy_dir "$REPO_ROOT/managed/people_flow_project/config" "$STAGE_DIR/managed/people_flow_project/config"
if [ "$INCLUDE_WEIGHTS" = "1" ]; then
if ! dir_has_files "$PEOPLE_FLOW_WEIGHTS_SOURCE"; then
echo "people-flow weights requested but missing under $PEOPLE_FLOW_WEIGHTS_SOURCE" >&2
exit 1
fi
copy_dir "$PEOPLE_FLOW_WEIGHTS_SOURCE" "$STAGE_DIR/managed/people_flow_project/weights"
fi
python3 - "$OUTPUT_DIR" "$STAGE_DIR" "$BUNDLE_PATH" <<'PY'
from pathlib import Path
import sys
import zipfile
output_dir = Path(sys.argv[1])
stage_dir = Path(sys.argv[2])
bundle_path = Path(sys.argv[3])
if bundle_path.exists():
bundle_path.unlink()
with zipfile.ZipFile(bundle_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
for path in sorted(stage_dir.rglob("*")):
arcname = path.relative_to(output_dir)
if path.is_dir():
if not any(path.iterdir()):
archive.writestr(f"{arcname.as_posix().rstrip('/')}/", "")
continue
archive.write(path, arcname.as_posix())
PY
cp "$SCRIPT_DIR/install-managed-portal-ota.sh" "$INSTALLER_PATH"
chmod +x "$INSTALLER_PATH"
echo "OTA bundle created: $BUNDLE_PATH"
echo "Versioned installer created: $INSTALLER_PATH"
if [ "$INCLUDE_WEIGHTS" = "1" ]; then
echo "Bundle includes managed/people_flow_project/weights"
else
echo "Bundle excludes managed/people_flow_project/weights; the installer will reuse the shared host weights directory if available"
fi

View File

@@ -31,7 +31,7 @@ class CountingConfig:
@dataclass
class AttributeConfig:
enabled: bool = True
enabled: bool = False
sample_every_n_frames: int = 12
max_samples_per_track: int = 5
min_person_box_width: int = 80

View File

@@ -27,6 +27,7 @@ from .webhook import dispatch_json_event
from .worker_status import write_worker_status
SUPPORTED_EXTENSIONS = {".mp4", ".mov", ".mkv", ".avi"}
LIVE_SUMMARY_INTERVAL_SECONDS = 15.0
def discover_videos(root: Path, pattern: str = "*.mp4") -> list[Path]:
@@ -40,10 +41,40 @@ def discover_videos(root: Path, pattern: str = "*.mp4") -> list[Path]:
return sorted(videos)
def resolve_inference_device(requested_device: str | None) -> str:
device = (requested_device or "").strip()
if not device:
return "cpu"
normalized = device.lower()
if normalized == "cpu":
return "cpu"
if normalized.startswith("cuda") or normalized.replace(",", "").isdigit():
try:
import torch
except ImportError:
print(
f"Requested inference device {device!r} but torch is unavailable; falling back to cpu.",
flush=True,
)
return "cpu"
if not torch.cuda.is_available():
print(
f"Requested inference device {device!r} but torch.cuda.is_available() is False; falling back to cpu.",
flush=True,
)
return "cpu"
return device
class PeopleFlowPipeline:
def __init__(self, config: AppConfig, output_root: Path) -> None:
self.config = config
self.output_root = ensure_dir(output_root)
self.inference_device = resolve_inference_device(self.config.yolo.device)
self.model = self._load_model()
def _load_model(self) -> Any:
@@ -174,6 +205,7 @@ class PeopleFlowPipeline:
last_processed_at = 0.0
last_processed_wall_time: datetime | None = None
next_heartbeat_at = time.monotonic() + 60.0
next_live_summary_at = time.monotonic()
frame_index = 0
capture = None
pixel_line = None
@@ -254,6 +286,7 @@ class PeopleFlowPipeline:
window_index += 1
window_start = window_end
window_end = window_start + timedelta(seconds=window_seconds)
next_live_summary_at = time.monotonic()
if counter is not None:
counter.reset()
if queue_tracker is not None:
@@ -329,6 +362,18 @@ class PeopleFlowPipeline:
next_heartbeat_at = current_time + 60.0
last_processed_wall_time = now
frame_index += 1
if current_time >= next_live_summary_at:
self._write_live_rtsp_summary(
latest_path=rtsp_paths["latest_json"],
source=source,
window_index=window_index,
window_start=window_start,
observed_at=now,
counter=counter,
attributes=attributes,
queue_tracker=queue_tracker,
)
next_live_summary_at = current_time + LIVE_SUMMARY_INTERVAL_SECONDS
update_status("processed_frame", force=True)
except KeyboardInterrupt:
pass
@@ -350,7 +395,7 @@ class PeopleFlowPipeline:
conf=self.config.yolo.conf,
iou=self.config.yolo.iou,
imgsz=self.config.yolo.imgsz,
device=self.config.yolo.device,
device=self.inference_device,
verbose=False,
classes=[0],
)
@@ -483,6 +528,8 @@ class PeopleFlowPipeline:
counter: LineCrossCounter | None,
attributes: AttributeAggregator,
queue_tracker: QueueWindowTracker | None,
*,
commit_queue_level: bool = True,
) -> dict:
age_counts, gender_counts, unknown_attributes, track_summaries = (
self._collect_track_summaries(
@@ -492,7 +539,11 @@ class PeopleFlowPipeline:
)
total_people = 0 if counter is None else counter.total_people
queue_metrics = (
queue_tracker.build_queue_metrics(window_start, window_end)
queue_tracker.build_queue_metrics(
window_start,
window_end,
commit_queue_level=commit_queue_level,
)
if queue_tracker is not None and self.config.queue.enabled
else {
"queue_time_threshold_seconds": self.config.queue.queue_time_threshold_seconds,
@@ -529,6 +580,31 @@ class PeopleFlowPipeline:
"queue_metrics": queue_metrics,
}
def _write_live_rtsp_summary(
self,
latest_path: Path,
source: str,
window_index: int,
window_start: datetime,
observed_at: datetime,
counter: LineCrossCounter | None,
attributes: AttributeAggregator,
queue_tracker: QueueWindowTracker | None,
) -> None:
payload = self._build_rtsp_summary(
source=source,
window_index=window_index,
window_start=window_start,
window_end=observed_at,
counter=counter,
attributes=attributes,
queue_tracker=queue_tracker,
commit_queue_level=False,
)
payload["event"] = "rtsp_live_summary"
payload["window_status"] = "in_progress"
write_json(latest_path, payload)
def _finalize_summary(
self,
video_path: Path,

View File

@@ -0,0 +1,104 @@
from __future__ import annotations
import json
import sys
from datetime import datetime
from pathlib import Path
from types import SimpleNamespace
from src.people_flow.models import AttributeConfig
from src.people_flow.pipeline import PeopleFlowPipeline, resolve_inference_device
def test_resolve_inference_device_keeps_cpu():
assert resolve_inference_device("cpu") == "cpu"
def test_resolve_inference_device_falls_back_when_cuda_unavailable(monkeypatch):
fake_torch = SimpleNamespace(
cuda=SimpleNamespace(is_available=lambda: False),
)
monkeypatch.setitem(sys.modules, "torch", fake_torch)
assert resolve_inference_device("cuda:0") == "cpu"
def test_resolve_inference_device_keeps_cuda_when_available(monkeypatch):
fake_torch = SimpleNamespace(
cuda=SimpleNamespace(is_available=lambda: True),
)
monkeypatch.setitem(sys.modules, "torch", fake_torch)
assert resolve_inference_device("cuda:0") == "cuda:0"
def test_attribute_config_defaults_to_disabled():
assert AttributeConfig().enabled is False
def test_write_live_rtsp_summary_updates_latest_json(tmp_path: Path):
pipeline = PeopleFlowPipeline.__new__(PeopleFlowPipeline)
observed_at = datetime.fromisoformat("2026-05-18T10:45:18+08:00")
window_start = datetime.fromisoformat("2026-05-18T10:30:00+08:00")
def fake_build_rtsp_summary(**kwargs):
return {
"event": "half_hour_report",
"window_start": kwargs["window_start"].isoformat(),
"window_end": kwargs["window_end"].isoformat(),
"total_people": 4,
"queue_metrics": {"queue_level": "normal"},
}
pipeline._build_rtsp_summary = fake_build_rtsp_summary # type: ignore[method-assign]
latest_path = tmp_path / "latest.json"
pipeline._write_live_rtsp_summary(
latest_path=latest_path,
source="rtsp://example",
window_index=0,
window_start=window_start,
observed_at=observed_at,
counter=None,
attributes=SimpleNamespace(),
queue_tracker=None,
)
payload = json.loads(latest_path.read_text(encoding="utf-8"))
assert payload["event"] == "rtsp_live_summary"
assert payload["window_status"] == "in_progress"
assert payload["window_start"] == window_start.isoformat()
assert payload["window_end"] == observed_at.isoformat()
def test_write_live_rtsp_summary_does_not_commit_queue_level(tmp_path: Path):
pipeline = PeopleFlowPipeline.__new__(PeopleFlowPipeline)
observed_at = datetime.fromisoformat("2026-05-18T10:45:18+08:00")
window_start = datetime.fromisoformat("2026-05-18T10:30:00+08:00")
captured: dict[str, object] = {}
def fake_build_rtsp_summary(**kwargs):
captured.update(kwargs)
return {
"event": "half_hour_report",
"window_start": kwargs["window_start"].isoformat(),
"window_end": kwargs["window_end"].isoformat(),
"total_people": 4,
"queue_metrics": {"queue_level": "normal"},
}
pipeline._build_rtsp_summary = fake_build_rtsp_summary # type: ignore[method-assign]
latest_path = tmp_path / "latest.json"
pipeline._write_live_rtsp_summary(
latest_path=latest_path,
source="rtsp://example",
window_index=0,
window_start=window_start,
observed_at=observed_at,
counter=None,
attributes=SimpleNamespace(),
queue_tracker=SimpleNamespace(),
)
assert captured["commit_queue_level"] is False

View File

@@ -17,3 +17,15 @@
- Trigger: the user required deployment to use `docker compose` only and explicitly disallowed host environment changes.
- Rule: for remote rollout tasks in this repo, prefer repository-contained `docker compose` changes and do not install packages, edit host configs, or mutate global environment state unless the user explicitly approves it.
- Preventive action: when a deployment is blocked, first fix Dockerfiles, compose files, env files, and mounted paths inside the repo before considering any host-level workaround.
## 2026-05-15
- Trigger: the `.11` OTA bundle host did not have a `zip` executable, and the current Python containers no longer exposed the historical `lap` overlay paths.
- Rule: OTA bundle publication must not assume host archive tools or historical runtime overlay paths are present.
- Preventive action: when cutting a release, package the ZIP with Python stdlib if `zip` is unavailable, treat overlay extraction as optional unless the paths are verified live in containers, and validate the final archive contents before upload.
## 2026-05-18
- Trigger: the user clarified that OTA installer updates should not keep repackaging and uploading the whole repository tree or fixed `people_flow_project` weights.
- Rule: managed-portal OTA releases should ship a minimal ZIP with deploy metadata and managed config only; `people_flow_project` weights should be reused from a stable host location unless the weights themselves changed or the host is new.
- Preventive action: when preparing OTA artifacts, use the minimal packaging script, exclude `managed/people_flow_project/weights` by default, and only publish a weights-bearing bundle for first-time installs or actual weight updates.

View File

@@ -2,33 +2,31 @@
## Checklist
- [x] Verify the local managed config files already match the requested webhook, 30-minute window, 5-minute queue threshold, and `2-5 normal / >5 crowded` defaults.
- [x] Restore any queue-level code or tests that still reflect the temporary `>= threshold` crowded behavior.
- [x] Run targeted validation for the touched queue-analytics logic.
- [x] Update `tasks/todo.md` review evidence and create a scoped git commit containing only the intended files.
- [x] Classify the remaining modified and untracked files into non-config code changes to commit versus local config/artifact files to exclude.
- [x] Run targeted validation for the code and script changes that will be committed.
- [x] Create a scoped git commit containing the non-config code changes only.
- [ ] Push the scoped commit to `origin/main`.
## Scope And Risks
- Scope: restore repository-local managed-service defaults so webhook delivery remains enabled, people-flow reporting flushes every 30 minutes, queue threshold is 5 minutes, fewer than 2 qualifying people means `人少`, `2-5` means `人数正常`, and more than `5` means `人多`, then commit the relevant changes.
- Expected touch points: `managed/people_flow_project/src/people_flow/queue_analytics.py`, `managed/people_flow_project/tests/test_queue_analytics.py`, and verification of local config files under `managed/*/config/` plus any generated OTA test output that should remain aligned.
- Risk: the worktree already contains unrelated modified and untracked files, so the commit must be scoped carefully to avoid pulling in unrelated work.
- Risk: `store_dwell_alert` still has a separate queue-level implementation with the same `>` crowded comparison, but the user asked specifically to restore local config defaults plus "the code"; if we only revert people-flow logic, we should be explicit about any remaining asymmetry.
- Scope: commit and push the remaining local non-config code changes in this repository while excluding local configuration files and generated artifacts.
- Expected touch points: currently modified code/docs/scripts such as `README.md`, `deploy/install-managed-portal-ota.sh`, `deploy/package-managed-portal-ota.sh`, `managed/people_flow_project/src/people_flow/{models.py,pipeline.py}`, `managed/people_flow_project/tests/test_pipeline.py`, and `tasks/lessons.md`.
- Risk: the worktree also contains local artifacts and config-adjacent outputs such as `test_output/`, `sim_workspace/`, `release-manifest.env`, `api_response.json`, and `output.txt`; these must not be swept into the commit by accident.
- Risk: some modified files, especially docs and deployment scripts, are related to earlier OTA work and may need only narrow validation rather than full end-to-end execution.
## Validation Intent
- Prove the local config files already encode `window_seconds: 1800`, `queue_time_threshold_seconds: 300`, `crowded_count_threshold: 5`, `normal_count_threshold: 2`, and webhook URLs.
- Prove the restored queue-level comparison classifies `over_threshold_count == 5` as `normal` and only `>5` as `crowded`.
- Prove the selected commit excludes local config files and generated artifacts.
- Run the narrowest meaningful checks for the people-flow pipeline/test changes and at least a syntax-level check for the OTA scripts.
## Review
- Status: completed.
- Config verification: local repository configs already matched the requested defaults before code edits.
- `managed/people_flow_project/config/local.yaml`: `webhook.url` points to managed queue, `rtsp.window_seconds=1800`, `queue_time_threshold_seconds=300`, `crowded_count_threshold=5`, `normal_count_threshold=2`.
- `managed/store_dwell_alert/config/local.yaml`: `webhook.url` points to managed queue, `queue_time_threshold_seconds=300`, `crowded_count_threshold=5`, `normal_count_threshold=2`.
- Generated OTA test configs under `test_output/managed-portal-test-ota/managed/.../config/local.yaml` were already aligned with the same values.
- Code restoration: reverted `managed/people_flow_project/src/people_flow/queue_analytics.py` so `queue_level="crowded"` now requires `over_threshold_count > crowded_count_threshold`, which restores the requested `2-5 normal / >5 crowded` behavior.
- Test restoration: updated `managed/people_flow_project/tests/test_queue_analytics.py` so the preview-state coverage remains intact while the boundary assertion now proves `over_threshold_count == crowded_count_threshold` stays `normal`.
- Validation:
- `PYTHONPATH=. pytest tests/test_queue_analytics.py` in `managed/people_flow_project`
- Result: `3 passed in 0.03s`
- Commit scope note: only `managed/people_flow_project/src/people_flow/queue_analytics.py`, `managed/people_flow_project/tests/test_queue_analytics.py`, and `tasks/todo.md` should be committed for this task because the worktree contains unrelated modified and untracked files.
- Status: in progress.
- Commit set selected:
- Included: `README.md`, `deploy/install-managed-portal-ota.sh`, `deploy/package-managed-portal-ota.sh`, `managed/people_flow_project/src/people_flow/models.py`, `managed/people_flow_project/src/people_flow/pipeline.py`, `managed/people_flow_project/tests/test_pipeline.py`, `tasks/lessons.md`, `tasks/todo.md`.
- Excluded as local config or generated artifacts: `test_output/`, `sim_workspace/`, `release-manifest.env`, `api_response.json`, `output.txt`.
- Validation completed:
- `PYTHONPATH=. pytest tests/test_pipeline.py tests/test_queue_analytics.py` in `managed/people_flow_project`
- Result: `9 passed in 0.66s`
- `sh -n deploy/install-managed-portal-ota.sh`
- `sh -n deploy/package-managed-portal-ota.sh`