Skip to content

Commit 825f4fb

Browse files
Suncussclaude
andcommitted
feat: Always Include Species filter; cap image attribution
- Add an "Always Include Species" list (Settings → Species Filter) that reports listed species even when the location filter rates them unlikely for the configured coordinates - Cap bird image attribution to a single line Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c18f78d commit 825f4fb

10 files changed

Lines changed: 236 additions & 49 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## [Unreleased]
44

5+
- Added an "Always Include Species" list (Settings → Species Filter) that reports the listed species even when the location filter rates them unlikely for your coordinates
6+
57
## [0.7.4] - 2026-05-31
68

79
- Bird Gallery tab switches now show a loading spinner while an uncached tab's query runs, instead of leaving the previous tab's cards on screen until the (sometimes slow) query resolves. Revisiting a recently viewed tab is still instant and shows its cached cards while refreshing in the background

backend/config/settings.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ def _get_positive_int_env(name: str, default: int) -> int:
2828
"detection": {"sensitivity": 0.75, "cutoff": 0.60, "species_filter_threshold": DEFAULT_SPECIES_FILTER_THRESHOLD},
2929
"species_filter": {
3030
"allowed_species": [], # If non-empty, ONLY detect these (bypasses location filter)
31-
"blocked_species": [] # Never detect these species
31+
"blocked_species": [], # Never detect these species
32+
"included_species": [] # Always detect these, even if the location filter excludes them
3233
},
3334
"audio": {
3435
"sources": [],

backend/model_service/inference_server.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ def process_audio_file(
272272
recording_length: float,
273273
allowed_species: list[str] | None,
274274
blocked_species: list[str] | None,
275+
included_species: list[str] | None = None,
275276
species_filter_threshold: float = DEFAULT_SPECIES_FILTER_THRESHOLD
276277
):
277278
"""Process an audio file and return detected species.
@@ -283,6 +284,10 @@ def process_audio_file(
283284
lat, lon: Location coordinates for species filtering
284285
sensitivity: Confidence adjustment parameter
285286
cutoff: Minimum confidence threshold
287+
allowed_species: If non-empty, detect ONLY these (bypasses location filter)
288+
blocked_species: Never detect these (always wins)
289+
included_species: Always detect these, even when the location filter
290+
would exclude them (no effect while allowed_species is set)
286291
287292
Returns:
288293
List of detection result dictionaries
@@ -349,6 +354,7 @@ def process_audio_file(
349354
# Normalize optional filter lists
350355
allowed_species = allowed_species or []
351356
blocked_species = blocked_species or []
357+
included_species = included_species or []
352358

353359
# Pre-compute loop-invariant values
354360
loc_active = location_context.source != 'disabled'
@@ -429,6 +435,10 @@ def process_audio_file(
429435
filtered_species_list.append(species_detection)
430436
elif species_label in location_context.allowed_species:
431437
filtered_species_list.append(species_detection)
438+
elif included_species and scientific_name in included_species:
439+
# Always-include list: keep despite low local probability
440+
logger.debug("Species force-included", extra={'species': scientific_name})
441+
filtered_species_list.append(species_detection)
432442
else:
433443
logger.debug("Species not in local species list", extra={'species': scientific_name})
434444

@@ -533,6 +543,7 @@ def analyze_audio_file():
533543
recording_length = audio_settings.get('recording_length', 9)
534544
allowed_species = species_filter_settings.get('allowed_species') or []
535545
blocked_species = species_filter_settings.get('blocked_species') or []
546+
included_species = species_filter_settings.get('included_species') or []
536547

537548
requested_model_type = runtime_settings.get('model', {}).get('type', settings.MODEL_TYPE)
538549
if requested_model_type != settings.MODEL_TYPE:
@@ -564,6 +575,7 @@ def analyze_audio_file():
564575
recording_length,
565576
allowed_species,
566577
blocked_species,
578+
included_species,
567579
species_filter_threshold
568580
)
569581

backend/tests/api/test_bird_image_api.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,7 @@ def fake_get(url, params=None, headers=None, timeout=None):
609609
def test_candidates_surfaces_429_with_retry_after(self, candidates_client):
610610
"""A 429 from Wikimedia becomes a structured error with status + Retry-After."""
611611
import requests as _requests
612+
612613
from core import api as api_module
613614
api_module.image_cache.clear()
614615

@@ -631,6 +632,7 @@ def test_concurrent_misses_share_one_upstream_fetch(self, candidates_client):
631632
"""Single-flight: two concurrent cache-misses for the same key do one fetch."""
632633
import threading as _threading
633634
import time as _time
635+
634636
from core import api as api_module
635637
api_module.image_cache.clear()
636638
api_module._wikimedia_inflight.clear()

backend/tests/audio/test_audio_manager.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,14 @@
1818
import pytest
1919

2020
from core.audio_manager import (
21-
BaseRecorder,
22-
PulseAudioRecorder,
2321
RTSP_PROBE_TIMEOUT_SECONDS,
2422
RTSP_SOCKET_TIMEOUT_US,
23+
BaseRecorder,
24+
PulseAudioRecorder,
2525
RtspRecorder,
2626
create_recorder,
2727
)
28+
2829
# Aliased on import: the source name starts with "test_", so importing it
2930
# under that name makes pytest try to collect it as a test case.
3031
from core.audio_manager import test_stream_url as probe_stream_url

backend/tests/model_service/test_api_contract.py

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,8 @@ def test_returns_empty_results_on_input_shape_mismatch(self, monkeypatch, caplog
247247
overlap=0.0,
248248
recording_length=9.0,
249249
allowed_species=[],
250-
blocked_species=[]
250+
blocked_species=[],
251+
included_species=[]
251252
)
252253

253254
assert results == []
@@ -256,3 +257,118 @@ def test_returns_empty_results_on_input_shape_mismatch(self, monkeypatch, caplog
256257
"Skipping audio file due to model input shape mismatch" in record.message
257258
for record in caplog.records
258259
)
260+
261+
262+
class TestSpeciesFilterRules:
263+
"""Test the 3-tier species filter: blocked > allowed-whitelist > location+include.
264+
265+
The candidate species (a Scaly-breasted Munia) is acoustically detected but
266+
excluded by the location filter — exactly the case the always-include list
267+
is for. American Robin stands in for "what the location filter does allow".
268+
"""
269+
270+
MUNIA = "Lonchura punctulata_Scaly-breasted Munia"
271+
MUNIA_SCI = "Lonchura punctulata"
272+
ROBIN = "Turdus migratorius_American Robin"
273+
274+
def _detected_names(
275+
self,
276+
monkeypatch,
277+
*,
278+
allowed_species,
279+
blocked_species,
280+
included_species,
281+
location_allows,
282+
candidate_label=MUNIA,
283+
):
284+
"""Run process_audio_file with one detected candidate and return the
285+
scientific names that survive filtering."""
286+
from model_service import inference_server
287+
from model_service.base_model import ChunkPrediction
288+
from model_service.location_filter import LocationContext
289+
290+
model = MagicMock()
291+
model.name = "birdnet"
292+
model.version = "2.4"
293+
model.sample_rate = 48000
294+
model.chunk_length_seconds = 3.0
295+
model.get_ebird_code.return_value = "lobmun"
296+
model.predict_chunk.return_value = ChunkPrediction(
297+
raw_top3=((candidate_label, 0.9),),
298+
candidates=((candidate_label, 0.9),),
299+
)
300+
301+
# Location filter is active but does NOT allow the candidate species.
302+
location_filter = MagicMock()
303+
location_filter.filter.return_value = LocationContext(
304+
source="meta_model_v2.4",
305+
threshold=0.03,
306+
allowed_species=frozenset(location_allows),
307+
probabilities={},
308+
)
309+
310+
monkeypatch.setattr(
311+
inference_server,
312+
"split_audio",
313+
lambda *args, **kwargs: [np.zeros(144000, dtype=np.float32)],
314+
)
315+
316+
results = inference_server.process_audio_file(
317+
model=model,
318+
location_filter=location_filter,
319+
audio_file_path="/tmp/20240615_103000.wav",
320+
lat=29.76,
321+
lon=-95.37,
322+
sensitivity=0.75,
323+
cutoff=0.60,
324+
overlap=0.0,
325+
recording_length=9.0,
326+
allowed_species=allowed_species,
327+
blocked_species=blocked_species,
328+
included_species=included_species,
329+
)
330+
return [r["scientific_name"] for r in results]
331+
332+
def test_included_species_overrides_location_filter(self, monkeypatch):
333+
"""A species the location filter excludes is kept when always-included."""
334+
names = self._detected_names(
335+
monkeypatch,
336+
allowed_species=[],
337+
blocked_species=[],
338+
included_species=[self.MUNIA_SCI],
339+
location_allows={self.ROBIN},
340+
)
341+
assert names == [self.MUNIA_SCI]
342+
343+
def test_dropped_without_include(self, monkeypatch):
344+
"""Control: the same species is dropped when not always-included."""
345+
names = self._detected_names(
346+
monkeypatch,
347+
allowed_species=[],
348+
blocked_species=[],
349+
included_species=[],
350+
location_allows={self.ROBIN},
351+
)
352+
assert names == []
353+
354+
def test_blocked_beats_included(self, monkeypatch):
355+
"""Blocked always wins, even over the always-include list."""
356+
names = self._detected_names(
357+
monkeypatch,
358+
allowed_species=[],
359+
blocked_species=[self.MUNIA_SCI],
360+
included_species=[self.MUNIA_SCI],
361+
location_allows={self.ROBIN},
362+
)
363+
assert names == []
364+
365+
def test_included_is_noop_under_allowed_whitelist(self, monkeypatch):
366+
"""An active Allowed whitelist takes precedence; include is inert."""
367+
names = self._detected_names(
368+
monkeypatch,
369+
allowed_species=["Turdus migratorius"], # whitelist active; munia not on it
370+
blocked_species=[],
371+
included_species=[self.MUNIA_SCI],
372+
location_allows={self.ROBIN},
373+
)
374+
assert names == []

frontend/src/components/BirdImageModal.vue

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -214,18 +214,21 @@
214214
<div class="mt-4 min-h-[2.5rem] text-sm text-gray-600">
215215
<p
216216
v-if="attributionText"
217-
class="leading-snug"
217+
class="flex items-baseline leading-snug"
218218
>
219-
{{ attributionText.prefix }}
219+
<span class="shrink-0">{{ attributionText.prefix }}&nbsp;</span>
220220
<a
221221
v-if="attributionText.authorUrl"
222222
:href="attributionText.authorUrl"
223223
target="_blank"
224224
rel="noopener noreferrer"
225-
class="text-blue-600 underline"
225+
class="text-blue-600 underline truncate min-w-0"
226226
>{{ attributionText.authorName }}</a>
227-
<span v-else>{{ attributionText.authorName }}</span>
228-
{{ attributionText.suffix }}
227+
<span
228+
v-else
229+
class="truncate min-w-0"
230+
>{{ attributionText.authorName }}</span>
231+
<span class="shrink-0">{{ attributionText.suffix }}</span>
229232
</p>
230233
<p
231234
v-else-if="selectedKind === 'reset'"

frontend/src/views/BirdDetails.vue

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,14 +73,15 @@
7373
</div>
7474
</template>
7575
<template v-else>
76-
<p>
77-
Photo by <a
76+
<p class="flex items-baseline">
77+
<span class="shrink-0">Photo by&nbsp;</span>
78+
<a
7879
:href="birdImageData.authorUrl"
7980
target="_blank"
8081
rel="noopener noreferrer"
81-
class="text-blue-600 underline"
82-
>{{
83-
birdImageData.authorName }}</a>, licensed under {{ birdImageData.licenseType }}
82+
class="text-blue-600 underline truncate min-w-0"
83+
>{{ birdImageData.authorName }}</a>
84+
<span class="shrink-0">,&nbsp;licensed under {{ birdImageData.licenseType }}</span>
8485
</p>
8586
</template>
8687
</div>

frontend/src/views/BirdGallery.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@
8686
<p>Uploaded by you</p>
8787
</template>
8888
<template v-else>
89-
<p>
89+
<p class="truncate">
9090
Photo by <a
9191
:href="bird.authorUrl"
9292
target="_blank"

0 commit comments

Comments
 (0)