Spaces:
Running
Running
| #!/usr/bin/env python3 | |
| """ | |
| Prompt-v2 evaluation harness. | |
| Re-runs the LLM categorization on every JS app currently served by | |
| /api/js-apps with a tightened prompt, and prints a side-by-side | |
| diff against the live (v1) classifications. | |
| This file lives outside the server runtime - it never gets pushed | |
| to the Space. It's only meant to be hand-iterated until the diff | |
| looks right, then the chosen prompt is ported into server/categorize.js | |
| and server/categories.js. | |
| Run: | |
| python3 scripts/evaluate-prompt-v2.py | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import os | |
| import re | |
| import ssl | |
| import sys | |
| import time | |
| import urllib.error | |
| import urllib.request | |
| from pathlib import Path | |
| from typing import Any | |
| # Python 3.14 on macOS ships without the system CA bundle wired into | |
| # urllib by default - HF endpoints fail with CERTIFICATE_VERIFY_FAILED. | |
| # This script is dev-local only and only talks to huggingface.co, so | |
| # bypassing verification here is acceptable (would NEVER do this in | |
| # the server runtime). | |
| _SSL_CTX = ssl._create_unverified_context() # noqa: S323 | |
| HF_INFERENCE_URL = "https://router.huggingface.co/v1/chat/completions" | |
| MODEL = "meta-llama/Llama-3.1-8B-Instruct" | |
| TEMPERATURE = 0 | |
| MAX_TOKENS = 120 | |
| README_MAX_CHARS = 3000 | |
| MAX_CATEGORIES_PER_APP = 3 | |
| JS_APPS_URL = "https://pollen-robotics-reachy-mini.hf.space/api/js-apps" | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Taxonomy v2 - 9 slugs (added "games") | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| CATEGORIES_V2: list[tuple[str, str]] = [ | |
| ( | |
| "music", | |
| "Music creation, playback, beats, songs, DJ mixing, instruments, " | |
| "blind-test music games. Requires actual music (rhythm/melody/song). " | |
| "NOT arbitrary audio (Morse code, alarms, TTS, sound effects).", | |
| ), | |
| ( | |
| "dance", | |
| "Dance choreographies, motion replay, kinetic shows, " | |
| "recording/replaying robot movements, dance parties.", | |
| ), | |
| ( | |
| "voice", | |
| "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.", | |
| ), | |
| ( | |
| "storytelling", | |
| "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 (use `voice`).", | |
| ), | |
| ( | |
| "kids", | |
| "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, general conversation are NOT kids.", | |
| ), | |
| ( | |
| "games", | |
| "Apps with a play loop: scores, rounds, win/lose conditions, " | |
| "quizzes, puzzles, sports simulations, dice/oracles (magic 8-ball), " | |
| "arcade-style mini-games.", | |
| ), | |
| ( | |
| "vision", | |
| "Apps where Reachy's camera DRIVES behaviour: face/hand/pose " | |
| "tracking, image classification, gesture detection, visual mimicry. " | |
| "NOT for apps that merely stream or display the camera feed.", | |
| ), | |
| ( | |
| "companion", | |
| "Apps with an EXPLICIT emotional/personality/buddy framing in the " | |
| "name or description (words like companion, buddy, mood, emotional, " | |
| "personality, pet, Tamagotchi). Being friendly is not enough.", | |
| ), | |
| ( | |
| "dev-tools", | |
| "RESERVED slug β see DECISION ALGORITHM step 1 below. 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.", | |
| ), | |
| ] | |
| ALLOWED = {slug for slug, _ in CATEGORIES_V2} | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Few-shot examples - cover the main pitfalls of v1 | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| FEW_SHOT = [ | |
| ( | |
| "Reachy Morse", | |
| "Send Morse code through Reachy's speaker.", | |
| ["dev-tools"], | |
| "(STEP 1 veto: pure technical artefact. NOT music.)", | |
| ), | |
| ( | |
| "WebRTC Demo", | |
| "Minimal WebRTC connection between Reachy and the browser.", | |
| ["dev-tools"], | |
| "(STEP 1 veto: protocol demo. NOT vision.)", | |
| ), | |
| ( | |
| "TTS Reachy Mini", | |
| "Browser TTS that plays out of Reachy Mini's speaker.", | |
| ["voice"], | |
| "(USER-FACING speech output is voice, NOT dev-tools.)", | |
| ), | |
| ( | |
| "Reachy Mochi - Emotional Companion", | |
| "Your pocket buddy that develops a mood and personality over time.", | |
| ["companion"], | |
| "(explicit emotional/companion framing)", | |
| ), | |
| ( | |
| "Reachy Alive", | |
| "(README empty; name suggests autonomy and life-like presence)", | |
| ["companion"], | |
| "(USE THE NAME when the README is empty; 'alive' = companion-like)", | |
| ), | |
| ( | |
| "Daily Surf Report", | |
| "Reachy reads today's surf report out loud.", | |
| ["voice"], | |
| "(NOT storytelling β a report has no narrative arc. " | |
| "NOT kids β surfing/sports are not kid-targeted.)", | |
| ), | |
| ( | |
| "Music Quiz", | |
| "Play a blind test music game with a dancing Reachy.", | |
| ["music", "games", "dance"], | |
| "(multi-label: three slugs truly co-apply, ordered by relevance)", | |
| ), | |
| ( | |
| "Mime Bot", | |
| "Reachy mimics your face live from your webcam.", | |
| ["vision"], | |
| "(NOT companion β mimicry is visual, no emotional framing.)", | |
| ), | |
| ] | |
| def build_system_prompt() -> str: | |
| taxonomy = "\n".join(f"- {slug}: {desc}" for slug, desc in CATEGORIES_V2) | |
| examples = "\n".join( | |
| f" - {name!r}: {desc!r}\n" | |
| f" β {{\"categories\": {json.dumps(cats)}}} {hint}" | |
| for name, desc, cats, hint in FEW_SHOT | |
| ) | |
| return f"""You classify a Reachy Mini robot app into a CLOSED list of categories. | |
| OUTPUT FORMAT | |
| Return ONLY a single JSON object: {{"categories": ["slug1", "slug2"]}}. | |
| Pick 1 to {MAX_CATEGORIES_PER_APP} slugs, ordered from most to least relevant. | |
| Use the EXACT slug. No prose, no code fences, no commentary outside the JSON. | |
| DECISION ALGORITHM (apply in order) | |
| STEP 1 β `dev-tools` veto | |
| Is this app a PURE technical artefact with no user-facing experience | |
| beyond "here is how the SDK / API works"? | |
| Examples that pass the veto: WebRTC demo, SDK probe, debug utility, | |
| raw remote-control interface, dev-only test space. | |
| Examples that DO NOT pass the veto (they are user-facing apps): | |
| TTS players, voice chat, music apps, storytelling, companions β | |
| even when the README is dev-heavy. | |
| β YES β return {{"categories": ["dev-tools"]}} and STOP. Never combine. | |
| β NO β continue to STEP 2. | |
| STEP 2 β Pick 1 to {MAX_CATEGORIES_PER_APP} user-facing slugs from the | |
| list below. Choose the MOST SPECIFIC categories. Order from most to | |
| least relevant. Multi-label is encouraged when two categories truly | |
| co-apply (e.g. music-and-dance, kids storytelling, vision game). | |
| If the README is empty or very sparse, USE THE NAME AND DESCRIPTION | |
| as the primary signal β do not bail to an empty list just because the | |
| README is thin. | |
| STEP 3 β Strict slug rules (each must hold, or DO NOT use the slug) | |
| - `companion`: requires EXPLICIT emotional / personality / buddy framing | |
| (companion, buddy, friend, mood, emotional, personality, pet, | |
| Tamagotchi-like, "alive", "life companion"). Being friendly is not | |
| enough. | |
| - `music`: requires actual music β rhythm, melody, songs, beats, DJ | |
| sets, instruments, music quizzes. Arbitrary audio (Morse, alarms, | |
| TTS, sound effects) is NOT music. | |
| - `vision`: requires the camera to DRIVE behaviour (tracking, | |
| classification, mimicry). Merely streaming or displaying the camera | |
| (WebRTC demos, remote-control viewers) is NOT vision. | |
| - `storytelling`: requires a narrative ARC β plot, characters, scenes. | |
| Daily reports, news, weather, Q&A are NOT storytelling (they are | |
| `voice`). | |
| - `games`: requires a play loop β score, rounds, win/lose, puzzles, | |
| quizzes, dice/oracles, sports simulations. | |
| - `kids`: requires kid-targeted framing (kids/children/curious minds/ | |
| bedtime/learning for kids) in the name or description. Lifestyle, | |
| sports, weather, general conversation are NOT kids. | |
| AVAILABLE CATEGORIES | |
| {taxonomy} | |
| REFERENCE EXAMPLES | |
| {examples} | |
| Do not include any text outside the JSON object.""" | |
| def build_user_prompt(name: str, description: str, readme: str) -> str: | |
| return ( | |
| f"App name: {name or '(unknown)'}\n" | |
| f"Short description: {description or '(none)'}\n\n" | |
| f"README excerpt:\n{readme or '(no README available)'}\n\n" | |
| f"Return the JSON now." | |
| ) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # README fetch + clean (mirrors server/categorize.js) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def fetch_readme(space_id: str) -> str: | |
| url = f"https://huggingface.co/spaces/{space_id}/raw/main/README.md" | |
| try: | |
| with urllib.request.urlopen(url, timeout=10, context=_SSL_CTX) as r: | |
| return r.read().decode("utf-8", errors="replace") | |
| except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError): | |
| return "" | |
| def clean_readme(raw: str) -> str: | |
| if not raw: | |
| return "" | |
| txt = raw | |
| txt = re.sub(r"^---\n[\s\S]*?\n---\n?", "", txt) | |
| txt = re.sub(r"!\[[^\]]*\]\([^)]+\)", "", txt) | |
| txt = re.sub(r"<img\b[^>]*>", "", txt, flags=re.IGNORECASE) | |
| txt = re.sub(r"\[!\[[^\]]*\]\([^)]+\)\]\([^)]+\)", "", txt) | |
| txt = re.sub(r"</?[a-zA-Z][^>]*>", "", txt) | |
| txt = re.sub(r"\n{3,}", "\n\n", txt) | |
| if len(txt) > README_MAX_CHARS: | |
| cut = txt.rfind("\n\n", 0, README_MAX_CHARS) | |
| if cut > README_MAX_CHARS // 2: | |
| txt = txt[:cut] | |
| else: | |
| txt = txt[:README_MAX_CHARS] | |
| return txt.strip() | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # LLM call | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def call_llm(hf_token: str, system: str, user: str) -> str | None: | |
| body = json.dumps( | |
| { | |
| "model": MODEL, | |
| "messages": [ | |
| {"role": "system", "content": system}, | |
| {"role": "user", "content": user}, | |
| ], | |
| "temperature": TEMPERATURE, | |
| "max_tokens": MAX_TOKENS, | |
| "response_format": {"type": "json_object"}, | |
| } | |
| ).encode("utf-8") | |
| req = urllib.request.Request( | |
| HF_INFERENCE_URL, | |
| data=body, | |
| headers={ | |
| "Authorization": f"Bearer {hf_token}", | |
| "Content-Type": "application/json", | |
| # Cloudflare in front of the router 403s the default | |
| # "Python-urllib/x.y" UA. Any reasonable UA passes. | |
| "User-Agent": "reachy-mini-prompt-eval/1.0", | |
| }, | |
| method="POST", | |
| ) | |
| try: | |
| with urllib.request.urlopen(req, timeout=30, context=_SSL_CTX) as r: | |
| data = json.loads(r.read().decode("utf-8")) | |
| return data.get("choices", [{}])[0].get("message", {}).get("content") | |
| except urllib.error.HTTPError as e: | |
| detail = e.read().decode("utf-8", errors="replace")[:200] | |
| print(f" β LLM HTTP {e.code}: {detail}", file=sys.stderr) | |
| return None | |
| except Exception as e: # noqa: BLE001 | |
| print(f" β LLM error: {e}", file=sys.stderr) | |
| return None | |
| def extract_json_obj(text: str) -> dict[str, Any] | None: | |
| if not text: | |
| return None | |
| start = text.find("{") | |
| if start == -1: | |
| return None | |
| depth = 0 | |
| for i in range(start, len(text)): | |
| c = text[i] | |
| if c == "{": | |
| depth += 1 | |
| elif c == "}": | |
| depth -= 1 | |
| if depth == 0: | |
| try: | |
| return json.loads(text[start : i + 1]) | |
| except json.JSONDecodeError: | |
| return None | |
| return None | |
| def sanitize(raw: Any) -> list[str]: | |
| if not isinstance(raw, list): | |
| return [] | |
| out: list[str] = [] | |
| seen: set[str] = set() | |
| for v in raw: | |
| if not isinstance(v, str): | |
| continue | |
| slug = v.strip().lower() | |
| if not slug or slug in seen or slug not in ALLOWED: | |
| continue | |
| seen.add(slug) | |
| out.append(slug) | |
| if len(out) >= MAX_CATEGORIES_PER_APP: | |
| break | |
| return out | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Main | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def read_hf_token() -> str: | |
| if os.environ.get("HF_TOKEN"): | |
| return os.environ["HF_TOKEN"] | |
| env_file = Path(__file__).resolve().parent.parent / ".env" | |
| if env_file.exists(): | |
| for line in env_file.read_text().splitlines(): | |
| m = re.match(r"^\s*HF_TOKEN\s*=\s*(.*?)\s*$", line) | |
| if m: | |
| v = m.group(1).strip().strip('"').strip("'") | |
| if v: | |
| return v | |
| raise SystemExit("HF_TOKEN not found in env or .env") | |
| def fetch_live_classifications() -> list[dict[str, Any]]: | |
| with urllib.request.urlopen(JS_APPS_URL, timeout=30, context=_SSL_CTX) as r: | |
| return json.load(r)["apps"] | |
| def main() -> int: | |
| hf_token = read_hf_token() | |
| apps = fetch_live_classifications() | |
| print(f"Loaded {len(apps)} JS apps from prod.\n") | |
| system = build_system_prompt() | |
| print(f"System prompt: {len(system)} chars, {system.count(chr(10))} lines.\n") | |
| results: list[dict[str, Any]] = [] | |
| for i, app in enumerate(apps, 1): | |
| sid = app["id"] | |
| name = app.get("name") or sid.split("/")[-1] | |
| desc = ( | |
| app.get("description") | |
| or (app.get("extra") or {}).get("cardData", {}).get("short_description") | |
| or "" | |
| ) | |
| old_cats = app.get("categories") or [] | |
| raw_readme = fetch_readme(sid) | |
| readme = clean_readme(raw_readme) | |
| user = build_user_prompt(name, desc, readme) | |
| reply = call_llm(hf_token, system, user) | |
| new_cats = sanitize((extract_json_obj(reply) or {}).get("categories")) | |
| changed = set(old_cats) != set(new_cats) | |
| marker = "Ξ" if changed else " " | |
| print( | |
| f" {marker} ({i:>2}/{len(apps)}) {name[:36]:<37} " | |
| f"old=[{', '.join(old_cats)}]" | |
| + (f" β new=[{', '.join(new_cats)}]" if changed else "") | |
| ) | |
| results.append( | |
| { | |
| "id": sid, | |
| "name": name, | |
| "old": old_cats, | |
| "new": new_cats, | |
| "changed": changed, | |
| } | |
| ) | |
| time.sleep(0.25) | |
| print() | |
| print("β" * 80) | |
| print("DIFF (only changed entries)") | |
| print("β" * 80) | |
| for r in results: | |
| if not r["changed"]: | |
| continue | |
| print( | |
| f" {r['name'][:38]:<40} " | |
| f"[{', '.join(r['old']) or 'β '}] β [{', '.join(r['new']) or 'β '}]" | |
| ) | |
| changed_count = sum(1 for r in results if r["changed"]) | |
| print() | |
| print(f"{changed_count}/{len(results)} entries changed.") | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |