Skip to content

feat(audience): expose device filter for filtered audiences#674

Open
RapidPoseidon wants to merge 4 commits into
mainfrom
feat(audience)/expose-device-filter-for-audiences
Open

feat(audience): expose device filter for filtered audiences#674
RapidPoseidon wants to merge 4 commits into
mainfrom
feat(audience)/expose-device-filter-for-audiences

Conversation

@RapidPoseidon

Copy link
Copy Markdown
Contributor

What

Enables DeviceFilter when deriving a filtered audience — the SDK follow-up to backend rapidata-backend#4697, now that the audience device-filter model is in the generated client.

Why

DeviceFilter worked for orders/jobs but ._to_audience_model() raised NotImplementedError, so RapidataAudience.filter([DeviceFilter(...)]) failed client-side. The backend now accepts a device audience filter, so the SDK can emit it.

How

  • DeviceFilter._to_audience_model() now returns IAudienceFilter(IAudienceFilterDeviceAudienceFilter(_t="DeviceFilter", deviceTypes=...)) (mirrors the existing _to_model() order path and the other _to_audience_model() filters).
  • BackendFilterMapper decodes IAudienceFilterDeviceAudienceFilter back into a DeviceFilter, so reading an audience's filters round-trips.
  • Docs (audiences.md) and the filter() docstring list DeviceFilter as supported — and I added the previously-missing DemographicFilter row while there.

Verification

  • pyright src/rapidata/rapidata_client — 0 errors.
  • black — clean.
  • mkdocs build — succeeds.
  • Manual encode→decode round-trip: DeviceFilter([PHONE, TABLET]) → audience model → back to an equal DeviceFilter.

Usage:

from rapidata import DeviceFilter, DeviceType
slice = base.filter([DeviceFilter([DeviceType.PHONE])])

🔗 Session: https://session-35b46b6a.poseidon.rapidata.internal/

The backend now supports a device audience filter, so DeviceFilter can be
used when deriving a filtered audience via RapidataAudience.filter().

- DeviceFilter._to_audience_model() now emits IAudienceFilterDeviceAudienceFilter
  instead of raising NotImplementedError.
- BackendFilterMapper decodes the device audience filter back to a DeviceFilter
  so reading an audience's filters round-trips.
- Docs + docstring list DeviceFilter (and the previously-missing DemographicFilter)
  as supported audience filters.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: lino <lino@rapidata.ai>
@claude

claude Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review

