From d72a9efb33e84218f0c3693a3053a7da39d8bf6c Mon Sep 17 00:00:00 2001 From: Mateo Date: Tue, 5 May 2026 19:14:48 +0200 Subject: [PATCH 1/4] refactor: expose attach_sequence_to_alert helper --- src/app/api/api_v1/endpoints/detections.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/api/api_v1/endpoints/detections.py b/src/app/api/api_v1/endpoints/detections.py index a8364b96..c72eaa4e 100644 --- a/src/app/api/api_v1/endpoints/detections.py +++ b/src/app/api/api_v1/endpoints/detections.py @@ -294,7 +294,7 @@ def _build_links_for_group( return links -async def _attach_sequence_to_alert( +async def attach_sequence_to_alert( sequence_: Sequence, camera: Camera, cameras: CameraCRUD, @@ -482,7 +482,7 @@ async def create_detection( if det_.id == det.id: det = updated - alert_id = await _attach_sequence_to_alert(sequence_, camera, cameras, sequences, alerts) + alert_id = await attach_sequence_to_alert(sequence_, camera, cameras, sequences, alerts) # Webhooks whs = await webhooks.fetch_all() From 4a62eeabda3087d7076d550acb89b2914799f279 Mon Sep 17 00:00:00 2001 From: Mateo Date: Tue, 5 May 2026 19:15:21 +0200 Subject: [PATCH 2/4] feat(sequences): re-attach sequence to alert when label reverts to wildfire_smoke --- src/app/api/api_v1/endpoints/sequences.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/app/api/api_v1/endpoints/sequences.py b/src/app/api/api_v1/endpoints/sequences.py index 8bc8c7e5..5f2478b3 100644 --- a/src/app/api/api_v1/endpoints/sequences.py +++ b/src/app/api/api_v1/endpoints/sequences.py @@ -12,6 +12,7 @@ from sqlmodel import delete, func, select from sqlmodel.ext.asyncio.session import AsyncSession +from app.api.api_v1.endpoints.detections import attach_sequence_to_alert from app.api.dependencies import get_alert_crud, get_camera_crud, get_detection_crud, get_jwt, get_sequence_crud from app.core.time import utcnow from app.crud import AlertCRUD, CameraCRUD, DetectionCRUD, SequenceCRUD @@ -256,6 +257,7 @@ async def label_sequence( if UserRole.ADMIN not in token_payload.scopes: await verify_org_rights(token_payload.organization_id, sequence.camera_id, cameras) + previous_label = sequence.is_wildfire updated = await sequences.update(sequence_id, payload) # If sequence is labeled as non-wildfire, remove it from alerts and refresh those alerts @@ -283,5 +285,23 @@ async def label_sequence( ) session.add(AlertSequence(alert_id=new_alert.id, sequence_id=sequence_id)) await session.commit() + # Reverting a previously non-wildfire label back to wildfire_smoke: re-run cone matching + elif ( + payload.is_wildfire == AnnotationType.WILDFIRE_SMOKE + and previous_label is not None + and previous_label != AnnotationType.WILDFIRE_SMOKE + ): + alert_ids_res = await session.exec( + select(AlertSequence.alert_id).where(AlertSequence.sequence_id == sequence_id) + ) + alert_ids = list(alert_ids_res.all()) + if alert_ids: + delete_links: Any = delete(AlertSequence).where(cast(Any, AlertSequence.sequence_id) == sequence_id) + await session.exec(delete_links) + await session.commit() + for aid in alert_ids: + await _refresh_alert_state(aid, session, alerts) + camera = cast(Camera, await cameras.get(sequence.camera_id, strict=True)) + await attach_sequence_to_alert(updated, camera, cameras, sequences, alerts) return updated From 0189cc003710d7fcf699ddb6830be36072e233c4 Mon Sep 17 00:00:00 2001 From: Mateo Date: Tue, 5 May 2026 19:21:59 +0200 Subject: [PATCH 3/4] test(sequences): cover wildfire-relabel re-attach branch --- src/app/api/api_v1/endpoints/sequences.py | 2 +- src/tests/endpoints/test_detections.py | 10 ++-- src/tests/endpoints/test_sequences.py | 62 +++++++++++++++++++++++ 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/app/api/api_v1/endpoints/sequences.py b/src/app/api/api_v1/endpoints/sequences.py index 5f2478b3..d65ddd2d 100644 --- a/src/app/api/api_v1/endpoints/sequences.py +++ b/src/app/api/api_v1/endpoints/sequences.py @@ -296,7 +296,7 @@ async def label_sequence( ) alert_ids = list(alert_ids_res.all()) if alert_ids: - delete_links: Any = delete(AlertSequence).where(cast(Any, AlertSequence.sequence_id) == sequence_id) + delete_links = delete(AlertSequence).where(cast(Any, AlertSequence.sequence_id) == sequence_id) await session.exec(delete_links) await session.commit() for aid in alert_ids: diff --git a/src/tests/endpoints/test_detections.py b/src/tests/endpoints/test_detections.py index 374ce6e2..0de800c4 100644 --- a/src/tests/endpoints/test_detections.py +++ b/src/tests/endpoints/test_detections.py @@ -13,7 +13,7 @@ from app.api.api_v1.endpoints import detections as detections_api from app.api.api_v1.endpoints.detections import ( - _attach_sequence_to_alert, + attach_sequence_to_alert, _build_links_for_group, _build_overlap_records, _fetch_alert_mapping, @@ -663,7 +663,7 @@ async def test_attach_sequence_to_alert_returns_without_overlap_records(detectio await detection_session.commit() await detection_session.refresh(sequence) - await _attach_sequence_to_alert(sequence, camera, cam_crud, seq_crud, alert_crud) + await attach_sequence_to_alert(sequence, camera, cam_crud, seq_crud, alert_crud) alerts = await alert_crud.fetch_all() assert alerts == [] @@ -1225,7 +1225,7 @@ async def test_attach_sequence_to_alert_creates_alert(detection_session: AsyncSe await detection_session.refresh(seq1) await detection_session.refresh(seq2) - await _attach_sequence_to_alert(seq2, cam2, cam_crud, seq_crud, alert_crud) + await attach_sequence_to_alert(seq2, cam2, cam_crud, seq_crud, alert_crud) alerts = await alert_crud.fetch_all() assert len(alerts) == 1 @@ -1327,7 +1327,7 @@ async def test_attach_sequence_does_not_bridge_to_distant_alert(detection_sessio await detection_session.refresh(seq_cam5) # Step 1 — attach cam5 sequence triangulates with cam7, creates smoke-A alert. - smoke_a_alert_id = await _attach_sequence_to_alert(seq_cam5, cam5, cam_crud, seq_crud, alert_crud) + smoke_a_alert_id = await attach_sequence_to_alert(seq_cam5, cam5, cam_crud, seq_crud, alert_crud) assert smoke_a_alert_id is not None smoke_a = await alert_crud.get(smoke_a_alert_id, strict=True) assert smoke_a.lat is not None @@ -1348,7 +1348,7 @@ async def test_attach_sequence_does_not_bridge_to_distant_alert(detection_sessio await detection_session.commit() await detection_session.refresh(seq_cam2) - target_id = await _attach_sequence_to_alert(seq_cam2, cam2, cam_crud, seq_crud, alert_crud) + target_id = await attach_sequence_to_alert(seq_cam2, cam2, cam_crud, seq_crud, alert_crud) # The cam2 sequence must land on a NEW alert, not the smoke-A one. assert target_id is not None diff --git a/src/tests/endpoints/test_sequences.py b/src/tests/endpoints/test_sequences.py index 5860e155..5213bde1 100644 --- a/src/tests/endpoints/test_sequences.py +++ b/src/tests/endpoints/test_sequences.py @@ -657,6 +657,68 @@ async def test_unit_label_sequence_as_other_smoke_refreshes_alert( assert updated_sequence.is_wildfire == AnnotationType.OTHER_SMOKE +@pytest.mark.asyncio +@patch("app.api.api_v1.endpoints.sequences.attach_sequence_to_alert", new_callable=AsyncMock) +@patch("app.api.api_v1.endpoints.sequences._refresh_alert_state", new_callable=AsyncMock) +async def test_unit_relabel_sequence_to_wildfire_smoke_reattaches( + mock_refresh_alert_state: AsyncMock, + mock_attach_sequence_to_alert: AsyncMock, +): + """Reverting a non-wildfire label back to wildfire_smoke should drop the lonely alert + and re-run cone matching to merge the sequence into an overlapping alert.""" + mock_sequence = Sequence( + id=1, + camera_id=1, + is_wildfire=AnnotationType.OTHER_SMOKE, + started_at=utcnow(), + last_seen_at=utcnow(), + ) + mock_camera = Camera(id=1, organization_id=1) + + mock_sequences_crud = AsyncMock() + mock_sequences_crud.get.return_value = mock_sequence + updated_seq = Sequence( + id=1, + camera_id=1, + is_wildfire=AnnotationType.WILDFIRE_SMOKE, + started_at=mock_sequence.started_at, + last_seen_at=mock_sequence.last_seen_at, + ) + mock_sequences_crud.update.return_value = updated_seq + + mock_cameras_crud = AsyncMock() + mock_cameras_crud.get.return_value = mock_camera + + mock_alerts_crud = AsyncMock() + + mock_session = AsyncMock() + mock_exec_result = MagicMock() + mock_exec_result.all.return_value = [202] # Currently linked to lonely alert 202 + mock_session.exec.return_value = mock_exec_result + + mock_token_payload = TokenPayload(sub=1, scopes=[UserRole.AGENT], organization_id=1) + payload = SequenceLabel(is_wildfire=AnnotationType.WILDFIRE_SMOKE) + + result = await label_sequence( + payload=payload, + sequence_id=1, + cameras=mock_cameras_crud, + sequences=mock_sequences_crud, + alerts=mock_alerts_crud, + session=mock_session, + token_payload=mock_token_payload, + ) + + mock_sequences_crud.update.assert_called_once_with(1, payload) + assert mock_session.exec.call_count == 2 # fetch alert_ids + delete links + mock_refresh_alert_state.assert_called_once_with(202, mock_session, mock_alerts_crud) + mock_attach_sequence_to_alert.assert_awaited_once_with( + updated_seq, mock_camera, mock_cameras_crud, mock_sequences_crud, mock_alerts_crud + ) + mock_alerts_crud.create.assert_not_called() + assert result.is_wildfire == AnnotationType.WILDFIRE_SMOKE + + @pytest.mark.asyncio async def test_unit_label_sequence_as_wildfire_smoke_does_not_refresh(): """Verify that labeling a sequence as wildfire smoke does NOT trigger an alert refresh.""" From bb5893ba676c9b2d6192af9e9519357024db5b88 Mon Sep 17 00:00:00 2001 From: Mateo Date: Tue, 5 May 2026 19:31:01 +0200 Subject: [PATCH 4/4] fix(sequences): anchor relabel re-attach window on the sequence's own time --- src/app/api/api_v1/endpoints/detections.py | 7 ++-- src/app/api/api_v1/endpoints/sequences.py | 39 ++++++++++------------ src/tests/endpoints/test_detections.py | 2 +- src/tests/endpoints/test_sequences.py | 7 +++- 4 files changed, 30 insertions(+), 25 deletions(-) diff --git a/src/app/api/api_v1/endpoints/detections.py b/src/app/api/api_v1/endpoints/detections.py index c72eaa4e..7305ce04 100644 --- a/src/app/api/api_v1/endpoints/detections.py +++ b/src/app/api/api_v1/endpoints/detections.py @@ -132,13 +132,15 @@ async def _get_recent_sequences( sequences: SequenceCRUD, camera_ids: List[int], sequence_: Sequence, + reference_time: Optional[datetime] = None, ) -> List[Sequence]: + anchor = reference_time if reference_time is not None else utcnow() recent_sequences = await sequences.fetch_all( in_pair=("camera_id", camera_ids), inequality_pair=( "last_seen_at", ">", - utcnow() - timedelta(seconds=settings.SEQUENCE_RELAXATION_SECONDS), + anchor - timedelta(seconds=settings.SEQUENCE_RELAXATION_SECONDS), ), ) if all(seq.id != sequence_.id for seq in recent_sequences): @@ -300,12 +302,13 @@ async def attach_sequence_to_alert( cameras: CameraCRUD, sequences: SequenceCRUD, alerts: AlertCRUD, + reference_time: Optional[datetime] = None, ) -> Optional[int]: """Assign the given sequence to an alert based on cone/time overlap.""" camera_by_id = await _get_camera_by_id(camera, cameras, sequence_.camera_id) # Fetch recent sequences for the organization based on recency of last_seen_at - recent_sequences = await _get_recent_sequences(sequences, list(camera_by_id.keys()), sequence_) + recent_sequences = await _get_recent_sequences(sequences, list(camera_by_id.keys()), sequence_, reference_time) # Build DataFrame for overlap computation records = _build_overlap_records(recent_sequences, camera_by_id) diff --git a/src/app/api/api_v1/endpoints/sequences.py b/src/app/api/api_v1/endpoints/sequences.py index d65ddd2d..0df874de 100644 --- a/src/app/api/api_v1/endpoints/sequences.py +++ b/src/app/api/api_v1/endpoints/sequences.py @@ -87,6 +87,18 @@ async def _refresh_alert_state(alert_id: int, session: AsyncSession, alerts: Ale ) +async def _detach_sequence_from_alerts(sequence_id: int, session: AsyncSession, alerts: AlertCRUD) -> None: + alert_ids_res = await session.exec(select(AlertSequence.alert_id).where(AlertSequence.sequence_id == sequence_id)) + alert_ids = list(alert_ids_res.all()) + if not alert_ids: + return + delete_links: Any = delete(AlertSequence).where(cast(Any, AlertSequence.sequence_id) == sequence_id) + await session.exec(delete_links) + await session.commit() + for aid in alert_ids: + await _refresh_alert_state(aid, session, alerts) + + def _serialize_sequence(sequence: Sequence, detections_count: int = 0) -> SequenceRead: return SequenceRead(**sequence.model_dump(), detections_count=detections_count) @@ -262,16 +274,7 @@ async def label_sequence( # If sequence is labeled as non-wildfire, remove it from alerts and refresh those alerts if payload.is_wildfire is not None and payload.is_wildfire != AnnotationType.WILDFIRE_SMOKE: - alert_ids_res = await session.exec( - select(AlertSequence.alert_id).where(AlertSequence.sequence_id == sequence_id) - ) - alert_ids = list(alert_ids_res.all()) - if alert_ids: - delete_links: Any = delete(AlertSequence).where(cast(Any, AlertSequence.sequence_id) == sequence_id) - await session.exec(delete_links) - await session.commit() - for aid in alert_ids: - await _refresh_alert_state(aid, session, alerts) + await _detach_sequence_from_alerts(sequence_id, session, alerts) # Create a fresh alert for this sequence alone camera = cast(Camera, await cameras.get(sequence.camera_id, strict=True)) new_alert = await alerts.create( @@ -291,17 +294,11 @@ async def label_sequence( and previous_label is not None and previous_label != AnnotationType.WILDFIRE_SMOKE ): - alert_ids_res = await session.exec( - select(AlertSequence.alert_id).where(AlertSequence.sequence_id == sequence_id) - ) - alert_ids = list(alert_ids_res.all()) - if alert_ids: - delete_links = delete(AlertSequence).where(cast(Any, AlertSequence.sequence_id) == sequence_id) - await session.exec(delete_links) - await session.commit() - for aid in alert_ids: - await _refresh_alert_state(aid, session, alerts) + await _detach_sequence_from_alerts(sequence_id, session, alerts) camera = cast(Camera, await cameras.get(sequence.camera_id, strict=True)) - await attach_sequence_to_alert(updated, camera, cameras, sequences, alerts) + # Anchor the candidate window on the sequence's own time so old relabels still merge + await attach_sequence_to_alert( + updated, camera, cameras, sequences, alerts, reference_time=sequence.last_seen_at + ) return updated diff --git a/src/tests/endpoints/test_detections.py b/src/tests/endpoints/test_detections.py index 0de800c4..a4612ddf 100644 --- a/src/tests/endpoints/test_detections.py +++ b/src/tests/endpoints/test_detections.py @@ -13,7 +13,6 @@ from app.api.api_v1.endpoints import detections as detections_api from app.api.api_v1.endpoints.detections import ( - attach_sequence_to_alert, _build_links_for_group, _build_overlap_records, _fetch_alert_mapping, @@ -25,6 +24,7 @@ _maybe_update_alert, _parse_bbox, _resolve_groups_and_locations, + attach_sequence_to_alert, create_detection, ) from app.core.config import settings diff --git a/src/tests/endpoints/test_sequences.py b/src/tests/endpoints/test_sequences.py index 5213bde1..b97cc418 100644 --- a/src/tests/endpoints/test_sequences.py +++ b/src/tests/endpoints/test_sequences.py @@ -713,7 +713,12 @@ async def test_unit_relabel_sequence_to_wildfire_smoke_reattaches( assert mock_session.exec.call_count == 2 # fetch alert_ids + delete links mock_refresh_alert_state.assert_called_once_with(202, mock_session, mock_alerts_crud) mock_attach_sequence_to_alert.assert_awaited_once_with( - updated_seq, mock_camera, mock_cameras_crud, mock_sequences_crud, mock_alerts_crud + updated_seq, + mock_camera, + mock_cameras_crud, + mock_sequences_crud, + mock_alerts_crud, + reference_time=mock_sequence.last_seen_at, ) mock_alerts_crud.create.assert_not_called() assert result.is_wildfire == AnnotationType.WILDFIRE_SMOKE