446 lines
25 KiB
Python
446 lines
25 KiB
Python
"""Dona V9: daily-life cockpit. Local-first, explicit writes, no credentials stored here.
|
||
|
||
A deterministic first-pass router. All unrecognised messages return None to the
|
||
existing Hermes conversation. This module never mutates Hermes sessions.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import re
|
||
import sqlite3
|
||
import threading
|
||
import unicodedata
|
||
from datetime import datetime, timedelta
|
||
from pathlib import Path
|
||
from zoneinfo import ZoneInfo
|
||
|
||
from flask import Blueprint, jsonify, request
|
||
|
||
VERSION = '9.0'
|
||
TZ = ZoneInfo(os.getenv('TZ', 'Europe/Paris'))
|
||
_DB_LOCK = threading.RLock()
|
||
MAX_MEMORY = 100
|
||
MAX_PINS = 12
|
||
|
||
# Original, clearly labelled suggestions. No claim to represent third-party recipes.
|
||
RECIPES = [
|
||
dict(id='quiche', title='Quiche lorraine (variante familiale)', minutes=45, ingredients=['pâte brisée','œufs','crème','lardons','lait'], steps=['Préchauffe le four à 180 °C.','Dépose la pâte dans un moule et répartis les lardons préalablement revenus.','Bats 3 œufs avec 20 cl de crème et un peu de lait, puis verse sur la pâte.','Enfourne environ 35 minutes, jusqu’à cuisson complète.'], tags=['oeuf','quiche','facile']),
|
||
dict(id='riz-tomate', title='Riz aux tomates et haricots', minutes=25, ingredients=['riz','tomates','haricots verts','oignon','huile d’olive'], steps=['Fais cuire le riz selon les instructions du paquet.','Fais revenir l’oignon et les tomates coupées.','Ajoute les haricots verts cuits et le riz, puis mélange.'], tags=['vegetarien','riz','tomate','haricot']),
|
||
dict(id='omelette', title='Omelette aux légumes', minutes=20, ingredients=['œufs','tomates','courgette','fromage'], steps=['Coupe et fais revenir les légumes.','Bats les œufs et verse-les dans la poêle.','Ajoute un peu de fromage et cuis jusqu’à ce que les œufs soient pris.'], tags=['oeuf','facile']),
|
||
dict(id='pates', title='Pâtes sauce tomate maison', minutes=25, ingredients=['pâtes','tomates','oignon','ail','huile d’olive'], steps=['Fais cuire les pâtes.','Laisse mijoter tomates, ail et oignon 15 minutes.','Mélange avec les pâtes.'], tags=['vegetarien','facile']),
|
||
dict(id='gratin', title='Gratin de pommes de terre et légumes', minutes=55, ingredients=['pommes de terre','courgette','crème','fromage'], steps=['Préchauffe le four à 190 °C.','Coupe les légumes en fines rondelles.','Verse la crème, parsème de fromage et cuis jusqu’à tendreté.'], tags=['vegetarien']),
|
||
dict(id='poulet-riz', title='Poulet au riz et carottes', minutes=35, ingredients=['poulet','riz','carottes','oignon'], steps=['Fais cuire le riz.','Fais dorer le poulet puis cuis-le complètement avec carottes et oignon.','Sers ensemble.'], tags=['famille','riz']),
|
||
dict(id='soupe', title='Soupe douce de légumes', minutes=40, ingredients=['pommes de terre','carottes','poireau','eau'], steps=['Lave et coupe les légumes.','Laisse cuire dans l’eau 30 minutes.','Mixe et ajuste l’assaisonnement.'], tags=['vegetarien','hiver']),
|
||
dict(id='salade', title='Salade de riz composée', minutes=20, ingredients=['riz','tomates','maïs','thon','huile d’olive'], steps=['Fais cuire puis refroidir le riz.','Ajoute les légumes et le thon égoutté.','Assaisonne et conserve au frais.'], tags=['riz','rapide']),
|
||
dict(id='galettes', title='Galettes de pommes de terre', minutes=30, ingredients=['pommes de terre','œufs','farine','huile'], steps=['Râpe les pommes de terre et presse-les.','Mélange avec l’œuf et un peu de farine.','Fais cuire les galettes à cœur sur les deux faces.'], tags=['famille']),
|
||
dict(id='chili-doux', title='Chili doux aux haricots rouges', minutes=30, ingredients=['haricots rouges','tomates','riz','oignon','maïs'], steps=['Fais revenir l’oignon et les tomates.','Ajoute haricots rouges et maïs puis réchauffe.','Sers avec le riz.'], tags=['vegetarien','riz']),
|
||
dict(id='croque', title='Croque-monsieur et crudités', minutes=15, ingredients=['pain de mie','jambon','fromage','tomates'], steps=['Garnis le pain de mie de jambon et fromage.','Passe au four ou à l’appareil jusqu’à ce que le fromage fonde.','Sers avec des crudités.'], tags=['rapide','famille']),
|
||
dict(id='poisson', title='Poisson au four et légumes', minutes=35, ingredients=['filet de poisson','pommes de terre','carottes','huile d’olive'], steps=['Préchauffe à 190 °C.','Fais précuire les pommes de terre.','Enfourne le poisson et les légumes jusqu’à cuisson complète du poisson.'], tags=['famille']),
|
||
]
|
||
|
||
|
||
def norm(text):
|
||
text = unicodedata.normalize('NFKD', str(text or '').casefold().replace('œ','oe').replace('æ','ae'))
|
||
return re.sub(r'[^a-z0-9 ]+', ' ', ''.join(c for c in text if not unicodedata.combining(c))).strip()
|
||
|
||
|
||
def _data_dir():
|
||
return Path(os.getenv('DONA_V9_DATA_DIR', '/opt/alfred/app/data')).resolve()
|
||
|
||
|
||
def _connect():
|
||
root = _data_dir()
|
||
root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||
db = sqlite3.connect(str(root / 'dona_v9.sqlite3'), timeout=8)
|
||
db.row_factory = sqlite3.Row
|
||
db.execute('CREATE TABLE IF NOT EXISTS memories (id INTEGER PRIMARY KEY, text TEXT NOT NULL, created_at TEXT NOT NULL)')
|
||
db.execute('CREATE TABLE IF NOT EXISTS pins (id INTEGER PRIMARY KEY, title TEXT NOT NULL, kind TEXT NOT NULL, prompt TEXT NOT NULL, created_at TEXT NOT NULL)')
|
||
db.commit()
|
||
return db
|
||
|
||
|
||
def _now():
|
||
return datetime.now(TZ)
|
||
|
||
|
||
def memories():
|
||
with _DB_LOCK, _connect() as db:
|
||
return [dict(r) for r in db.execute('SELECT * FROM memories ORDER BY id DESC LIMIT ?', (MAX_MEMORY,))]
|
||
|
||
|
||
def remember(text):
|
||
text = str(text or '').strip()
|
||
if not 3 <= len(text) <= 400:
|
||
raise ValueError('Souvenir entre 3 et 400 caractères requis.')
|
||
with _DB_LOCK, _connect() as db:
|
||
if db.execute('SELECT COUNT(*) FROM memories').fetchone()[0] >= MAX_MEMORY:
|
||
raise ValueError('Mémoire pleine : supprime d’abord un ancien souvenir.')
|
||
db.execute('INSERT INTO memories(text,created_at) VALUES (?,?)', (text, _now().isoformat()))
|
||
db.commit()
|
||
return text
|
||
|
||
|
||
def forget_id(value):
|
||
with _DB_LOCK, _connect() as db:
|
||
cursor = db.execute('DELETE FROM memories WHERE id=?', (int(value),))
|
||
db.commit()
|
||
return bool(cursor.rowcount)
|
||
|
||
|
||
def pins():
|
||
with _DB_LOCK, _connect() as db:
|
||
return [dict(r) for r in db.execute('SELECT * FROM pins ORDER BY id DESC LIMIT ?', (MAX_PINS,))]
|
||
|
||
|
||
def add_pin(kind, title, prompt):
|
||
if kind not in ('recipe','menu','briefing','monitor','documents','routine','daily'):
|
||
raise ValueError('Type de carte non pris en charge.')
|
||
if not title or len(title)>100 or len(prompt)>300:
|
||
raise ValueError('Carte invalide.')
|
||
with _DB_LOCK, _connect() as db:
|
||
if db.execute('SELECT COUNT(*) FROM pins').fetchone()[0] >= MAX_PINS:
|
||
raise ValueError('12 cartes maximum.')
|
||
db.execute('INSERT INTO pins(title,kind,prompt,created_at) VALUES (?,?,?,?)', (title,kind,prompt,_now().isoformat()))
|
||
db.commit()
|
||
|
||
|
||
def remove_pin(value):
|
||
with _DB_LOCK, _connect() as db:
|
||
row=db.execute('DELETE FROM pins WHERE id=?', (int(value),))
|
||
db.commit()
|
||
return bool(row.rowcount)
|
||
|
||
|
||
def match_recipes(ingredients):
|
||
have = [norm(x) for x in ingredients if len(str(x).strip())>=2]
|
||
have = [x for x in have if x][:20]
|
||
scored=[]
|
||
for r in RECIPES:
|
||
found=[x for x in r['ingredients'] if any(y in norm(x) or norm(x) in y for y in have)]
|
||
if found:scored.append({**r, 'matched':found,'missing':[x for x in r['ingredients'] if x not in found], 'score':len(found)})
|
||
return sorted(scored,key=lambda x:(-x['score'],len(x['missing']),x['minutes']))[:8]
|
||
|
||
|
||
def recipe_search(query):
|
||
q=norm(query)
|
||
q=re.sub(r'^(?:de la|des|du|de l|de|la|le|l|un|une)\s+', '',q)
|
||
if not q:return list(RECIPES)[:8]
|
||
matches=[r for r in RECIPES if q in norm(r['title']) or any(q in norm(t) for t in r['tags'])]
|
||
if not matches:
|
||
terms=[w for w in q.split() if len(w)>3 and w not in ('recette','montre','faire','comment')]
|
||
matches=[r for r in RECIPES if any(t in norm(r['title']) for t in terms)]
|
||
return matches[:8]
|
||
|
||
|
||
def _menu(days=3, people=2, child=True):
|
||
days=max(1,min(int(days),7))
|
||
chosen=['poulet-riz','pates','gratin','quiche','poisson','chili-doux','soupe'][:days]
|
||
index={r['id']:r for r in RECIPES}
|
||
menu=[]; shopping=[]
|
||
for i,rid in enumerate(chosen):
|
||
r=index[rid]
|
||
menu.append({'day':i+1,'title':r['title'],'recipe':r})
|
||
shopping.extend(r['ingredients'])
|
||
return {'days':days,'people':people,'child':child,'meals':menu,'shopping':list(dict.fromkeys(shopping)),
|
||
'note':'Propositions familiales originales, sans allergies ni restrictions connues. Quantités exactes à adapter aux portions et à l’appétit.'}
|
||
|
||
|
||
def _events(days):
|
||
from services import ha_views
|
||
start=_now();end=start+timedelta(days=days)
|
||
raw=ha_views.get_events(start,end)
|
||
return [{k:str(e.get(k) or '')[:220] for k in ('title','start','end','location')} for e in raw.get('events',[])[:35]],raw.get('errors',[])
|
||
|
||
|
||
def _weather():
|
||
from services import ha_weather
|
||
raw=ha_weather.get_weather()
|
||
current=raw.get('current') or {}
|
||
return {'description':current.get('description',''), 'temperature':current.get('temperature'),
|
||
'unit':raw.get('temperature_unit','°C'), 'source':'Home Assistant'}
|
||
|
||
|
||
def briefing(days=1, planning=False):
|
||
result={'date':_now().date().isoformat(),'days':days,'events':[],'weather':None,'errors':[]}
|
||
try:result['events'],errors=_events(days);result['errors']+=errors
|
||
except Exception as exc:result['errors'].append('Agenda Home Assistant indisponible : '+type(exc).__name__)
|
||
try:result['weather']=_weather()
|
||
except Exception as exc:result['errors'].append('Météo Home Assistant indisponible : '+type(exc).__name__)
|
||
if planning:result['suggested_menu']=_menu(min(days,3))
|
||
return result
|
||
|
||
|
||
def _docs_dir():
|
||
return Path(os.getenv('DONA_V9_DOCS_DIR','/opt/alfred/documents')).resolve()
|
||
|
||
|
||
def docs_search(query):
|
||
"""No file operations outside the explicitly configured documents directory."""
|
||
root=_docs_dir()
|
||
if not root.is_dir():return {'found':[],'note':f'Dossier documentaire absent : {root}'}
|
||
words=[x for x in norm(query).split() if len(x)>=3][:8]
|
||
if not words:return {'found':[],'note':'Précise au moins un terme de recherche.'}
|
||
found=[];scanned=0
|
||
for p in root.rglob('*'):
|
||
if len(found)>25 or scanned>=400:break
|
||
if p.is_symlink() or not p.is_file() or p.suffix.lower() not in ('.txt','.md','.pdf'):continue
|
||
try:
|
||
if not p.resolve().is_relative_to(root) or p.stat().st_size>3_000_000:continue
|
||
scanned+=1
|
||
if p.suffix.lower()=='.pdf':
|
||
try:
|
||
from pypdf import PdfReader
|
||
except ImportError:continue
|
||
reader=PdfReader(str(p),strict=False)
|
||
paragraphs=[(f'page {i+1}',(page.extract_text() or '')[:40000]) for i,page in enumerate(reader.pages[:45])]
|
||
else:paragraphs=[('texte',p.read_text(encoding='utf-8',errors='replace')[:60000])]
|
||
for section,body in paragraphs:
|
||
low=norm(body)
|
||
if all(w in low or w in norm(p.name) for w in words):
|
||
at=min((low.find(w) for w in words if w in low),default=0)
|
||
# Approximate snippet position, result shown as an excerpt only.
|
||
snippet=body[max(0,at-150):at+480].replace('\n',' ').strip()
|
||
found.append({'file':str(p.relative_to(root)),'section':section,'snippet':snippet[:600]})
|
||
break
|
||
except (OSError,ValueError,RuntimeError,PermissionError):continue
|
||
except Exception:continue # Corrupt PDFs may raise library-specific exceptions.
|
||
return {'found':found[:25],'note':('Résultats extraits des fichiers locaux.' if found else 'Aucun passage correspondant. PDF : paquet pypdf facultatif.')}
|
||
|
||
|
||
def routine_preview():
|
||
configured=[x.strip() for x in os.getenv('DONA_V9_BEDTIME_LIGHTS','').split(',') if x.strip()]
|
||
entities=[x for x in configured if re.fullmatch(r'light\.[a-z0-9_]+',x)][:30]
|
||
return {'name':'Bonne nuit','lights':entities,'executable':bool(entities),
|
||
'message':'Aucune action sans confirmation explicite. Configure DONA_V9_BEDTIME_LIGHTS pour activer l’extinction des lumières.' if not entities else 'Extinction des seules lumières autorisées, après confirmation.'}
|
||
|
||
|
||
def routine_execute():
|
||
preview=routine_preview()
|
||
if not preview['lights']:raise ValueError('Aucune lumière autorisée : routine inactive.')
|
||
from services import ha_views
|
||
# Trusted, administrator-configured entity IDs only. No user-provided service/action.
|
||
ha_views._request('POST','/api/services/light/turn_off',payload={'entity_id':preview['lights']})
|
||
return {'done':preview['lights'],'message':'Commande envoyée à Home Assistant.'}
|
||
|
||
|
||
def supervision():
|
||
"""Hermes/MCP, new isolated session; no invented metric when offline."""
|
||
try:
|
||
from services import dona_dynamic, dona_dashboard_agent
|
||
board=dona_dynamic.build_dashboard('Tableau de bord Proxmox et Wazuh : état réel des nœuds et alertes disponibles',dona_dashboard_agent.ask_dashboard)
|
||
return {'dashboard':board, 'note':'Données rapportées par Hermes et ses outils MCP ; vérifier la date et les sources.'}
|
||
except Exception as exc:
|
||
return {'dashboard':None,'note':'Supervision indisponible : '+type(exc).__name__+'. Vérifie les MCP Proxmox/Wazuh.'}
|
||
|
||
|
||
def child_quiz():
|
||
return {'questions':[
|
||
{'question':'Combien font 7 × 8 ?','answer':'56'},
|
||
{'question':'Quel est le nom de la planète rouge ?','answer':'Mars'},
|
||
{'question':'Quel organe permet de respirer ?','answer':'Les poumons'},
|
||
],'note':'Quiz familial local. Aucun accès aux outils d’administration ou à la domotique.'}
|
||
|
||
|
||
def _intent(text):
|
||
s=str(text or '').strip()
|
||
if not 3 <= len(s) <= 500:return None
|
||
lower=norm(s)
|
||
# Let the existing YouTube navigation and movie/video search router own those requests.
|
||
if re.search(r'\b(video|youtube|waze|google maps|itineraire|trajet)\b',lower):return None
|
||
if re.search(r'\b(retiens que|souviens toi que|memorise que)\b',lower):
|
||
m=re.search(r'(?:retiens que|souviens toi que|memorise que)\s+(.+)$',s,re.I)
|
||
if m:return ('remember',m.group(1).strip())
|
||
if re.search(r'\b(que sais tu de moi|montre mes souvenirs|mes souvenirs)\b',lower):return ('memories','')
|
||
if re.search(r'\b(quels? problemes?.{0,30}(serveurs?|proxmox|wazuh)|supervision|etat.{0,30}(serveurs?|proxmox|wazuh))\b',lower):return ('monitor','')
|
||
if re.search(r'\b(bonne nuit|je vais me coucher|routine du soir)\b',lower):return ('routine','')
|
||
if re.search(r'\b(quiz|question de culture generale|petit jeu de questions)\b',lower):return ('child','')
|
||
if re.search(r'\b(document|documentation|manuel|mode d emploi|dans mes fichiers|dans mes documents)\b',lower) and re.search(r'\b(cherche|recherche|retrouve|trouve|montre|lis|explique)\b',lower):
|
||
q=re.sub(r'^(?:dona|alfred)\s*[, :]?','',s,flags=re.I)
|
||
q=re.sub(r'\b(?:cherche|recherche|retrouve|trouve|montre|lis|explique)(?:[ -]moi)?\b','',q,flags=re.I)
|
||
q=re.sub(r'\b(?:dans|sur)\s+(?:mes|la|le|les)\s+(?:documents?|documentation|fichiers?|manuels?)\b','',q,flags=re.I)
|
||
return ('documents',q.strip(' .?:!'))
|
||
if re.search(r'\b(organise|planifie|planification)\b',lower) and re.search(r'\b(journee|demain|semaine)\b',lower):return ('daily','')
|
||
if re.search(r'\b(briefing|resume|programme|quoi.{0,30}(attend|prevu)|qu est ce qui m attend)\b',lower) and re.search(r'\b(aujourd hui|demain|journee|matin|agenda|calendrier|briefing|semaine)\b',lower):return ('briefing','')
|
||
if re.search(r'\b(menu|menus|repas|manger|cuisiner|recettes|idees de recettes)\b',lower) and re.search(r'\b(jours?|semaine|idees?|propose|prepare)\b',lower):return ('menu','')
|
||
m=re.search(r'\b(?:j ai|il me reste|avec)\s+(?:des? |du |de la |d )?(.{5,180}?)\s+(?:que faire|quoi faire|quoi cuisiner|que cuisiner|pour cuisiner|\?)',lower)
|
||
if m:
|
||
original=re.search(r"(?:j[’']ai|il me reste|avec)\s+(.{5,180}?)\s+(?:que faire|quoi faire|quoi cuisiner|que cuisiner|pour cuisiner|\?)",s,re.I)
|
||
value=original.group(1) if original else m.group(1)
|
||
return ('ingredients',value)
|
||
if re.search(r'\b(recette|cuisiner|preparer)\b',lower):
|
||
q=re.sub(r'^.*?\b(?:recette (?:de |du |de la |de l |d |des |pour )?|comment (?:preparer|cuisiner) |cuisiner |preparer )','',lower).strip()
|
||
if q:return ('recipe',q)
|
||
return None
|
||
|
||
|
||
def is_request(text):return _intent(text) is not None
|
||
|
||
|
||
def handle(text):
|
||
intent=_intent(text)
|
||
if not intent:return None
|
||
kind,arg=intent
|
||
if kind=='remember':
|
||
item=remember(arg)
|
||
return {'kind':'memories','title':'Souvenir enregistré','memories':memories(),'speech':'Je retiens : '+item}
|
||
if kind=='memories':return {'kind':'memories','title':'Mes souvenirs','memories':memories(),'speech':'Voici les souvenirs enregistrés avec ton accord.'}
|
||
if kind=='recipe':
|
||
rec=recipe_search(arg)
|
||
return {'kind':'recipe','title':'Recettes','recipes':rec,'speech':('Voici '+str(len(rec))+' recettes possibles.' if rec else 'Je ne trouve pas cette recette dans le petit catalogue local. Une source externe pourra être ajoutée ensuite.'),'source':'Suggestions locales, sans API externe'}
|
||
if kind=='ingredients':
|
||
ing=[re.sub(r'^(?:des?|du|de la|de l|d)\s+', '',x).strip(' ,.;') for x in re.split(r'\s*,\s*|\s+et\s+',arg) if x.strip(' ,.;')]
|
||
rec=match_recipes(ing)
|
||
return {'kind':'recipe','title':'À cuisiner avec ce que tu as','recipes':rec,'speech':('J’ai trouvé '+str(len(rec))+' idées. Je te montre aussi les ingrédients manquants.' if rec else 'Je n’ai pas trouvé de recette correspondante dans le catalogue local.'),'source':'Suggestions locales, ingrédients manquants indiqués'}
|
||
if kind=='menu':
|
||
match=re.search(r'\b([1-7])\s*jours?\b',norm(text))
|
||
days=int(match.group(1)) if match else 3
|
||
payload=_menu(days)
|
||
return {'kind':'menu','title':'Menus pour '+str(days)+' jours', 'menu':payload,'speech':'Voici des idées de repas pour '+str(days)+' jours et une liste de courses à vérifier.'}
|
||
if kind=='briefing':
|
||
payload=briefing(1)
|
||
return {'kind':'briefing','title':'Briefing du jour','briefing':payload,'speech':_brief_speech(payload)}
|
||
if kind=='daily':
|
||
payload=briefing(3,True)
|
||
return {'kind':'daily','title':'Organiser les prochains jours','briefing':payload,'speech':_brief_speech(payload)+' Je te propose aussi un menu familial.'}
|
||
if kind=='documents':
|
||
data=docs_search(arg)
|
||
return {'kind':'documents','title':'Bibliothèque documentaire','documents':data,'speech':str(len(data['found']))+' document(s) correspondant(s).'}
|
||
if kind=='routine':return {'kind':'routine','title':'Routine du soir','routine':routine_preview(),'speech':'Je t’affiche les actions prévues. Je ne change rien sans confirmation.'}
|
||
if kind=='child':return {'kind':'child','title':'Quiz enfant','quiz':child_quiz(),'speech':'Voici trois questions pour jouer.'}
|
||
if kind=='monitor':
|
||
data=supervision()
|
||
return {'kind':'monitor','title':'Supervision','monitor':data,'speech':'Voici le résultat de supervision. Vérifie les sources indiquées.'}
|
||
return None
|
||
|
||
|
||
def _brief_speech(data):
|
||
n=len(data.get('events',[]));w=data.get('weather') or {}
|
||
speech='Tu as '+str(n)+' événement'+('s' if n!=1 else '')+' dans le planning.'
|
||
if w.get('temperature') is not None:speech+=' Il fait environ '+str(w['temperature'])+' degrés.'
|
||
if data.get('errors'):speech+=' Certaines données ne sont pas disponibles.'
|
||
return speech
|
||
|
||
|
||
def handle_and_emit(text,socketio):
|
||
try:result=handle(text)
|
||
except (ValueError,sqlite3.Error,OSError) as exc:
|
||
result={'kind':'error','title':'Dona','error':str(exc)[:240],'speech':str(exc)[:200]}
|
||
if result:
|
||
socketio.emit('dona_suite_v9',result)
|
||
return result.get('speech') or ''
|
||
return None
|
||
|
||
|
||
def _authorized():
|
||
if not request.is_json or request.headers.get('X-Alfred-Request')!='1':return False
|
||
origin=request.headers.get('Origin')
|
||
if origin:
|
||
from urllib.parse import urlsplit
|
||
value=urlsplit(origin)
|
||
return value.scheme in ('http','https') and value.netloc==request.host
|
||
return request.headers.get('Sec-Fetch-Site','same-origin') in ('same-origin','none')
|
||
|
||
|
||
def add_to_alfred_courses(items, list_module=None):
|
||
"""Use Alfred's own deduplicating add_item(kind, label), SQLite and event schema.
|
||
|
||
Items that are already active in the original list are not reported as added.
|
||
Never creates a new shopping list, Home Assistant todo list, or local copy.
|
||
"""
|
||
if list_module is None:
|
||
from services import alfred_lists as list_module
|
||
added=[]; already_present=[]; seen=set()
|
||
for raw in items:
|
||
label=list_module.clean_label(raw, 'courses')
|
||
key=list_module.fold(label)
|
||
if key in seen:continue
|
||
seen.add(key)
|
||
try:
|
||
result=list_module.add_item('courses',label)
|
||
except Exception as exc:
|
||
raise RuntimeError(f'Ajout partiel : {len(added)} ajouté(s), {len(already_present)} déjà présent(s).') from exc
|
||
item=result['item']['label']
|
||
if result.get('already_present'):already_present.append(item)
|
||
else:added.append(item)
|
||
return {'added':added,'already_present':already_present}
|
||
|
||
|
||
def register_suite(app,socketio):
|
||
bp=Blueprint('dona_suite_v9', __name__)
|
||
|
||
@bp.get('/api/dona/v9/config')
|
||
def v9_config():
|
||
return jsonify(ok=True,version=VERSION,docs_dir=str(_docs_dir()),routine=routine_preview(),features=['cuisine','memoire','briefing','routines','documents','supervision','enfant','journee','cartes'])
|
||
|
||
@bp.get('/api/dona/v9/memory')
|
||
def v9_memory():return jsonify(ok=True,memories=memories())
|
||
|
||
@bp.post('/api/dona/v9/memory')
|
||
def v9_save_memory():
|
||
if not _authorized():return jsonify(ok=False,error='Requête non autorisée.'),403
|
||
try:return jsonify(ok=True,text=remember((request.get_json(silent=True) or {}).get('text','')))
|
||
except ValueError as exc:return jsonify(ok=False,error=str(exc)),400
|
||
|
||
@bp.post('/api/dona/v9/memory/delete')
|
||
def v9_delete_memory():
|
||
if not _authorized():return jsonify(ok=False,error='Requête non autorisée.'),403
|
||
try:return jsonify(ok=forget_id((request.get_json(silent=True) or {}).get('id')))
|
||
except (TypeError,ValueError):return jsonify(ok=False,error='ID invalide.'),400
|
||
|
||
@bp.get('/api/dona/v9/pins')
|
||
def v9_pins():return jsonify(ok=True,pins=pins())
|
||
|
||
@bp.post('/api/dona/v9/pins')
|
||
def v9_add_pin():
|
||
if not _authorized():return jsonify(ok=False,error='Requête non autorisée.'),403
|
||
x=request.get_json(silent=True) or {}
|
||
try:add_pin(x.get('kind'),str(x.get('title','')),str(x.get('prompt','')))
|
||
except ValueError as exc:return jsonify(ok=False,error=str(exc)),400
|
||
return jsonify(ok=True)
|
||
|
||
@bp.post('/api/dona/v9/pins/delete')
|
||
def v9_delete_pin():
|
||
if not _authorized():return jsonify(ok=False,error='Requête non autorisée.'),403
|
||
try:return jsonify(ok=remove_pin((request.get_json(silent=True) or {}).get('id')))
|
||
except (TypeError,ValueError):return jsonify(ok=False,error='ID invalide.'),400
|
||
|
||
@bp.post('/api/dona/v9/ask')
|
||
def v9_ask():
|
||
if not _authorized():return jsonify(ok=False,error='Requête non autorisée.'),403
|
||
text=(request.get_json(silent=True) or {}).get('text','')
|
||
try:
|
||
output=handle(text)
|
||
if output is None:return jsonify(ok=False,error='Commande non reconnue, utilise la conversation normale pour une question générale.'),400
|
||
return jsonify(ok=True,result=output)
|
||
except (ValueError,OSError,sqlite3.Error) as exc:return jsonify(ok=False,error=str(exc)),400
|
||
|
||
@bp.post('/api/dona/v9/shopping/add')
|
||
def v9_shopping():
|
||
"""Bridge to the EXISTING Alfred Listes SQLite module, never a new list."""
|
||
if not _authorized():return jsonify(ok=False,error='Requête non autorisée.'),403
|
||
data=request.get_json(silent=True) or {}
|
||
items=data.get('items')
|
||
if not isinstance(items,list) or not 1<=len(items)<=50 or any(not isinstance(x,str) or not 2<=len(x.strip())<=100 for x in items):
|
||
return jsonify(ok=False,error='Liste invalide.'),400
|
||
try:
|
||
result=add_to_alfred_courses(items)
|
||
except Exception as exc:
|
||
app.logger.warning('Dona V9 course list: %s',type(exc).__name__)
|
||
return jsonify(ok=False,error='Liste de courses Alfred indisponible ou pleine. Vérifie les logs.'),503
|
||
if result['added']:
|
||
socketio.emit('alfred_lists_changed',{'source':'dona_v9_cuisine','kind':'courses','action':'add_item'})
|
||
return jsonify(ok=True,**result)
|
||
|
||
@bp.post('/api/dona/v9/routine/execute')
|
||
def v9_routine_execute():
|
||
if not _authorized():return jsonify(ok=False,error='Requête non autorisée.'),403
|
||
data=request.get_json(silent=True) or {}
|
||
if data.get('confirm')!='Eteindre les lumieres autorisees':return jsonify(ok=False,error='Confirmation explicite requise.'),400
|
||
try:return jsonify(ok=True,result=routine_execute())
|
||
except Exception as exc:
|
||
app.logger.warning('Dona routine refused: %s',type(exc).__name__)
|
||
return jsonify(ok=False,error='Routine indisponible : '+type(exc).__name__),503
|
||
|
||
app.register_blueprint(bp)
|