kappai commited on
Commit
24521f4
·
verified ·
1 Parent(s): 7ae7977

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +391 -144
app.py CHANGED
@@ -1,8 +1,9 @@
1
  import os
2
  import re
3
  import json
4
- import gradio as gr
5
  from typing import Dict, Any, List, Optional
 
 
6
  from huggingface_hub import InferenceClient
7
 
8
  # ────────────────────────────────────────────────────────────────────────────────
@@ -26,15 +27,15 @@ GUIDES = {
26
  "mouvement": "Mise sur le NEAT (mouvements du quotidien): marche, escaliers, étirements courts, pauses actives, mini-rituels autour du café ou des appels.",
27
  "cerveau": "Stimulation douce: curiosité, mémoire, respiration, mini-jeux, apprentissage de 2-3 minutes. Zéro jargon médical.",
28
  "liens": "Interactions humaines simples: gratitude, messages courts, appel bref, se joindre à quelqu’un, sourire, entraide. Inclusif, bienveillant.",
29
- "bien-etre": "Auto-soin micro: pauses, respiration, sommeil régulier, journal rapide, limiter les écrans un moment. Ton chaleureux, zéro culpabilisation."
30
  },
31
  "en": {
32
  "alimentation": "Focus on simple habits: hydration, fruit/veg, meal rhythm, portions, smart snacks. No strict diets, no moralizing.",
33
  "mouvement": "Lean into NEAT: daily walking, stairs, quick stretches, active breaks, tiny rituals around coffee or calls.",
34
  "cerveau": "Gentle stimulation: curiosity, memory, breathing, tiny games, 2–3 min learning. No medical jargon.",
35
  "liens": "Simple human interactions: gratitude, short messages, brief call, joining someone, smiling, helping. Inclusive, kind tone.",
36
- "bien-etre": "Micro self-care: breaks, breathing, steady sleep, quick journaling, small screen limits. Warm tone, no guilt."
37
- }
38
  }
39
 
40
  # Few-shot examples to anchor style (one per category per language)
@@ -44,136 +45,141 @@ FEWSHOTS = {
44
  "questions": [
45
  "Quelle boisson te donne envie de boire plus d’eau dans la journée ?",
46
  "Quel ajout simple rend ton petit-déj plus rassasiant ?",
47
- "Quand as-tu naturellement faim d’un fruit ou d’un yaourt ?"
48
  ],
49
  "micro_actions": [
50
  "Remplir une gourde ce matin.",
51
- "Ajouter un fruit à la collation de l’après-midi."
52
- ]
53
  },
54
  "mouvement": {
55
  "questions": [
56
  "Quel trajet pourrais-tu faire à pied au moins une fois cette semaine ?",
57
  "Quelle pause-active de 2 minutes peux-tu glisser entre deux tâches ?",
58
- "Qu’est-ce qui te fait bouger sans y penser (ex: marcher au téléphone) ?"
59
  ],
60
  "micro_actions": [
61
  "Monter un étage par les escaliers aujourd’hui.",
62
- "Faire 5 étirements doux après le café."
63
- ]
64
  },
65
  "cerveau": {
66
  "questions": [
67
  "Qu’est-ce qui a suscité ta curiosité aujourd’hui ?",
68
  "Quel moment t’irait pour 3 minutes de respiration ?",
69
- "Quel mini-jeu aimes-tu pour réveiller l’esprit (ex: 3 mots fléchés) ?"
70
  ],
71
  "micro_actions": [
72
  "Programmer un minuteur de 3 minutes pour respirer.",
73
- "Lire un paragraphe d’un sujet nouveau ce soir."
74
- ]
75
  },
76
  "liens": {
77
  "questions": [
78
  "Qui pourrais-tu remercier aujourd’hui et comment ?",
79
  "À qui enverrais-tu un message court pour reprendre contact ?",
80
- "Avec qui partagerais-tu une courte marche cette semaine ?"
81
  ],
82
  "micro_actions": [
83
  "Envoyer un message de gratitude à une personne.",
84
- "Proposer une pause-café de 10 minutes."
85
- ]
86
  },
87
  "bien-etre": {
88
  "questions": [
89
  "Quel signal t’indique qu’il est temps de faire une pause ?",
90
  "Quelle routine de 2 minutes t’aide à te recentrer ?",
91
- "Quel moment favorise un coucher plus régulier ?"
92
  ],
93
  "micro_actions": [
94
  "Éteindre les écrans 10 minutes plus tôt ce soir.",
95
- "Écrire 3 lignes sur ton humeur du jour."
96
- ]
97
- }
98
  },
99
  "en": {
100
  "alimentation": {
101
  "questions": [
102
  "What drink makes you want to sip more water through the day?",
103
  "What small add-on makes your breakfast more filling?",
104
- "When do you naturally crave a fruit or yogurt?"
105
  ],
106
  "micro_actions": [
107
  "Fill a water bottle this morning.",
108
- "Add one fruit to your afternoon snack."
109
- ]
110
  },
111
  "mouvement": {
112
  "questions": [
113
  "Which short trip could you walk at least once this week?",
114
  "Which 2-minute active break fits between two tasks?",
115
- "What makes you move without noticing (e.g., walking on calls)?"
116
  ],
117
  "micro_actions": [
118
  "Take one flight of stairs today.",
119
- "Do 5 light stretches after coffee."
120
- ]
121
  },
122
  "cerveau": {
123
  "questions": [
124
  "What sparked your curiosity today?",
125
  "When could you do 3 minutes of breathing?",
126
- "Which mini-game wakes you up (e.g., 3 crossword clues)?"
127
  ],
128
  "micro_actions": [
129
  "Set a 3-minute timer to breathe.",
130
- "Read one paragraph on a new topic tonight."
131
- ]
132
  },
133
  "liens": {
134
  "questions": [
135
  "Who could you thank today—and how?",
136
  "Who might you text briefly to reconnect?",
137
- "Who could you invite for a short walk this week?"
138
  ],
139
  "micro_actions": [
140
  "Send a gratitude message to one person.",
141
- "Offer a 10-minute coffee break."
142
- ]
143
  },
144
  "bien-etre": {
145
  "questions": [
146
  "What cue tells you it’s time for a pause?",
147
  "What 2-minute routine helps you reset?",
148
- "What time supports a steadier bedtime?"
149
  ],
150
  "micro_actions": [
151
  "Turn screens off 10 minutes earlier tonight.",
152
- "Write three lines about your mood today."
153
- ]
154
- }
155
- }
156
  }
157
 
 
 
 
 
158
  def build_prompt(lang: str, category_key: str, variant: str) -> str:
159
  cat = next((c for c in CATEGORIES if c["key"] == category_key), None)
160
  if not cat:
161
  category_key = "alimentation"
162
  cat = next((c for c in CATEGORIES if c["key"] == category_key), None)
163
 
164
- # Variant tone
 
 
165
  variant_fr = (
166
- "Contrainte de ton: *ludique et original* (‘meilleur’)."
167
- if variant == "best" else
168
- "Contrainte de ton: *introspectif et authentique* (‘plus sincère’)."
169
  )
170
  variant_en = (
171
- "Tone constraint: *playful and original* (‘best’)."
172
- if variant == "best" else
173
- "Tone constraint: *introspective and authentic* (‘most sincere’)."
174
  )
175
 
176
- # Shared schema
177
  schema = (
178
  "{\n"
179
  ' "category": "<category_key>",\n'
@@ -185,27 +191,6 @@ def build_prompt(lang: str, category_key: str, variant: str) -> str:
185
  "}"
186
  )
187
 
188
- # Safety & style guardrails
189
- safety_fr = (
190
- "Règles de sécurité et de style:\n"
191
- "- Zéro conseil médical, pas de diagnostic, pas d’injonction.\n"
192
- "- Langage bienveillant, inclusif, non culpabilisant.\n"
193
- "- Items concrets, réalisables en 1–3 minutes quand c’est pertinent.\n"
194
- "- Phrases courtes. Pas de listes imbriquées. Pas d’emojis dans la sortie.\n"
195
- "- Réponds STRICTEMENT en JSON valide, sans texte autour."
196
- )
197
- safety_en = (
198
- "Safety & style rules:\n"
199
- "- No medical advice, no diagnosis, no injunctions.\n"
200
- "- Kind, inclusive, non-judgmental language.\n"
201
- "- Concrete items, doable in 1–3 minutes when relevant.\n"
202
- "- Short sentences. No nested lists. No emojis in output.\n"
203
- "- Respond in STRICT, valid JSON only, with nothing else."
204
- )
205
-
206
- guide = GUIDES[lang][category_key]
207
- few = FEWSHOTS[lang][category_key]
208
-
209
  if lang == "fr":
210
  system = (
211
  "Tu es l’IA du jeu de cartes Neurovie, inspiré du modèle FINGER. "
@@ -217,19 +202,25 @@ def build_prompt(lang: str, category_key: str, variant: str) -> str:
217
  "- Si l’autre joueur l’a, il la donne et continue. Sinon, tu pioches.\n"
218
  "Variante: chacun pioche à son tour; le meilleur ou le plus sincère garde la carte."
219
  )
 
 
 
 
 
 
 
220
  user = (
221
  f"Catégorie: {cat['fr']} {cat['icon']}. {variant_fr}\n"
222
  f"Guide de ciblage: {guide}\n"
223
  "Produit attendu: 3 QUESTIONS et 2 MICRO-ACTIONS, chacune en 1 phrase, concrète.\n"
224
- "Adapte le ton selon la contrainte. Respecte scrupuleusement le schéma JSON.\n"
225
- "Exemple de style (à imiter, ne pas répéter tel quel):\n"
226
- f"- Questions: {few['questions']}\n"
227
- f"- Micro-actions: {few['micro_actions']}\n"
228
  "Schéma JSON strict:\n"
229
  f"{schema}\n"
230
  "RENVOIE UNIQUEMENT DU JSON VALIDE."
231
  )
232
- content = f"{system}\n\n{rules}\n\n{safety_fr}\n\n{user}"
 
233
  else:
234
  system = (
235
  "You are the AI for the Neurovie card game, inspired by the FINGER model. "
@@ -241,29 +232,29 @@ def build_prompt(lang: str, category_key: str, variant: str) -> str:
241
  "- If they have it, they give it and keep asking; otherwise, draw.\n"
242
  "Variant: players draw in turn; the best or most sincere keeps the card."
243
  )
 
 
 
 
 
 
 
244
  user = (
245
  f"Category: {cat['en']} {cat['icon']}. {variant_en}\n"
246
  f"Targeting guide: {guide}\n"
247
  "Expected output: 3 QUESTIONS and 2 MICRO-ACTIONS, each one sentence, concrete.\n"
248
- "Match the requested tone. Follow the JSON schema exactly.\n"
249
- "Style example (imitate tone; do not copy verbatim):\n"
250
- f"- Questions: {few['questions']}\n"
251
- f"- Micro-actions: {few['micro_actions']}\n"
252
  "Strict JSON schema:\n"
253
  f"{schema}\n"
254
- "RETURN JSON ONLY."
255
  )
256
- content = f"{system}\n\n{rules}\n\n{safety_en}\n\n{user}"
257
-
258
- return content
259
 
260
- # ────────────────────────────────────────────────────────────────────────────────
261
- # Inference helpers
262
 
263
  def try_parse_json(text: str) -> Optional[Dict[str, Any]]:
264
  if not text:
265
  return None
266
- # Grab last JSON object
267
  match = re.search(r"\{[\s\S]*\}\s*$", text.strip())
268
  if not match:
269
  return None
@@ -272,42 +263,46 @@ def try_parse_json(text: str) -> Optional[Dict[str, Any]]:
272
  except Exception:
273
  return None
274
 
275
- def normalize_output(data: Dict[str, Any], lang: str, category_key: str, variant: str) -> Dict[str, Any]:
276
- """Ensure exactly 3 questions, 2 micro_actions, set tone, fill missing fields."""
277
- q = data.get("questions", [])
278
- m = data.get("micro_actions", [])
279
- # basic sanitization
280
- q = [str(s).strip().rstrip() for s in q if str(s).strip()]
281
- m = [str(s).strip().rstrip() for s in m if str(s).strip()]
282
- # clip/extend
283
  q = (q + [""] * 3)[:3]
284
  m = (m + [""] * 2)[:2]
285
 
286
- tone = data.get("tone", "")
287
- if not tone:
288
- tone = ("ludique" if (lang == "fr" and variant == "best")
289
- else "sincère" if lang == "fr"
290
- else "playful" if variant == "best"
291
- else "sincere")
292
-
293
- safety = data.get("safety_notes", "")
294
- if not safety:
295
- safety = "Reste bienveillant·e, évite les conseils médicaux." if lang == "fr" else "Be kind, avoid medical advice."
 
 
 
 
 
296
 
297
- out = {
298
  "category": category_key,
299
  "language": lang,
300
  "questions": q,
301
  "micro_actions": m,
302
  "tone": tone,
303
- "safety_notes": safety,
304
  }
305
- return out
306
 
307
  def model_call(prompt: str) -> str:
308
  client = InferenceClient(model=MODEL_ID, token=HF_TOKEN)
309
 
310
- # Prefer chat endpoint when available
311
  try:
312
  resp = client.chat.completions.create(
313
  model=MODEL_ID,
@@ -316,22 +311,30 @@ def model_call(prompt: str) -> str:
316
  top_p=0.92,
317
  max_tokens=220,
318
  )
319
- content = resp.choices[0].message["content"] if hasattr(resp.choices[0], "message") else resp.choices[0].message.content
 
 
 
 
 
320
  if isinstance(content, list):
321
- content = "".join(part.get("text", "") if isinstance(part, dict) else str(part) for part in content)
 
 
 
322
  return content.strip()
323
  except Exception:
324
  pass
325
 
326
  # Fallback to text_generation
327
- resp = client.text_generation(
328
  prompt,
329
  max_new_tokens=220,
330
  temperature=0.85,
331
  top_p=0.92,
332
  return_full_text=False,
333
- )
334
- return resp.strip()
335
 
336
  def generate(lang: str, category_key: str, variant: str) -> Dict[str, str]:
337
  prompt = build_prompt(lang, category_key, variant)
@@ -344,7 +347,6 @@ def generate(lang: str, category_key: str, variant: str) -> Dict[str, str]:
344
 
345
  parsed = try_parse_json(raw_text) if raw_text else None
346
 
347
- # Local fallback if needed (pull from fewshots to keep style tight)
348
  if not parsed:
349
  few = FEWSHOTS[lang][category_key]
350
  parsed = {
@@ -352,58 +354,303 @@ def generate(lang: str, category_key: str, variant: str) -> Dict[str, str]:
352
  "language": lang,
353
  "questions": few["questions"][:3],
354
  "micro_actions": few["micro_actions"][:2],
355
- "tone": ("ludique" if (lang == "fr" and variant == "best")
356
- else "sincère" if lang == "fr"
357
- else "playful" if variant == "best"
358
- else "sincere"),
359
- "safety_notes": "Reste bienveillant·e, évite les conseils médicaux." if lang == "fr" else "Be kind, avoid medical advice.",
 
 
 
 
 
 
 
 
 
360
  }
361
- raw_text = "(local few-shot fallback used)"
362
 
363
  normalized = normalize_output(parsed, lang, category_key, variant)
364
 
365
- pretty = [
366
  f"Category: {normalized['category']} | Lang: {normalized['language']} | Tone: {normalized['tone']}",
367
  "",
368
  "Questions:",
369
- *[f"• {q}" for q in normalized["questions"]],
370
- "",
371
- "Micro-actions:",
372
- *[f"• {m}" for m in normalized["micro_actions"]],
373
  ]
 
 
 
 
374
  if normalized.get("safety_notes"):
