Spaces:
Sleeping
Sleeping
| """Family Co-Pilot — Shared Custody Calendar Assistant. | |
| Single-file Gradio app. Separated/divorced parents describe their children's | |
| events in natural language (French or English); an AI organizes everything into | |
| a visual weekly calendar with custody-aware meal tracking and conflict detection. | |
| Model: microsoft/Phi-3-mini-4k-instruct (3.8B — Tiny Titan eligible), loaded via | |
| transformers + @spaces.GPU on ZeroGPU. Locally (no `spaces`), a deterministic | |
| rule-based parser handles the same phrases so the app is testable without a GPU. | |
| The whole visual layer is custom HTML rendered into gr.HTML blocks, styled after | |
| the Family Co-Pilot mockup (gradient header, card panels, avatar child cards, | |
| custom chat bubbles, vertical day cards). A FR/EN toggle re-labels the UI and the | |
| system-prompt language; the AI always answers in whatever language the user types. | |
| Run: python app.py -> http://localhost:7860 | |
| """ | |
| from __future__ import annotations | |
| import datetime as dt | |
| import html | |
| import json | |
| import os | |
| import re | |
| import unicodedata | |
| import gradio as gr | |
| # Optional ZeroGPU decorator (no-op locally). Presence of `spaces` is also our | |
| # signal that we're on the Space and should use the real model. | |
| try: | |
| import spaces # type: ignore | |
| GPU = spaces.GPU(duration=120) | |
| HAS_SPACES = True | |
| except Exception: # pragma: no cover - local / non-Spaces environment | |
| def GPU(fn): | |
| return fn | |
| HAS_SPACES = False | |
| FORCE_MODEL = os.environ.get("FAMILY_COPILOT_MODEL", "").lower() in ("1", "true", "yes") | |
| USE_MODEL = HAS_SPACES or FORCE_MODEL | |
| MODEL_ID = "microsoft/Phi-3-mini-4k-instruct" | |
| # =========================================================================== | |
| # Palette (mockup variables) | |
| # =========================================================================== | |
| P1 = "#3B82F6" # Parent 1 — blue (Maman) | |
| P2 = "#EC4899" # Parent 2 — pink (Papa) | |
| EMERALD = "#10B981" # activities / confirmations | |
| AMBER = "#EF9F27" # conflicts | |
| VIOLET = "#8B5CF6" # user chat bubbles | |
| # Selectable per-parent / per-partner colors (FIX 1) | |
| COLORS = {"blue": "#3B82F6", "pink": "#EC4899", "purple": "#8B5CF6", | |
| "green": "#10B981", "gold": "#EF9F27"} | |
| COLOR_KEYS = ["blue", "pink", "purple", "green", "gold"] | |
| def color_hex(key: str, fallback: str = "#3B82F6") -> str: | |
| return COLORS.get(key, fallback) | |
| def hex_to_rgba(hx: str, a: float) -> str: | |
| hx = hx.lstrip("#") | |
| r, g, b = int(hx[0:2], 16), int(hx[2:4], 16), int(hx[4:6], 16) | |
| return f"rgba({r},{g},{b},{a})" | |
| LEVELS = [ | |
| "Crèche (0-3 ans)", | |
| "Maternelle (3-5 ans)", | |
| "Primaire (6-10 ans)", | |
| "Collège (11-14 ans)", | |
| "Lycée (15-18 ans)", | |
| ] | |
| CUSTODY_PATTERNS = ["One week each", "2-2-3", "Every other weekend", "Custom"] | |
| # =========================================================================== | |
| # Internationalisation | |
| # =========================================================================== | |
| DAYS = { | |
| "fr": ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"], | |
| "en": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], | |
| } | |
| DAYS_FULL = { | |
| "fr": ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche"], | |
| "en": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"], | |
| } | |
| MONTHS = { | |
| "fr": ["janvier", "février", "mars", "avril", "mai", "juin", "juillet", | |
| "août", "septembre", "octobre", "novembre", "décembre"], | |
| "en": ["January", "February", "March", "April", "May", "June", "July", | |
| "August", "September", "October", "November", "December"], | |
| } | |
| CUSTODY_LABEL = { | |
| "fr": {"One week each": "Semaine alternée", "2-2-3": "2-2-3", | |
| "Every other weekend": "Un week-end sur deux", "Custom": "Personnalisé"}, | |
| "en": {"One week each": "Week on / week off", "2-2-3": "2-2-3", | |
| "Every other weekend": "Every other weekend", "Custom": "Custom"}, | |
| } | |
| I18N = { | |
| "fr": { | |
| "subtitle": "La garde partagée, simplifiée. Session locale privée · FR & EN", | |
| "family": "1. Votre famille", | |
| "num_children": "Nombre d'enfants", | |
| "parents": "Parents", | |
| "custody": "Mode de garde", | |
| "chat": "2. Parlez à votre co-pilote", | |
| "calendar": "3. Calendrier", | |
| "save": "💾 Enregistrer la famille", | |
| "edit": "✏️ Modifier la famille", | |
| "send": "Envoyer", | |
| "prev": "‹ Préc.", "today": "Aujourd'hui", "next": "Suiv. ›", | |
| "child": "Enfant", | |
| "conflict_title": "Conflit détecté", | |
| "no_conflict": "Aucun conflit cette semaine — tout roule !", | |
| "summary_title": "Résumé de la semaine", | |
| "events": "événements", "conflict_m": "conflit", "meals_m": "repas ajustés", | |
| "leg_activity": "Activité", "leg_conflict": "Conflit", | |
| "week": "Semaine", "this_week": "cette semaine", "nothing": "rien de prévu", | |
| "meals_lbl": "Repas", "week_of": "semaine de", | |
| "school_day": "Jour d'école", "half_day": "Demi-journée", | |
| "weekend": "Week-end", "activities": "Activités", | |
| "greet": ("Bonjour 👋 Décrivez-moi les activités, repas, trajets ou questions " | |
| "de garde. Je mets le calendrier à jour et je signale les conflits."), | |
| "moderation": ("💙 Cet espace est réservé au planning des enfants. Merci de " | |
| "rester factuel et respectueux. Reformulez votre message en vous " | |
| "concentrant sur l'organisation."), | |
| "partners": "Partenaires (optionnel)", | |
| "add_p1": "+ Ajouter un·e partenaire pour Parent 1", | |
| "add_p2": "+ Ajouter un·e partenaire pour Parent 2", | |
| "partner_of": "Partenaire de", | |
| "with_help": "avec l'aide de", | |
| }, | |
| "en": { | |
| "subtitle": "Shared custody, simplified. Private local session · FR & EN", | |
| "family": "1. Your family", | |
| "num_children": "Number of children", | |
| "parents": "Parents", | |
| "custody": "Custody pattern", | |
| "chat": "2. Talk to your co-pilot", | |
| "calendar": "3. Calendar", | |
| "save": "💾 Save family setup", | |
| "edit": "✏️ Edit family", | |
| "send": "Send", | |
| "prev": "‹ Prev", "today": "Today", "next": "Next ›", | |
| "child": "Child", | |
| "conflict_title": "Conflict detected", | |
| "no_conflict": "No conflicts this week — all clear!", | |
| "summary_title": "This week's summary", | |
| "events": "events", "conflict_m": "conflict", "meals_m": "meals set", | |
| "leg_activity": "Activity", "leg_conflict": "Conflict", | |
| "week": "Week", "this_week": "this week", "nothing": "nothing planned", | |
| "meals_lbl": "Meals", "week_of": "week of", | |
| "school_day": "School day", "half_day": "Half day", | |
| "weekend": "Weekend", "activities": "Activities", | |
| "greet": ("Hi 👋 Tell me about activities, meals, pick-ups or custody " | |
| "questions. I'll update the calendar and flag any conflicts."), | |
| "moderation": ("💙 This space is for your children's schedule. Please stay " | |
| "factual and respectful. Rephrase focusing on the schedule."), | |
| "partners": "Partners (optional)", | |
| "add_p1": "+ Add a partner for Parent 1", | |
| "add_p2": "+ Add a partner for Parent 2", | |
| "partner_of": "Partner of", | |
| "with_help": "with help from", | |
| }, | |
| } | |
| # Example chips: (button label, message inserted on click) | |
| CHIPS = { | |
| "fr": [ | |
| ("⚽ Lucas foot mercredi 17h", "Lucas a football mercredi 17h"), | |
| ("🎂 anniversaire samedi 14h-17h", | |
| "Emma est invitée à l'anniversaire de Zoé samedi 14h-17h"), | |
| ("🍽 Lucas mange chez moi mercredi", "Lucas mange chez moi mercredi"), | |
| ("❓ Qui est de garde le week-end du 21 ?", "Qui est de garde le week-end du 21 ?"), | |
| ], | |
| "en": [ | |
| ("⚽ Lucas football Wed 17h", "Lucas has football Wednesday 17h"), | |
| ("🎂 birthday Saturday 14h-17h", | |
| "Emma is invited to Zoé's birthday Saturday 14h-17h"), | |
| ("🍽 Lucas eats at mine Wednesday", "Lucas eats at mine Wednesday"), | |
| ("❓ Who's on duty the weekend of the 21st?", | |
| "Who is on duty the weekend of the 21st?"), | |
| ], | |
| } | |
| def lang_of(state: dict) -> str: | |
| return state.get("lang", "fr") | |
| # =========================================================================== | |
| # Moderation (FIX 1) — block insults / aggression BEFORE they reach the chat | |
| # =========================================================================== | |
| # Single-word insults (FR + EN). Stored normalized (accents stripped, repeated | |
| # letters collapsed) and matched on whole tokens so substrings of innocent words | |
| # ("con" in "concert", "ass" in "pass") never trigger. | |
| _RAW_BLOCK_WORDS = [ | |
| # FR | |
| "con", "conne", "connard", "connasse", "conard", "salaud", "salop", "salope", | |
| "salopard", "pute", "putain", "putes", "merde", "merdique", "encule", "enculer", | |
| "enculed", "nique", "niquer", "niqer", "debile", "abruti", "abrutie", "cretin", | |
| "imbecile", "pétasse", "petasse", "batard", "ordure", "trouduc", "demeure", | |
| "demeured", "raclure", "pourriture", "naze", "ducon", "fdp", "ntm", "pd", | |
| # EN | |
| "fuck", "fucker", "fucking", "fuk", "shit", "shitty", "asshole", "bitch", | |
| "bastard", "dick", "douche", "douchebag", "idiot", "idiote", "stupid", "dumb", | |
| "dumbass", "moron", "loser", "jerk", "crap", "prick", "wanker", "twat", | |
| ] | |
| _BLOCK_PHRASES_RAW = [ | |
| "ta gueule", "ferme ta gueule", "ferme la", "trou du cul", "va te faire", | |
| "vas te faire", "nique ta", "fils de pute", "fuck you", "fuck off", "piss off", | |
| "screw you", "shut up", "go to hell", | |
| ] | |
| # Target phrases that signal an insult aimed at the other parent/family. | |
| _TARGET_RE = re.compile( | |
| r"\b(ta mere|ta maman|ton pere|ton papa|ton ex|ta moitie|votre ex|" | |
| r"your (mum|mom|dad|mother|father|ex))\b") | |
| # Clearly-pejorative adjectives — only block these when aimed at a target above | |
| # (avoids blocking neutral scheduling chatter). | |
| _NEG_RAW = { | |
| "mechant", "mechante", "horrible", "pathetique", "minable", "lamentable", | |
| "incapable", "irresponsable", "nul", "nulle", "egoiste", "useless", "pathetic", | |
| "worthless", "selfish", "awful", "terrible", "hopeless", | |
| } | |
| def _normalize_mod(text: str) -> str: | |
| """Lowercase, strip accents, undo common leetspeak, collapse repeats.""" | |
| t = unicodedata.normalize("NFD", (text or "").lower()) | |
| t = "".join(c for c in t if unicodedata.category(c) != "Mn") | |
| t = t.translate(str.maketrans({"0": "o", "1": "i", "3": "e", "4": "a", | |
| "5": "s", "7": "t", "@": "a", "$": "s", "€": "e"})) | |
| return re.sub(r"(.)\1+", r"\1", t) # collapse runs: connnard -> conard | |
| _BLOCK_WORDS = {_normalize_mod(w) for w in _RAW_BLOCK_WORDS} | |
| _BLOCK_PHRASES = [_normalize_mod(p) for p in _BLOCK_PHRASES_RAW] | |
| _NEG_WORDS = {_normalize_mod(w) for w in _NEG_RAW} | |
| def is_blocked(message: str) -> bool: | |
| """True if the message contains an insult / aggression and must be hidden.""" | |
| norm = _normalize_mod(message) | |
| tokens = re.findall(r"[a-z]+", norm) | |
| for tok in tokens: | |
| if tok in _BLOCK_WORDS or (tok.endswith("s") and tok[:-1] in _BLOCK_WORDS): | |
| return True | |
| for ph in _BLOCK_PHRASES: | |
| if ph in norm: | |
| return True | |
| if _TARGET_RE.search(norm) and any(tok in _NEG_WORDS for tok in tokens): | |
| return True | |
| return False | |
| # =========================================================================== | |
| # CSS (translated from the mockup, scoped for Gradio) | |
| # =========================================================================== | |
| CSS = """ | |
| :root{ | |
| --bg:#0f0f14;--panel:#171823;--line:#2a2d3d;--text:#f8f8fb;--muted:#9aa0b8; | |
| --blue:#3B82F6;--pink:#EC4899;--green:#10B981;--amber:#EF9F27;--violet:#8B5CF6; | |
| } | |
| .gradio-container{ | |
| background:radial-gradient(circle at 20% 0%,rgba(59,130,246,.16),transparent 28%), | |
| radial-gradient(circle at 80% 5%,rgba(236,72,153,.15),transparent 30%), | |
| #0f0f14 !important; | |
| color:#f8f8fb !important; max-width:1520px !important; margin:0 auto !important; | |
| font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Arial,sans-serif !important;} | |
| /* Force the (light) Base theme dark everywhere so no wrapper leaks white */ | |
| .gradio-container,.gradio-container .block,.gradio-container .form, | |
| .gradio-container .wrap,.gradio-container .container{ | |
| --block-background-fill:#10121b;--block-border-color:#34384b; | |
| --block-label-background-fill:transparent;--block-label-text-color:#9aa0b8; | |
| --background-fill-primary:#10121b;--background-fill-secondary:#141620; | |
| --input-background-fill:#10121b;--input-background-fill-focus:#141620; | |
| --input-border-color:#34384b;--input-border-color-focus:#4a4f68; | |
| --border-color-primary:#34384b;--border-color-accent:#34384b; | |
| --body-text-color:#f8f8fb;--body-text-color-subdued:#9aa0b8; | |
| --neutral-700:#c8cce0;--neutral-800:#141620;--neutral-900:#10121b; | |
| --panel-background-fill:transparent;--body-background-fill:transparent; | |
| --checkbox-background-color:#10121b;--checkbox-background-color-selected:#3B82F6;} | |
| .gradio-container .block,.gradio-container .form,.gradio-container .panel, | |
| .gradio-container .gap{background:transparent !important;border:none !important;box-shadow:none !important;} | |
| footer{display:none !important;} | |
| .gradio-container .prose h1,.gradio-container .prose h2,.gradio-container .prose h3{color:#f8f8fb;} | |
| /* ---- base buttons ---- */ | |
| .gradio-container button{background:#121521 !important;color:#dbe1ff !important; | |
| border:1px solid #34384b !important;box-shadow:none !important;font-weight:700 !important; | |
| border-radius:12px !important;transition:border-color .15s ease,filter .15s ease;} | |
| .gradio-container button:hover{border-color:#4a4f68 !important;filter:brightness(1.05);} | |
| /* ===================== HEADER ===================== */ | |
| #fc-header{background:linear-gradient(180deg,rgba(255,255,255,.045),rgba(255,255,255,.015)) !important; | |
| border:1px solid #2a2d3d !important;border-radius:22px !important; | |
| box-shadow:0 18px 50px rgba(0,0,0,.35) !important;padding:8px 22px !important; | |
| align-items:center !important;flex-wrap:nowrap !important;margin-bottom:6px; | |
| overflow:hidden !important;} | |
| #fc-header > *{flex-shrink:0;} | |
| #fc-header .gr-form,#fc-header .block{overflow:visible !important;} | |
| .brand{display:flex;align-items:center;gap:14px;} | |
| .brand .logo{font-size:32px;line-height:1;} | |
| .brand h1{margin:0;font-size:25px;letter-spacing:.2px;color:#f8f8fb;font-weight:800; | |
| white-space:nowrap;} | |
| .brand h1 .grad{background:linear-gradient(90deg,var(--blue),var(--pink)); | |
| -webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent; | |
| color:#EC4899;padding-right:2px;} /* pink fallback if background-clip unsupported */ | |
| .brand p{margin:3px 0 0;color:var(--muted);font-size:12.5px;} | |
| .pill{display:inline-flex;align-items:center;gap:7px;border:1px solid rgba(16,185,129,.35); | |
| background:rgba(16,185,129,.12);color:#7ee7c5;border-radius:999px;padding:8px 14px; | |
| font-weight:700;font-size:12px;white-space:nowrap;} | |
| #guide-btn button{width:42px !important;min-width:42px !important;height:42px !important; | |
| border-radius:50% !important;padding:0 !important;font-size:18px !important;font-weight:900 !important; | |
| color:#ff79c6 !important;background:#151622 !important;} | |
| /* language toggle */ | |
| #lang-toggle{min-width:0 !important;} | |
| #lang-toggle fieldset{border:1px solid #34384b !important;background:#10121b !important; | |
| border-radius:999px !important;padding:3px !important;display:flex !important;gap:2px !important;} | |
| #lang-toggle label{margin:0 !important;padding:5px 12px !important;border-radius:999px !important; | |
| font-size:12px !important;font-weight:800 !important;color:#9aa0b8 !important;cursor:pointer; | |
| border:none !important;background:transparent !important;} | |
| #lang-toggle label.selected,#lang-toggle input:checked + span{color:#fff !important;} | |
| #lang-toggle label:has(input:checked){background:linear-gradient(90deg,var(--blue),var(--pink)) !important; | |
| color:#fff !important;} | |
| #lang-toggle input{display:none !important;} | |
| #lang-toggle span{color:inherit !important;font-weight:800 !important;} | |
| /* ===================== PANELS ===================== */ | |
| #left-panel,#center-panel,#right-panel{ | |
| background:linear-gradient(180deg,rgba(255,255,255,.04),rgba(255,255,255,.015)) !important; | |
| border:1px solid #2a2d3d !important;border-radius:22px !important; | |
| box-shadow:0 18px 50px rgba(0,0,0,.35) !important;padding:20px !important;} | |
| .label{font-size:12px;letter-spacing:.16em;text-transform:uppercase;color:#b8bdd6; | |
| font-weight:900;margin:0 0 14px;} | |
| .fldlabel{display:block;color:var(--muted);font-size:12.5px;margin:0 0 7px;} | |
| .subttl{margin-top:22px;} | |
| /* ---- form controls ---- */ | |
| /* text + number inputs: a single clean dark field */ | |
| #left-panel input:not([type=radio]),#left-panel textarea, | |
| #center-panel input,#center-panel textarea{ | |
| background:#10121b !important;color:#f8f8fb !important;border:1px solid #34384b !important; | |
| border-radius:13px !important;box-shadow:none !important;} | |
| #left-panel input::placeholder,#center-panel input::placeholder, | |
| #center-panel textarea::placeholder{color:#6a7090 !important;} | |
| #left-panel label span,#left-panel .label-wrap span{color:var(--muted) !important;} | |
| /* dropdowns (.ddl): collapse Gradio's nested wrappers into ONE outer field | |
| (FIX 2 — kills the double-border / box-in-box look). The border lives on the | |
| .ddl block; every inner wrapper + the inner <input> is stripped flat. */ | |
| #left-panel .ddl{background:#10121b !important;border:1px solid #34384b !important; | |
| border-radius:13px !important;box-shadow:none !important;overflow:visible !important;} | |
| #left-panel .ddl .wrap,#left-panel .ddl .wrap-inner,#left-panel .ddl .secondary-wrap, | |
| #left-panel .ddl .container,#left-panel .ddl .form,#left-panel .ddl label, | |
| #left-panel .ddl .block{background:transparent !important;border:none !important; | |
| box-shadow:none !important;padding:0 !important;} | |
| #left-panel .ddl input{background:transparent !important;border:none !important; | |
| box-shadow:none !important;padding:10px 12px !important;border-radius:13px !important;} | |
| #left-panel .ddl ul.options{background:#10121b !important;border:1px solid #34384b !important; | |
| border-radius:13px !important;margin-top:4px !important;} | |
| #left-panel .ddl .options .item:hover,#left-panel .ddl li.item:hover{background:#20233a !important;} | |
| /* ---- per-parent color swatches (FIX 1) ---- */ | |
| .colorpick{display:flex !important;gap:8px !important;margin-top:9px !important; | |
| flex-wrap:wrap !important;background:transparent !important;border:none !important; | |
| padding:0 !important;box-shadow:none !important;} | |
| .colorpick fieldset,.colorpick .wrap{display:flex !important;gap:8px !important; | |
| background:transparent !important;border:none !important;padding:0 !important;box-shadow:none !important;} | |
| .colorpick input{display:none !important;} | |
| .colorpick label{width:22px !important;height:22px !important;min-width:22px !important; | |
| border-radius:50% !important;cursor:pointer;margin:0 !important;padding:0 !important; | |
| border:none !important;box-shadow:none !important;transition:transform .1s ease;} | |
| .colorpick label:hover{transform:scale(1.14);} | |
| .colorpick label > span,.colorpick label span{display:none !important;} | |
| .colorpick label:nth-of-type(1){background:#3B82F6 !important;} | |
| .colorpick label:nth-of-type(2){background:#EC4899 !important;} | |
| .colorpick label:nth-of-type(3){background:#8B5CF6 !important;} | |
| .colorpick label:nth-of-type(4){background:#10B981 !important;} | |
| .colorpick label:nth-of-type(5){background:#EF9F27 !important;} | |
| .colorpick label:has(input:checked){box-shadow:0 0 0 2px #171823,0 0 0 4px #fff !important;} | |
| /* ---- child cards ---- */ | |
| .child{border:1px solid var(--line);background:#141620;border-radius:16px;padding:13px;margin-top:12px;} | |
| .childtop{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;} | |
| .tag{font-size:11px;letter-spacing:.11em;text-transform:uppercase;color:#b6b9cc;font-weight:800;} | |
| .avatar{width:34px;height:34px;border-radius:50%;display:grid;place-items:center;font-weight:900; | |
| font-size:15px;} | |
| .avatar.blue{background:rgba(59,130,246,.18);color:#8fbaff;} | |
| .avatar.pink{background:rgba(236,72,153,.18);color:#ff9ad3;} | |
| /* ---- buttons: save / edit / send ---- */ | |
| #save-btn button{background:linear-gradient(90deg,var(--blue),var(--pink)) !important; | |
| color:#fff !important;border:none !important;font-weight:900 !important;letter-spacing:.02em !important; | |
| padding:13px !important;margin-top:8px;} | |
| #edit-btn button{background:#141620 !important;border:1px solid var(--line) !important; | |
| color:#cfd5f0 !important;width:100% !important;} | |
| /* parents / partners (FIX 3) */ | |
| .fldmini{font-size:11px;color:#8b93b0;font-weight:700;margin:0 0 5px;letter-spacing:.02em;} | |
| .addpartner button{background:#141620 !important;border:1px dashed #3a3f55 !important; | |
| color:#b9c0e0 !important;font-size:12px !important;font-weight:600 !important; | |
| width:100% !important;padding:9px !important;} | |
| .addpartner button:hover{border-color:var(--violet) !important;color:#e7e2ff !important;} | |
| .partnerbox{border:1px solid var(--line);background:#141620;border-radius:14px; | |
| padding:11px 12px;margin-top:8px;} | |
| .partnerlbl{font-size:11px;color:#cdbcff;font-weight:800;margin:0 0 6px;letter-spacing:.02em;} | |
| .rmpartner button{background:rgba(239,68,68,.12) !important;border:1px solid rgba(239,68,68,.4) !important; | |
| color:#ff9b9b !important;width:40px !important;min-width:40px !important;font-size:15px !important; | |
| font-weight:900 !important;padding:0 !important;} | |
| .rmpartner button:hover{border-color:#ef4444 !important;color:#fff !important;} | |
| #send-btn button{background:linear-gradient(90deg,var(--violet),var(--pink)) !important; | |
| color:#fff !important;border:none !important;font-weight:900 !important;height:100%;} | |
| .collapsed{border:1px solid var(--line);background:#141620;border-radius:16px;padding:15px 16px; | |
| font-size:14px;color:#e7ebff;font-weight:600;line-height:1.5;} | |
| /* ===================== CHAT ===================== */ | |
| .chat{display:flex;flex-direction:column-reverse;gap:12px;height:330px;overflow-y:auto; | |
| padding:6px 4px 2px;} | |
| .bubble{max-width:78%;padding:12px 15px;border-radius:18px;font-size:14px;line-height:1.5;} | |
| .bubble.ai{background:#202436;border:1px solid #30364a;color:#dfe4ff;align-self:flex-start;} | |
| .bubble.user{align-self:flex-end;margin-left:auto;color:#f3f1ff; | |
| background:linear-gradient(90deg,rgba(59,130,246,.22),rgba(139,92,246,.28)); | |
| border:1px solid rgba(139,92,246,.4);} | |
| .bubble.confirm{border-left:4px solid var(--green);background:rgba(16,185,129,.10);color:#d8ffee;} | |
| .bubble.confirm b{color:#8af0c8;} | |
| /* moderation system note — neutral/amber, centred, not a chat side */ | |
| .bubble.system{align-self:center;max-width:94%;text-align:center;color:#f3d59a; | |
| background:rgba(239,159,39,.09);border:1px solid rgba(239,159,39,.32); | |
| border-radius:14px;font-size:13px;} | |
| #composer{margin-top:12px;gap:10px !important;} | |
| #composer textarea{min-height:48px !important;} | |
| /* ---- chips ---- */ | |
| .chip button{border:1px solid #34384b !important;background:#121521 !important; | |
| border-radius:999px !important;color:#cfd5f0 !important;font-size:12.5px !important; | |
| font-weight:600 !important;padding:9px 12px !important;width:100% !important;} | |
| .chip button:hover{border-color:var(--pink) !important;color:#ffd3ec !important;} | |
| /* ---- conflict + summary cards ---- */ | |
| .conflict{margin-top:6px;border:1px solid rgba(239,159,39,.35);background:rgba(239,159,39,.08); | |
| border-radius:18px;padding:14px 16px;} | |
| .conflict b{color:#ffc36b;font-size:14px;} | |
| .conflict .suggestion{margin-top:8px;color:#f3d59a;font-size:13px;line-height:1.5;} | |
| .okcard{margin-top:6px;border:1px solid rgba(16,185,129,.32);background:rgba(16,185,129,.08); | |
| border-radius:18px;padding:14px 16px;color:#7fe8c4;font-size:13.5px;font-weight:600;} | |
| .summary{margin-top:14px;border:1px solid rgba(59,130,246,.28);background:rgba(59,130,246,.07); | |
| border-radius:18px;padding:15px 16px;} | |
| .summary h3{margin:0 0 8px;font-size:16px;color:#dbe6ff;} | |
| .summary .sub{color:#c3cbe6;font-size:13px;line-height:1.6;} | |
| .summary .sub b{color:#fff;} | |
| .summary .mealhdr{margin-top:9px;color:#8fa4d6;font-size:11px;letter-spacing:.08em; | |
| text-transform:uppercase;font-weight:800;} | |
| /* ===================== CALENDAR ===================== */ | |
| #weekbar{align-items:center !important;margin-bottom:12px;} | |
| .wk-title b{font-size:17px;} | |
| .wk-title .sub{color:var(--muted);font-size:12.5px;margin-top:2px;} | |
| .navbtn button{padding:9px 12px !important;font-size:12.5px !important;} | |
| .vday{position:relative;border:1px solid var(--line);border-radius:18px;background:#12141d; | |
| padding:13px 14px 12px;margin-bottom:10px;overflow:hidden;animation:pop .3s ease;} | |
| .vday.blue{box-shadow:inset 0 4px 0 var(--blue);} | |
| .vday.pink{box-shadow:inset 0 4px 0 var(--pink);} | |
| .vday.today{outline:1px solid #3a3f57;} | |
| @keyframes pop{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}} | |
| .vhead{display:flex;align-items:flex-start;justify-content:space-between;} | |
| .vhead b{font-size:15px;display:block;} | |
| .vhead span.sub{font-size:12px;color:var(--muted);} | |
| .vparent{font-size:11.5px;font-weight:900;white-space:nowrap;display:inline-flex;align-items:center;gap:5px;} | |
| .vparent.blue{color:#7db0ff;} | |
| .vparent.pink{color:#ff8bc9;} | |
| .vparent .d{width:8px;height:8px;border-radius:50%;display:inline-block;} | |
| .vbody{margin-top:9px;display:flex;flex-direction:column;gap:7px;} | |
| .event{border-radius:13px;padding:9px 11px;border:1px solid rgba(16,185,129,.35); | |
| background:rgba(16,185,129,.12);font-size:12.5px;color:#d6ffee;} | |
| .event.pink{border-color:rgba(236,72,153,.35);background:rgba(236,72,153,.12);color:#ffd9ee;} | |
| .event.amber{border-color:rgba(239,159,39,.35);background:rgba(239,159,39,.12);color:#ffe6bd;} | |
| .event .who{font-weight:800;color:#fff;} | |
| .event .tm{color:#9aa0b8;font-size:11.5px;} | |
| .event .helper{display:inline-flex;align-items:center;gap:4px;margin-top:5px;padding:2px 8px; | |
| border-radius:999px;background:rgba(139,92,246,.16);border:1px solid rgba(139,92,246,.4); | |
| color:#cdbcff;font-size:10.5px;font-weight:700;} | |
| .vmeals{margin-top:10px;display:flex;gap:6px;flex-wrap:wrap;} | |
| .vmeals span{font-size:11px;border:1px solid #33384a;background:#161927;border-radius:999px; | |
| padding:5px 9px;color:#c4c9df;} | |
| .legend{display:flex;gap:14px;flex-wrap:wrap;margin-top:12px;color:var(--muted);font-size:12px;} | |
| .legend span{display:inline-flex;align-items:center;gap:6px;} | |
| .legend .dot{width:9px;height:9px;border-radius:50%;display:inline-block;} | |
| .bottom{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-top:14px;} | |
| .metric{border:1px solid var(--line);background:#13151f;border-radius:15px;padding:13px;} | |
| .metric b{font-size:23px;display:block;line-height:1;} | |
| .metric small{color:var(--muted);display:block;margin-top:5px;font-size:12px;} | |
| /* ===================== GUIDE MODAL ===================== */ | |
| /* No `display` here: Gradio's visible=False sets display:none; when visible the | |
| column is display:flex, so align/justify center the card. */ | |
| #guide-modal{position:fixed;inset:0;z-index:1000;background:rgba(8,8,12,.84); | |
| align-items:center;justify-content:center;padding:30px;} | |
| /* the card itself is this Gradio Column — a positioning context, clips overflow */ | |
| #guide-card{position:relative !important;max-width:700px !important;width:100% !important; | |
| max-height:86vh !important;background:#171823 !important;border:1px solid #2a2d3d !important; | |
| border-radius:22px !important;box-shadow:0 24px 70px rgba(0,0,0,.6) !important; | |
| overflow:hidden !important;padding:0 !important;gap:0 !important;} | |
| /* scrollable content region (the only thing that scrolls) */ | |
| .modal-body{overflow-y:auto;overflow-x:hidden;max-height:86vh;padding:26px 30px 28px;} | |
| .modal-body::-webkit-scrollbar{width:9px;} | |
| .modal-body::-webkit-scrollbar-thumb{background:#2f3346;border-radius:999px;} | |
| .modal-body::-webkit-scrollbar-track{background:transparent;} | |
| .modal-h1{font-size:23px;font-weight:800;color:#f8f8fb;margin:0 34px 4px 0;} | |
| .modal-p{color:#aab;font-size:13.5px;margin:0 0 8px;line-height:1.55;} | |
| .modal-sec{margin-top:16px;} | |
| .modal-h2{display:inline-block;font-size:12px;font-weight:800;color:var(--pink); | |
| letter-spacing:.06em;text-transform:uppercase;margin-bottom:4px;} | |
| .modal-sec p{color:#dfe2ee;font-size:13px;margin:4px 0 0;line-height:1.6;} | |
| .modal-sec ul{margin:7px 0 0;padding-left:18px;color:#dfe2ee;font-size:13px;line-height:1.75;} | |
| .modal-sec code{background:#10121b;border:1px solid #2a2d3d;border-radius:6px;padding:1px 6px; | |
| font-size:12px;color:#9fd0ff;} | |
| .modal-priv{margin-top:18px;padding:11px 13px;border-radius:14px; | |
| background:rgba(16,185,129,.08);border:1px solid rgba(16,185,129,.35);color:#7fe8c4;font-size:12.5px;} | |
| /* × pinned to the card's top-right corner (absolute within #guide-card) */ | |
| .modal-x{position:absolute !important;top:13px;right:13px;z-index:5;flex:none !important; | |
| width:40px !important;min-width:40px !important;} | |
| .modal-x button{width:40px !important;min-width:40px !important;height:40px !important; | |
| border-radius:11px !important;font-size:15px !important;background:#1d2030 !important; | |
| border:1px solid #343a4f !important;color:#c8cce0 !important;padding:0 !important;} | |
| .modal-x button:hover{border-color:var(--pink) !important;color:#f9a8d4 !important;} | |
| /* ===================== FOOTER ===================== */ | |
| #fc-footer{text-align:center;color:#777d99;font-size:13px;padding:22px 0 8px;} | |
| #fc-footer b{color:var(--pink);} | |
| """ | |
| # =========================================================================== | |
| # Guide modal content | |
| # =========================================================================== | |
| def guide_html() -> str: | |
| return """ | |
| <div class='modal-body'> | |
| <div class='modal-h1'>👨👩👧👦 How Family Co-Pilot works</div> | |
| <div class='modal-p'>A calendar assistant for separated parents sharing custody. | |
| Describe your kids' lives in plain language — the AI organizes it, tracks who's | |
| on duty, manages meals, and flags conflicts with suggestions.</div> | |
| <div class='modal-sec'><span class='modal-h2'>What it does</span> | |
| <p>Turns everyday sentences into a shared-custody calendar: events, pick-ups, | |
| meals and "who has the kids" — all in one place, in French or English.</p></div> | |
| <div class='modal-sec'><span class='modal-h2'>How to talk to it</span> | |
| <p>Type naturally. The AI replies in the language you use.</p> | |
| <ul> | |
| <li>🇫🇷 <code>Lucas a football mercredi 17h</code></li> | |
| <li>🇫🇷 <code>Emma est invitée à l'anniversaire de Zoé samedi 14h-17h</code></li> | |
| <li>🇫🇷 <code>Je ne peux pas récupérer Lucas mardi, est-ce que Marc peut ?</code></li> | |
| <li>🇬🇧 <code>Add the school show on Thursday at 18h</code></li> | |
| <li>🇬🇧 <code>Who is on duty the weekend of the 21st?</code></li> | |
| </ul></div> | |
| <div class='modal-sec'><span class='modal-h2'>How meals work</span> | |
| <p>Each child gets a meal per day, defaulting from school level + custody: | |
| <b>🏫 cantine</b> on full school days, <b>🏠 at home</b> with the on-duty | |
| parent on Wednesdays (half-day) and weekends, and <b>👨👩 special arrangement</b> | |
| when you ask for the other parent. Override any day — | |
| <code>Lucas mange chez moi mercredi</code>.</p></div> | |
| <div class='modal-sec'><span class='modal-h2'>Custody patterns</span> | |
| <ul> | |
| <li><b>One week each</b> — parents alternate full weeks.</li> | |
| <li><b>2-2-3</b> — Mon-Tue with one parent, Wed-Thu the other, Fri-Sun alternating.</li> | |
| <li><b>Every other weekend</b> — weekdays with one parent, weekends alternate.</li> | |
| <li><b>Custom</b> — describe it in your own words.</li> | |
| </ul> | |
| <p>Days are tinted by who's on duty: <b style='color:#3B82F6'>blue = Parent 1</b>, | |
| <b style='color:#EC4899'>pink = Parent 2</b>.</p></div> | |
| <div class='modal-sec'><span class='modal-h2'>Powered by Phi-3</span> | |
| <p>Runs on <code>microsoft/Phi-3-mini-4k-instruct</code> (3.8B) — a small model | |
| that fits the "Tiny Titan" class. Nothing is sent to a third-party cloud.</p></div> | |
| <div class='modal-priv'>🔒 <b>Privacy:</b> your family data stays in this browser | |
| session only. Nothing is stored on a server. Close the tab and it's gone.</div> | |
| </div> | |
| """ | |
| # =========================================================================== | |
| # State helpers | |
| # =========================================================================== | |
| def today() -> dt.date: | |
| return dt.date.today() | |
| def default_state() -> dict: | |
| """Seed a believable family + a few events in the current week so the app is | |
| never empty on load.""" | |
| mon = monday_of(today(), 0) | |
| st = { | |
| "lang": "fr", | |
| "family": { | |
| "children": [ | |
| {"name": "Lucas", "age": 8, "level": "Primaire (6-10 ans)"}, | |
| {"name": "Emma", "age": 11, "level": "Collège (11-14 ans)"}, | |
| ], | |
| "parent1": "Maman", | |
| "parent2": "Papa", | |
| "partner1": "", | |
| "partner2": "Marc", | |
| "color1": "blue", | |
| "color2": "pink", | |
| "pcolor1": "green", | |
| "pcolor2": "green", | |
| "custody": "One week each", | |
| "custody_custom": "", | |
| }, | |
| "events": [], | |
| "meals": {}, # {iso_date: {child: "cantine"|"home"|"other"}} | |
| "week_offset": 0, | |
| "next_id": 1, | |
| "messages": [{"role": "assistant", "content": I18N["fr"]["greet"]}], | |
| "chat": [], | |
| } | |
| # seed events (Wed football, Thu piano, Sat birthday) | |
| seed = [ | |
| ("Lucas", "Football", mon + dt.timedelta(days=2), "17:00", 60, "⚽"), | |
| ("Emma", "Piano", mon + dt.timedelta(days=3), "14:00", 60, "🎵"), | |
| ("Emma", "Anniversaire de Zoé", mon + dt.timedelta(days=5), "14:00", 180, "🎂"), | |
| ] | |
| for child, name, d, tm, dur, icon in seed: | |
| duty, _, _ = duty_info(d, st["family"]) | |
| st["events"].append({ | |
| "id": st["next_id"], "child": child, "event": name, | |
| "date": d.isoformat(), "time": tm, "duration": dur, | |
| "parent_duty": duty, "meal": None, "pickup": None, "notes": "", "icon": icon, | |
| }) | |
| st["next_id"] += 1 | |
| return st | |
| # =========================================================================== | |
| # Custody + meal logic | |
| # =========================================================================== | |
| def monday_of(d: dt.date, offset_weeks: int) -> dt.date: | |
| return d - dt.timedelta(days=d.weekday()) + dt.timedelta(weeks=offset_weeks) | |
| def iso_week(d: dt.date) -> int: | |
| return d.isocalendar()[1] | |
| def duty_info(d: dt.date, fam: dict) -> tuple[str, int, str]: | |
| """Return (parent_name, parent_index(1/2), color) on duty for date `d`.""" | |
| p1 = (fam.get("parent1") or "Parent 1").strip() or "Parent 1" | |
| p2 = (fam.get("parent2") or "Parent 2").strip() or "Parent 2" | |
| pat = fam.get("custody", "One week each") | |
| wd = d.weekday() # Mon=0 .. Sun=6 | |
| even = (iso_week(d) % 2 == 0) | |
| def pick(idx): | |
| if idx == 1: | |
| return (p1, 1, color_hex(fam.get("color1", "blue"), P1)) | |
| return (p2, 2, color_hex(fam.get("color2", "pink"), P2)) | |
| if pat == "2-2-3": | |
| if wd in (0, 1): | |
| return pick(1) | |
| if wd in (2, 3): | |
| return pick(2) | |
| return pick(1 if even else 2) # Fri-Sun alternate | |
| if pat == "Every other weekend": | |
| if wd >= 5: # weekends alternate | |
| return pick(2 if even else 1) | |
| return pick(1) # weekdays Parent 1 | |
| # "One week each" (and Custom fallback): even ISO week -> Parent 2 | |
| return pick(2 if even else 1) | |
| def default_meal(child: dict, d: dt.date, fam: dict) -> str | None: | |
| """Default meal code for a child on date `d`: 'cantine' | 'home' | None.""" | |
| level = child.get("level", "") | |
| wd = d.weekday() | |
| if level.startswith("Crèche"): | |
| return "cantine" if wd < 5 else "home" # daycare meals included | |
| if wd >= 5: | |
| return "home" # weekend with on-duty parent | |
| if wd == 2: | |
| return "home" # Wednesday half-day -> lunch at home | |
| return "cantine" # full school day | |
| MEAL_ICON = {"cantine": "🏫", "home": "🏠", "other": "👨👩"} | |
| MEAL_LABEL = {"cantine": "cantine", "home": "repas maison", "other": "autre parent"} | |
| def meal_for(child: dict, d: dt.date, state: dict) -> str | None: | |
| """Effective meal: override if present, else default.""" | |
| ov = state.get("meals", {}).get(d.isoformat(), {}) | |
| if child["name"] in ov: | |
| return ov[child["name"]] | |
| return default_meal(child, d, state["family"]) | |
| # =========================================================================== | |
| # Activity icon detection | |
| # =========================================================================== | |
| ICON_RULES = [ | |
| (r"foot|football|soccer", "⚽"), | |
| (r"basket|hand\b|handball|rugby|volley", "🏀"), | |
| (r"natation|swim|piscine", "🏊"), | |
| (r"tennis|badminton|ping", "🎾"), | |
| (r"sport|gym|judo|karate|athl|course|vélo|velo|escalade", "🏃"), | |
| (r"piano|musi|music|guitar|guitare|violon|chant|solf|orchestre", "🎵"), | |
| (r"danse|dance|ballet|hip.?hop", "💃"), | |
| (r"anniv|birthday|f[eê]te|party", "🎂"), | |
| (r"m[eé]decin|docteur|doctor|dentiste|dentist|rdv|rendez|orthod|p[eé]diatre", "🏥"), | |
| (r"th[eé][aâ]tre|theatre|cin[eé]ma|spectacle|show|concert", "🎭"), | |
| (r"[eé]cole|school|classe|cours|sortie scolaire|coll[eè]ge|lyc[eé]e", "🏫"), | |
| (r"\bchien|chiot|\bdog\b", "🐕"), | |
| (r"\bchat\b|chaton|\bcat\b", "🐈"), | |
| (r"animal|animaux|\bpet\b|lapin|hamster|rabbit", "🐾"), | |
| (r"piscine|plage|parc|park", "🏞️"), | |
| (r"art|peinture|dessin|draw|paint", "🎨"), | |
| ] | |
| def icon_for(text: str) -> str: | |
| t = (text or "").lower() | |
| for pat, ic in ICON_RULES: | |
| if re.search(pat, t): | |
| return ic | |
| return "📌" | |
| # =========================================================================== | |
| # Natural-language date / time parsing (fallback path + question answering) | |
| # =========================================================================== | |
| WEEKDAYS_MAP = { | |
| "lundi": 0, "monday": 0, "mardi": 1, "tuesday": 1, "mercredi": 2, "wednesday": 2, | |
| "jeudi": 3, "thursday": 3, "vendredi": 4, "friday": 4, | |
| "samedi": 5, "saturday": 5, "dimanche": 6, "sunday": 6, | |
| } | |
| def detect_lang(text: str) -> str: | |
| t = (text or "").lower() | |
| fr_hits = len(re.findall(r"\b(le|la|les|un|une|des|est|de|à|et|chez|qui|quand|" | |
| r"garde|enfant|repas|cantine|semaine|weekend|anniversaire|" | |
| r"récup|peut|mange|vacances|spectacle)\b", t)) | |
| en_hits = len(re.findall(r"\b(the|is|at|and|on|who|when|child|meal|week|" | |
| r"birthday|pick|can|eats|school|show|add)\b", t)) | |
| return "fr" if fr_hits >= en_hits else "en" | |
| def parse_date(text: str, base: dt.date) -> dt.date | None: | |
| """Best-effort: weekday names, today/tomorrow, 'du 21'/'le 21', 'weekend'.""" | |
| t = text.lower() | |
| if re.search(r"\b(aujourd'hui|today)\b", t): | |
| return base | |
| if re.search(r"\b(demain|tomorrow)\b", t): | |
| return base + dt.timedelta(days=1) | |
| if re.search(r"apr[eè]s.?demain|day after tomorrow", t): | |
| return base + dt.timedelta(days=2) | |
| # weekday NAME + day NUMBER (e.g. "lundi 8") -> that exact date (FIX 1). | |
| wn = resolve_weekday_number(t, base) | |
| if wn is not None: | |
| if wn["match"]: | |
| return wn["resolved"] | |
| return None # day/weekday conflict — caller asks for clarification | |
| # explicit weekday name (no number) -> next occurrence (>= today) | |
| for name, wd in WEEKDAYS_MAP.items(): | |
| if re.search(r"\b" + name + r"\b", t): | |
| delta = (wd - base.weekday()) % 7 | |
| return base + dt.timedelta(days=delta) | |
| # "weekend" / "week-end" -> upcoming Saturday | |
| if re.search(r"week.?end", t): | |
| m = re.search(r"week.?end\s+(?:du|of)\s+(?:the\s+)?(\d{1,2})", t) | |
| if m: | |
| return day_of_month(int(m.group(1)), base) | |
| delta = (5 - base.weekday()) % 7 | |
| return base + dt.timedelta(days=delta) | |
| # "du 21" / "le 21" / "on the 21st" | |
| m = re.search(r"\b(?:du|le|on the|the)\s+(\d{1,2})(?:er|st|nd|rd|th)?\b", t) | |
| if m: | |
| return day_of_month(int(m.group(1)), base) | |
| # bare "21/06" or "21-06" | |
| m = re.search(r"\b(\d{1,2})[/-](\d{1,2})\b", t) | |
| if m: | |
| dd, mm = int(m.group(1)), int(m.group(2)) | |
| try: | |
| yr = base.year if mm >= base.month else base.year + 1 | |
| return dt.date(yr, mm, dd) | |
| except ValueError: | |
| return None | |
| return None | |
| def day_of_month(day: int, base: dt.date) -> dt.date | None: | |
| """The given day-of-month, this month or next if already passed.""" | |
| try: | |
| cand = base.replace(day=day) | |
| except ValueError: | |
| return None | |
| if cand < base: | |
| if base.month == 12: | |
| cand = dt.date(base.year + 1, 1, day) | |
| else: | |
| try: | |
| cand = dt.date(base.year, base.month + 1, day) | |
| except ValueError: | |
| return None | |
| return cand | |
| def _nth_of_month(n: int, base: dt.date, month_offset: int = 0) -> dt.date | None: | |
| """The n-th day of base's month shifted by month_offset (None if invalid).""" | |
| y, mo = base.year, base.month + month_offset | |
| while mo > 12: | |
| mo -= 12 | |
| y += 1 | |
| while mo < 1: | |
| mo += 12 | |
| y -= 1 | |
| try: | |
| return dt.date(y, mo, n) | |
| except ValueError: | |
| return None | |
| def _nearest_weekday(around: dt.date, wd: int) -> dt.date: | |
| """The date closest to `around` whose weekday == wd.""" | |
| for delta in (0, -1, 1, -2, 2, -3, 3): | |
| d = around + dt.timedelta(days=delta) | |
| if d.weekday() == wd: | |
| return d | |
| return around | |
| def resolve_weekday_number(text: str, base: dt.date) -> dict | None: | |
| """Combine an explicit weekday NAME + day-of-month NUMBER (e.g. 'lundi 8'). | |
| Returns None if the text has no weekday name + day number pair. Otherwise a | |
| dict describing whether they are consistent: | |
| match=True -> 'resolved' holds the exact date (the named weekday that | |
| falls on that number, in the current or next month). | |
| match=False -> the number's date is a *different* weekday; 'date_for_n' | |
| and 'named_near' let the caller ask for clarification. | |
| """ | |
| low = text.lower() | |
| named_wd = named_name = None | |
| for name, wd in WEEKDAYS_MAP.items(): | |
| if re.search(r"\b" + name + r"\b", low): | |
| named_wd, named_name = wd, name | |
| break | |
| if named_wd is None: | |
| return None | |
| # Find a day-of-month number that is NOT a time. A number is a TIME if it is | |
| # followed by 'h', ':MM', or an am/pm marker (17h, 5:30, 5 PM) — exclude those. | |
| not_time = r"(?!\s*(?:h\d{0,2}|:\d{2}|[ap]\.?\s*m\b))" | |
| n = None | |
| m = re.search(r"\b" + named_name + r"\b\s+(?:le\s+|the\s+)?(\d{1,2})\b" + not_time, low) | |
| if m: | |
| n = int(m.group(1)) | |
| else: | |
| m = re.search(r"\b(?:le|du|on the|the)\s+(\d{1,2})\b" + not_time, low) | |
| if m: | |
| n = int(m.group(1)) | |
| else: | |
| for mm in re.finditer(r"\b(\d{1,2})\b", low): | |
| before = low[mm.start() - 1] if mm.start() else "" | |
| rest = low[mm.end():] | |
| if before == ":" or re.match(r"\s*(?:h\d{0,2}|:\d{2}|[ap]\.?\s*m\b)", rest): | |
| continue | |
| n = int(mm.group(1)) | |
| break | |
| if n is None or not (1 <= n <= 31): | |
| return None | |
| cand = _nth_of_month(n, base, 0) # current month's n-th | |
| cand2 = _nth_of_month(n, base, 1) # next month's n-th | |
| resolved = None | |
| if cand and cand.weekday() == named_wd: | |
| resolved = cand | |
| elif cand2 and cand2.weekday() == named_wd: | |
| resolved = cand2 | |
| date_for_n = cand or cand2 | |
| return { | |
| "n": n, "named_wd": named_wd, "named_name": named_name, | |
| "match": resolved is not None, "resolved": resolved, | |
| "date_for_n": date_for_n, | |
| "named_near": _nearest_weekday(date_for_n, named_wd) if date_for_n else None, | |
| } | |
| # Named times of day -> default HH:MM (FR + EN). 'afternoon' has no \bnoon\b | |
| # boundary so it never collides with 'noon'. | |
| _NAMED_TIMES = [ | |
| (r"\bnoon\b|\bmidi\b", "12:00"), | |
| (r"\bmidnight\b|\bminuit\b", "00:00"), | |
| (r"\bmorning\b|\bmatin", "09:00"), | |
| (r"\bafternoon\b|apr[eè]s.?midi|\baprem\b", "14:00"), | |
| (r"\bevening\b|\bsoir", "18:00"), | |
| ] | |
| def parse_time(text: str) -> tuple[str | None, int]: | |
| """Return (start 'HH:MM' or None, duration_minutes). | |
| Handles 24h French style (17h, 17h30, 17:00, 14h-17h ranges) AND 12h English | |
| style (5 PM, 5:30pm, 8 AM) plus named periods (noon, morning, evening…).""" | |
| t = text.lower() | |
| # 1) explicit range "14h-17h" | |
| rng = re.search(r"(\d{1,2})\s*h\s*(\d{0,2})\s*[-àa]\s*(\d{1,2})\s*h\s*(\d{0,2})", t) | |
| if rng: | |
| sh, sm, eh, em = rng.groups() | |
| start = f"{int(sh):02d}:{int(sm or 0):02d}" | |
| dur = (int(eh) * 60 + int(em or 0)) - (int(sh) * 60 + int(sm or 0)) | |
| return start, max(30, dur) | |
| # 2) 12h AM/PM — "5 PM", "5PM", "5:00 PM", "8 a.m." (checked before colon/Xh) | |
| ap = re.search(r"\b(\d{1,2})(?::(\d{2}))?\s*([ap])\.?\s*m\.?\b", t) | |
| if ap: | |
| h = int(ap.group(1)) % 12 | |
| if ap.group(3) == "p": | |
| h += 12 | |
| return f"{h:02d}:{int(ap.group(2) or 0):02d}", 60 | |
| # 3) 24h "17h" / "17h30" | |
| one = re.search(r"\b(\d{1,2})\s*h\s*(\d{2})?\b", t) | |
| if one: | |
| return f"{int(one.group(1)):02d}:{int(one.group(2) or 0):02d}", 60 | |
| # 4) "17:00" | |
| colon = re.search(r"\b(\d{1,2}):(\d{2})\b", t) | |
| if colon: | |
| return f"{int(colon.group(1)):02d}:{colon.group(2)}", 60 | |
| # 5) named period of day | |
| for pat, val in _NAMED_TIMES: | |
| if re.search(pat, t): | |
| return val, 60 | |
| return None, 60 | |
| # =========================================================================== | |
| # Rule-based fallback "AI" (used locally / when the model is unavailable) | |
| # =========================================================================== | |
| # ---- cancellation detection (FIX) ---- | |
| # Strong words almost always mean "remove this event"; weak ones only count when | |
| # they actually match an existing event (avoids hijacking "plus de pain"). | |
| _STRONG_CANCEL = re.compile( | |
| r"annul|supprim|d[eé]command|\bcancel(?:l?ed|l?ing)?\b|retir[eé]|enlev", re.I) | |
| _WEAK_CANCEL = re.compile(r"\bplus de\b|n'?(?:y a|est|a)\s+plus\b|no more\b|" | |
| r"\bdrop(?:ped)?\b|\bremove\b|\bdelete\b", re.I) | |
| def parse_cancel_phrases(message: str, state: dict) -> list[dict]: | |
| """Find existing events the message asks to cancel; return remove updates.""" | |
| low = message.lower() | |
| named = [c["name"] for c in state["family"]["children"] if c["name"].lower() in low] | |
| matches = [] | |
| for e in state["events"]: | |
| ev_words = [w for w in re.split(r"[^a-zà-ÿ]+", e["event"].lower()) if len(w) >= 4] | |
| if any(w in low for w in ev_words): # event keyword present | |
| matches.append(e) | |
| if named: # restrict to named child if any | |
| narrowed = [e for e in matches if e["child"] in named] | |
| if narrowed: | |
| matches = narrowed | |
| return [{"action": "remove", "child": e["child"], "event": e["event"], | |
| "date": e["date"]} for e in matches] | |
| # Phrases that READ like a calendar confirmation. Used by on_send to detect a | |
| # reply that claims a change so it can be vetoed when the state did not actually | |
| # change (FIX 1 — keeps the confirmation text and the calendar in sync). | |
| _CONFIRM_RE = re.compile( | |
| r"ajout|\badded\b|mis à jour|\bupdated\b|annul|\bcancel|supprim|🗑|✅|🍽", re.I) | |
| def not_understood(lang: str) -> str: | |
| if lang == "fr": | |
| return ("🤔 Je n'ai pas tout saisi. Essayez par exemple « Lucas a foot mercredi 17h » " | |
| "ou « Qui est de garde samedi ? ».") | |
| return ("🤔 I didn't quite catch that. Try e.g. \"Lucas has football Wednesday 17h\" " | |
| "or \"Who is on duty Saturday?\".") | |
| def already_exists(lang: str) -> str: | |
| return ("📅 Cet événement existe déjà dans le calendrier." if lang == "fr" | |
| else "📅 This event is already in the calendar.") | |
| def is_duplicate_event(state: dict, u: dict) -> bool: | |
| """True if an identical event (child, activity, date, time) already exists.""" | |
| ev = (u.get("event") or "").strip().lower() | |
| child, date, tm = u.get("child"), u.get("date"), u.get("time") | |
| for e in state["events"]: | |
| if (e.get("child") == child and e.get("date") == date | |
| and e.get("time") == tm | |
| and (e.get("event") or "").strip().lower() == ev): | |
| return True | |
| return False | |
| # ---- pet support (FIX 2) ---- | |
| _PET_RE = re.compile(r"\b(animal|animaux|chien|chienne|chiot|chat|chatte|chaton|" | |
| r"dog|cat|pet|lapin|rabbit|hamster|nac)\b", re.I) | |
| # care-related verbs that confirm a pet sentence is a pet *task* | |
| _PET_CARE_RE = re.compile(r"s'?occup|garde|promen|sort(?:ir|e)|balad|nourri|emm[eè]ne|" | |
| r"v[eé]t[eé]rin|\bvet\b|walk|feed|sitt", re.I) | |
| def detect_pet(message: str): | |
| """Return (icon, fr_label, en_label) for a mentioned pet, or None.""" | |
| low = message.lower() | |
| if re.search(r"\bchien|chienne|chiot|\bdog\b", low): | |
| return ("🐕", "chien", "dog") | |
| if re.search(r"\bchat\b|chatte|chaton|\bcat\b", low): | |
| return ("🐈", "chat", "cat") | |
| if re.search(r"\blapin\b|rabbit", low): | |
| return ("🐰", "lapin", "rabbit") | |
| if re.search(r"\bhamster\b", low): | |
| return ("🐹", "hamster", "hamster") | |
| if re.search(r"\b(animal|animaux|pet|nac)\b", low): | |
| return ("🐾", "animal", "pet") | |
| return None | |
| def fallback_respond(message: str, state: dict, lang: str = "fr") -> tuple[str, list[dict]]: | |
| """Deterministic parser that mimics the model's behavior for common phrases. | |
| `lang` ('fr'/'en') is the UI toggle and drives every reply's language. | |
| Returns (reply_text, [calendar_update, ...]).""" | |
| fam = state["family"] | |
| base = today() | |
| low = message.lower() | |
| # ---- question: "qui est de garde ..." / "who is on duty ..." ---- | |
| if re.search(r"qui est de garde|qui garde|who.*(on duty|has|keep|custody)|de garde", low): | |
| d = parse_date(message, base) or base | |
| duty, _, _ = duty_info(d, fam) | |
| wk = iso_week(d) | |
| if lang == "fr": | |
| return (f"📅 Le {fmt_date_fr(d)} (semaine {wk}), c'est **{duty}** qui est de garde.", | |
| []) | |
| return (f"📅 On {d.strftime('%A %d %B')} (week {wk}), **{duty}** is on duty.", []) | |
| # ---- cancellation: "Le piano d'Emma est annulé" -> remove the event ---- | |
| strong = bool(_STRONG_CANCEL.search(low)) | |
| if strong or _WEAK_CANCEL.search(low): | |
| cancels = parse_cancel_phrases(message, state) | |
| if cancels: | |
| names = ", ".join(f"{u['event']} ({u['child']})" for u in cancels) | |
| reply = (f"🗑️ Annulé : {names}. Le calendrier est mis à jour." | |
| if lang == "fr" else | |
| f"🗑️ Cancelled: {names}. Calendar updated.") | |
| return reply, cancels | |
| if strong: # clear cancel intent but nothing matched — don't create an event | |
| return (("🔍 Je n'ai pas trouvé l'événement à annuler. Précisez l'activité " | |
| "et l'enfant (ex. « le piano d'Emma »).") if lang == "fr" else | |
| ("🔍 I couldn't find that event to cancel. Name the activity and " | |
| "child (e.g. \"Emma's piano\")."), []) | |
| # ---- meal override ---- | |
| meal_updates = parse_meal_phrases(message, state) | |
| if meal_updates: | |
| lines = [] | |
| for mu in meal_updates: | |
| lines.append(f"{mu['child']} — {MEAL_LABEL[mu['meal_code']]} " | |
| f"{fmt_date_fr(dt.date.fromisoformat(mu['date']))}") | |
| if lang == "fr": | |
| reply = "🍽️ Repas mis à jour :\n- " + "\n- ".join(lines) | |
| else: | |
| reply = "🍽️ Meals updated:\n- " + "\n- ".join(lines) | |
| return reply, meal_updates | |
| # ---- availability question ---- | |
| if re.search(r"ne peux pas|peux pas r[eé]cup|can'?t pick|cannot pick|unavailable", low): | |
| d = parse_date(message, base) or base | |
| helper = fam.get("partner2") or fam.get("partner1") or "" | |
| duty, _, _ = duty_info(d, fam) | |
| if lang == "fr": | |
| sug = (f" {helper} (nouveau partenaire) pourrait aider à récupérer les enfants." | |
| if helper else " Pensez à demander à l'autre parent ou à un proche.") | |
| return (f"🤝 Noté pour {fmt_date_fr(d)}. {duty} est de garde ce jour-là.{sug}", []) | |
| sug = (f" {helper} could help with pick-up." if helper | |
| else " Consider asking the other parent or a relative.") | |
| return (f"🤝 Noted for {d.strftime('%A %d %B')}. {duty} is on duty that day.{sug}", []) | |
| # ---- vacation / open-ended planning ---- | |
| if re.search(r"vacances|vacation|holiday", low) and "?" in message: | |
| if lang == "fr": | |
| return ("🏖️ Pour les vacances, dites-moi les dates et qui prend quelle période " | |
| "(ex. « 1ère semaine chez Maman, 2ème chez Papa ») et je les ajoute.", []) | |
| return ("🏖️ For the holidays, tell me the dates and who takes which stretch " | |
| "(e.g. \"first week with Mum, second with Dad\") and I'll add them.", []) | |
| # ---- weekday / day-number mismatch: "lundi 9" when the 9th is a Tuesday ---- | |
| wn = resolve_weekday_number(message, base) | |
| if wn is not None and not wn["match"] and wn["date_for_n"]: | |
| actual = wn["date_for_n"] | |
| near = wn["named_near"] | |
| named_fr = DAYS_FULL["fr"][wn["named_wd"]].lower() | |
| actual_fr = DAYS_FULL["fr"][actual.weekday()].lower() | |
| if lang == "fr": | |
| reply = (f"📅 Le {actual.day} {MONTHS['fr'][actual.month - 1]} est un " | |
| f"{actual_fr}, pas un {named_fr}. Voulez-vous dire " | |
| f"{actual_fr} {actual.day} ou {named_fr} {near.day} ?") | |
| else: | |
| named_en = DAYS_FULL["en"][wn["named_wd"]] | |
| actual_en = DAYS_FULL["en"][actual.weekday()] | |
| reply = (f"📅 {MONTHS['en'][actual.month - 1]} {actual.day} is a " | |
| f"{actual_en}, not a {named_en}. Did you mean " | |
| f"{actual_en} {actual.day} or {named_en} {near.day}?") | |
| return reply, [] | |
| # ---- pet care: "qui s'occupe du chien mercredi ?" -> recurring pet task ---- | |
| pet = detect_pet(message) | |
| child_named = any(c["name"].lower() in low for c in fam["children"]) | |
| if pet and (not child_named or _PET_CARE_RE.search(low)): | |
| icon, fr_label, en_label = pet | |
| d = parse_date(message, base) or base | |
| start, dur = parse_time(message) | |
| duty, _, _ = duty_info(d, fam) | |
| # assignee: a named parent/partner, else a relative helper, else on-duty parent | |
| people = [fam.get("parent1"), fam.get("parent2"), | |
| fam.get("partner1"), fam.get("partner2")] | |
| named = next((p for p in people if p and p.strip() | |
| and p.lower() in low), None) | |
| helper = None if named else detect_helper(message) | |
| assignee = named or (helper["name"] if helper else duty) | |
| label = (f"Garde du {fr_label}" if lang == "fr" else f"{en_label.capitalize()} care") | |
| upd = {"action": "add", "child": "", "event": label, "date": d.isoformat(), | |
| "time": start, "duration": dur, "parent_duty": assignee, | |
| "meal": None, "pickup": None, "notes": "pet", "icon": icon, | |
| "helper": helper} | |
| if lang == "fr": | |
| reply = (f"{icon} **{label}** le {fmt_date_fr(d)}" | |
| + (f" à {start}" if start else "") | |
| + f" · responsable : {assignee}. Ajouté au calendrier.") | |
| else: | |
| reply = (f"{icon} **{label}** on {d.strftime('%A %d %B')}" | |
| + (f" at {start}" if start else "") | |
| + f" · in charge: {assignee}. Added to the calendar.") | |
| return reply, [upd] | |
| # ---- event creation ---- | |
| updates = parse_event_phrases(message, state, lang) | |
| if updates: | |
| parts = [] | |
| for u in updates: | |
| d = dt.date.fromisoformat(u["date"]) | |
| duty = u.get("parent_duty") or duty_info(d, fam)[0] | |
| tm = u.get("time") or "" | |
| hp = u.get("helper") | |
| help_txt = (f" · {I18N[lang]['with_help']} {hp['name']}" if hp else "") | |
| if lang == "fr": | |
| parts.append(f"**{u['event']}** pour {u['child']} le {fmt_date_fr(d)}" | |
| + (f" à {tm}" if tm else "") | |
| + f" · parent de garde : {duty} (semaine {iso_week(d)}){help_txt}.") | |
| else: | |
| parts.append(f"**{u['event']}** for {u['child']} on {d.strftime('%A %d %B')}" | |
| + (f" at {tm}" if tm else "") | |
| + f" · parent on duty: {duty} (week {iso_week(d)}){help_txt}.") | |
| head = "✅ Ajouté.\n- " if lang == "fr" else "✅ Added.\n- " | |
| return head + "\n- ".join(parts), updates | |
| # ---- fallback ---- | |
| return not_understood(lang), [] | |
| def parse_meal_phrases(message: str, state: dict) -> list[dict]: | |
| """Detect meal statements; return calendar_update meal dicts.""" | |
| fam = state["family"] | |
| low = message.lower() | |
| if not re.search(r"mange|cantine|repas|d[eé]jeune|lunch|eat|meal", low): | |
| return [] | |
| child_names = [c["name"] for c in fam["children"]] | |
| who = None | |
| for nm in child_names: | |
| if nm.lower() in low: | |
| who = nm | |
| break | |
| targets = [who] if who else child_names | |
| base = today() | |
| found_days = [] | |
| for name, wd in WEEKDAYS_MAP.items(): | |
| if re.search(r"\b" + name + r"\b", low): | |
| delta = (wd - base.weekday()) % 7 | |
| found_days.append(base + dt.timedelta(days=delta)) | |
| if not found_days: | |
| d = parse_date(message, base) | |
| if d: | |
| found_days = [d] | |
| if not found_days: | |
| return [] | |
| if re.search(r"cantine|canteen|[eé]cole", low): | |
| code = "cantine" | |
| elif re.search(r"chez (moi|papa|maman|nous|lui|elle)|maison|home|at mine|chez l'autre", low): | |
| code = "home" | |
| p1 = (fam.get("parent1") or "").lower() | |
| p2 = (fam.get("parent2") or "").lower() | |
| m = re.search(r"chez (\w+)", low) | |
| if m and m.group(1) in (p1, p2): | |
| code = "other" | |
| else: | |
| code = "home" | |
| out = [] | |
| for d in found_days: | |
| for t in targets: | |
| out.append({"action": "meal", "child": t, "date": d.isoformat(), "meal_code": code}) | |
| return out | |
| # ---- helper / family-relation detection (FIX 4) ---- | |
| # A relative or friend mentioned as handling a task is recorded as an event | |
| # helper — no need to add them to the family setup. | |
| HELPER_RULES = [ | |
| (r"\b(tonton|oncle)\s+[a-zà-ÿ][\wà-ÿ'-]+", "oncle"), | |
| (r"\b(tata|tatie|tante)\s+[a-zà-ÿ][\wà-ÿ'-]+", "tante"), | |
| (r"\buncle\s+[a-z][\w'-]+", "uncle"), | |
| (r"\b(aunt|auntie)\s+[a-z][\w'-]+", "aunt"), | |
| (r"\b(mamie|mamy|grand-?m[eè]re|grandma|granny|grandmother)\b", "grand-mère"), | |
| (r"\b(papy|papi|grand-?p[eè]re|grandpa|grandfather)\b", "grand-père"), | |
| (r"\b(ma|my)\s+(s[œo]ur|sister)\b", "sœur"), | |
| (r"\b(mon|my)\s+(fr[eè]re|brother)\b", "frère"), | |
| (r"\b(ma|my)\s+(m[eè]re|maman|mum|mom|mother)\b", "grand-mère"), | |
| (r"\b(mon|my)\s+(p[eè]re|papa|dad|father)\b", "grand-père"), | |
| (r"\b(la|the)\s+(nounou|nourrice|baby-?sitter|nanny)\b", "nounou"), | |
| (r"\b(la)\s+voisine\b|\bthe\s+neighbou?r\b", "voisin·e"), | |
| (r"\ble\s+voisin\b", "voisin"), | |
| (r"\b(ma|mon|my)\s+(cousine|cousin)\b", "cousin·e"), | |
| (r"\b(un|une|a)\s+(ami|amie|friend|copain|copine)\b", "ami·e"), | |
| ] | |
| def detect_helper(message: str) -> dict | None: | |
| """Return {'name': <as written>, 'relation': <category>} or None.""" | |
| for pat, rel in HELPER_RULES: | |
| m = re.search(pat, message, re.IGNORECASE) | |
| if m: | |
| name = re.sub(r"\s+", " ", m.group(0)).strip() | |
| return {"name": name, "relation": rel} | |
| return None | |
| def parse_event_phrases(message: str, state: dict, lang: str = "fr") -> list[dict]: | |
| """Detect one event from a sentence; return [calendar_update].""" | |
| fam = state["family"] | |
| low = message.lower() | |
| child_names = [c["name"] for c in fam["children"]] | |
| who = None | |
| for nm in child_names: | |
| if re.search(r"\b" + re.escape(nm.lower()) + r"\b", low): | |
| who = nm | |
| break | |
| d = parse_date(message, today()) | |
| if d is None: | |
| return [] | |
| if who is None: | |
| who = child_names[0] if child_names else "?" | |
| start, dur = parse_time(message) | |
| helper = detect_helper(message) | |
| label = message | |
| if helper: | |
| label = re.sub(re.escape(helper["name"]), " ", label, flags=re.IGNORECASE) | |
| for nm in child_names: | |
| label = re.sub(re.escape(nm), "", label, flags=re.IGNORECASE) | |
| label = re.sub(r"\b(a|à|has|have|le|la|les|de|du|the|on|at|est invit[eé]e?|" | |
| r"ajoute|add|invited to|invit[eé]e? [aà])\b", " ", label, flags=re.IGNORECASE) | |
| for name in WEEKDAYS_MAP: | |
| label = re.sub(r"\b" + name + r"\b", "", label, flags=re.IGNORECASE) | |
| label = re.sub(r"\d{1,2}\s*h\s*\d{0,2}(\s*[-àa]\s*\d{1,2}\s*h\s*\d{0,2})?", "", label) | |
| label = re.sub(r"\d{1,2}:\d{2}", "", label) | |
| label = re.sub(r"\b\d{1,2}(?::\d{2})?\s*[ap]\.?\s*m\.?\b", "", label, flags=re.IGNORECASE) | |
| label = re.sub(r"\b(noon|midnight|morning|afternoon|evening|midi|minuit|matin[ée]*|" | |
| r"apr[eè]s.?midi|aprem|soir[ée]*)\b", "", label, flags=re.IGNORECASE) | |
| label = re.sub(r"week.?end|du \d+|le \d+|aujourd'hui|today|demain|tomorrow", "", label, | |
| flags=re.IGNORECASE) | |
| label = re.sub(r"\s+", " ", label).strip(" ,.-'").strip() | |
| # When a helper handles a pick-up, give the event a clean label. | |
| if re.search(r"r[eé]cup|pick", low) and (not label or helper): | |
| label = "Pick-up" if lang == "en" else "Récupération" | |
| if not label: | |
| label = "Événement" | |
| label = label[:1].upper() + label[1:] | |
| duty, _, _ = duty_info(d, fam) | |
| return [{ | |
| "action": "add", "child": who, "event": label, "date": d.isoformat(), | |
| "time": start, "duration": dur, "parent_duty": duty, "meal": None, | |
| "pickup": None, "notes": "", "icon": icon_for(message), "helper": helper, | |
| }] | |
| def fmt_date_fr(d: dt.date) -> str: | |
| return f"{DAYS_FULL['fr'][d.weekday()].lower()} {d.day} {MONTHS['fr'][d.month - 1]}" | |
| def fmt_date(d: dt.date, lang: str) -> str: | |
| name = DAYS_FULL[lang][d.weekday()] | |
| if lang == "fr": | |
| name = name.lower() | |
| return f"{name} {d.day} {MONTHS[lang][d.month - 1]}" | |
| # =========================================================================== | |
| # Phi-3 model path | |
| # =========================================================================== | |
| _model = None | |
| _tokenizer = None | |
| def _ensure_model(): | |
| global _model, _tokenizer | |
| if _model is None: | |
| import torch | |
| from transformers import AutoModelForCausalLM, AutoTokenizer | |
| _tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True) | |
| _model = AutoModelForCausalLM.from_pretrained( | |
| MODEL_ID, torch_dtype=torch.bfloat16, device_map="auto", | |
| trust_remote_code=True, | |
| ) | |
| return _model, _tokenizer | |
| def build_system_prompt(state: dict, lang: str = "fr") -> str: | |
| fam = state["family"] | |
| cal = [{k: e[k] for k in ("child", "event", "date", "time", "parent_duty")} | |
| for e in state["events"]] | |
| ui_lang = "French" if lang == "fr" else "English" | |
| return ( | |
| "You are Family Co-Pilot, a calendar assistant for shared custody. " | |
| "You help separated parents organize their children's schedules.\n\n" | |
| f"Current family: {json.dumps(fam, ensure_ascii=False)}\n" | |
| f"Current calendar: {json.dumps(cal, ensure_ascii=False)}\n" | |
| f"Current custody schedule: {fam.get('custody')}\n" | |
| f"Today's date: {today().isoformat()}\n" | |
| f"Interface language: {ui_lang}\n\n" | |
| "When the user describes an event or asks a question:\n" | |
| "1. Parse the information (who, what, when, where)\n" | |
| "2. Check against the custody schedule (which parent is on duty)\n" | |
| "3. Detect any conflicts\n" | |
| "4. Respond with a clear confirmation or suggestion\n" | |
| "5. If the calendar should change, return a JSON update block:\n" | |
| '<calendar_update>\n{"action": "add", "child": "Lucas", "event": "Football", ' | |
| '"date": "2026-06-11", "time": "17:00", "duration": 60, "parent_duty": "Papa", ' | |
| '"meal": null, "pickup": {"time": "18:00", "by": "Papa"}, "notes": ""}\n' | |
| "</calendar_update>\n" | |
| "If a relative or friend is mentioned as handling a task (e.g. \"ma sœur\", " | |
| "\"tonton Pierre\", \"la nounou\", \"the babysitter\"), add a helper field to the " | |
| 'update — "helper": {"name": "ma sœur", "relation": "sister of the parent"} — ' | |
| "instead of treating them as a parent. Two parents are fixed; helpers are " | |
| "per-event only.\n" | |
| "If the user cancels an event (annulé, supprimé, cancelled, \"plus de\"), remove " | |
| 'it: {"action": "remove", "child": "Emma", "event": "Piano", "date": "..."}.\n' | |
| "For a pet/animal task (chien, chat, dog, cat, pet…), it is NOT a child: set " | |
| '"child": "", use an event like "Dog care" with a pet emoji icon, and assign ' | |
| "parent_duty to the on-duty parent or the named person.\n" | |
| "Only claim you added/updated something when you actually return a valid " | |
| "calendar_update with a real date.\n\n" | |
| f"IMPORTANT: the interface language is {ui_lang}. Reply ONLY in {ui_lang}, " | |
| "regardless of the language the user writes in. " | |
| "Be warm, practical, never judgmental about the family situation." | |
| ) | |
| def _model_generate(messages: list[dict]) -> str: | |
| model, tok = _ensure_model() | |
| inputs = tok.apply_chat_template( | |
| messages, add_generation_prompt=True, return_tensors="pt").to(model.device) | |
| out = model.generate( | |
| inputs, max_new_tokens=512, do_sample=False, | |
| pad_token_id=tok.eos_token_id) | |
| return tok.decode(out[0][inputs.shape[1]:], skip_special_tokens=True).strip() | |
| def parse_calendar_updates(raw: str, state: dict) -> tuple[str, list[dict]]: | |
| """Split model output into (visible_text, [update dicts]).""" | |
| updates = [] | |
| for block in re.findall(r"<calendar_update>(.*?)</calendar_update>", raw, re.DOTALL): | |
| try: | |
| data = json.loads(block.strip()) | |
| if isinstance(data, dict): | |
| updates.append(data) | |
| elif isinstance(data, list): | |
| updates.extend(d for d in data if isinstance(d, dict)) | |
| except json.JSONDecodeError: | |
| continue | |
| text = re.sub(r"<calendar_update>.*?</calendar_update>", "", raw, flags=re.DOTALL).strip() | |
| return text or "✅ Done.", updates | |
| def ai_respond(message: str, state: dict, lang: str = "fr") -> tuple[str, list[dict]]: | |
| """Top-level dispatch: real model on the Space, rule-based fallback locally. | |
| `lang` is the UI toggle value and fixes the response language.""" | |
| if USE_MODEL: | |
| try: | |
| messages = [{"role": "system", "content": build_system_prompt(state, lang)}] | |
| for turn in state.get("chat", [])[-6:]: | |
| messages.append(turn) | |
| messages.append({"role": "user", "content": message}) | |
| raw = _model_generate(messages) | |
| return parse_calendar_updates(raw, state) | |
| except Exception as e: # degrade gracefully to the rule-based path | |
| print(f"[model error] {e}") | |
| return fallback_respond(message, state, lang) | |
| # =========================================================================== | |
| # Apply updates to state | |
| # =========================================================================== | |
| def apply_update(state: dict, u: dict) -> bool: | |
| """Apply one calendar update. Returns True iff the state actually changed — | |
| on_send relies on this to keep the confirmation text and the calendar in | |
| sync (FIX 1).""" | |
| action = (u.get("action") or "add").lower() | |
| fam = state["family"] | |
| if action == "meal": | |
| d = u.get("date") | |
| child = u.get("child") | |
| code = u.get("meal_code") or u.get("meal") | |
| if d and child and code: | |
| state["meals"].setdefault(d, {})[child] = code | |
| return True | |
| return False | |
| if action in ("remove", "delete", "cancel"): | |
| ev, child, date = u.get("event"), u.get("child"), u.get("date") | |
| def _matches(e): | |
| if child and e["child"] != child: | |
| return False | |
| if date and e["date"] != date: # date optional | |
| return False | |
| if ev and ev.lower() not in e["event"].lower(): | |
| return False | |
| return bool(child or date or ev) # never wipe everything blindly | |
| before = len(state["events"]) | |
| state["events"] = [e for e in state["events"] if not _matches(e)] | |
| return len(state["events"]) < before | |
| if action == "modify": | |
| for e in state["events"]: | |
| if e["child"] == u.get("child") and e["date"] == u.get("date"): | |
| for k in ("event", "time", "duration", "parent_duty", "notes", "pickup"): | |
| if u.get(k) is not None: | |
| e[k] = u[k] | |
| e["icon"] = icon_for(e["event"]) | |
| return True | |
| action = "add" | |
| if action == "add": | |
| d = u.get("date") | |
| if not d: | |
| return False | |
| try: | |
| dd = dt.date.fromisoformat(d) | |
| except (ValueError, TypeError): | |
| return False | |
| child = u.get("child") | |
| child = "?" if child is None else child # "" allowed (pet tasks) | |
| if not str(u.get("event") or "").strip() and not child: | |
| return False # nothing meaningful to add | |
| if is_duplicate_event(state, {**u, "child": child}): | |
| return False # FIX 2 — never add a duplicate | |
| e = { | |
| "id": state["next_id"], | |
| "child": child, | |
| "event": u.get("event") or "Événement", | |
| "date": d, | |
| "time": u.get("time"), | |
| "duration": u.get("duration") or 60, | |
| "parent_duty": u.get("parent_duty") or duty_info(dd, fam)[0], | |
| "meal": u.get("meal"), | |
| "pickup": u.get("pickup"), | |
| "notes": u.get("notes") or "", | |
| "icon": u.get("icon") or icon_for(u.get("event") or ""), | |
| "helper": u.get("helper"), | |
| } | |
| state["next_id"] += 1 | |
| state["events"].append(e) | |
| if u.get("meal") in ("cantine", "home", "other") and child: | |
| state["meals"].setdefault(d, {})[child] = u["meal"] | |
| return True | |
| return False | |
| # =========================================================================== | |
| # Conflict detection (localized) | |
| # =========================================================================== | |
| def time_to_min(t: str | None) -> int | None: | |
| if not t: | |
| return None | |
| m = re.match(r"(\d{1,2}):(\d{2})", t) | |
| return int(m.group(1)) * 60 + int(m.group(2)) if m else None | |
| def detect_conflicts(state: dict, lang: str = "fr") -> list[dict]: | |
| """Return list of {text, suggestion} conflict alerts, localized.""" | |
| fam = state["family"] | |
| helper = fam.get("partner2") or fam.get("partner1") or "" | |
| out = [] | |
| evs = state["events"] | |
| AND = " et " if lang == "fr" else " and " | |
| # 1) same child double-booked (overlapping times same day) | |
| by_child_day: dict = {} | |
| for e in evs: | |
| by_child_day.setdefault((e["child"], e["date"]), []).append(e) | |
| for (child, date), group in by_child_day.items(): | |
| timed = [g for g in group if time_to_min(g["time"]) is not None] | |
| timed.sort(key=lambda g: time_to_min(g["time"])) | |
| for a, b in zip(timed, timed[1:]): | |
| a_end = time_to_min(a["time"]) + (a.get("duration") or 60) | |
| if a_end > time_to_min(b["time"]): | |
| d = dt.date.fromisoformat(date) | |
| if lang == "fr": | |
| out.append({ | |
| "text": f"<b>{child}</b> est double-booké {fmt_date(d, lang)} : " | |
| f"« {a['event']} » et « {b['event']} » se chevauchent.", | |
| "suggestion": "Décaler l'un des deux" | |
| + (f", ou demander à {helper} d'accompagner." if helper else "."), | |
| }) | |
| else: | |
| out.append({ | |
| "text": f"<b>{child}</b> is double-booked {fmt_date(d, lang)}: " | |
| f"\"{a['event']}\" and \"{b['event']}\" overlap.", | |
| "suggestion": "Move one of them" | |
| + (f", or ask {helper} to take one." if helper else "."), | |
| }) | |
| # 2) two children, different events, same date+time -> both parents needed | |
| by_day_time: dict = {} | |
| for e in evs: | |
| if e["time"]: | |
| by_day_time.setdefault((e["date"], e["time"]), []).append(e) | |
| for (date, time), group in by_day_time.items(): | |
| kids = sorted({g["child"] for g in group}) | |
| if len(kids) >= 2: | |
| d = dt.date.fromisoformat(date) | |
| duty, _, _ = duty_info(d, fam) | |
| names = AND.join(kids) | |
| if lang == "fr": | |
| out.append({ | |
| "text": f"{names} ont chacun un événement {fmt_date(d, lang)} à {time} — " | |
| f"un seul parent de garde ({duty}).", | |
| "suggestion": (f"{helper} (partenaire) pourrait accompagner l'un d'eux." | |
| if helper else "Coordonner avec l'autre parent."), | |
| }) | |
| else: | |
| out.append({ | |
| "text": f"{names} each have an event {fmt_date(d, lang)} at {time} — " | |
| f"only one parent on duty ({duty}).", | |
| "suggestion": (f"{helper} (partner) could take one of them." | |
| if helper else "Coordinate with the other parent."), | |
| }) | |
| # 3) pickup by someone not on duty | |
| for e in evs: | |
| pk = e.get("pickup") | |
| if isinstance(pk, dict) and pk.get("by"): | |
| d = dt.date.fromisoformat(e["date"]) | |
| duty, _, _ = duty_info(d, fam) | |
| by = pk["by"] | |
| if by not in (duty, helper) and by not in (fam.get("partner1"), fam.get("partner2")): | |
| if lang == "fr": | |
| out.append({ | |
| "text": f"Récupération de {e['child']} (« {e['event']} ») prévue par {by}, " | |
| f"mais {duty} est de garde {fmt_date(d, lang)}.", | |
| "suggestion": "Confirmer qui récupère l'enfant.", | |
| }) | |
| else: | |
| out.append({ | |
| "text": f"{e['child']}'s pick-up (\"{e['event']}\") is set for {by}, " | |
| f"but {duty} is on duty {fmt_date(d, lang)}.", | |
| "suggestion": "Confirm who collects the child.", | |
| }) | |
| return out | |
| # =========================================================================== | |
| # Renderers | |
| # =========================================================================== | |
| def md_to_html(text: str) -> str: | |
| t = html.escape(text or "") | |
| t = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", t) | |
| return t.replace("\n", "<br>") | |
| def events_on(state: dict, d: dt.date) -> list[dict]: | |
| iso = d.isoformat() | |
| evs = [e for e in state["events"] if e["date"] == iso] | |
| return sorted(evs, key=lambda e: time_to_min(e["time"]) if e["time"] else 9999) | |
| def fmt_time_short(t: str | None) -> str: | |
| if not t: | |
| return "" | |
| h, m = t.split(":") | |
| return f"{int(h)}h" + (m if m != "00" else "") | |
| # ---- header / brand ---- # | |
| def render_header(lang: str) -> str: | |
| t = I18N[lang] | |
| # Inline gradient styles so nothing in Gradio's wrapper/.prose can override | |
| # them — guarantees "Co-Pilot" always renders (gradient, pink fallback). | |
| grad = ("background:linear-gradient(90deg,#3B82F6,#EC4899);" | |
| "-webkit-background-clip:text;background-clip:text;" | |
| "-webkit-text-fill-color:transparent;color:#EC4899;font-weight:800;") | |
| return ("<div class='brand'><div class='logo'>👨👩👧👦</div><div>" | |
| "<h1 style='margin:0;font-size:25px;font-weight:800;white-space:nowrap;" | |
| "letter-spacing:.2px;color:#f8f8fb'>Family " | |
| f"<span style=\"{grad}\">Co-Pilot</span></h1>" | |
| f"<p>{t['subtitle']}</p></div></div>") | |
| def badge_html() -> str: | |
| txt = "🟢 Phi-3 live" if USE_MODEL else "🌐 Local demo mode" | |
| return f"<span class='pill'>{txt}</span>" | |
| # ---- chat ---- # | |
| def render_chat(state: dict) -> str: | |
| rows = [] | |
| for m in reversed(state.get("messages", [])): | |
| role = m.get("role") | |
| if role == "note": | |
| cls = "system" | |
| elif role == "assistant": | |
| cls = "ai confirm" if m.get("confirm") else "ai" | |
| else: | |
| cls = "user" | |
| rows.append(f"<div class='bubble {cls}'>{md_to_html(m.get('content', ''))}</div>") | |
| return "<div class='chat'>" + "".join(rows) + "</div>" | |
| # ---- left-panel child header + collapsed summary ---- # | |
| def render_child_hdr(i: int, state: dict, lang: str) -> str: | |
| t = I18N[lang] | |
| kids = state["family"]["children"] | |
| name = kids[i]["name"] if i < len(kids) else "" | |
| initial = (name[:1] or "?").upper() | |
| color = "blue" if i % 2 == 0 else "pink" | |
| return (f"<div class='childtop'><span class='tag'>{t['child']} {i + 1}</span>" | |
| f"<span class='avatar {color}'>{html.escape(initial)}</span></div>") | |
| def render_collapsed(state: dict, lang: str) -> str: | |
| fam = state["family"] | |
| kids = " + ".join(f"{html.escape(c['name'])} ({c['age']})" for c in fam["children"]) | |
| cust = CUSTODY_LABEL[lang].get(fam["custody"], fam["custody"]) | |
| extra = [] | |
| if fam.get("partner1"): | |
| extra.append(f"+{html.escape(fam['partner1'])}") | |
| if fam.get("partner2"): | |
| extra.append(f"+{html.escape(fam['partner2'])}") | |
| partners = (" · " + " ".join(extra)) if extra else "" | |
| return (f"<div class='collapsed'>👨👩👧👦 {kids} · " | |
| f"{html.escape(fam['parent1'])} ↔ {html.escape(fam['parent2'])}" | |
| f"{partners} · {html.escape(cust)}</div>") | |
| def partner_label_html(lang: str, parent_name: str) -> str: | |
| nm = (parent_name or "").strip() or "Parent" | |
| return f"<p class='partnerlbl'>👤 {I18N[lang]['partner_of']} {html.escape(nm)}</p>" | |
| # ---- calendar ---- # | |
| def day_subtitle(d: dt.date, evs: list, lang: str) -> str: | |
| t = I18N[lang] | |
| wd = d.weekday() | |
| if wd >= 5: | |
| return t["weekend"] | |
| if wd == 2: | |
| return t["half_day"] | |
| if evs: | |
| return t["activities"] | |
| return t["school_day"] | |
| def render_vday(state: dict, d: dt.date, lang: str) -> str: | |
| fam = state["family"] | |
| duty, idx, color = duty_info(d, fam) # color = on-duty parent's color | |
| is_today = " today" if d == today() else "" | |
| evs = events_on(state, d) | |
| sub = day_subtitle(d, evs, lang) | |
| ev_bg, ev_bd = hex_to_rgba(color, 0.13), hex_to_rgba(color, 0.42) | |
| body = [] | |
| for e in evs: | |
| tm = fmt_time_short(e["time"]) | |
| hp = e.get("helper") | |
| helper_html = (f"<br><span class='helper'>👤 {html.escape(hp['name'])}</span>" | |
| if hp and hp.get("name") else "") | |
| who = (f"<span class='who'>{html.escape(e['child'])}</span> " | |
| if e.get("child") else "") | |
| body.append( | |
| f"<div class='event' style='border:1px solid {ev_bd};background:{ev_bg};" | |
| f"color:#eef1fb'>{e.get('icon') or '📌'} " | |
| f"{who}{html.escape(e['event'])}" | |
| + (f" <span class='tm'>· {tm}</span>" if tm else "") | |
| + helper_html + "</div>") | |
| body_html = f"<div class='vbody'>{''.join(body)}</div>" if body else "" | |
| meal_bits = [] | |
| for c in fam["children"]: | |
| code = meal_for(c, d, state) | |
| if code: | |
| meal_bits.append(f"<span>{MEAL_ICON[code]} {html.escape(c['name'])}</span>") | |
| meals_html = f"<div class='vmeals'>{''.join(meal_bits)}</div>" if meal_bits else "" | |
| return ( | |
| f"<div class='vday{is_today}' style='box-shadow:inset 0 4px 0 {color}'>" | |
| "<div class='vhead'>" | |
| f"<div><b>{DAYS[lang][d.weekday()]} {d.day}</b><span class='sub'>{sub}</span></div>" | |
| f"<span class='vparent' style='color:{color}'>" | |
| f"<span class='d' style='background:{color}'></span>{html.escape(duty)}</span></div>" | |
| f"{body_html}{meals_html}</div>" | |
| ) | |
| def render_cal_title(state: dict, lang: str) -> str: | |
| t = I18N[lang] | |
| mon = monday_of(today(), state.get("week_offset", 0)) | |
| wk = iso_week(mon) | |
| sub = f"{mon.strftime('%d/%m')} – {(mon + dt.timedelta(days=6)).strftime('%d/%m/%Y')}" | |
| if state.get("week_offset", 0) == 0: | |
| sub += f" · {t['this_week']}" | |
| return f"<div class='wk-title'><b>{t['week']} {wk}</b><div class='sub'>{sub}</div></div>" | |
| def render_calendar_days(state: dict, lang: str) -> str: | |
| fam = state["family"] | |
| mon = monday_of(today(), state.get("week_offset", 0)) | |
| days = "".join(render_vday(state, mon + dt.timedelta(days=i), lang) for i in range(7)) | |
| t = I18N[lang] | |
| c1 = color_hex(fam.get("color1", "blue"), P1) | |
| c2 = color_hex(fam.get("color2", "pink"), P2) | |
| items = [ | |
| f"<span><i class='dot' style='background:{c1}'></i>{html.escape(fam['parent1'] or 'Parent 1')}</span>", | |
| f"<span><i class='dot' style='background:{c2}'></i>{html.escape(fam['parent2'] or 'Parent 2')}</span>", | |
| ] | |
| if fam.get("partner1"): | |
| pc = color_hex(fam.get("pcolor1", "green"), EMERALD) | |
| items.append(f"<span><i class='dot' style='background:{pc}'></i>{html.escape(fam['partner1'])}</span>") | |
| if fam.get("partner2"): | |
| pc = color_hex(fam.get("pcolor2", "green"), EMERALD) | |
| items.append(f"<span><i class='dot' style='background:{pc}'></i>{html.escape(fam['partner2'])}</span>") | |
| items.append(f"<span><i class='dot' style='background:{AMBER}'></i>{t['leg_conflict']}</span>") | |
| legend = "<div class='legend'>" + "".join(items) + "</div>" | |
| return days + legend | |
| def render_metrics(state: dict, lang: str) -> str: | |
| t = I18N[lang] | |
| mon = monday_of(today(), state.get("week_offset", 0)) | |
| week_dates = {(mon + dt.timedelta(days=i)).isoformat() for i in range(7)} | |
| n_events = sum(1 for e in state["events"] if e["date"] in week_dates) | |
| n_conf = len(detect_conflicts(state, lang)) | |
| n_meals = sum(len(state.get("meals", {}).get(iso, {})) for iso in week_dates) | |
| conf_color = AMBER if n_conf else "#f8f8fb" | |
| return ( | |
| "<div class='bottom'>" | |
| f"<div class='metric'><b>{n_events}</b><small>{t['events']}</small></div>" | |
| f"<div class='metric'><b style='color:{conf_color}'>{n_conf}</b>" | |
| f"<small>{t['conflict_m']}</small></div>" | |
| f"<div class='metric'><b style='color:{EMERALD}'>{n_meals}</b>" | |
| f"<small>{t['meals_m']}</small></div>" | |
| "</div>" | |
| ) | |
| def render_conflicts(state: dict, lang: str) -> str: | |
| t = I18N[lang] | |
| conflicts = detect_conflicts(state, lang) | |
| if not conflicts: | |
| return f"<div class='okcard'>✓ {t['no_conflict']}</div>" | |
| blocks = [] | |
| for c in conflicts: | |
| blocks.append( | |
| f"<div class='conflict'><b>⚠ {t['conflict_title']}</b>" | |
| f"<div class='suggestion'>{c['text']}<br>💡 {c['suggestion']}</div></div>") | |
| return "".join(blocks) | |
| def render_summary(state: dict, lang: str) -> str: | |
| t = I18N[lang] | |
| fam = state["family"] | |
| mon = monday_of(today(), state.get("week_offset", 0)) | |
| wk = iso_week(mon) | |
| duty_mon, _, _ = duty_info(mon, fam) | |
| week_dates = {(mon + dt.timedelta(days=i)).isoformat() for i in range(7)} | |
| lines = [] | |
| for c in fam["children"]: | |
| evs = [e for e in state["events"] | |
| if e["child"] == c["name"] and e["date"] in week_dates] | |
| evs.sort(key=lambda e: e["date"]) | |
| if evs: | |
| bits = [] | |
| for e in evs: | |
| d = dt.date.fromisoformat(e["date"]) | |
| tm = fmt_time_short(e["time"]) | |
| bits.append(f"{e['event']} {DAYS[lang][d.weekday()].lower()}" + (f" {tm}" if tm else "")) | |
| lines.append(f"<b>{html.escape(c['name'])}</b> : " + ", ".join(html.escape(b) for b in bits)) | |
| else: | |
| lines.append(f"<b>{html.escape(c['name'])}</b> : {t['nothing']}") | |
| meal_lines = [] | |
| for c in fam["children"]: | |
| counts = {"cantine": 0, "home": 0, "other": 0} | |
| for i in range(7): | |
| d = mon + dt.timedelta(days=i) | |
| code = meal_for(c, d, state) | |
| if code: | |
| counts[code] += 1 | |
| meal_lines.append( | |
| f"<b>{html.escape(c['name'])}</b> : {counts['cantine']}× 🏫, " | |
| f"{counts['home']}× 🏠" + (f", {counts['other']}× 👨👩" if counts['other'] else "")) | |
| head = (f"{t['week']} {wk} · {t['week_of']} <b>{html.escape(duty_mon)}</b>" | |
| if lang == "en" else | |
| f"{t['week']} {wk} · {t['week_of']} <b>{html.escape(duty_mon)}</b>") | |
| return ( | |
| "<div class='summary'>" | |
| f"<h3>{t['summary_title']}</h3>" | |
| f"<div class='sub'>{head}<br>{'<br>'.join(lines)}" | |
| f"<div class='mealhdr'>{t['meals_lbl']}</div>{'<br>'.join(meal_lines)}</div>" | |
| "</div>" | |
| ) | |
| # =========================================================================== | |
| # Display bundle + event handlers | |
| # =========================================================================== | |
| def display_bundle(state: dict) -> tuple: | |
| lang = lang_of(state) | |
| return ( | |
| render_chat(state), | |
| render_conflicts(state, lang), | |
| render_summary(state, lang), | |
| render_cal_title(state, lang), | |
| render_calendar_days(state, lang), | |
| render_metrics(state, lang), | |
| ) | |
| def on_save(count, *vals): | |
| """vals = n1,a1,l1 ... n4,a4,l4, p1,p2,partner1,partner2, | |
| c1,c2,pc1,pc2, custody, custom, state""" | |
| (*child_fields, p1, p2, partner1, partner2, | |
| c1, c2, pc1, pc2, custody, custom, state) = vals | |
| state = state or default_state() | |
| lang = lang_of(state) | |
| n = int(count) | |
| children = [] | |
| for i in range(4): | |
| name = (child_fields[i * 3] or "").strip() | |
| age = child_fields[i * 3 + 1] | |
| level = child_fields[i * 3 + 2] | |
| if i < n and name: | |
| children.append({"name": name, "age": int(age or 0), "level": level}) | |
| if not children: | |
| children = [{"name": "Enfant", "age": 6, "level": LEVELS[2]}] | |
| state["family"] = { | |
| "children": children, | |
| "parent1": (p1 or "Parent 1").strip(), | |
| "parent2": (p2 or "Parent 2").strip(), | |
| "partner1": (partner1 or "").strip(), | |
| "partner2": (partner2 or "").strip(), | |
| "color1": c1 if c1 in COLORS else "blue", | |
| "color2": c2 if c2 in COLORS else "pink", | |
| "pcolor1": pc1 if pc1 in COLORS else "green", | |
| "pcolor2": pc2 if pc2 in COLORS else "green", | |
| "custody": custody, | |
| "custody_custom": (custom or "").strip(), | |
| } | |
| return (state, gr.update(visible=False), gr.update(visible=True), | |
| render_collapsed(state, lang), *display_bundle(state)) | |
| def on_edit(): | |
| return gr.update(visible=True), gr.update(visible=False) | |
| # ---- partner add / remove (FIX 3) ---- # | |
| def on_add_partner(parent_name, state): | |
| lang = lang_of(state or {}) | |
| return (gr.update(visible=True), gr.update(visible=False), | |
| partner_label_html(lang, parent_name)) | |
| def on_remove_partner(): | |
| # hide the box, show the add button again, and clear the field value | |
| return gr.update(visible=False), gr.update(visible=True), "" | |
| def on_send(message, lang_choice, state): | |
| message = (message or "").strip() | |
| state = state or default_state() | |
| # The UI toggle ("FR"/"EN") is authoritative for the response language and is | |
| # synced into state so the model/system-prompt and all replies match it. | |
| lang = "en" if str(lang_choice).upper() == "EN" else "fr" | |
| state["lang"] = lang | |
| if not message: | |
| return state, "", *display_bundle(state) | |
| # Moderation: insult/aggression never enters the chat or the model. | |
| # We add ONLY a neutral system note; the user's text is discarded entirely. | |
| if is_blocked(message): | |
| msgs = state.setdefault("messages", []) | |
| msgs.append({"role": "note", "content": I18N[lang]["moderation"]}) | |
| state["messages"] = msgs[-40:] | |
| return state, "", *display_bundle(state) | |
| reply, updates = ai_respond(message, state, lang) | |
| # Apply every update IN THIS callback and verify it landed. The confirmation | |
| # must reflect the real calendar state, never just the intent. We also skip | |
| # duplicates and report them distinctly. | |
| changed = had_dup = False | |
| for u in updates: | |
| act = (u.get("action") or "add").lower() | |
| if act in ("add", "modify") and is_duplicate_event(state, u): | |
| had_dup = True | |
| continue | |
| if apply_update(state, u): | |
| changed = True | |
| if not changed: | |
| if had_dup: # all adds were duplicates | |
| reply = already_exists(lang) | |
| elif _CONFIRM_RE.search(reply or ""): # phantom "Ajouté" — veto it | |
| reply = not_understood(lang) | |
| msgs = state.setdefault("messages", []) | |
| msgs.append({"role": "user", "content": message}) | |
| msgs.append({"role": "assistant", "content": reply, "confirm": changed}) | |
| state["messages"] = msgs[-40:] | |
| # Model context: only real user/assistant turns (never notes). | |
| state["chat"] = [{"role": m["role"], "content": m["content"]} | |
| for m in state["messages"] | |
| if m.get("role") in ("user", "assistant")][-12:] | |
| return state, "", *display_bundle(state) | |
| def on_week_nav(delta, state): | |
| state = state or default_state() | |
| state["week_offset"] = state.get("week_offset", 0) + delta | |
| return state, *display_bundle(state) | |
| def on_today(state): | |
| state = state or default_state() | |
| state["week_offset"] = 0 | |
| return state, *display_bundle(state) | |
| def on_count_change(count): | |
| n = int(count) | |
| return [gr.update(visible=(i < n)) for i in range(4)] | |
| def on_chip(i, state): | |
| state = state or default_state() | |
| return CHIPS[lang_of(state)][i][1] | |
| def on_lang(choice, state): | |
| state = state or default_state() | |
| lang = "fr" if choice == "FR" else "en" | |
| state["lang"] = lang | |
| t = I18N[lang] | |
| chips = CHIPS[lang] | |
| fam = state["family"] | |
| return ( | |
| state, | |
| render_header(lang), # header_html | |
| f"<label class='fldlabel'>{t['num_children']}</label>", # count_label | |
| f"<p class='label'>{t['family']}</p>", # t_family | |
| f"<p class='label subttl'>{t['parents']}</p>", # t_parents | |
| f"<p class='label subttl'>{t['partners']}</p>", # t_partners | |
| gr.update(value=t["add_p1"]), # add_p1_btn | |
| gr.update(value=t["add_p2"]), # add_p2_btn | |
| partner_label_html(lang, fam["parent1"]), # p1_partner_label | |
| partner_label_html(lang, fam["parent2"]), # p2_partner_label | |
| f"<p class='label subttl'>{t['custody']}</p>", # t_custody | |
| f"<p class='label'>{t['chat']}</p>", # t_chat | |
| f"<p class='label'>{t['calendar']}</p>", # t_calendar | |
| render_child_hdr(0, state, lang), | |
| render_child_hdr(1, state, lang), | |
| render_child_hdr(2, state, lang), | |
| render_child_hdr(3, state, lang), | |
| gr.update(value=t["save"]), # save_btn | |
| gr.update(value=t["edit"]), # edit_btn | |
| gr.update(value=t["send"]), # send_btn | |
| gr.update(value=t["prev"]), # prev_btn | |
| gr.update(value=t["today"]), # today_btn | |
| gr.update(value=t["next"]), # next_btn | |
| gr.update(value=chips[0][0]), | |
| gr.update(value=chips[1][0]), | |
| gr.update(value=chips[2][0]), | |
| gr.update(value=chips[3][0]), | |
| render_collapsed(state, lang), # collapsed_html | |
| *display_bundle(state), | |
| ) | |
| # =========================================================================== | |
| # App | |
| # =========================================================================== | |
| def build_app(): | |
| init = default_state() | |
| fam0 = init["family"] | |
| L = "fr" | |
| t0 = I18N[L] | |
| with gr.Blocks(css=CSS, title="Family Co-Pilot", theme=gr.themes.Base()) as demo: | |
| state = gr.State(init) | |
| # ----------------------------- HEADER ----------------------------- # | |
| with gr.Row(elem_id="fc-header"): | |
| with gr.Column(scale=1, min_width=320): | |
| header_html = gr.HTML(render_header(L)) | |
| with gr.Column(scale=0, min_width=150): | |
| badge = gr.HTML(badge_html()) | |
| with gr.Column(scale=0, min_width=104): | |
| lang_toggle = gr.Radio(["FR", "EN"], value="FR", show_label=False, | |
| container=False, elem_id="lang-toggle") | |
| with gr.Column(scale=0, min_width=52): | |
| guide_btn = gr.Button("?", elem_id="guide-btn") | |
| with gr.Row(equal_height=False): | |
| # ===================== LEFT — Family (340px) ===================== # | |
| with gr.Column(scale=28, min_width=320, elem_id="left-panel"): | |
| t_family = gr.HTML(f"<p class='label'>{t0['family']}</p>") | |
| with gr.Column(visible=True) as setup_form: | |
| count_label = gr.HTML(f"<label class='fldlabel'>{t0['num_children']}</label>") | |
| count = gr.Dropdown(["1", "2", "3", "4"], value=str(len(fam0["children"])), | |
| show_label=False, container=False, | |
| elem_classes=["ddl"]) | |
| child_hdrs, child_groups = [], [] | |
| child_names, child_ages, child_levels = [], [], [] | |
| defaults = fam0["children"] + [ | |
| {"name": "", "age": 6, "level": LEVELS[2]}] * 4 | |
| for i in range(4): | |
| with gr.Group(visible=(i < len(fam0["children"])), | |
| elem_classes=["child"]) as grp: | |
| hdr = gr.HTML(render_child_hdr(i, init, L)) | |
| nm = gr.Textbox(value=defaults[i]["name"], show_label=False, | |
| container=False, placeholder="Prénom / Name") | |
| with gr.Row(): | |
| ag = gr.Number(value=defaults[i]["age"], show_label=False, | |
| container=False, minimum=0, maximum=18, | |
| precision=0, scale=1) | |
| lv = gr.Dropdown(LEVELS, value=defaults[i]["level"], | |
| show_label=False, container=False, scale=2, | |
| elem_classes=["ddl"]) | |
| child_groups.append(grp) | |
| child_hdrs.append(hdr) | |
| child_names.append(nm) | |
| child_ages.append(ag) | |
| child_levels.append(lv) | |
| t_parents = gr.HTML(f"<p class='label subttl'>{t0['parents']}</p>") | |
| with gr.Row(): | |
| with gr.Column(): | |
| gr.HTML("<p class='fldmini'>Parent 1</p>") | |
| parent1 = gr.Textbox(value=fam0["parent1"], show_label=False, | |
| container=False, placeholder="Maman") | |
| p1_color = gr.Radio(COLOR_KEYS, value=fam0["color1"], | |
| show_label=False, container=False, | |
| elem_classes=["colorpick"]) | |
| with gr.Column(): | |
| gr.HTML("<p class='fldmini'>Parent 2</p>") | |
| parent2 = gr.Textbox(value=fam0["parent2"], show_label=False, | |
| container=False, placeholder="Papa") | |
| p2_color = gr.Radio(COLOR_KEYS, value=fam0["color2"], | |
| show_label=False, container=False, | |
| elem_classes=["colorpick"]) | |
| t_partners = gr.HTML(f"<p class='label subttl'>{t0['partners']}</p>") | |
| has_p1 = bool(fam0.get("partner1")) | |
| has_p2 = bool(fam0.get("partner2")) | |
| add_p1_btn = gr.Button(t0["add_p1"], elem_classes=["addpartner"], | |
| visible=not has_p1) | |
| with gr.Group(elem_classes=["partnerbox"], visible=has_p1) as partner1_group: | |
| p1_partner_label = gr.HTML(partner_label_html(L, fam0["parent1"])) | |
| with gr.Row(): | |
| partner1_inp = gr.Textbox(value=fam0.get("partner1", ""), | |
| show_label=False, container=False, | |
| placeholder="Marc", scale=5) | |
| rm_p1_btn = gr.Button("✕", elem_classes=["rmpartner"], | |
| scale=0, min_width=44) | |
| pc1_color = gr.Radio(COLOR_KEYS, value=fam0.get("pcolor1", "green"), | |
| show_label=False, container=False, | |
| elem_classes=["colorpick"]) | |
| add_p2_btn = gr.Button(t0["add_p2"], elem_classes=["addpartner"], | |
| visible=not has_p2) | |
| with gr.Group(elem_classes=["partnerbox"], visible=has_p2) as partner2_group: | |
| p2_partner_label = gr.HTML(partner_label_html(L, fam0["parent2"])) | |
| with gr.Row(): | |
| partner2_inp = gr.Textbox(value=fam0.get("partner2", ""), | |
| show_label=False, container=False, | |
| placeholder="Sophie", scale=5) | |
| rm_p2_btn = gr.Button("✕", elem_classes=["rmpartner"], | |
| scale=0, min_width=44) | |
| pc2_color = gr.Radio(COLOR_KEYS, value=fam0.get("pcolor2", "green"), | |
| show_label=False, container=False, | |
| elem_classes=["colorpick"]) | |
| t_custody = gr.HTML(f"<p class='label subttl'>{t0['custody']}</p>") | |
| custody = gr.Dropdown(CUSTODY_PATTERNS, value=fam0["custody"], | |
| show_label=False, container=False, | |
| elem_classes=["ddl"]) | |
| custody_custom = gr.Textbox( | |
| visible=False, show_label=False, container=False, lines=2, | |
| placeholder="Décrivez votre garde… / Describe your custody…") | |
| save_btn = gr.Button(t0["save"], elem_id="save-btn") | |
| with gr.Column(visible=False) as setup_collapsed: | |
| collapsed_html = gr.HTML(render_collapsed(init, L)) | |
| edit_btn = gr.Button(t0["edit"], elem_id="edit-btn") | |
| # ===================== CENTER — Chat (1fr) ===================== # | |
| with gr.Column(scale=46, min_width=440, elem_id="center-panel"): | |
| t_chat = gr.HTML(f"<p class='label'>{t0['chat']}</p>") | |
| chat_html = gr.HTML(render_chat(init)) | |
| with gr.Row(elem_id="composer"): | |
| msg = gr.Textbox(show_label=False, container=False, scale=8, lines=1, | |
| placeholder="Lucas a football mercredi 17h…", | |
| autofocus=True) | |
| send_btn = gr.Button(t0["send"], elem_id="send-btn", scale=1, min_width=92) | |
| with gr.Row(): | |
| ex1 = gr.Button(CHIPS[L][0][0], elem_classes=["chip"]) | |
| ex2 = gr.Button(CHIPS[L][1][0], elem_classes=["chip"]) | |
| with gr.Row(): | |
| ex3 = gr.Button(CHIPS[L][2][0], elem_classes=["chip"]) | |
| ex4 = gr.Button(CHIPS[L][3][0], elem_classes=["chip"]) | |
| conflicts_html = gr.HTML(render_conflicts(init, L)) | |
| summary_html = gr.HTML(render_summary(init, L)) | |
| # ===================== RIGHT — Calendar (470px) ===================== # | |
| with gr.Column(scale=40, min_width=420, elem_id="right-panel"): | |
| t_calendar = gr.HTML(f"<p class='label'>{t0['calendar']}</p>") | |
| with gr.Row(elem_id="weekbar"): | |
| with gr.Column(scale=1, min_width=140): | |
| cal_title = gr.HTML(render_cal_title(init, L)) | |
| prev_btn = gr.Button(t0["prev"], elem_classes=["navbtn"], scale=0, min_width=78) | |
| today_btn = gr.Button(t0["today"], elem_classes=["navbtn"], scale=0, min_width=92) | |
| next_btn = gr.Button(t0["next"], elem_classes=["navbtn"], scale=0, min_width=78) | |
| calendar_days = gr.HTML(render_calendar_days(init, L)) | |
| metrics_html = gr.HTML(render_metrics(init, L)) | |
| gr.HTML( | |
| "<div id='fc-footer'>Built by <b>Cognitive Engineering</b> 🇨🇭 — " | |
| "a parent who understands the chaos · appliedai.ch</div>") | |
| # ----------------------------- MODAL ----------------------------- # | |
| with gr.Column(visible=False, elem_id="guide-modal") as guide_modal: | |
| with gr.Column(elem_id="guide-card"): | |
| guide_close = gr.Button("✕", elem_classes=["modal-x"]) | |
| gr.HTML(guide_html()) | |
| # ----------------------------- WIRING ----------------------------- # | |
| DISPLAY = [chat_html, conflicts_html, summary_html, cal_title, | |
| calendar_days, metrics_html] | |
| count.change(on_count_change, inputs=[count], outputs=child_groups) | |
| custody.change(lambda c: gr.update(visible=(c == "Custom")), | |
| inputs=[custody], outputs=[custody_custom]) | |
| save_inputs = [] | |
| for i in range(4): | |
| save_inputs += [child_names[i], child_ages[i], child_levels[i]] | |
| save_inputs += [parent1, parent2, partner1_inp, partner2_inp, | |
| p1_color, p2_color, pc1_color, pc2_color, | |
| custody, custody_custom, state] | |
| save_btn.click(on_save, inputs=[count, *save_inputs], | |
| outputs=[state, setup_form, setup_collapsed, collapsed_html, *DISPLAY]) | |
| edit_btn.click(on_edit, outputs=[setup_form, setup_collapsed]) | |
| # partner add / remove (FIX 3) | |
| add_p1_btn.click(on_add_partner, inputs=[parent1, state], | |
| outputs=[partner1_group, add_p1_btn, p1_partner_label]) | |
| rm_p1_btn.click(on_remove_partner, | |
| outputs=[partner1_group, add_p1_btn, partner1_inp]) | |
| add_p2_btn.click(on_add_partner, inputs=[parent2, state], | |
| outputs=[partner2_group, add_p2_btn, p2_partner_label]) | |
| rm_p2_btn.click(on_remove_partner, | |
| outputs=[partner2_group, add_p2_btn, partner2_inp]) | |
| # keep "Partner of <name>" labels in sync when a parent name changes | |
| parent1.change(lambda nm, s: partner_label_html(lang_of(s), nm), | |
| inputs=[parent1, state], outputs=p1_partner_label) | |
| parent2.change(lambda nm, s: partner_label_html(lang_of(s), nm), | |
| inputs=[parent2, state], outputs=p2_partner_label) | |
| send_btn.click(on_send, inputs=[msg, lang_toggle, state], outputs=[state, msg, *DISPLAY]) | |
| msg.submit(on_send, inputs=[msg, lang_toggle, state], outputs=[state, msg, *DISPLAY]) | |
| for idx, exb in enumerate([ex1, ex2, ex3, ex4]): | |
| exb.click(lambda s, i=idx: on_chip(i, s), inputs=[state], outputs=msg) | |
| prev_btn.click(lambda s: on_week_nav(-1, s), inputs=[state], outputs=[state, *DISPLAY]) | |
| next_btn.click(lambda s: on_week_nav(1, s), inputs=[state], outputs=[state, *DISPLAY]) | |
| today_btn.click(on_today, inputs=[state], outputs=[state, *DISPLAY]) | |
| LANG_OUT = [state, header_html, count_label, t_family, t_parents, t_partners, | |
| add_p1_btn, add_p2_btn, p1_partner_label, p2_partner_label, | |
| t_custody, t_chat, t_calendar, *child_hdrs, save_btn, edit_btn, | |
| send_btn, prev_btn, today_btn, next_btn, ex1, ex2, ex3, ex4, | |
| collapsed_html, *DISPLAY] | |
| lang_toggle.change(on_lang, inputs=[lang_toggle, state], outputs=LANG_OUT) | |
| guide_btn.click(lambda: gr.update(visible=True), outputs=guide_modal) | |
| guide_close.click(lambda: gr.update(visible=False), outputs=guide_modal) | |
| return demo | |
| if __name__ == "__main__": | |
| build_app().launch() | |