Spaces:
Sleeping
Sleeping
| import os | |
| import re | |
| import json | |
| import random | |
| from typing import Dict, Any, List, Optional | |
| import gradio as gr | |
| from huggingface_hub import InferenceClient | |
| # Load external CSS file into a string so Gradio can inject it | |
| CSS_PATH = os.path.join(os.path.dirname(__file__), "style.css") | |
| try: | |
| with open(CSS_PATH, encoding="utf-8") as f: | |
| CUSTOM_CSS = f.read() | |
| except FileNotFoundError: | |
| CUSTOM_CSS = "" | |
| # ──────────────────────────────────────────────────────────────────────────────── | |
| # CONFIG | |
| # Default to a serverless text-generation model. You can override this in Space | |
| # settings by defining a MODEL_ID variable if you want to experiment. | |
| MODEL_ID = os.environ.get("MODEL_ID", "google/gemma-2-2b-it") | |
| # Personal access token from your Hugging Face account (Space secret). | |
| HF_TOKEN = os.environ.get("HF_TOKEN") | |
| REPO_PATH = "/data/questions.json" # where we store generated questions | |
| CATEGORIES = [ | |
| {"key": "alimentation", "icon": "🍎", "fr": "Alimentation", "en": "Nutrition"}, | |
| {"key": "mouvement", "icon": "🦘", "fr": "Mouvement", "en": "Movement"}, | |
| {"key": "cerveau", "icon": "🧠", "fr": "Cerveau", "en": "Brain"}, | |
| {"key": "liens", "icon": "🤝", "fr": "Liens", "en": "Connections"}, | |
| {"key": "bien-etre", "icon": "💬", "fr": "Bien-être", "en": "Well-being"}, | |
| ] | |
| GUIDES = { | |
| "fr": { | |
| "alimentation": "Habitudes simples: hydratation, fruits/légumes, collations, rythme des repas. Pas de régime strict, pas de moralisation.", | |
| "mouvement": "Mouvement du quotidien: marche, escaliers, étirements courts, pauses actives. Pas de performance sportive.", | |
| "cerveau": "Stimulation douce: curiosité, respiration, mini-jeux, petit apprentissage. Zéro jargon médical.", | |
| "liens": "Interactions simples: gratitude, messages courts, appels brefs, moments partagés. Ton chaleureux, inclusif.", | |
| "bien-etre": "Micro bien-être: pauses, sommeil régulier, respirations, petits rituels qui apaisent. Jamais culpabilisant.", | |
| }, | |
| "en": { | |
| "alimentation": "Simple habits: hydration, fruit/veg, snacks, meal rhythm. No strict diets, no moralizing.", | |
| "mouvement": "Daily movement: walking, stairs, light stretches, active breaks. No performance pressure.", | |
| "cerveau": "Gentle stimulation: curiosity, breathing, tiny games, small learning moments. No medical jargon.", | |
| "liens": "Simple connections: gratitude, short texts, quick calls, shared moments. Warm and inclusive tone.", | |
| "bien-etre": "Micro well-being: breaks, sleep rhythm, breathing, tiny soothing rituals. Never guilt-based.", | |
| }, | |
| } | |
| # Themes | |
| THEME_KEYS = ["family", "friends", "romance", "silly", "education"] | |
| THEME_LABELS = { | |
| "fr": { | |
| "family": "famille", | |
| "friends": "amis", | |
| "romance": "romance", | |
| "silly": "décalé", | |
| "education": "éducation", | |
| }, | |
| "en": { | |
| "family": "family", | |
| "friends": "friends", | |
| "romance": "romance", | |
| "silly": "silly", | |
| "education": "education", | |
| }, | |
| } | |
| THEME_DESCRIPTIONS = { | |
| "fr": { | |
| "family": "Thème famille : liens intergénérationnels, rituels familiaux, souvenirs partagés.", | |
| "friends": "Thème amis : complicité, soutien, moments légers, retrouvailles.", | |
| "romance": "Thème romance : connexion, douceur, attention à l’autre, moments à deux.", | |
| "silly": "Thème décalé : questions ludiques, inattendues, créatives, pour faire rire.", | |
| "education": "Thème éducation : curiosité, apprentissages, petites découvertes du quotidien.", | |
| }, | |
| "en": { | |
| "family": "Family theme: intergenerational bonds, family rituals, shared memories.", | |
| "friends": "Friends theme: support, playfulness, shared moments, reconnection.", | |
| "romance": "Romance theme: connection, tenderness, attention to each other, moments for two.", | |
| "silly": "Silly theme: playful, unexpected, creative questions that invite laughter.", | |
| "education": "Education theme: curiosity, learning, tiny everyday discoveries.", | |
| }, | |
| } | |
| # Few-shot + fallback pools | |
| FEWSHOTS = { | |
| "fr": { | |
| "alimentation": { | |
| "questions": [ | |
| "Quelle boisson te donne envie de boire plus d’eau dans la journée ?", | |
| "Quel ajout simple rend ton petit-déj plus rassasiant ?", | |
| "Quand as-tu naturellement faim d’un fruit ou d’un yaourt ?", | |
| "Quelle petite habitude t’aide à ne pas sauter de repas ?", | |
| "Quel plat simple te fait du bien après une journée chargée ?", | |
| "Quelle collation t’aide à tenir jusqu’au dîner sans avoir trop faim ?", | |
| ], | |
| "micro_actions": [ | |
| "Remplir une gourde ce matin.", | |
| "Ajouter un fruit à la collation de l’après-midi.", | |
| "Remplacer une boisson sucrée par un verre d’eau aujourd’hui.", | |
| "Préparer un snack simple pour demain.", | |
| ], | |
| }, | |
| "mouvement": { | |
| "questions": [ | |
| "Quel trajet pourrais-tu faire à pied au moins une fois cette semaine ?", | |
| "Quelle pause-active de 2 minutes peux-tu glisser entre deux tâches ?", | |
| "Qu’est-ce qui te fait bouger sans y penser (ex: marcher au téléphone) ?", | |
| "Quel moment conviendrait pour quelques étirements doux chaque jour ?", | |
| "Avec qui aimerais-tu partager une courte marche ?", | |
| "Quel geste te réveille le matin (étirement, marche, danse exprès…) ?", | |
| ], | |
| "micro_actions": [ | |
| "Monter un étage par les escaliers aujourd’hui.", | |
| "Faire 5 étirements doux après le café.", | |
| "Se lever pendant un appel et marcher quelques pas.", | |
| "Programmer une mini-alarme « bouger » dans l’après-midi.", | |
| ], | |
| }, | |
| "cerveau": { | |
| "questions": [ | |
| "Qu’est-ce qui a suscité ta curiosité aujourd’hui ?", | |
| "Quel moment t’irait pour 3 minutes de respiration ?", | |
| "Quel mini-jeu aimes-tu pour réveiller l’esprit (ex: 3 mots fléchés) ?", | |
| "Quel petit sujet aimerais-tu explorer cette semaine ?", | |
| "Quelle activité calme t’aide à passer du travail au repos ?", | |
| "Quel souvenir récent t’a fait sourire en y repensant ?", | |
| ], | |
| "micro_actions": [ | |
| "Programmer un minuteur de 3 minutes pour respirer.", | |
| "Lire un paragraphe d’un sujet nouveau ce soir.", | |
| "Faire un mini-jeu de cerveau (3 mots fléchés, Sudoku, etc.).", | |
| "Noter une idée ou question qui t’intrigue.", | |
| ], | |
| }, | |
| "liens": { | |
| "questions": [ | |
| "Qui pourrais-tu remercier aujourd’hui et comment ?", | |
| "À qui enverrais-tu un message court pour reprendre contact ?", | |
| "Avec qui partagerais-tu une courte marche cette semaine ?", | |
| "Avec qui aimerais-tu avoir une vraie conversation bientôt ?", | |
| "Quand t’es-tu senti·e soutenu·e pour la dernière fois, et par qui ?", | |
| "Qui aimerais-tu encourager cette semaine ?", | |
| ], | |
| "micro_actions": [ | |
| "Envoyer un message de gratitude à une personne.", | |
| "Proposer une pause-café de 10 minutes.", | |
| "Envoyer une photo ou un souvenir à quelqu’un avec un petit mot.", | |
| "Poser une vraie question à quelqu’un sur sa journée.", | |
| ], | |
| }, | |
| "bien-etre": { | |
| "questions": [ | |
| "Quel signal t’indique qu’il est temps de faire une pause ?", | |
| "Quelle routine de 2 minutes t’aide à te recentrer ?", | |
| "Quel moment favorise un coucher plus régulier ?", | |
| "Qu’est-ce qui t’aide à te sentir plus léger·e en fin de journée ?", | |
| "Quel endroit de ton quotidien te donne une sensation de calme ?", | |
| "Quand as-tu l’impression de vraiment respirer ?", | |
| ], | |
| "micro_actions": [ | |
| "Éteindre les écrans 10 minutes plus tôt ce soir.", | |
| "Écrire 3 lignes sur ton humeur du jour.", | |
| "Prendre 5 respirations lentes avant de changer d’activité.", | |
| "Planifier une mini-pause de 5 minutes pour toi demain.", | |
| ], | |
| }, | |
| }, | |
| "en": { | |
| "alimentation": { | |
| "questions": [ | |
| "What drink makes you want to sip more water through the day?", | |
| "What small add-on makes your breakfast more filling?", | |
| "When do you naturally crave a fruit or yogurt?", | |
| "What tiny habit helps you not skip meals?", | |
| "What simple dinner feels gentle after a long day?", | |
| "Which snack helps you stay focused without a big energy crash?", | |
| ], | |
| "micro_actions": [ | |
| "Fill a water bottle this morning.", | |
| "Add one fruit to your afternoon snack.", | |
| "Swap one sugary drink for water today.", | |
| "Plan a simple snack for tomorrow.", | |
| ], | |
| }, | |
| "mouvement": { | |
| "questions": [ | |
| "Which short trip could you walk at least once this week?", | |
| "Which 2-minute active break fits between two tasks?", | |
| "What makes you move without noticing (e.g., walking on calls)?", | |
| "When would a short stretch break feel good each day?", | |
| "Where do you naturally end up walking more?", | |
| "What small move helps you wake up in the morning?", | |
| ], | |
| "micro_actions": [ | |
| "Take one flight of stairs today.", | |
| "Do 5 light stretches after coffee.", | |
| "Stand up and walk during one call.", | |
| "Set a tiny “move” reminder for this afternoon.", | |
| ], | |
| }, | |
| "cerveau": { | |
| "questions": [ | |
| "What sparked your curiosity today?", | |
| "When could you do 3 minutes of breathing?", | |
| "Which mini-game wakes you up (e.g., 3 crossword clues)?", | |
| "What small topic would you like to learn about this week?", | |
| "What gentle activity helps you shift from work to rest?", | |
| "What recent memory made you smile when you thought of it again?", | |
| ], | |
| "micro_actions": [ | |
| "Set a 3-minute timer to breathe.", | |
| "Read one paragraph on a new topic tonight.", | |
| "Play a tiny brain game.", | |
| "Write down one idea or question that interests you.", | |
| ], | |
| }, | |
| "liens": { | |
| "questions": [ | |
| "Who could you thank today—and how?", | |
| "Who might you text briefly to reconnect?", | |
| "Who could you invite for a short walk this week?", | |
| "Who would you like to have a real conversation with soon?", | |
| "When did you last feel supported, and by whom?", | |
| "Who would you like to encourage this week?", | |
| ], | |
| "micro_actions": [ | |
| "Send a gratitude message to one person.", | |
| "Offer a 10-minute coffee break.", | |
| "Send a photo or memory to someone with a short note.", | |
| "Ask someone one genuine question about their day.", | |
| ], | |
| }, | |
| "bien-etre": { | |
| "questions": [ | |
| "What cue tells you it’s time for a pause?", | |
| "What 2-minute routine helps you reset?", | |
| "What time supports a steadier bedtime?", | |
| "What helps you feel lighter at the end of the day?", | |
| "Which place in your daily life feels calming?", | |
| "When do you feel like you can really breathe?", | |
| ], | |
| "micro_actions": [ | |
| "Turn screens off 10 minutes earlier tonight.", | |
| "Write three lines about your mood today.", | |
| "Take 5 slow breaths before changing tasks.", | |
| "Schedule a 5-minute mini-break for yourself tomorrow.", | |
| ], | |
| }, | |
| }, | |
| } | |
| # ──────────────────────────────────────────────────────────────────────────────── | |
| # REPOSITORY HELPERS (questions only, per category) | |
| # ──────────────────────────────────────────────────────────────────────────────── | |
| # REPOSITORY HELPERS (questions only, per language + category) | |
| def _default_repo() -> Dict[str, Dict[str, List[str]]]: | |
| """ | |
| Structure: | |
| { | |
| "fr": {"alimentation": [...], "mouvement": [...], ...}, | |
| "en": {"alimentation": [...], "mouvement": [...], ...} | |
| } | |
| """ | |
| base_per_lang = {c["key"]: [] for c in CATEGORIES} | |
| return {"fr": dict(base_per_lang), "en": dict(base_per_lang)} | |
| def load_repo() -> Dict[str, Dict[str, List[str]]]: | |
| os.makedirs(os.path.dirname(REPO_PATH), exist_ok=True) | |
| if not os.path.exists(REPO_PATH): | |
| data = _default_repo() | |
| with open(REPO_PATH, "w", encoding="utf-8") as f: | |
| json.dump(data, f, ensure_ascii=False, indent=2) | |
| return data | |
| try: | |
| with open(REPO_PATH, "r", encoding="utf-8") as f: | |
| data = json.load(f) | |
| except Exception: | |
| data = _default_repo() | |
| with open(REPO_PATH, "w", encoding="utf-8") as f: | |
| json.dump(data, f, ensure_ascii=False, indent=2) | |
| return data | |
| # If this is the old format (categories at top level), reset to new format. | |
| if not isinstance(data, dict) or "fr" not in data or "en" not in data: | |
| data = _default_repo() | |
| with open(REPO_PATH, "w", encoding="utf-8") as f: | |
| json.dump(data, f, ensure_ascii=False, indent=2) | |
| return data | |
| # Ensure both languages + all categories exist | |
| base = _default_repo() | |
| for lang in ("fr", "en"): | |
| src = data.get(lang, {}) | |
| if isinstance(src, dict): | |
| for k, v in src.items(): | |
| if k in base[lang] and isinstance(v, list): | |
| base[lang][k] = v | |
| return base | |
| def save_repo(data: Dict[str, Dict[str, List[str]]]) -> None: | |
| os.makedirs(os.path.dirname(REPO_PATH), exist_ok=True) | |
| with open(REPO_PATH, "w", encoding="utf-8") as f: | |
| json.dump(data, f, ensure_ascii=False, indent=2) | |
| # ──────────────────────────────────────────────────────────────────────────────── | |
| # PROMPT + MODEL HELPERS | |
| def build_prompt(lang: str, category_key: str, theme: str) -> str: | |
| cat = next((c for c in CATEGORIES if c["key"] == category_key), None) | |
| if not cat: | |
| category_key = "alimentation" | |
| cat = next((c for c in CATEGORIES if c["key"] == category_key), None) | |
| guide = GUIDES[lang][category_key] | |
| few = FEWSHOTS[lang][category_key] | |
| theme_desc = THEME_DESCRIPTIONS[lang].get(theme, "") | |
| schema = ( | |
| "{\n" | |
| ' "category": "<category_key>",\n' | |
| ' "language": "<fr|en>",\n' | |
| ' "questions": ["q1", "q2", "q3", "q4"],\n' | |
| ' "micro_actions": ["m1", "m2"],\n' | |
| ' "theme": "<family|friends|romance|silly|education>",\n' | |
| ' "safety_notes": ""\n' | |
| "}" | |
| ) | |
| example_questions = few["questions"][:2] | |
| example_micro = few["micro_actions"][:2] | |
| if lang == "fr": | |
| return f""" | |
| Tu es l’IA du jeu de cartes Neurovie (modèle FINGER). | |
| Tu crées des cartes-question pour parler de routines du quotidien. | |
| - Catégorie: {cat['fr']} {cat['icon']}. | |
| - Focus: {guide} | |
| - Thème actuel: {theme_desc} | |
| - Format: 4 questions + 2 micro-actions, 1 phrase courte chacune. | |
| - Style: concret, bienveillant, sans jugement. | |
| - Interdit: conseils médicaux, diagnostics, emojis. | |
| Réponds UNIQUEMENT en JSON, sans texte autour, selon ce schéma: | |
| {schema} | |
| Exemple de style (à VARIER, ne pas copier): | |
| Questions: {example_questions} | |
| Micro-actions: {example_micro} | |
| Maintenant, renvoie un NOUVEAU JSON différent de l'exemple. | |
| """.strip() | |
| else: | |
| return f""" | |
| You are the AI for the Neurovie card game (FINGER model). | |
| You create question-cards about everyday routines. | |
| - Category: {cat['en']} {cat['icon']}. | |
| - Focus: {guide} | |
| - Current theme: {theme_desc} | |
| - Format: 4 questions + 2 micro-actions, one short sentence each. | |
| - Style: concrete, kind, non-judgmental. | |
| - Forbidden: medical advice, diagnoses, emojis. | |
| Reply ONLY with JSON, no extra text, in this shape: | |
| {schema} | |
| Style example (to vary, do NOT copy): | |
| Questions: {example_questions} | |
| Micro-actions: {example_micro} | |
| Now return a NEW JSON different from the example. | |
| """.strip() | |
| def try_parse_json(text: str) -> Optional[Dict[str, Any]]: | |
| """ | |
| Try to extract a JSON object from the model output. | |
| Handles cases where the model wraps JSON in ``` or ```json fences. | |
| """ | |
| if not text: | |
| return None | |
| stripped = text.strip() | |
| # If the model wrapped the JSON in ``` or ```json fences, strip them. | |
| if stripped.startswith("```"): | |
| lines = stripped.splitlines() | |
| # Drop the first line (``` or ```json) | |
| lines = lines[1:] | |
| # Drop final line if it's a closing fence | |
| if lines and lines[-1].strip().startswith("```"): | |
| lines = lines[:-1] | |
| stripped = "\n".join(lines).strip() | |
| # Now look for the first {...} block | |
| match = re.search(r"\{[\s\S]*\}", stripped) | |
| if not match: | |
| return None | |
| candidate = match.group(0) | |
| try: | |
| return json.loads(candidate) | |
| except Exception: | |
| return None | |
| def model_call(prompt: str) -> str: | |
| """ | |
| Call Hugging Face Inference API using the conversational (chat) task. | |
| This matches models like google/gemma-2-2b-it which only support 'conversational'. | |
| """ | |
| if not MODEL_ID: | |
| raise RuntimeError("MODEL_ID env var is empty. Set it or use the default.") | |
| if not HF_TOKEN: | |
| raise RuntimeError( | |
| "HF_TOKEN is not set. Add a Hugging Face token as a Space secret named HF_TOKEN." | |
| ) | |
| client = InferenceClient(model=MODEL_ID, token=HF_TOKEN) | |
| try: | |
| resp = client.chat.completions.create( | |
| model=MODEL_ID, | |
| messages=[ | |
| { | |
| "role": "system", | |
| "content": ( | |
| "You generate JSON only. " | |
| "Do not add any explanation outside of the JSON object." | |
| ), | |
| }, | |
| { | |
| "role": "user", | |
| "content": prompt, | |
| }, | |
| ], | |
| max_tokens=260, | |
| temperature=0.9, | |
| top_p=0.92, | |
| ) | |
| except Exception as e: | |
| raise RuntimeError(f"Inference API error: {e}") from e | |
| # Extract text from the first choice | |
| try: | |
| message = resp.choices[0].message | |
| content = message.content | |
| except Exception as e: | |
| raise RuntimeError(f"Unexpected chat response format: {e}") from e | |
| # content can be a string or a list of parts | |
| if isinstance(content, list): | |
| # Newer HF SDK sometimes uses list-of-parts format | |
| parts = [] | |
| for part in content: | |
| # part may be a dict like {"type": "text", "text": "..."} | |
| if isinstance(part, dict) and "text" in part: | |
| parts.append(part["text"]) | |
| else: | |
| parts.append(str(part)) | |
| text = "".join(parts) | |
| else: | |
| text = str(content) | |
| text = text.strip() | |
| if not text: | |
| raise RuntimeError("Empty response from model.") | |
| return text | |
| def normalize_output( | |
| data: Dict[str, Any], | |
| lang: str, | |
| category_key: str, | |
| theme: str, | |
| ) -> Dict[str, Any]: | |
| """ | |
| Make model output always valid, even if the model returns emojis, wrong category labels, | |
| capitalized names, or unexpected keys. | |
| """ | |
| # --- FIX CATEGORY --- | |
| raw_cat = str(data.get("category", "")).strip() | |
| # Strip emojis | |
| raw_cat = re.sub(r"[^\w\- ]+", "", raw_cat) | |
| # Lowercase | |
| raw_cat = raw_cat.lower() | |
| # Map likely variants to internal keys | |
| mapping = { | |
| "alimentation": "alimentation", | |
| "nutrition": "alimentation", | |
| "mouvement": "mouvement", | |
| "movement": "mouvement", | |
| "cerveau": "cerveau", | |
| "brain": "cerveau", | |
| "liens": "liens", | |
| "links": "liens", | |
| "bienetre": "bien-etre", | |
| "bien-etre": "bien-etre", | |
| "wellbeing": "bien-etre", | |
| "well being": "bien-etre", | |
| } | |
| # Choose corrected category | |
| clean_cat = mapping.get(raw_cat, category_key) | |
| # --- FIX QUESTIONS --- | |
| q = [str(x).strip() for x in data.get("questions", []) if str(x).strip()] | |
| q = (q + [""] * 4)[:4] | |
| # --- FIX MICRO-ACTIONS --- | |
| m = [str(x).strip() for x in data.get("micro_actions", []) if str(x).strip()] | |
| m = (m + [""] * 2)[:2] | |
| # --- THEME --- | |
| model_theme = str(data.get("theme", "")).strip().lower() | |
| if model_theme not in THEME_KEYS: | |
| model_theme = theme # fall back to selected theme key | |
| # --- SAFETY NOTES --- | |
| safety_notes = str(data.get("safety_notes", "")) | |
| return { | |
| "category": clean_cat, | |
| "language": lang, | |
| "questions": q, | |
| "micro_actions": m, | |
| "theme": model_theme, | |
| "safety_notes": safety_notes, | |
| } | |
| def ai_generate(lang: str, category_key: str, theme: str) -> Dict[str, Any]: | |
| """ | |
| Try to call the model. If anything fails or JSON is invalid, | |
| fall back to shuffling the few-shots and include a safety_notes message. | |
| """ | |
| prompt = build_prompt(lang, category_key, theme) | |
| try: | |
| raw_text = model_call(prompt) | |
| parsed = try_parse_json(raw_text) if raw_text else None | |
| if parsed: | |
| return normalize_output(parsed, lang, category_key, theme) | |
| # Model replied but not valid JSON | |
| few = FEWSHOTS[lang][category_key] | |
| q_pool = few["questions"][:] | |
| m_pool = few["micro_actions"][:] | |
| random.shuffle(q_pool) | |
| random.shuffle(m_pool) | |
| return { | |
| "category": category_key, | |
| "language": lang, | |
| "questions": (q_pool + [""] * 4)[:4], | |
| "micro_actions": (m_pool + [""] * 2)[:2], | |
| "theme": theme, | |
| "safety_notes": ( | |
| "Model replied but JSON parsing failed. " | |
| f"raw_text starts with: {repr(raw_text[:160])}" | |
| ), | |
| } | |
| except Exception as e: | |
| # Any HF / network / auth error ends up here | |
| few = FEWSHOTS[lang][category_key] | |
| q_pool = few["questions"][:] | |
| m_pool = few["micro_actions"][:] | |
| random.shuffle(q_pool) | |
| random.shuffle(m_pool) | |
| return { | |
| "category": category_key, | |
| "language": lang, | |
| "questions": (q_pool + [""] * 4)[:4], | |
| "micro_actions": (m_pool + [""] * 2)[:2], | |
| "theme": theme, | |
| "safety_notes": f"Model call error: {type(e).__name__}: {e}", | |
| } | |
| # ──────────────────────────────────────────────────────────────────────────────── | |
| # MAIN LOGIC: REPO + SESSION "SEEN" QUESTIONS | |
| def get_questions_and_micro( | |
| lang: str, | |
| category_key: str, | |
| variant: str, | |
| seen: List[str], | |
| ) -> Dict[str, Any]: | |
| ... | |
| seen_set = set(seen or []) | |
| repo = load_repo() | |
| lang_repo = repo.get(lang, {}) | |
| repo_qs = lang_repo.get(category_key, []) | |
| unseen_repo = [q for q in repo_qs if q and q not in seen_set] | |
| used_ai = False | |
| theme_used = theme | |
| safety_notes = "" | |
| if len(unseen_repo) >= 4: | |
| # entirely from repo, no AI call | |
| random.shuffle(unseen_repo) | |
| questions = unseen_repo[:4] | |
| # micro-actions from local fewshot pool (cheap) | |
| m_pool = FEWSHOTS[lang][category_key]["micro_actions"][:] | |
| random.shuffle(m_pool) | |
| micro = (m_pool + ["", ""])[:2] | |
| safety_notes = "" | |
| else: | |
| # need fresh AI content | |
| ai_out = ai_generate(lang, category_key, theme) | |
| questions = ai_out["questions"] | |
| micro = ai_out["micro_actions"] | |
| theme_used = ai_out.get("theme", theme) | |
| safety_notes = ai_out.get("safety_notes", "") | |
| used_ai = True | |
| # store new questions in the repo for this language only | |
| new_qs = [q for q in questions if q and q not in repo_qs] | |
| if new_qs: | |
| updated = repo_qs + new_qs | |
| repo.setdefault(lang, {}) | |
| repo[lang][category_key] = updated | |
| save_repo(repo) | |
| # update seen for this session | |
| for q in questions: | |
| if q: | |
| seen_set.add(q) | |
| payload = { | |
| "category": category_key, | |
| "language": lang, | |
| "questions": questions, | |
| "micro_actions": micro, | |
| "source": "ai" if used_ai else "repo", | |
| "theme": theme_used, | |
| "safety_notes": safety_notes, | |
| } | |
| # For UI we only return questions + micro + updated seen. | |
| return { | |
| "questions": questions, | |
| "micro_actions": micro, | |
| "seen": list(seen_set), | |
| } | |
| # ──────────────────────────────────────────────────────────────────────────────── | |
| # UI – pastel, animated, color-coded cards | |
| def _map_category(choice: str) -> str: | |
| mapping = { | |
| "alimentation 🍎": "alimentation", | |
| "Nutrition 🍎": "alimentation", | |
| "mouvement 🦘": "mouvement", | |
| "Movement 🦘": "mouvement", | |
| "cerveau 🧠": "cerveau", | |
| "Brain 🧠": "cerveau", | |
| "liens 🤝": "liens", | |
| "Connections 🤝": "liens", | |
| "bien-etre 💬": "bien-etre", | |
| "Well-being 💬": "bien-etre", | |
| } | |
| return mapping.get(choice, "alimentation") | |
| def _map_theme(label: str, lang: str) -> str: | |
| """ | |
| Convert the user-visible theme label back to its internal key. | |
| Works for both languages and also accepts the key itself as fallback. | |
| """ | |
| label = (label or "").strip().lower() | |
| # First check if it's already a key | |
| if label in THEME_KEYS: | |
| return label | |
| # Otherwise map via THEME_LABELS | |
| for key in THEME_KEYS: | |
| if label == THEME_LABELS["fr"][key].lower() or label == THEME_LABELS["en"][key].lower(): | |
| return key | |
| # Fallback | |
| return "family" | |
| def _card_html(category_key: str, kind: str, title: str, body: str, delay_s: float) -> str: | |
| kind_attr = "question" if kind == "q" else "micro" | |
| cat_class = f"nv-card--cat-{category_key}" | |
| # each card has its own animation delay => "dealing" feel | |
| return ( | |
| f"<div class='nv-card {cat_class}' data-kind='{kind_attr}' " | |
| f"style='animation-delay:{delay_s:.2f}s'><div class='nv-card-title'>{title}</div>" | |
| f"<div>{body}</div></div>" | |
| ) | |
| def update_cards(lang: str, category_choice: str, theme_choice: str, seen: List[str]): | |
| category_key = _map_category(category_choice) | |
| theme_key = _map_theme(theme_choice, lang) | |
| result = get_questions_and_micro(lang, category_key, theme_key, seen or []) | |
| questions = result["questions"] | |
| micro = result["micro_actions"] | |
| new_seen = result["seen"] | |
| # Stagger cards a bit | |
| delays_q = [0.05, 0.10, 0.15, 0.20] | |
| delays_m = [0.25, 0.30] | |
| if lang == "fr": | |
| q_prefix = "Question" | |
| m_prefix = "Micro-action" | |
| else: | |
| q_prefix = "Question" | |
| m_prefix = "Micro-action" | |
| q_htmls = [] | |
| for i in range(4): | |
| text = questions[i] if i < len(questions) else "" | |
| q_htmls.append( | |
| _card_html( | |
| category_key, | |
| "q", | |
| f"{q_prefix} {i+1}", | |
| text, | |
| delays_q[i], | |
| ) | |
| ) | |
| m_htmls = [] | |
| for i in range(2): | |
| text = micro[i] if i < len(micro) else "" | |
| m_htmls.append( | |
| _card_html( | |
| category_key, | |
| "m", | |
| f"{m_prefix} {i+1}", | |
| text, | |
| delays_m[i], | |
| ) | |
| ) | |
| return (*q_htmls, *m_htmls, new_seen) | |
| # ──────────────────────────────────────────────────────────────────────────────── | |
| # UI TEXT TRANSLATIONS | |
| def get_ui_texts(lang: str) -> Dict[str, Any]: | |
| if lang == "fr": | |
| header = """ | |
| <div class="nv-fade"> | |
| <div class="nv-badge">NEUROVIE · FINGER</div> | |
| <div class="nv-title">Question Studio</div> | |
| <div class="nv-subtitle"> | |
| Questions minimalistes pour conversations riches — 4 questions et 2 micro-actions par tirage. | |
| </div> | |
| </div> | |
| """ | |
| category_choices = [ | |
| "alimentation 🍎", | |
| "mouvement 🦘", | |
| "cerveau 🧠", | |
| "liens 🤝", | |
| "bien-etre 💬", | |
| ] | |
| theme_choices = [THEME_LABELS["fr"][k] for k in THEME_KEYS] | |
| return { | |
| "header_html": header, | |
| "language_label": "Langue", | |
| "theme_label": "Thème", | |
| "category_label": "Catégorie", | |
| "questions_label": "Questions", | |
| "micro_label": "Micro-actions", | |
| "button_text": "Générer un tirage ✨", | |
| "category_choices": category_choices, | |
| "category_default": "alimentation 🍎", | |
| "theme_choices": theme_choices, | |
| "theme_default": THEME_LABELS["fr"]["family"], | |
| "note_text": ( | |
| "Note : les cartes s’estompent doucement avec le temps, " | |
| "pour symboliser la mémoire qui s’efface." | |
| ), | |
| } | |
| else: | |
| header = """ | |
| <div class="nv-fade"> | |
| <div class="nv-badge">NEUROVIE · FINGER</div> | |
| <div class="nv-title">Question Studio</div> | |
| <div class="nv-subtitle"> | |
| Minimal prompts for rich conversations — 4 questions and 2 micro-actions per draw. | |
| </div> | |
| </div> | |
| """ | |
| category_choices = [ | |
| "Nutrition 🍎", | |
| "Movement 🦘", | |
| "Brain 🧠", | |
| "Connections 🤝", | |
| "Well-being 💬", | |
| ] | |
| theme_choices = [THEME_LABELS["en"][k] for k in THEME_KEYS] | |
| return { | |
| "header_html": header, | |
| "language_label": "Language", | |
| "theme_label": "Theme", | |
| "category_label": "Category", | |
| "questions_label": "Questions", | |
| "micro_label": "Micro-actions", | |
| "button_text": "Generate card set ✨", | |
| "category_choices": category_choices, | |
| "category_default": "Nutrition 🍎", | |
| "theme_choices": theme_choices, | |
| "theme_default": THEME_LABELS["en"]["family"], | |
| "note_text": ( | |
| "Note: the cards gently fade over time, " | |
| "to echo how memories can fade." | |
| ), | |
| } | |
| def update_ui_language(lang: str): | |
| t = get_ui_texts(lang) | |
| return ( | |
| t["header_html"], | |
| f"<div class='nv-label nv-fade'>{t['language_label']}</div>", | |
| f"<div class='nv-label nv-fade'>{t['theme_label']}</div>", | |
| f"<div class='nv-label nv-fade'>{t['category_label']}</div>", | |
| f"<div class='nv-label nv-fade'>{t['questions_label']}</div>", | |
| f"<div class='nv-label nv-fade'>{t['micro_label']}</div>", | |
| gr.update(choices=t["theme_choices"], value=t["theme_default"]), | |
| gr.update(choices=t["category_choices"], value=t["category_default"]), | |
| gr.update(value=t["button_text"]), | |
| f"<div class='nv-note'>{t['note_text']}</div>", | |
| ) | |
| # ──────────────────────────────────────────────────────────────────────────────── | |
| # GRADIO APP | |
| with gr.Blocks(title="Neurovie – Question Studio") as demo: | |
| # Inline the CSS content from style.css | |
| if CUSTOM_CSS: | |
| gr.HTML(f"<style>{CUSTOM_CSS}</style>") | |
| seen_state = gr.State([]) # per-session list of seen questions | |
| ui_texts = get_ui_texts("fr") | |
| with gr.Column(elem_classes="nv-shell"): | |
| header = gr.HTML(ui_texts["header_html"]) | |
| # Settings | |
| # Language box | |
| with gr.Column(elem_classes="nv-section"): | |
| lang_label_html = gr.HTML( | |
| f"<div class='nv-label nv-fade'>{ui_texts['language_label']}</div>" | |
| ) | |
| lang = gr.Radio( | |
| choices=["fr", "en"], | |
| value="fr", | |
| show_label=False, | |
| elem_classes="nv-pills", | |
| ) | |
| # Theme box | |
| with gr.Column(elem_classes="nv-section"): | |
| theme_label_html = gr.HTML( | |
| f"<div class='nv-label nv-fade'>{ui_texts['theme_label']}</div>" | |
| ) | |
| theme = gr.Radio( | |
| choices=ui_texts["theme_choices"], | |
| value=ui_texts["theme_default"], | |
| show_label=False, | |
| elem_classes="nv-pills", | |
| ) | |
| with gr.Column(elem_classes="nv-section"): | |
| category_label_html = gr.HTML(f"<div class='nv-label nv-fade'>{ui_texts['category_label']}</div>") | |
| category = gr.Radio( | |
| choices=ui_texts["category_choices"], | |
| value=ui_texts["category_default"], | |
| show_label=False, | |
| elem_classes="nv-pills", | |
| ) | |
| btn = gr.Button(ui_texts["button_text"]) | |
| # Question & micro-action cards | |
| with gr.Row(elem_classes="nv-section"): | |
| with gr.Column(): | |
| questions_label_html = gr.HTML(f"<div class='nv-label nv-fade'>{ui_texts['questions_label']}</div>") | |
| with gr.Column(elem_classes="nv-card-grid"): | |
| q1 = gr.HTML() | |
| q2 = gr.HTML() | |
| q3 = gr.HTML() | |
| q4 = gr.HTML() | |
| with gr.Column(): | |
| micro_label_html = gr.HTML(f"<div class='nv-label nv-fade'>{ui_texts['micro_label']}</div>") | |
| with gr.Column(elem_classes="nv-card-grid"): | |
| m1 = gr.HTML() | |
| m2 = gr.HTML() | |
| # Small explanatory note under the cards | |
| note_html = gr.HTML( | |
| f"<div class='nv-note'>{ui_texts['note_text']}</div>" | |
| ) | |
| # Update labels & category choices when language changes | |
| lang.change( | |
| fn=update_ui_language, | |
| inputs=[lang], | |
| outputs=[ | |
| header, | |
| lang_label_html, | |
| theme_label_html, | |
| category_label_html, | |
| questions_label_html, | |
| micro_label_html, | |
| theme, | |
| category, | |
| btn, | |
| note_html, | |
| ], | |
| show_progress=False | |
| ) | |
| btn.click( | |
| update_cards, | |
| [lang, category, theme, seen_state], | |
| [q1, q2, q3, q4, m1, m2, seen_state], | |
| show_progress=False, # hide Gradio built-in progress indicator | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch(ssr_mode=False) | |