375
- pretty += ["", f"Notes: {normalized['safety_notes']}"]
 
376
 
377
  return {
378
- "pretty_text": "\n".join(pretty),
379
- "raw_json": json.dumps(normalized, ensure_ascii=False, indent=2)
380
  }
381
 
382
  # ────────────────────────────────────────────────────────────────────────────────
383
- # GRADIO UI
384
- with gr.Blocks(title="Neurovie – FINGER Question Generator") as demo:
385
- gr.Markdown("# Neurovie – FINGER Question Generator")
386
- gr.Markdown("Aggressively guided prompts with category-aware examples. Strict JSON, then friendly display.")
387
-
388
- with gr.Row():
389
- lang = gr.Radio(choices=["fr", "en"], value="fr", label="Lang / Language")
390
- category = gr.Radio(
391
- choices=[c["key"] for c in CATEGORIES],
392
- value="alimentation",
393
- label="Catégorie / Category"
394
- )
395
- variant = gr.Radio(choices=["best", "sincere"], value="best", label="Variante")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
396
 
397
- btn = gr.Button("Generate")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
398
 
399
- pretty = gr.Textbox(label="Output", lines=14)
400
- raw_json = gr.Code(label="Parsed JSON", language="json")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
401
 
402
- def on_click(l, c, v):
403
- out = generate(l, c, v)
404
- return out["pretty_text"], out["raw_json"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
405
 
406
- btn.click(on_click, inputs=[lang, category, variant], outputs=[pretty, raw_json])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
407
 
408
  if __name__ == "__main__":
409
  demo.launch()
 
1
  import os
2
  import re
3
  import json
 
4
  from typing import Dict, Any, List, Optional
5
+
6
+ import gradio as gr
7
  from huggingface_hub import InferenceClient
8
 
9
  # ────────────────────────────────────────────────────────────────────────────────
 
27
  "mouvement": "Mise sur le NEAT (mouvements du quotidien): marche, escaliers, étirements courts, pauses actives, mini-rituels autour du café ou des appels.",
28
  "cerveau": "Stimulation douce: curiosité, mémoire, respiration, mini-jeux, apprentissage de 2-3 minutes. Zéro jargon médical.",
29
  "liens": "Interactions humaines simples: gratitude, messages courts, appel bref, se joindre à quelqu’un, sourire, entraide. Inclusif, bienveillant.",
30
+ "bien-etre": "Auto-soin micro: pauses, respiration, sommeil régulier, journal rapide, limiter les écrans un moment. Ton chaleureux, zéro culpabilisation.",
31
  },
32
  "en": {
33
  "alimentation": "Focus on simple habits: hydration, fruit/veg, meal rhythm, portions, smart snacks. No strict diets, no moralizing.",
34
  "mouvement": "Lean into NEAT: daily walking, stairs, quick stretches, active breaks, tiny rituals around coffee or calls.",
35
  "cerveau": "Gentle stimulation: curiosity, memory, breathing, tiny games, 2–3 min learning. No medical jargon.",
36
  "liens": "Simple human interactions: gratitude, short messages, brief call, joining someone, smiling, helping. Inclusive, kind tone.",
37
+ "bien-etre": "Micro self-care: breaks, breathing, steady sleep, quick journaling, small screen limits. Warm tone, no guilt.",
38
+ },
39
  }
40
 
41
  # Few-shot examples to anchor style (one per category per language)
 
45
  "questions": [
46
  "Quelle boisson te donne envie de boire plus d’eau dans la journée ?",
47
  "Quel ajout simple rend ton petit-déj plus rassasiant ?",
48
+ "Quand as-tu naturellement faim d’un fruit ou d’un yaourt ?",
49
  ],
50
  "micro_actions": [
51
  "Remplir une gourde ce matin.",
52
+ "Ajouter un fruit à la collation de l’après-midi.",
53
+ ],
54
  },
55
  "mouvement": {
56
  "questions": [
57
  "Quel trajet pourrais-tu faire à pied au moins une fois cette semaine ?",
58
  "Quelle pause-active de 2 minutes peux-tu glisser entre deux tâches ?",
59
+ "Qu’est-ce qui te fait bouger sans y penser (ex: marcher au téléphone) ?",
60
  ],
61
  "micro_actions": [
62
  "Monter un étage par les escaliers aujourd’hui.",
63
+ "Faire 5 étirements doux après le café.",
64
+ ],
65
  },
66
  "cerveau": {
67
  "questions": [
68
  "Qu’est-ce qui a suscité ta curiosité aujourd’hui ?",
69
  "Quel moment t’irait pour 3 minutes de respiration ?",
70
+ "Quel mini-jeu aimes-tu pour réveiller l’esprit (ex: 3 mots fléchés) ?",
71
  ],
72
  "micro_actions": [
73
  "Programmer un minuteur de 3 minutes pour respirer.",
74
+ "Lire un paragraphe d’un sujet nouveau ce soir.",
75
+ ],
76
  },
77
  "liens": {
78
  "questions": [
79
  "Qui pourrais-tu remercier aujourd’hui et comment ?",
80
  "À qui enverrais-tu un message court pour reprendre contact ?",
81
+ "Avec qui partagerais-tu une courte marche cette semaine ?",
82
  ],
83
  "micro_actions": [
84
  "Envoyer un message de gratitude à une personne.",
85
+ "Proposer une pause-café de 10 minutes.",
86
+ ],
87
  },