Overview: Small, focused change that enables DeviceFilter for filtered audiences by implementing DeviceFilter._to_audience_model() (previously NotImplementedError) and adding the corresponding decode path in BackendFilterMapper, plus a docs update. This mirrors the existing pattern for CountryFilter/LanguageFilter/DemographicFilter, and the underlying generated model (IAudienceFilterDeviceAudienceFilter, already registered in IAudienceFilter's oneOf) was already present on main, so this PR is purely the wiring — no generated-client or mustache-template changes needed, consistent with CLAUDE.md.

Code quality

  • _to_audience_model() correctly mirrors the existing _to_model() order-path construction (device_filter.py:40-54), and the lazy imports match the file's established style.
  • BackendFilterMapper additions follow the exact same import-caching / isinstance-dispatch pattern used for the other filters — good consistency.
  • Unrelated whitespace-only reformat of _to_model()'s deviceTypes=[...] (line wrap) is harmless and presumably a byproduct of running black, per project convention.

Potential issues

  • _backend_filter_mapper.py:156-161: the decode uses DeviceType(getattr(dt, "value", dt)) instead of the more direct DeviceType(dt.value) used nowhere else in this file. It's defensible as a guard against LazyValidatedModel's lazy-construct fallback (where an unrecognized enum value could leave a raw string instead of an ApiDeviceType member), but it's the first place this defensive pattern appears in the mapper — worth a one-line comment explaining why it's needed here specifically (e.g. "guards against lazily-constructed raw values"), otherwise it reads like inconsistent style vs. the neighboring branches.
  • No else needed since it fits the existing elif chain, but it might be worth double-checking that an empty device_types list round-trips correctly (i.e. DeviceFilter([]) isn't rejected by either the client or backend model) — not obviously handled either way in this diff.

Test coverage

  • No automated tests are added, but this matches the existing repo convention (no test suite exists under src/rapidata), and the PR description notes a manual encode→decode round-trip was verified. Given there's no test harness precedent to follow here, this is acceptable, though a quick round-trip unit test (encode DeviceFilter → decode via BackendFilterMapper → assert equality) would be cheap insurance if/when a test suite is introduced.

Docs

  • audiences.md and the filter() docstring updates are accurate and match the actual supported filter list. Nice catch also backfilling the previously-missing DemographicFilter row.

Security / performance

  • No concerns — this is a pure data-mapping change with no I/O, no new external input parsing beyond existing enum validation, and no meaningful performance impact.

Overall: clean, well-scoped change that follows established conventions. The one thing I'd tighten up is a short comment on the getattr(dt, "value", dt) fallback so future readers don't mistake it for stray inconsistency.

Comment thread docs/audiences.md Outdated
|---|---|
| `CountryFilter` | ISO-3166 country code (e.g. `["US", "CA"]`) |
| `LanguageFilter` | Spoken / device language (e.g. `["en", "de"]`) |
| `DemographicFilter` | Demographic attribute (e.g. `DemographicFilter("age", ["18-29"])`) |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why did we expose this as well?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if demographic filters are indeed already supported. then please at least put them in an enum

@LinoGiger LinoGiger self-requested a review July 13, 2026 11:56

@LinoGiger LinoGiger left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

didn't mean to approve

@LinoGiger LinoGiger marked this pull request as ready for review July 13, 2026 11:57
Keep this PR focused on the device filter per review feedback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: lino <lino@rapidata.ai>
@RapidPoseidon

Copy link
Copy Markdown
Contributor Author

Good call — the DemographicFilter row was an unrelated drive-by addition (the table was missing it, but the docstring already listed it). I've dropped it so this PR stays scoped to the device filter. DeviceFilter itself uses the DeviceType enum already.

On the underlying point: demographic identifiers/values are currently free strings (DemographicFilter("age", ["18-29"])), and turning those into an enum is a worthwhile but separate change with its own design (which identifiers/buckets are valid). Happy to open a follow-up for that if you'd like.

@claude

claude Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review

Overview

Small, well-scoped PR that finishes wiring DeviceFilter through to audiences: _to_audience_model() now emits IAudienceFilterDeviceAudienceFilter instead of raising NotImplementedError, and BackendFilterMapper decodes it back for the read path. Docs and docstring are updated to list the new filter.

Code quality

  • DeviceFilter._to_audience_model() mirrors the existing _to_model() (order-path) implementation and the sibling _to_audience_model() methods (CountryFilter, LanguageFilter) almost exactly — same lazy-import style, same _t/alias construction pattern. Good consistency with the codebase conventions.
  • The generated IAudienceFilterDeviceAudienceFilter model and its registration in IAudienceFilter's oneOf schema list already exist in src/rapidata/api_client, so no openapi/templates/*.mustache changes were needed here — correctly out of scope per CLAUDE.md.
  • BackendFilterMapper.backend_filter_from_rapidata_filter follows the established lazy-import/cache pattern used for the other filter types.

Potential issue: decode defensiveness

elif isinstance(actual_instance, BackendDeviceAudienceFilter):
    from rapidata.rapidata_client.filter.models.device_type import DeviceType
    return ClientDeviceFilter(
        [DeviceType(getattr(dt, "value", dt)) for dt in actual_instance.device_types]
    )

getattr(dt, "value", dt) is defensive against LazyValidatedModel's lazy-construct path (where an unrecognized backend DeviceType enum value could leave device_types as raw strings rather than enum instances). That's a reasonable and consistent use of the existing lazy-validation mechanism — worth a one-line comment explaining why the fallback exists, since it isn't obvious from this call site alone (there's no other precedent for this exact pattern elsewhere in the filter mapper).

Docs discrepancy

The PR description says "I added the previously-missing DemographicFilter row while there," but the diff and the current docs/audiences.md "Supported filters" table only add a DeviceFilter row — DemographicFilter is still absent from that table (it's only mentioned in the filter() docstring in rapidata_audience.py). Worth double-checking whether that row was meant to be included, since it's referenced in both the PR body and the docstring as a supported filter.

Test coverage

No automated tests were added (matches this repo's existing convention of no unit test suite under src/rapidata/rapidata_client), so verification here relies on the manual round-trip mentioned in the PR description. Given the somewhat subtle lazy-validation interaction above, a small round-trip unit test (encode → decode → equality) would be valuable if/when this project adds test infra, but isn't a blocker given current conventions.

Security / performance

No concerns — this is a pure data-mapping change with no new I/O, external input handling, or hot-path performance implications.

Overall: solid, minimally-scoped change that follows existing patterns closely. Only the docs table gap above seems worth resolving before merge.

Address review: demographic identifiers were free strings (and the occupation
identifier is a versioned, unguessable value). DemographicFilter now takes a
DemographicIdentifier enum (AGE / GENDER / OCCUPATION) instead of a raw string,
and the backend->client mapper decodes it back to the enum.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: lino <lino@rapidata.ai>
@RapidPoseidon

Copy link
Copy Markdown
Contributor Author

Done — addressed both comments:

  1. Demographic identifiers are now an enum. Added DemographicIdentifier (AGE / GENDER / OCCUPATION); DemographicFilter now takes it instead of a raw string. This was well worth it — the occupation identifier is actually "occupation-2026-07-01" (versioned), which no one would type correctly by hand.
  2. Re-added DemographicFilter to the docs table, now that it's type-safe, with the enum example.

DeviceFilter already used the DeviceType enum. pyright clean, encode/decode round-trips verified for both filters.

Note this is a breaking change for any caller currently passing a string (DemographicFilter("age", ...)DemographicFilter(DemographicIdentifier.AGE, ...)).

@claude

claude Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review: feat(audience): expose device filter for filtered audiences

Overview

This PR completes the DeviceFilter to filtered-audience path by implementing DeviceFilter._to_audience_model() (previously NotImplementedError) and teaching BackendFilterMapper to decode IAudienceFilterDeviceAudienceFilter back into a DeviceFilter. It also updates docs/docstrings, and as a secondary change converts DemographicFilter.identifier from a plain str to a new DemographicIdentifier enum.

The core DeviceFilter work is clean and directly mirrors the existing _to_model() / other _to_audience_model() implementations (same lazy-import style, same ApiDeviceType(dt._to_backend_model()) mapping). No issues there.

Potential issues

1. Scope creep: DemographicFilter.identifier str to enum change isn't mentioned in the PR description, and looks like a breaking change

  • DemographicIdentifier.OCCUPATION = "occupation-2026-07-01" (filter/models/demographic_identifier.py:19). Previously identifier was a free-form str, and the docstring's own example used a raw string for occupation. Any caller currently passing the literal string "occupation" will now fail Pydantic validation ("occupation" is not a valid DemographicIdentifier value), since the field type is now DemographicIdentifier. This is a real behavioral break for existing users of DemographicFilter, but it's not called out in the What/Why/How section - worth flagging explicitly and possibly splitting into its own PR/changelog entry independent of the device-filter work.
  • Note: passing a matching string like "age" still works because Pydantic coerces raw values into enum members, so this only breaks identifiers whose value changed (i.e. occupation).

2. Decode-side robustness regression for DemographicFilter

  • _backend_filter_mapper.py:150 now does DemographicIdentifier(actual_instance.identifier). If the backend ever returns an identifier that isn't one of age/gender/occupation-2026-07-01 (e.g. an audience created before the occupation identifier was versioned, or a newer identifier the SDK doesn't know about yet), this raises ValueError instead of round-tripping the raw string as before. This turns "read an audience's filters" into something that can now hard-fail on data that previously decoded fine. Worth considering a graceful fallback rather than only supporting the exact enum values currently known to the SDK.

3. Minor: unnecessary defensive coercion for device types

  • _backend_filter_mapper.py:162: DeviceType(getattr(dt, "value", dt)). Per the generated model, IAudienceFilterDeviceAudienceFilter.device_types is typed List[ApiDeviceType] where ApiDeviceType(str, Enum), so dt is always an enum member and dt.value always resolves - the getattr(..., dt) fallback path is dead code. Harmless, but inconsistent with the more direct DemographicIdentifier(actual_instance.identifier) conversion two branches above. Could simplify to DeviceType(dt.value).

4. Minor style: ad-hoc imports inside elif branches

  • The new DemographicIdentifier/DeviceType imports in backend_filter_from_rapidata_filter are done inline per-branch, whereas the rest of the file follows a lazy-import-and-cache pattern (_ensure_backend_filters_imported / _ensure_client_filters_imported). Not a bug, just slightly inconsistent with the file's established structure.

Test coverage

No automated tests are added (the repo doesn't appear to have a test suite at all, matching existing convention), and verification is described as a manual encode-decode round trip. Given the enum-based decode paths introduced here can now raise on previously-valid data (see item 2), a unit test for BackendFilterMapper.backend_filter_from_rapidata_filter round-tripping DeviceFilter/DemographicFilter would help guard against regressions if/when a test harness is added.

Nits

  • Docs and docstring updates (audiences.md, rapidata_audience.py) look accurate and match the new DemographicIdentifier usage.
  • The DemographicFilter/DeviceFilter _to_audience_model() implementations are consistent with sibling filters (CountryFilter, LanguageFilter) in structure.

Overall the DeviceFilter audience support itself is solid and well-aligned with existing patterns. The main thing I'd want addressed before merging is clarifying/scoping the DemographicIdentifier occupation-value change (item 1) since it reads as an unannounced breaking change riding along with this feature.

Comment thread docs/audiences.md Outdated
|---|---|
| `CountryFilter` | ISO-3166 country code (e.g. `["US", "CA"]`) |
| `LanguageFilter` | Spoken / device language (e.g. `["en", "de"]`) |
| `DemographicFilter` | Demographic attribute (e.g. `DemographicFilter(DemographicIdentifier.AGE, ["18-29"])`) |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no but i want the whole thing to be enum - so also the 2nd values since the ranges are predefined in the backend

…generic DemographicFilter

Per review: demographic audience filtering should be fully enum-based, and
only age and gender for now. AgeFilter and GenderFilter (which already use the
AgeGroup / Gender enums) now implement _to_audience_model, emitting a
DemographicAudienceFilter with the 'age' / 'gender' identifier. The mapper
decodes those back to the respective filter.

Removes the free-string DemographicFilter and the DemographicIdentifier enum:
occupation is deferred, and the per-dimension filters cover age + gender in a
type-safe way. RapidataFilters now exposes Age / Gender instead of Demographic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: lino <lino@rapidata.ai>
@RapidPoseidon

Copy link
Copy Markdown
Contributor Author

Reworked per your call — age and gender only, fully enum:

  • AgeFilter and GenderFilter now work for filtered audiences (they already take the AgeGroup / Gender enums, whose values match the backend demographic strings exactly). E.g. base.filter([AgeFilter([AgeGroup.BETWEEN_18_29]), GenderFilter([Gender.FEMALE])]).
  • Removed the free-string DemographicFilter and the DemographicIdentifier enum entirely. RapidataFilters now exposes Age / Gender instead of Demographic.
  • Occupation is deferred — its value set isn't defined in rapidata-backend (only the versioned occupation-2026-07-01 identifier), so it needs its own enum + source later.

pyright clean, encode/decode round-trips verified for age, gender, and device. Device filter (this PR's original purpose) is unchanged.

@RapidPoseidon RapidPoseidon requested a review from LinoGiger July 13, 2026 14:21
@claude

claude Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review

Overall this is a clean, well-scoped follow-up — the encode/decode symmetry between _to_model()/_to_audience_model() is good, the enum round-tripping for AgeGroup/Gender is correct, and the docs/docstring updates match the new API surface. One correctness concern and a few minor notes below.

Potential bug: unsupported demographic identifiers break audience listing, not just decoding

In _backend_filter_mapper.py (backend_filter_from_rapidata_filter), any DemographicAudienceFilter whose identifier isn't exactly "age" or "gender" now raises ValueError:

raise ValueError(
    f"Unsupported demographic filter identifier '{identifier}'. Only 'age' and 'gender' are supported."
)

Before this PR, DemographicFilter accepted arbitrary string identifiers (per the earlier commit message: "demographic identifiers were free strings"), so any audience filter using e.g. "occupation" (or any other legacy identifier) would decode fine. Now, both RapidataAudienceManager.get_audience_by_id and find_audiences (rapidata_audience_manager.py:90 and :134) eagerly map every filter on every returned audience via this method with no try/except. That means:

  • get_audience_by_id will raise for any audience that has a pre-existing non-age/gender demographic filter (e.g. one created via the old free-string DemographicFilter before this change shipped), instead of returning it.
  • find_audiences is worse: it's a paginated list call, so one audience anywhere in the page with an unsupported identifier will blow up the entire page's results, not just that one audience.

If there's any chance production data already has demographic filters with identifiers outside {age, gender} (the PR body itself mentions occupation was previously possible and is only "deferred", not removed backend-side), this is a real regression. Worth either confirming no such data exists/can exist, or making the decode path degrade gracefully (e.g. skip/log unknown filters) rather than failing the whole call.

Minor

  • DeviceFilter decode is stylistically inconsistent with Age/Gender. The age/gender decode paths route through the backend enum first (AgeGroup(AgeUserFilterModelAgeGroup(value))), whereas the device path does DeviceType(getattr(dt, "value", dt)), reaching for .value with a fallback that's never needed (the backend device_types are always api_client.models.device_type.DeviceType enum instances, which always have .value). Not a bug, just worth aligning with the other two for consistency — e.g. DeviceType(dt.value).
  • DeviceFilter isn't added to RapidataFilters. Age = AgeFilter / Gender = GenderFilter were added to rapidata_filters.py, but there's no Device = DeviceFilter, even though this PR is specifically about enabling DeviceFilter for audiences. Looks like a pre-existing gap rather than something this PR broke, but since this PR touches that exact file, it'd be a natural time to add it for consistency.
  • Test coverage. No automated tests exist for _backend_filter_mapper.py's encode/decode round-trip (tests/ has no hits for DeviceFilter|AgeFilter|GenderFilter|BackendFilterMapper). Given the enum re-wrapping here (client enum → backend enum → string → backend enum → client enum) is nontrivial, a small unit test for BackendFilterMapper.backend_filter_from_rapidata_filter covering AgeFilter, GenderFilter, DeviceFilter, and the unsupported-identifier case would catch regressions cheaply.

Nits

  • Everything else (docs, docstrings, exports in both __init__.py files) is consistent and correctly updated for the DemographicFilterAgeFilter/GenderFilter split.

No security concerns — this is all client-side model translation with no new I/O, secrets, or untrusted input handling.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants