Spaces:
Running
Running
| /** | |
| * 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; | |
| } | |