88
  "bien-etre": {
89
  "questions": [
90
  "Quel signal t’indique qu’il est temps de faire une pause ?",
91
  "Quelle routine de 2 minutes t’aide à te recentrer ?",
92
+ "Quel moment favorise un coucher plus régulier ?",
93
  ],
94
  "micro_actions": [
95
  "Éteindre les écrans 10 minutes plus tôt ce soir.",
96
+ "Écrire 3 lignes sur ton humeur du jour.",
97
+ ],
98
+ },
99
  },
100
  "en": {
101
  "alimentation": {
102
  "questions": [
103
  "What drink makes you want to sip more water through the day?",
104
  "What small add-on makes your breakfast more filling?",
105
+ "When do you naturally crave a fruit or yogurt?",
106
  ],
107
  "micro_actions": [
108
  "Fill a water bottle this morning.",
109
+ "Add one fruit to your afternoon snack.",
110
+ ],
111
  },
112
  "mouvement": {
113
  "questions": [
114
  "Which short trip could you walk at least once this week?",
115
  "Which 2-minute active break fits between two tasks?",
116
+ "What makes you move without noticing (e.g., walking on calls)?",
117
  ],
118
  "micro_actions": [
119
  "Take one flight of stairs today.",
120
+ "Do 5 light stretches after coffee.",
121
+ ],
122
  },
123
  "cerveau": {
124
  "questions": [
125
  "What sparked your curiosity today?",
126
  "When could you do 3 minutes of breathing?",
127
+ "Which mini-game wakes you up (e.g., 3 crossword clues)?",
128
  ],
129
  "micro_actions": [
130
  "Set a 3-minute timer to breathe.",
131
+ "Read one paragraph on a new topic tonight.",
132
+ ],
133
  },
134
  "liens": {
135
  "questions": [
136
  "Who could you thank today—and how?",
137
  "Who might you text briefly to reconnect?",
138
+ "Who could you invite for a short walk this week?",
139
  ],
140
  "micro_actions": [
141
  "Send a gratitude message to one person.",
142
+ "Offer a 10-minute coffee break.",
143
+ ],
144
  },
145
  "bien-etre": {
146
  "questions": [
147
  "What cue tells you it’s time for a pause?",
148
  "What 2-minute routine helps you reset?",
149
+ "What time supports a steadier bedtime?",
150
  ],
151
  "micro_actions": [
152
  "Turn screens off 10 minutes earlier tonight.",
153
+ "Write three lines about your mood today.",
154
+ ],
155
+ },
156
+ },
157
  }
158
 
159
+ # ────────────────────────────────────────────────────────────────────────────────
160
+ # PROMPT & MODEL HELPERS
161
+
162
+
163
  def build_prompt(lang: str, category_key: str, variant: str) -> str:
164
  cat = next((c for c in CATEGORIES if c["key"] == category_key), None)
165
  if not cat:
166
  category_key = "alimentation"
167
  cat = next((c for c in CATEGORIES if c["key"] == category_key), None)
168
 
169
+ guide = GUIDES[lang][category_key]
170
+ few = FEWSHOTS[lang][category_key]
171
+
172
  variant_fr = (
173
+ "Contrainte de ton: ludique et original (‘meilleur’)."
174
+ if variant == "best"
175
+ else "Contrainte de ton: introspectif et authentique (‘plus sincère’)."
176
  )
177
  variant_en = (
178
+ "Tone constraint: playful and original (‘best’)."
179
+ if variant == "best"
180
+ else "Tone constraint: introspective and authentic (‘most sincere’)."
181
  )
182
 
 
183
  schema = (
184
  "{\n"
185
  ' "category": "<category_key>",\n'
 
191
  "}"
192
  )
193
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  if lang == "fr":
195
  system = (
196
  "Tu es l’IA du jeu de cartes Neurovie, inspiré du modèle FINGER. "
 
202
  "- Si l’autre joueur l’a, il la donne et continue. Sinon, tu pioches.\n"
203
  "Variante: chacun pioche à son tour; le meilleur ou le plus sincère garde la carte."
204
  )
205
+ safety = (
206
+ "Règles de sécurité et de style:\n"
207
+ "- Zéro conseil médical, pas de diagnostic, pas d’injonction.\n"
208
+ "- Langage bienveillant, inclusif, non culpabilisant.\n"
209
+ "- Items concrets, réalisables en 1–3 minutes quand c’est pertinent.\n"
210
+ "- Phrases courtes. Pas d’emojis. Pas de texte hors JSON.\n"
211
+ )
212
  user = (
213
  f"Catégorie: {cat['fr']} {cat['icon']}. {variant_fr}\n"
214
  f"Guide de ciblage: {guide}\n"
215
  "Produit attendu: 3 QUESTIONS et 2 MICRO-ACTIONS, chacune en 1 phrase, concrète.\n"
216
+ "Respecte le schéma JSON ci-dessous. Imites le style, pas le contenu exact, des exemples.\n"
217
+ f"Exemples de style (ne pas copier mot à mot): questions={few['questions']} micro_actions={few['micro_actions']}\n"
 
 
218
  "Schéma JSON strict:\n"
219
  f"{schema}\n"
220
  "RENVOIE UNIQUEMENT DU JSON VALIDE."
221
  )
222
+ return f"{system}\n\n{rules}\n\n{safety}\n\n{user}"
223
+
224
  else:
