Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

ovos-media-classifier

License: Apache-2.0 Python

Pre-1.0 software. APIs may still change without notice. It is the classification engine ovos-ocp-pipeline-plugin uses for media classification; pin a version and expect breaking changes between releases.

A self-describing, pluggable media-intent classifier for voice assistants. Given a spoken request, such as "play some music", "watch an anime", or "read me a chapter of Dune", it answers, fast and offline, what kind of media is wanted. The OVOS Common Play (OCP) pipeline then routes the request to the right provider and player.

It is a router, not a resolver. It gates the request (is this a media request at all?), routes it by media_type / playback_type (which MediaProviders to call, and which to skip), applies content policy (adult drops adult providers), and hands the providers a mediavocab.Signals as search context. It does not resolve a title to a stream. The providers do that, so Signals.title carries the raw query by design. The real test of a router is whether it routes real speech correctly. See the routing eval.

It is the single home for OCP's media-command NLP. It is multi-task: every request is classified along several orthogonal axes at once, instead of into one label.

  • domain: is this a media request at all (ocp_play / ocp_control / not_ocp)
  • media_type: the concrete mediavocab.MediaType leaf (music, movie, podcast, …)
  • playback_type: the modality (audio / video / paged / interactive)
  • structure: the temporal shape (single / episodic / continuous / collection)
  • explicitness: clean / adult
  • tags: a multi-label, namespaced descriptive axis: genre:rock / mood:chill / era:1980s
  • qualifiers: result-narrowing filters: black_and_white / silent / live / subtitled / …
  • content-form genres: adult / anime / animation / asmr (drives the content filter)

The supervision comes from translatable .intent templates, slot-filled with real entity metadata (IMDb, MusicBrainz, AniList, LibriVox, …). The trained backend ships as a rules to learned-context to learned-context+NER ladder, packaged in self-describing ONNX bundles. Each rung is a drop-in upgrade over the last.

Quickstart

pip install ovos-media-classifier
from ovos_media_classifier import load_media_classifier

clf = load_media_classifier()                  # bundled .voc keyword classifier, zero deps
clf.classify("play some music", "en-us")       # -> (<MediaType.MUSIC: 'music'>, 0.6)

That is the whole minimum: install, load, classify. The default needs no model files and no ML dependencies. It runs fully offline.

The full multi-axis result, and a provider-ready mediavocab.Signals:

clf.classify_full("i want to watch an anime", "en-us").as_dict()
# {'media_type': 'episodic_series', 'playback_type': 'video', 'structure': 'episodic',
#  'domain': 'ocp_play', 'genres': ['anime'], 'confidence': 0.6, 'control_intent': None}

clf.classify_tags("play some 80s rock", "en-us")  # -> ['genre:rock', 'era:1980s']  (trained backend)

clf.to_signals("play some music", "en-us")        # -> mediavocab.Signals (hand to a MediaProvider)

(classify_full().as_dict() carries the single-label axes plus the content-form genres. The multi-label tags / qualifiers axes are read with their own classify_tags() / classify_qualifiers() methods. On the zero-dep keyword default, tags stays empty. Only the trained ONNX backend fills it.)

classify_full is context-aware. The minimal call is classify_full(query, lang). Two optional arguments thread per-query context with no retraining: player_status (now-playing state, for control / "play something else" follow-ups) and ner_list (the user's live entities, for NER matching and the embedding router's runtime injection). See contextual classification.

Benchmark

The headline result is the lift across the ladder: the deterministic keyword rules, then a model on context (keyword) features only, then the same model once a NER store has surfaced the user's entities. Per-axis, on the held-out test split (34,700 synthetic utterances):

axis (metric) rules learned-context learned-context+NER
domain (acc) 0.833 0.866 0.986
media_type (acc) 0.629 0.778 0.964
playback_type (acc) 0.702 0.895 0.988
structure (acc) 0.708 0.907 0.990
explicitness (acc) 0.988 0.989 0.997
content_form_genres (macro-F1) 0.706 0.738 0.975
qualifiers (macro-F1) 0.000 0.746 0.906
tags (macro-F1) 0.000 0.547 0.581

Content filter (driven by the content_form_genres axis), same ladder:

rung adult recall hentai recall false-block median ms p95 ms bundle
rules 0.481 0.510 0.000 0.32 0.50 —
learned-context 0.481 0.510 0.000 0.21 0.25 176 KiB
learned-context+NER 0.922 0.936 0.001 0.21 0.26 289 KiB

Sub-millisecond, in a 289 KiB bundle. Honesty notes: these numbers come from the synthetic eval split. They measure the model's capability given populated features, not field accuracy on arbitrary speech (the keyword floor is about 0.29 on a neutral real-text split). The keyword backend is the zero-dependency default. The context+NER column needs a wired-in NER store to surface the ner_* features. And tags stays low by design (about 0.58). See limitations. Full table and method: docs/model.md · benchmarks/.

Axes vs. tags

