family-copilot / app.py
CognitiveEngineering's picture
Upload app.py with huggingface_hub
26d4474 verified
Raw
History Blame Contribute Delete
107 kB
"""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."
)
@GPU
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()