reachy-mini-api / server /categories.js
tfrere's picture
tfrere HF Staff
refactor(api): clearer config file names
3b73f6b
Raw
History Blame Contribute Delete
12.7 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.
*/
/**
* Default taxonomy version, used as a fallback when the dataset's
* `config/taxonomy.json` is missing/unreadable. The LIVE version is
* exported as the mutable `TAXONOMY_VERSION` below and is overwritten
* by `loadTaxonomyFromDataset()` at boot.
*
* Bump the version (in the dataset file, or here for the fallback)
* 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 and reclassifies them on the next pass.
* Cosmetic edits (label / emoji) don't need a bump since they 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.
* - v4 (data-driven): the canonical list now lives in the dataset
* at `config/taxonomy.json`; this array is only the cold-start
* fallback. Editing categories no longer requires a code deploy.
*/
const DEFAULT_TAXONOMY_VERSION = 4;
/**
* Default category list (cold-start fallback). The canonical, editable
* list lives in the dataset at `config/taxonomy.json` and is loaded
* over this at boot. 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.
*/
const DEFAULT_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.',
},
];
// ───────────────────────────────────────────────────────────────────
// Live taxonomy state
// ───────────────────────────────────────────────────────────────────
// The active taxonomy starts as the hardcoded default and is replaced
// in place by `loadTaxonomyFromDataset()` at boot. We keep the
// version as a mutable `export let` so consumers that imported it
// (e.g. categoryCache.js) observe the loaded value via the ES module
// live binding - they read it at runtime, after the loader has run.
let activeCategories = DEFAULT_CATEGORIES;
let activeAllowedSlugs = new Set(DEFAULT_CATEGORIES.map((c) => c.slug));
export let TAXONOMY_VERSION = DEFAULT_TAXONOMY_VERSION;
// Where the canonical, hand-editable taxonomy lives in the store
// dataset. Sibling of `config/official-app-list.json` / `config/blocked-app-list.json`.
const TAXONOMY_FILE_PATH = 'config/taxonomy.json';
export function isValidSlug(slug) {
return activeAllowedSlugs.has(slug);
}
/**
* Full taxonomy object (WITH descriptions) used to seed / mirror the
* dataset file. Shape matches what `loadTaxonomyFromDataset()` parses.
*/
export function getFullTaxonomy() {
return {
version: TAXONOMY_VERSION,
categories: activeCategories.map((c) => ({
slug: c.slug,
label: c.label,
emoji: c.emoji,
description: c.description,
})),
};
}
/**
* Load the canonical taxonomy from the dataset's `config/taxonomy.json`
* and swap it in over the hardcoded default. Best-effort: a missing
* file, a 404, malformed JSON, or an empty list all keep the default
* (so the server always has a working taxonomy). Call this ONCE at
* boot, BEFORE loading the category cache (so the cache's stale-version
* pruning compares against the live version).
*
* Returns true when the dataset taxonomy was applied, false when we
* fell back to the default.
*/
export async function loadTaxonomyFromDataset(repoName, token) {
const url = `https://huggingface.co/datasets/${repoName}/resolve/main/${TAXONOMY_FILE_PATH}`;
try {
const res = await fetch(url, {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
if (!res.ok) {
console.log(
`[Taxonomy] ${TAXONOMY_FILE_PATH} not found on ${repoName} ` +
`(HTTP ${res.status}) - using built-in default (v${TAXONOMY_VERSION}).`,
);
return false;
}
const data = await res.json();
const version = Number.isInteger(data?.version) ? data.version : null;
const rawCats = Array.isArray(data?.categories) ? data.categories : null;
if (version === null || !rawCats) {
console.warn(
`[Taxonomy] Malformed ${TAXONOMY_FILE_PATH} on ${repoName} - ` +
`keeping built-in default (v${TAXONOMY_VERSION}).`,
);
return false;
}
const cleaned = [];
const seen = new Set();
for (const c of rawCats) {
if (!c || typeof c.slug !== 'string') continue;
const slug = c.slug.trim().toLowerCase();
if (!slug || seen.has(slug)) continue;
seen.add(slug);
cleaned.push({
slug,
label: typeof c.label === 'string' && c.label.trim() ? c.label : slug,
emoji: typeof c.emoji === 'string' ? c.emoji : '',
description: typeof c.description === 'string' ? c.description : '',
});
}
if (cleaned.length === 0) {
console.warn(
`[Taxonomy] ${TAXONOMY_FILE_PATH} has no valid categories - ` +
`keeping built-in default (v${TAXONOMY_VERSION}).`,
);
return false;
}
activeCategories = cleaned;
activeAllowedSlugs = new Set(cleaned.map((c) => c.slug));
TAXONOMY_VERSION = version;
console.log(
`[Taxonomy] Loaded ${cleaned.length} categories (v${version}) from ` +
`${repoName}/${TAXONOMY_FILE_PATH}.`,
);
return true;
} catch (err) {
console.warn(
`[Taxonomy] Load failed (${err.message}) - keeping built-in ` +
`default (v${TAXONOMY_VERSION}).`,
);
return false;
}
}
/**
* 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 the active taxonomy, 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 activeCategories.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 activeCategories.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 (!activeAllowedSlugs.has(slug)) continue;
seen.add(slug);
out.push(slug);
if (out.length >= maxCategories) break;
}
return out;
}