Files
Alfredv2/alfred/app.backup.camera/services/alfred_intents.py
T
2026-10-26 00:06:24 +01:00

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()