225
  system = (
226
  "You are the AI for the Neurovie card game, inspired by the FINGER model. "
 
232
  "- If they have it, they give it and keep asking; otherwise, draw.\n"
233
  "Variant: players draw in turn; the best or most sincere keeps the card."
234
  )
235
+ safety = (
236
+ "Safety & style rules:\n"
237
+ "- No medical advice, no diagnosis, no orders.\n"
238
+ "- Kind, inclusive, non-judgmental language.\n"
239
+ "- Concrete items, doable in 1–3 minutes when relevant.\n"
240
+ "- Short sentences. No emojis. No text outside JSON.\n"
241
+ )
242
  user = (
243
  f"Category: {cat['en']} {cat['icon']}. {variant_en}\n"
244
  f"Targeting guide: {guide}\n"
245
  "Expected output: 3 QUESTIONS and 2 MICRO-ACTIONS, each one sentence, concrete.\n"
246
+ "Follow the JSON schema below. Imitate style, not exact wording, of examples.\n"
247
+ f"Style examples (do not copy verbatim): questions={few['questions']} micro_actions={few['micro_actions']}\n"
 
 
248
  "Strict JSON schema:\n"
249
  f"{schema}\n"
250
+ "RETURN VALID JSON ONLY."
251
  )
252
+ return f"{system}\n\n{rules}\n\n{safety}\n\n{user}"
 
 
253
 
 
 
254
 
255
  def try_parse_json(text: str) -> Optional[Dict[str, Any]]:
256
  if not text:
257
  return None
 
258
  match = re.search(r"\{[\s\S]*\}\s*$", text.strip())
259
  if not match:
260
  return None
 
263
  except Exception:
264
  return None
265
 
266
+
267
+ def normalize_output(
268
+ data: Dict[str, Any], lang: str, category_key: str, variant: str
269
+ ) -> Dict[str, Any]:
270
+ q = [str(x).strip() for x in data.get("questions", []) if str(x).strip()]
271
+ m = [str(x).strip() for x in data.get("micro_actions", []) if str(x).strip()]
272
+
 
273
  q = (q + [""] * 3)[:3]
274
  m = (m + [""] * 2)[:2]
275
 
276
+ if not data.get("tone"):
277
+ if lang == "fr":
278
+ tone = "ludique" if variant == "best" else "sincère"
279
+ else:
280
+ tone = "playful" if variant == "best" else "sincere"
281
+ else:
282
+ tone = str(data["tone"])
283
+
284
+ safety_notes = data.get("safety_notes")
285
+ if not safety_notes:
286
+ safety_notes = (
287
+ "Reste bienveillant·e, évite les conseils médicaux."
288
+ if lang == "fr"
289
+ else "Be kind, avoid medical advice."
290
+ )
291
 
292
+ return {
293
  "category": category_key,
294
  "language": lang,
295
  "questions": q,
296
  "micro_actions": m,
297
  "tone": tone,
298
+ "safety_notes": safety_notes,
299
  }
300
+
301
 
302
  def model_call(prompt: str) -> str:
303
  client = InferenceClient(model=MODEL_ID, token=HF_TOKEN)
304
 
305
+ # Try chat-style first
306
  try:
307
  resp = client.chat.completions.create(
308
  model=MODEL_ID,
 
311
  top_p=0.92,
312
  max_tokens=220,
313
  )
314
+ choice = resp.choices[0]
315
+ content = (
316
+ choice.message["content"]
317
+ if isinstance(choice.message, dict)
318
+ else choice.message.content
319
+ )
320
  if isinstance(content, list):
321
+ content = "".join(
322
+ part.get("text", "") if isinstance(part, dict) else str(part)
323
+ for part in content
324
+ )
325
  return content.strip()
326
  except Exception:
327
  pass
328
 
329
  # Fallback to text_generation
330
+ return client.text_generation(
331
  prompt,
332
  max_new_tokens=220,
333
  temperature=0.85,
334
  top_p=0.92,
335
  return_full_text=False,
336
+ ).strip()
337
+
338
 
339
  def generate(lang: str, category_key: str, variant: str) -> Dict[str, str]:
340
  prompt = build_prompt(lang, category_key, variant)
 
347
 
348
  parsed = try_parse_json(raw_text) if raw_text else None
349
 
 
350
  if not parsed:
351
  few = FEWSHOTS[lang][category_key]
352
  parsed = {
 
354
  "language": lang,
355
  "questions": few["questions"][:3],
356
  "micro_actions": few["micro_actions"][:2],
357
+ "tone": (
358
+ "ludique"
359
+ if (lang == "fr" and variant == "best")
360
+ else "sincère"
361
+ if lang == "fr"
362
+ else "playful"
363
+ if variant == "best"
364
+ else "sincere"
365
+ ),
366
+ "safety_notes": (
367
+ "Reste bienveillant·e, évite les conseils médicaux."
368
+ if lang == "fr"
369
+ else "Be kind, avoid medical advice."
370
+ ),
371
  }
 
372
 
373
  normalized = normalize_output(parsed, lang, category_key, variant)
374
 
375
+ lines: List[str] = [
376
  f"Category: {normalized['category']} | Lang: {normalized['language']} | Tone: {normalized['tone']}",
377
  "",
378
  "Questions:",
 
 
 
 
379
  ]
