Reachy_Mini / server /categories.js
tfrere's picture
tfrere HF Staff
feat(api): expose category taxonomy to clients
20e2e73
Raw
History Blame Contribute Delete
7.92 kB
/**
* Predefined taxonomy for JS Reachy Mini apps.
*
* These slugs are the ONLY valid output values for the LLM
* inference step (anything else is dropped at parse time) and
* the values consumers (mobile shell, website) filter on.
*
* Why a closed list instead of free-form tags
* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
* The HF Spaces catalog has no usable categorization for the
* reachy_mini_js_app subset (only platform/SDK tags). We bridge
* the gap by inferring categories with an LLM, but we have to
* constrain the model's output: a closed list keeps category
* pages stable, lets us pre-pick emojis/labels, and avoids the
* "30 near-duplicate slugs" problem you'd get with free-form.
*
* Bumping the taxonomy
* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
* Adding, removing or renaming a slug changes the meaning of
* cached entries. Bump TAXONOMY_VERSION when you do that: the
* cache layer compares each entry's `taxonomyVersion` against
* the live one and recomputes stale ones on the next pass.
*/
/**
* Bump this when the slug list OR the descriptions change in a way
* that affects the LLM output. The cache layer invalidates entries
* whose taxonomyVersion is older than this and reclassifies them on
* the next pass. We don't bump it for cosmetic edits (label / emoji)
* since those don't reach the LLM.
*
* History:
* - v1: initial 8-slug taxonomy.
* - v2: added `games`, tightened `kids` + `dev-tools` descriptions,
* switched the prompt to a DECISION ALGORITHM with few-shot.
* - v3: switched from multi-label (up to 3 slugs) to single-label
* (exactly 1 slug). Each app surfaces in exactly one category
* section on the mobile shell - no duplicates across swipers.
* - v4: renamed `dance` to `motion` (broader: marionette, replay,
* choreography without music). Music-driven dance parties
* now belong to `music` since music is what drives them.
*/
export const TAXONOMY_VERSION = 4;
/**
* Canonical category list. Keep slugs short, kebab-case, and
* memorable: they end up in URLs (e.g. `?cat=music`) and in
* filter chips on mobile.
*
* The `description` field is the SOLE source of truth the LLM
* sees - keep them factual, scope-bounded, and example-led so
* the model has signal for both inclusion and exclusion.
*/
export const CATEGORIES = [
{
slug: 'music',
label: 'Music & Beats',
emoji: '๐ŸŽต',
description:
'Music creation, playback, beats, songs, DJ mixing, instruments, ' +
'blind-test music games, AND music-driven dance parties (Reachy ' +
'dances to a song). Requires actual music (rhythm / melody / song). ' +
'Arbitrary audio (Morse code, alarms, TTS, sound effects) is NOT ' +
'music. Pure choreography without music belongs to `motion`.',
},
{
slug: 'motion',
label: 'Motion & Movement',
emoji: '๐Ÿฆพ',
description:
"Apps that drive Reachy's physical movement on its own: motion " +
'replay, marionette-style remote control of the body, kinetic ' +
'shows, choreographies WITHOUT music, expressive body language. ' +
'If the movement is synced to music, use `music` instead.',
},
{
slug: 'voice',
label: 'Voice & Conversation',
emoji: '๐Ÿ—ฃ๏ธ',
description:
'Reachy talks, listens, or holds a real-time voice ' +
'conversation: TTS players, LLM-driven chat (OpenAI Realtime, ' +
'Claude, Perplexity), wake-word demos, daily reports / news / ' +
'weather read aloud.',
},
{
slug: 'storytelling',
label: 'Stories',
emoji: '๐Ÿ“–',
description:
'Narrative stories WITH plot and characters: interactive ' +
'fiction, bedtime tales, audio adventures, choose-your-own-' +
'adventure. NOT for daily reports, news, weather, or Q&A ' +
'(those are `voice`).',
},
{
slug: 'kids',
label: 'For Kids',
emoji: '๐Ÿง’',
description:
'Apps that EXPLICITLY target children: the words kids / ' +
"children / 'for curious minds' / bedtime / 'learning for kids' " +
'must appear in the name or description, OR the app must be ' +
'obviously kid-targeted. Combines with `storytelling`, `voice`, ' +
'or `games`. Lifestyle, sports, weather, generic personality / ' +
'narration / fun framings are NOT kids.',
},
{
slug: 'games',
label: 'Games & Play',
emoji: '๐ŸŽฎ',
description:
'Apps with a play loop: scores, rounds, win/lose conditions, ' +
'quizzes, puzzles, sports simulations, dice/oracles (magic ' +
'8-ball), arcade-style mini-games.',
},
{
slug: 'vision',
label: 'Vision & Camera',
emoji: '๐Ÿ‘๏ธ',
description:
"Apps where Reachy's camera DRIVES behaviour: face/hand/pose " +
'tracking, image classification, gesture detection, visual ' +
'mimicry. Merely streaming or displaying the camera feed ' +
'(WebRTC demos, remote-control viewers) is NOT vision.',
},
{
slug: 'companion',
label: 'Companion',
emoji: '๐Ÿค',
description:
'Apps with an EXPLICIT emotional / personality / buddy framing ' +
'in the name or description (companion, buddy, friend, mood, ' +
'emotional, personality, pet, Tamagotchi-like, "alive", ' +
'"life companion"). Being friendly is not enough.',
},
{
slug: 'dev-tools',
label: 'Dev & Demos',
emoji: '๐Ÿ› ๏ธ',
description:
'RESERVED slug - see DECISION ALGORITHM step 1 in the prompt. ' +
'Use ONLY for pure technical artefacts (debug utilities, SDK ' +
'probes, minimal protocol demos, dev-only test spaces) with no ' +
'end-user experience. When used, it is the SOLE category - ' +
'never combined with another slug.',
},
];
export const ALLOWED_SLUGS = new Set(CATEGORIES.map((c) => c.slug));
export function isValidSlug(slug) {
return ALLOWED_SLUGS.has(slug);
}
/**
* Public projection of the taxonomy meant to be shipped to clients
* (mobile shell, website filter chips). We strip the `description`
* field on purpose: it is sized + worded for the LLM prompt and
* carries no UI value (clients render `label` + `emoji`). Render
* order is the index in `CATEGORIES`, surfaced as `order` so a
* client that needs to re-sort (e.g. alphabetical view) keeps the
* canonical order one field-away.
*
* The shape is intentionally minimal and stable:
* `{ slug, label, emoji, order }`. Adding optional fields later
* (e.g. `color`, `shortLabel`) is forward-compatible; renaming or
* dropping one is a breaking change for any client mirror.
*/
export function getPublicTaxonomy() {
return CATEGORIES.map((c, index) => ({
slug: c.slug,
label: c.label,
emoji: c.emoji,
order: index,
}));
}
/**
* Render the taxonomy as a bulleted list for the LLM prompt.
* Format mirrors what the model is asked to output (slug first)
* to nudge it towards copying the exact string back.
*/
export function buildLlmCategoryList() {
return CATEGORIES.map((c) => `- ${c.slug}: ${c.description}`).join('\n');
}
/**
* Sanitize a raw LLM-returned list of slugs:
* - drop non-strings
* - lowercase + trim
* - drop unknown slugs (hallucinations)
* - dedupe while preserving order (the model orders by relevance)
* - cap to MAX_CATEGORIES
*
* Returns a fresh array; never mutates input.
*/
export function sanitizeSlugs(raw, maxCategories = 3) {
if (!Array.isArray(raw)) return [];
const seen = new Set();
const out = [];
for (const v of raw) {
if (typeof v !== 'string') continue;
const slug = v.trim().toLowerCase();
if (!slug || seen.has(slug)) continue;
if (!ALLOWED_SLUGS.has(slug)) continue;
seen.add(slug);
out.push(slug);
if (out.length >= maxCategories) break;
}
return out;
}