190 lines
9.2 KiB
Python
190 lines
9.2 KiB
Python
"""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()
|