380
+ lines.extend(f"• {q}" for q in normalized["questions"])
381
+ lines.append("")
382
+ lines.append("Micro-actions:")
383
+ lines.extend(f"• {m}" for m in normalized["micro_actions"])
384
  if normalized.get("safety_notes"):
385
+ lines.append("")
386
+ lines.append(f"Notes: {normalized['safety_notes']}")
387
 
388
  return {
389
+ "pretty_text": "\n".join(lines),
390
+ "raw_json": json.dumps(normalized, ensure_ascii=False, indent=2),
391
  }
392
 
393
  # ────────────────────────────────────────────────────────────────────────────────
394
+ # UI – minimal, palette-matched, animated
395
+
396
+ CUSTOM_CSS = """
397
+ :root {
398
+ --nv-bg: #f6f1ea;
399
+ --nv-card: #fffdf8;
400
+ --nv-border: #e3d7c7;
401
+ --nv-accent: #f27c5e;
402
+ --nv-accent-soft: #ffe0cf;
403
+ --nv-accent-teal: #7ab7b7;
404
+ --nv-text-main: #262626;
405
+ --nv-text-muted: #6c6459;
406
+ }
407
+
408
+ /* background + blobs */
409
+ .gradio-container {
410
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif;
411
+ background: radial-gradient(circle at top left, #fff5eb 0, #f6f1ea 50%, #f2ede5 100%);
412
+ max-width: 900px !important;
413
+ margin: 0 auto !important;
414
+ padding: 32px 0 40px 0;
415
+ position: relative;
416
+ }
417
+
418
+ @keyframes nvBlobFloat {
419
+ 0% { transform: translate3d(0, 0, 0) scale(1); opacity: 0.65; }
420
+ 50% { transform: translate3d(10px, -8px, 0) scale(1.04); opacity: 0.9; }
421
+ 100% { transform: translate3d(0, 0, 0) scale(1); opacity: 0.65; }
422
+ }
423
+
424
+ .gradio-container::before,
425
+ .gradio-container::after {
426
+ content: "";
427
+ position: fixed;
428
+ width: 260px;
429
+ height: 260px;
430
+ border-radius: 999px;
431
+ z-index: -1;
432
+ filter: blur(3px);
433
+ animation: nvBlobFloat 14s ease-in-out infinite alternate;
434
+ }
435
+ .gradio-container::before {
436
+ top: -80px;
437
+ left: -60px;
438
+ background: radial-gradient(circle at 30% 30%, #ffd1bf, transparent 60%);
439
+ }
440
+ .gradio-container::after {
441
+ bottom: -120px;
442
+ right: -40px;
443
+ background: radial-gradient(circle at 70% 70%, #bfe8ff, transparent 60%);
444
+ animation-delay: 4s;
445
+ }
446
+
447
+ /* main card */
448
+ @keyframes nvCardIn {
449
+ 0% { opacity: 0; transform: translateY(8px) scale(0.98); }
450
+ 100% { opacity: 1; transform: translateY(0) scale(1); }
451
+ }
452
+ .nv-shell {
453
+ background: var(--nv-card);
454
+ border-radius: 32px;
455
+ border: 1px solid var(--nv-border);
456
+ box-shadow:
457
+ 0 20px 40px rgba(15, 23, 42, 0.12),
458
+ 0 1px 0 rgba(255, 255, 255, 0.8) inset;
459
+ padding: 26px 24px 22px;
460
+ animation: nvCardIn 500ms ease-out;
461
+ }
462
+
463
+ .nv-badge {
464
+ font-size: 0.78rem;
465
+ letter-spacing: 0.16em;
466
+ text-transform: uppercase;
467
+ color: var(--nv-text-muted);
468
+ }
469
+ .nv-title {
470
+ font-size: 1.9rem;
471
+ font-weight: 650;
472
+ margin-top: 4px;
473
+ }
474
+ .nv-subtitle {
475
+ font-size: 0.95rem;
476
+ color: var(--nv-text-muted);
477
+ margin-top: 6px;
478
+ margin-bottom: 18px;
479
+ }
480
 
481
+ /* sections */
482
+ .nv-section {
483
+ background: #fbf6ee;
484
+ border-radius: 24px;
485
+ border: 1px solid #e6dac7;
486
+ padding: 16px 16px 14px;
487
+ margin-bottom: 14px;
488
+ }
489
+ .nv-label {
490
+ font-size: 0.78rem;
491
+ letter-spacing: 0.12em;
492
+ text-transform: uppercase;
493
+ color: #a29078;
494
+ margin-bottom: 8px;
495
+ }
496
 
497
+ /* pills */
498
+ .nv-pills label {
499
+ border-radius: 999px !important;
500
+ border: 1px solid #decfbb !important;
501
+ padding: 6px 11px !important;
502
+ font-size: 0.84rem !important;
503
+ background: #fdf8f1;
504
+ transition: background 150ms ease, transform 120ms ease, box-shadow 120ms ease;
505
+ }
506
+ .nv-pills input:checked + label {
507
+ background: linear-gradient(135deg, #ffe0cf, #ffd2c0) !important;
508
+ border-color: var(--nv-accent) !important;
509
+ color: var(--nv-text-main) !important;
510
+ box-shadow: 0 4px 10px rgba(242, 124, 94, 0.2);
511
+ transform: translateY(-1px);
512
+ }
513
+
514
+ /* button */
515
+ @keyframes nvPulse {
516
+ 0% { transform: translateY(0) scale(1); box-shadow: 0 10px 22px rgba(242, 124, 94, 0.30); }
517
+ 50% { transform: translateY(-1px) scale(1.02); box-shadow: 0 14px 26px rgba(242, 124, 94, 0.40); }
518
+ 100% { transform: translateY(0) scale(1); box-shadow: 0 10px 22px rgba(242, 124, 94, 0.30); }
519
+ }
520
+ .gr-button {
521
+ border-radius: 999px !important;
522
+ padding: 10px 20px !important;
523
+ font-weight: 600 !important;
524
+ font-size: 0.96rem !important;
525
+ border: none !important;
526
+ background:
527
+ radial-gradient(circle at 0 0, #ffe0cf, transparent 55%),
528
+ linear-gradient(135deg, #f6a37d, #f27c5e) !important;
529
+ color: #fff !important;
530
+ animation: nvPulse 4s ease-in-out infinite;
531
+ }
532
+ .gr-button:hover {
533
+ animation-duration: 1.4s;
534
+ }
535
 
536
+ /* output fade */
537
+ @keyframes nvFadeIn {
538
+ 0% { opacity: 0; transform: translateY(4px); }
539
+ 100% { opacity: 1; transform: translateY(0); }
540
+ }
541
+ .nv-output {
542
+ background: #f6f8fc;
543
+ border-radius: 18px;
544
+ border: 1px solid #d8e2f2;
545
+ padding: 14px;
546
+ font-size: 0.93rem;
547
+ animation: nvFadeIn 350ms ease-out;
548
+ }
549
+
550
+ /* code box */
551
+ .nv-json code, .nv-json textarea {
552
+ border-radius: 16px !important;
553
+ }
554
+
555
+ /* mobile */
556
+ @media (max-width: 640px) {
557
+ .nv-shell {
558
+ border-radius: 24px;
559
+ padding: 20px 16px 18px;
560
+ }
561
+ .nv-title {
562
+ font-size: 1.6rem;
563
+ }
564
+ }
565
+ """
566
+
567
+ def _map_category(choice: str) -> str:
568
+ mapping = {
569
+ "alimentation 🍎": "alimentation",
570
+ "mouvement 🦘": "mouvement",
571
+ "cerveau 🧠": "cerveau",
572
+ "liens 🤝": "liens",
573
+ "bien-etre 💬": "bien-etre",
574
+ }
575
+ return mapping.get(choice, "alimentation")
576
+
577
+
578
+ def click_handler(l, c, v):
579
+ c_key = _map_category(c)
580
+ out = generate(l, c_key, v)
581
+ return out["pretty_text"], out["raw_json"]
582
+
583
+
584
+ with gr.Blocks(title="Neurovie – Question Studio") as demo:
585
+ # inject CSS
586
+ gr.HTML(f"<style>{CUSTOM_CSS}</style>")
587
+
588
+ with gr.Column(elem_classes="nv-shell"):
589
+ gr.HTML(
590
+ """
591
+ <div>
592
+ <div class="nv-badge">NEUROVIE · FINGER</div>
593
+ <div class="nv-title">Question Studio</div>
594
+ <div class="nv-subtitle">
595
+ Minimal prompts for rich conversations — draw 3 questions and 2 micro-actions.
596
+ </div>
597
+ </div>
598
+ """
599
+ )
600
 
