"""Conservative French voice router. Never guesses dates for destructive actions.""" from __future__ import annotations import os import re import threading import time as monotonic_time import unicodedata from datetime import date, datetime, timedelta, time from zoneinfo import ZoneInfo TZ = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris')) MONTHS = {m:i for i,m in enumerate('janvier fevrier mars avril mai juin juillet aout septembre octobre novembre decembre'.split(),1)} DAYS = {'lundi':0,'mardi':1,'mercredi':2,'jeudi':3,'vendredi':4,'samedi':5,'dimanche':6} PENDING_LOCK = threading.Lock() PENDING_DELETE = {'value':None, 'expires':0} PENDING_CREATE = {'value':None, 'expires':0} def simplify(text): return ''.join(c for c in unicodedata.normalize('NFD',text.lower()) if unicodedata.category(c)!='Mn') def parse_day(s, now=None): now = now or datetime.now(TZ) text = simplify(s) today = now.date() if re.search(r'\b(apres.demain)\b',text):return today+timedelta(days=2) if re.search(r'\bdemain\b',text):return today+timedelta(days=1) if re.search(r'\b(aujourd.hui|ce matin|cet apres.midi|ce soir|cette nuit|dans la journee)\b',text):return today m = re.search(r'\b(?:le\s+)?(\d{1,2})(?:er)?\s+(janvier|fevrier|mars|avril|mai|juin|juillet|aout|septembre|octobre|novembre|decembre)(?:\s+(20\d{2}))?\b',text) if m: month=MONTHS[m.group(2)];day=int(m.group(1));year=int(m.group(3)) if m.group(3) else today.year candidate=date(year,month,day) if not m.group(3) and candidate < today: candidate=date(year+1,month,day) return candidate m=re.search(r'\b(?:le\s+)?(\d{1,2})[/-](\d{1,2})(?:[/-](20\d{2}))?\b',text) if m: year=int(m.group(3)) if m.group(3) else today.year candidate=date(year,int(m.group(2)),int(m.group(1))) if not m.group(3) and candidate < today: candidate=date(year+1,candidate.month,candidate.day) return candidate for d, weekday in DAYS.items(): if re.search(r'\b'+d+r'\b',text): delta=(weekday-today.weekday())%7 if 'prochain' in text and delta == 0: delta=7 return today+timedelta(days=delta) return None def parse_clock(s): text=simplify(s) m=re.search(r'\b(?:a|vers|de|pour)\s+(\d{1,2})(?:\s*h(?:\s*(\d{1,2}))?|\s*heures?(?:\s*(\d{1,2}))?)\b',text) if not m: return None hh=int(m.group(1)); mm=int(m.group(2) or m.group(3) or 0) if hh>23 or mm>59: raise ValueError('Heure invalide') return time(hh,mm) def period(s, now=None): now = now or datetime.now(TZ) text=simplify(s) explicit_day=parse_day(text, now) morning = 'matin' in text afternoon = 'apres-midi' in text evening = 'ce soir' in text or 'ce soir' in text days=re.search(r'\b(?:sur|pour|pendant|les)?\s*(\d{1,2})\s*jours?\b',text) if explicit_day: start=datetime.combine(explicit_day,time.min,TZ) end=start+timedelta(days=1) if morning:start=start.replace(hour=6);end=start.replace(hour=12) elif afternoon:start=start.replace(hour=12);end=start.replace(hour=18) elif evening:start=start.replace(hour=18);end=(start+timedelta(days=1)).replace(hour=0) return start,end,'day' if days: n=int(days.group(1)) if not 1<=n<=31: raise ValueError('Demander entre 1 et 31 jours') start=datetime.combine(now.date(),time.min,TZ) return start,start+timedelta(days=n), 'days' if 'mois' in text: start=datetime(now.year,now.month,1,tzinfo=TZ) next_month = datetime(now.year+int(now.month==12), now.month%12+1,1,tzinfo=TZ) return start,next_month,'month' if 'semaine' in text: start=datetime.combine(now.date()-timedelta(days=now.weekday()),time.min,TZ) if 'prochaine' in text:start+=timedelta(days=7) return start,start+timedelta(days=7),'week' start=datetime.combine(now.date(),time.min,TZ) return start,start+timedelta(days=7),'days' def classify(text): t=simplify(text).strip() if not t:return None if re.search(r'\b(annule|non merci|laisse tomber)\b', t) and (current_delete() or current_create()):return {'kind':'cancel_pending'} if re.search(r'\b(confirme|oui supprime|confirmer la suppression|oui c.est bon|oui valide)\b',t): if current_delete():return {'kind':'confirm_delete'} if current_create():return {'kind':'confirm_create'} if re.search(r'\b(accueil|reviens|retour|ferme (le|la) (planning|calendrier|vue)|ecran principal)\b',t) and not re.search(r'\b(rendez.vous|reunion|evenement)\b',t):return {'kind':'home'} if re.search(r'\b(montre|affiche|voir|regarde)\b.*\b(camera|cameras|video|videos)\b',t):return {'kind':'cameras'} if re.search(r'\b(affiche|montre|donne|quelles|quelle|voir)\b.*\b(temperature|temperatures|capteurs|chauffage)\b',t):return {'kind':'temperatures'} # Do not hijack general requests for images, HA or Hermes. if re.search(r'\b(supprime|efface|retire|annule)\b.*\b(rendez.vous|evenement|agenda|calendrier|planning)\b',t):return {'kind':'delete'} if re.search(r'\b(ajoute|ajouter|cree|creer|inscris|planifie|note)\b.*\b(rendez.vous|evenement|agenda|calendrier|planning)\b',t):return {'kind':'create'} cal_words=r'\b(agenda|calendrier|planning|rendez.vous|evenements?)\b' query_words=r'\b(pre[v]?u|prevu|programme|organise|j.ai|ai.je|quoi|qu.est.ce|choses?|occupe|libre)\b' time_words=r'\b(demain|aujourd.hui|matin|soir|apres-midi|journee|semaine|mois|jours?|novembre|janvier|fevrier|mars|avril|mai|juin|juillet|aout|septembre|octobre|decembre)\b' if re.search(cal_words,t) or (re.search(query_words,t) and re.search(time_words,t)) or ('8 novembre' in t): return {'kind':'calendar', 'question': bool(re.search(query_words,t))} return None def extract_create(text, calendars): """Parse straightforward instructions only. Missing/ambiguous fields become a prefilled form.""" raw=text.strip(); normalized=simplify(raw) day=parse_day(raw) hour=parse_clock(raw) options=[c for c in calendars if c.get('can_create')] preferred=os.getenv('HA_DEFAULT_CALENDAR','').strip() matching=[] for c in options: if simplify(c['name']) in normalized or simplify(c['entity_id'].split('.',1)[-1]).replace('_',' ') in normalized: matching.append(c) calendar = matching[0]['entity_id'] if len(matching)==1 else None if calendar is None and preferred in {c['entity_id'] for c in options}:calendar=preferred if calendar is None and len(options)==1:calendar=options[0]['entity_id'] # Strip date and time syntax to derive a plausible title. title=re.sub(r'^.*?\b(?:ajoute|ajouter|cree|creer|inscris|planifie|note)\b', '', normalized, count=1) title=re.sub(r'\b(?:dans|sur)\s+(?:le\s+)?calendrier\s+[\w\s_-]+?(?=\b(?:le|demain|aujourd.hui|a)\b|$)', ' ', title) title=re.sub(r'\b(?:le\s+)?\d{1,2}\s+(?:'+ '|'.join(MONTHS)+ r')(?:\s+20\d{2})?\b',' ',title) title=re.sub(r'\b(?:demain|aujourd.hui|apres.demain|lundi|mardi|mercredi|jeudi|vendredi|samedi|dimanche)\b',' ',title) title=re.sub(r'\b(?:a|de)\s+\d{1,2}\s*(?:h|heures?)\s*\d{0,2}\b',' ',title) title=re.sub(r'\b(?:dans|sur)\s+(?:le\s+)?(?:calendrier|agenda)\b.*$', ' ',title) title=re.sub(r'^\s*(?:moi|un|une|le|la|les|rendez.vous|evenement|pour|de|a)\s+', '',title) title=re.sub(r'\s+', ' ', title).strip(' ,.:') # A guided confirmation is safer than using a guessed title. title=title[:200] data={'title':title,'calendar_id':calendar or '', 'date':day.isoformat() if day else '', 'time':hour.strftime('%H:%M') if hour else '','duration_minutes':60} complete=bool(day and hour and calendar and title and title not in ('calendrier','agenda','rendez vous')) return data, complete def extract_search(text): normalized=simplify(text) normalized=re.sub(r'^.*?\b(?:supprime|efface|retire|annule)\b','',normalized,count=1) normalized=re.sub(r'\b(?:le\s+)?\d{1,2}\s+(?:'+ '|'.join(MONTHS)+ r')\b','',normalized) normalized=re.sub(r'\b(demain|aujourd.hui|apres.demain|lundi|mardi|mercredi|jeudi|vendredi|samedi|dimanche)\b','',normalized) normalized=re.sub(r'\b(de|du|mon|ma|un|une|le|la|les|rendez.vous|evenement|agenda|calendrier|planning)\b',' ',normalized) return re.sub(r'\s+',' ',normalized).strip() def set_delete(event): with PENDING_LOCK: PENDING_DELETE['value']=event PENDING_DELETE['expires']=monotonic_time.monotonic()+90 def current_delete(): with PENDING_LOCK: if monotonic_time.monotonic()>PENDING_DELETE['expires']: PENDING_DELETE['value']=None return dict(PENDING_DELETE['value']) if PENDING_DELETE['value'] else None def clear_delete(): with PENDING_LOCK: p=PENDING_DELETE['value'];PENDING_DELETE['value']=None return p def set_create(details): with PENDING_LOCK: PENDING_CREATE['value']=dict(details) PENDING_CREATE['expires']=monotonic_time.monotonic()+90 def current_create(): with PENDING_LOCK: if monotonic_time.monotonic()>PENDING_CREATE['expires']: PENDING_CREATE['value']=None return dict(PENDING_CREATE['value']) if PENDING_CREATE['value'] else None def clear_create(): with PENDING_LOCK: p=PENDING_CREATE['value'];PENDING_CREATE['value']=None return p def cancel_pending(): clear_create();clear_delete()