The single-label heads above are axes: exactly one answer per query (a request is audio, is episodic). The open-vocabulary descriptive signals, genre, mood, and era, are not axes. A query can carry several at once, and they all live in slot value text (the decade is in the year, the mood is in the activity phrase). So they fold into one multi-label, namespaced tags head (genre: / mood: / era:) instead of three starved single-label heads. classify_content_genres() / classify_mood() / classify_era() read the matching slice. This framing keeps the axis count honest while still modeling the descriptive signal. See classification-model.md.

Backends

load_media_classifier(config) returns one classifier. They all implement the same AbstractMediaClassifier contract, so callers never care which one ran, and any load failure falls back to the keyword default.

Backend What it is Install
keyword (.voc) zero-dependency phrase matching, high-precision, abstains when unsure. The offline default core
NER Aho-Corasick exact match over the user's entity lists (their real library) [ner]
embedding-router (hybrid) learned open-vocab router: keyword stays the floor, the router fills keyword's abstains using a gazetteer plus the user's injected library [onnx]
ONNX the trained multi-task per-axis heads, loaded from a self-describing bundle [onnx]
external any classifier registered under opm.media.classifier a plugin
pip install ovos-media-classifier[ner]     # entity-list matching (the user's library)
pip install ovos-media-classifier[onnx]    # trained ONNX + embedding-router backends

The keyword backend is the floor; a learned backend has to earn its place. The zero-dep keyword classifier is deliberately high-precision and abstains to GENERIC when it has no cue. This is a safe outcome, because an abstain still lets every provider search. The embedding-router hybrid keeps keyword as the first pass and only fills those abstains. It resolves open-vocabulary titles by injecting the user's own library as entities at runtime (no retraining). That entity injection is what lowers the mis-route rate below the keyword floor. See the routing eval.

Live routing uses a bounded entity set. Entity / gazetteer matching cost scales with the number of injected titles, so live classification runs on a bounded set: the user's library plus a capped popular gazetteer (default about 1000 titles per type, p95 a few ms). A 1M-entity set (for example full MusicBrainz) is for OFFLINE tagging only, never live classification. The optional online metadatarr.resolve layer (about seconds per title) is for offline tagging or a long-running agent. It stays off by default and never runs in the live OCP pipeline.

See docs/backends.md. To write your own backend, or train your own bundle (including adding a brand-new axis end-to-end), see docs/extending.md.

Content filtering

A detect-to-block moderation layer recognizes sensitive requests so OVOS can refuse them. It reads the content_form_genres axis, so adult can be flagged independently of the media-type leaf (a single leaf mistake never unblocks it). adult is blocked by default (lift it with allow_adult_content). The adult / hentai data exists for detection only, never for provision.

from ovos_media_classifier import ContentFilter
ContentFilter().check(clf, "play some porn", "en-us")   # (True, 'blocked genre: adult')

See content filtering.

Command vs content classification

This package classifies a voice command (what does the user want?). That is a different problem from mediavocab.text.classify, which classifies a piece of catalog content (what kind of item is this?). They share the mediavocab.MediaType vocabulary but answer opposite questions. Do not substitute one for the other. See taxonomy.md.

Documentation

Start at docs/index.md for the audience-routing table, or read the glossary first if the terms are new.

Credits

Media-metadata datasets by TigreGotico on Hugging Face.

License

Apache-2.0.

Metadata

Release files for ovos-media-classifier 0.2.1a2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for ovos-media-classifier 0.2.1a2
File Size Uploaded Provenance Actions
ovos_media_classifier-0.2.1a2.tar.gz 399.6 kB Without provenance Details for ovos_media_classifier-0.2.1a2.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for ovos-media-classifier 0.2.1a2
File Interpreter ABI Platform Actions
ovos_media_classifier-0.2.1a2-py3-none-any.whl Python 3 none any Details for ovos_media_classifier-0.2.1a2-py3-none-any.whl

Total release size: 971.9 kB

Release files / ovos_media_classifier-0.2.1a2.tar.gz

Download URL ovos_media_classifier-0.2.1a2.tar.gz
Size 399.6 kB
Tags Source
SHA-256 checksum
How to use checksums
c554ae5b3b19d90d783ec0bca8eb97037ce53aaa91b5a401a47ceefda0a3210f
BLAKE2b-256 checksum
How to use checksums
398844d5f9b215954dfb0f32fcfa6175e4a2d843894761295c82bad739381531
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.14

Release files / ovos_media_classifier-0.2.1a2-py3-none-any.whl

Download URL ovos_media_classifier-0.2.1a2-py3-none-any.whl
Size 572.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
90c8b7ebacdddc04da7d91c677c58bdad43c6ca183325db97a13a9707bb89aaa
BLAKE2b-256 checksum
How to use checksums
e85de80d8b55a37c7c4a6a6818d784a75d480528e3ed3439b3e8745ac5e4f35f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.14

Security for ovos-media-classifier 0.2.1a2

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page