601
+ with gr.Row(elem_classes="nv-section"):
602
+ with gr.Column():
603
+ gr.HTML("<div class='nv-label'>Language</div>")
604
+ lang = gr.Radio(
605
+ choices=["fr", "en"],
606
+ value="fr",
607
+ show_label=False,
608
+ elem_classes="nv-pills",
609
+ )
610
+ with gr.Column():
611
+ gr.HTML("<div class='nv-label'>Tone</div>")
612
+ variant = gr.Radio(
613
+ choices=["best", "sincere"],
614
+ value="best",
615
+ show_label=False,
616
+ elem_classes="nv-pills",
617
+ )
618
+
619
+ with gr.Column(elem_classes="nv-section"):
620
+ gr.HTML("<div class='nv-label'>Category</div>")
621
+ category = gr.Radio(
622
+ choices=[
623
+ "alimentation 🍎",
624
+ "mouvement 🦘",
625
+ "cerveau 🧠",
626
+ "liens 🤝",
627
+ "bien-etre 💬",
628
+ ],
629
+ value="alimentation 🍎",
630
+ show_label=False,
631
+ elem_classes="nv-pills",
632
+ )
633
+
634
+ btn = gr.Button("Generate card set ✨")
635
+
636
+ with gr.Column(elem_classes="nv-section"):
637
+ gr.HTML("<div class='nv-label'>Preview</div>")
638
+ pretty = gr.Textbox(
639
+ label="",
640
+ lines=12,
641
+ show_label=False,
642
+ elem_classes="nv-output",
643
+ )
644
+
645
+ with gr.Column(elem_classes=["nv-section", "nv-json"]):
646
+ gr.HTML("<div class='nv-label'>JSON (for dev)</div>")
647
+ raw_json = gr.Code(
648
+ label="",
649
+ language="json",
650
+ show_label=False,
651
+ )
652
+
653
+ btn.click(click_handler, [lang, category, variant], [pretty, raw_json])
654
 
655
  if __name__ == "__main__":
656
  demo.launch()