V1
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
"""Fast voice routing for visual actions; Hermes still handles general tasks."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime, timedelta, time
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from . import ha_views as ha
|
||||
from . import alfred_intents as intents
|
||||
from . import ha_weather
|
||||
|
||||
TZ = ZoneInfo(os.getenv('ALFRED_TIMEZONE','Europe/Paris'))
|
||||
|
||||
|
||||
def _label(s,e):
|
||||
return f"du {s:%d/%m} au {(e-timedelta(days=1)):%d/%m}"
|
||||
|
||||
|
||||
def _event_summary(events, question):
|
||||
count=len(events)
|
||||
if count == 0:return "Non, je n'ai trouvé aucun événement pour cette période." if question else 'Aucun événement prévu sur cette période.'
|
||||
names=', '.join(ev['title'] for ev in events[:3])
|
||||
if question:
|
||||
return f"Oui, {count} événement{'s' if count>1 else ''} prévu{'s' if count>1 else ''}. {names}." if count<=3 else f"Oui, {count} événements sont prévus. Notamment : {names}."
|
||||
return f"Voici ton planning avec {count} événement{'s' if count>1 else ''}."
|
||||
|
||||
|
||||
def _calendar(text, question=False):
|
||||
start,end,view=intents.period(text)
|
||||
payload=ha.get_events(start,end)
|
||||
content={'type':'calendar','view':view,'title':'Planning','start':start.isoformat(),'end':end.isoformat(),
|
||||
'events':payload['events'],'calendars':payload['calendars'],'errors':payload['errors']}
|
||||
speech=_event_summary(payload['events'],question)
|
||||
if payload['errors']: speech+=' Certains calendriers sont indisponibles.'
|
||||
return content,speech
|
||||
|
||||
|
||||
def _create(text):
|
||||
calendars=ha.list_calendars()
|
||||
details, complete=intents.extract_create(text,calendars)
|
||||
if not complete:
|
||||
return {'type':'calendar_form','values':details,'calendars':calendars},'Je prépare le rendez-vous. Complète ou vérifie les informations à l’écran avant de l’enregistrer.'
|
||||
# No calendar mutation until a second explicit confirmation.
|
||||
intents.set_create(details)
|
||||
pretty=datetime.fromisoformat(details['date']).strftime('%d/%m')
|
||||
speech=f"Je vais ajouter {details['title']} le {pretty} à {details['time']}. Dis confirme pour enregistrer, ou annule."
|
||||
return {'type':'calendar_form','values':details,'calendars':calendars,'awaiting_confirmation':True},speech
|
||||
|
||||
|
||||
def _delete(text):
|
||||
now=datetime.now(TZ)
|
||||
day=intents.parse_day(text,now)
|
||||
if day:
|
||||
start=datetime.combine(day,time.min,TZ); end=start+timedelta(days=1)
|
||||
else:
|
||||
start=datetime.combine(now.date(),time.min,TZ);end=start+timedelta(days=45)
|
||||
data=ha.get_events(start,end)
|
||||
needle=intents.extract_search(text)
|
||||
events=[e for e in data['events'] if intents.simplify(needle) in intents.simplify(e['title'])] if needle else data['events']
|
||||
if not events:
|
||||
return {'type':'calendar','view':'days','start':start.isoformat(),'end':end.isoformat(),**data},'Je ne trouve pas cet événement. Aucun événement n’a été supprimé.'
|
||||
if len(events)!=1:
|
||||
return {'type':'delete_candidates','events':events[:30]},f"J'ai trouvé {len(events)} événements correspondants. Choisis celui à supprimer sur l'écran."
|
||||
ev=events[0]
|
||||
if not ev['can_delete']:
|
||||
return {'type':'delete_candidates','events':[ev]},'Ce calendrier ne permet pas la suppression de cet événement depuis Alfred.'
|
||||
intents.set_delete(ev)
|
||||
warning=' Attention, si cet événement est récurrent, cela peut supprimer toute la série.' if not ev['recurrence_id'] else ''
|
||||
return {'type':'delete_candidates','events':[ev], 'awaiting_confirmation':True},f"Confirme la suppression de {ev['title']}, ou dis annule.{warning}"
|
||||
|
||||
|
||||
def _confirm_delete():
|
||||
e=intents.current_delete()
|
||||
if not e:return {'type':'text','text':'Confirmation expirée.'},'La demande de suppression a expiré.'
|
||||
# Recheck existence in HA to avoid deleting an event that changed meanwhile.
|
||||
start=datetime.fromisoformat(e['start'])-timedelta(days=1)
|
||||
end=datetime.fromisoformat(e['end'])+timedelta(days=1)
|
||||
found=ha.get_events(start,end,[e['calendar_id']])['events']
|
||||
if not any(x['id']==e['id'] and x['start']==e['start'] and x['recurrence_id']==e['recurrence_id'] for x in found):
|
||||
intents.clear_delete()
|
||||
return {'type':'text','text':'Cet événement a changé. Suppression annulée.'},'Cet événement a changé. Rien n’a été supprimé.'
|
||||
ha.delete_event(e['calendar_id'],e['id'],e['recurrence_id'] or None)
|
||||
intents.clear_delete()
|
||||
content,speech=_calendar('planning sur 7 jours')
|
||||
return content,'Événement supprimé. '+speech
|
||||
|
||||
|
||||
def _confirm_create():
|
||||
data=intents.current_create()
|
||||
if not data:return {'type':'text','text':'Confirmation expirée.'},'La demande de création a expiré.'
|
||||
if os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() not in ('1','true','yes'):
|
||||
return {'type':'calendar_form','values':data,'calendars':ha.list_calendars()},'L’écriture est désactivée. Active ALFRED_ALLOW_CALENDAR_WRITES dans la configuration.'
|
||||
start=datetime.fromisoformat(data['date']+'T'+data['time']).replace(tzinfo=TZ)
|
||||
end=start+timedelta(minutes=int(data.get('duration_minutes',60)))
|
||||
ha.create_event(data['calendar_id'],data['title'],start,end)
|
||||
intents.clear_create()
|
||||
content,speech=_calendar('planning sur 7 jours')
|
||||
return content,'Événement ajouté. '+speech
|
||||
|
||||
|
||||
def route(text, last_view=None):
|
||||
# Weather is a read-only dynamic view; avoid sending routine forecasts to Hermes.
|
||||
if ha_weather.is_weather_request(text, last_view):
|
||||
data = ha_weather.get_weather()
|
||||
return data, ha_weather.describe(data, text)
|
||||
intent=intents.classify(text)
|
||||
# Short temporal follow-ups to a displayed calendar: 'Et demain ?', 'Ce matin ?'
|
||||
if intent is None and last_view == 'calendar' and len(text.strip()) < 45:
|
||||
t=intents.simplify(text)
|
||||
if any(word in t for word in ('demain','matin','apres-midi','soir','journee','semaine','mois','jours','novembre','decembre','janvier','fevrier','mars','avril','mai','juin','juillet','aout','septembre','octobre')):
|
||||
intent={'kind':'calendar','question':True}
|
||||
if intent is None:return None
|
||||
kind=intent['kind']
|
||||
if kind=='home':return {'type':'home','title':'Alfred','text':'À ton écoute.'},'Je reviens à l’accueil.'
|
||||
if kind=='calendar':return _calendar(text, intent.get('question',False))
|
||||
if kind=='create':return _create(text)
|
||||
if kind=='delete':return _delete(text)
|
||||
if kind=='confirm_delete':
|
||||
if os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() not in ('1','true','yes'):
|
||||
return {'type':'text','text':'Écriture calendrier désactivée.'},'La suppression est désactivée dans la configuration.'
|
||||
return _confirm_delete()
|
||||
if kind=='confirm_create':return _confirm_create()
|
||||
if kind=='cancel_pending':
|
||||
intents.cancel_pending()
|
||||
return {'type':'home','text':'Demande annulée.'},'D’accord, aucune modification effectuée.'
|
||||
if kind=='temperatures':
|
||||
temps=ha.get_temperatures()
|
||||
return {'type':'temperatures','items':temps},f"Voici {len(temps)} mesures de température provenant de Home Assistant."
|
||||
if kind=='cameras':
|
||||
cameras=ha.list_cameras()
|
||||
return {'type':'cameras','items':cameras},f"Voici {len(cameras)} caméras Home Assistant disponibles."
|
||||
return None
|
||||
@@ -0,0 +1,277 @@
|
||||
"""Outfit recommendations and ComfyUI illustration for Alfred weather view.
|
||||
|
||||
This service reuses the same ComfyUI instance as Hermes, but calls ComfyUI
|
||||
HTTP directly to avoid replacing Alfred's central view while generating the
|
||||
illustration.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dotenv import load_dotenv
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import requests
|
||||
|
||||
from . import ha_weather
|
||||
|
||||
load_dotenv("/opt/hermes/MCP_comfyui/.env")
|
||||
|
||||
BASE = os.getenv(
|
||||
"COMFYUI_URL",
|
||||
"http://192.168.1.13:8188"
|
||||
).rstrip("/")
|
||||
|
||||
WORKFLOWS_DIR = Path(
|
||||
os.getenv(
|
||||
"WORKFLOWS_DIR",
|
||||
"/opt/hermes/MCP_comfyui/workflows"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
CACHE_DIR = Path(os.getenv('ALFRED_CACHE_DIR', str(Path(__file__).resolve().parents[1] / 'cache')))
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
CACHE_FILE = CACHE_DIR / 'weather_outfit_cache.json'
|
||||
BASE = os.getenv('COMFYUI_URL', 'http://127.0.0.1:8188').rstrip('/')
|
||||
WORKFLOWS_DIR = Path(os.getenv('WORKFLOWS_DIR', '/opt/hermes/MCP_comfyui/workflows'))
|
||||
#WORKFLOWS_DIR = Path(os.getenv('WORKFLOWS_DIR', str(Path(__file__).parent / 'workflows')))
|
||||
PROMPT_NODE = os.getenv('COMFY_PROMPT_NODE', '6')
|
||||
TIMEOUT = int(os.getenv('COMFYUI_TIMEOUT', '300'))
|
||||
TTL_HOURS = int(os.getenv('ALFRED_WEATHER_ILLUSTRATION_TTL_HOURS', '12'))
|
||||
ENABLED = os.getenv('ALFRED_WEATHER_ILLUSTRATIONS', 'true').lower() in ('1', 'true', 'yes', 'on')
|
||||
|
||||
|
||||
def _load_cache() -> dict:
|
||||
try:
|
||||
return json.loads(CACHE_FILE.read_text(encoding='utf-8'))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _save_cache(cache: dict) -> None:
|
||||
CACHE_FILE.write_text(json.dumps(cache, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
|
||||
|
||||
def _pick_day(data: dict) -> dict:
|
||||
target = str(data.get('focus_date') or '')
|
||||
for day in data.get('daily', []):
|
||||
if str(day.get('date')) == target:
|
||||
return day
|
||||
if data.get('daily'):
|
||||
return data['daily'][0]
|
||||
current = data.get('current', {})
|
||||
return {
|
||||
'date': target or datetime.now().date().isoformat(),
|
||||
'condition': current.get('condition', 'unknown'),
|
||||
'description': current.get('description', 'conditions non précisées'),
|
||||
'temperature': current.get('temperature'),
|
||||
'templow': current.get('temperature'),
|
||||
'precipitation_probability': None,
|
||||
'wind_speed': current.get('wind_speed'),
|
||||
}
|
||||
|
||||
|
||||
def _temp_band(high: float | None, low: float | None) -> str:
|
||||
ref = high if high is not None else low
|
||||
if ref is None:
|
||||
return 'mild'
|
||||
if ref < 6:
|
||||
return 'cold'
|
||||
if ref < 14:
|
||||
return 'cool'
|
||||
if ref < 23:
|
||||
return 'mild'
|
||||
return 'warm'
|
||||
|
||||
|
||||
def _recommend(day: dict) -> dict:
|
||||
high = day.get('temperature')
|
||||
low = day.get('templow')
|
||||
pop = day.get('precipitation_probability')
|
||||
wind = day.get('wind_speed')
|
||||
cond = str(day.get('condition') or '')
|
||||
band = _temp_band(high, low)
|
||||
rainy = cond in {'rainy', 'pouring', 'lightning-rainy', 'snowy-rainy'} or (pop is not None and pop >= 45)
|
||||
sunny = cond in {'sunny', 'partlycloudy'}
|
||||
windy = wind is not None and wind >= 25
|
||||
cold = band in {'cold', 'cool'}
|
||||
|
||||
child = []
|
||||
adult = []
|
||||
child_acc = []
|
||||
adult_acc = []
|
||||
|
||||
if band == 'cold':
|
||||
child += ['doudoune chaude', 'pantalon chaud', 'chaussures fermées']
|
||||
adult += ['manteau chaud', 'pull', 'pantalon', 'chaussures fermées']
|
||||
child_acc += ['bonnet', 'écharpe']
|
||||
adult_acc += ['écharpe']
|
||||
elif band == 'cool':
|
||||
child += ['veste légère', 'pantalon', 'baskets']
|
||||
adult += ['veste mi-saison', 'pull fin', 'jean', 'chaussures fermées']
|
||||
elif band == 'mild':
|
||||
child += ['sweat ou gilet', 'pantalon léger', 'baskets']
|
||||
adult += ['veste légère', 'haut confortable', 'jean ou chino', 'baskets']
|
||||
else:
|
||||
child += ['t-shirt', 'short ou pantalon léger', 'baskets légères']
|
||||
adult += ['t-shirt ou polo', 'tenue légère', 'chaussures légères']
|
||||
child_acc += ['casquette']
|
||||
adult_acc += ['lunettes de soleil']
|
||||
|
||||
if rainy:
|
||||
child_acc += ['imperméable', 'parapluie']
|
||||
adult_acc += ['parapluie']
|
||||
if 'chaussures fermées' not in child: child.append('chaussures fermées')
|
||||
if 'chaussures fermées' not in adult: adult.append('chaussures fermées')
|
||||
if windy:
|
||||
child_acc += ['coupe-vent']
|
||||
adult_acc += ['coupe-vent']
|
||||
if sunny and band != 'cold':
|
||||
if 'casquette' not in child_acc: child_acc += ['casquette']
|
||||
if 'lunettes de soleil' not in adult_acc: adult_acc += ['lunettes de soleil']
|
||||
|
||||
def uniq(seq):
|
||||
seen=[]
|
||||
for x in seq:
|
||||
if x not in seen: seen.append(x)
|
||||
return seen
|
||||
|
||||
return {
|
||||
'summary': day.get('description', 'Conditions du jour'),
|
||||
'child': {
|
||||
'title': 'Fille · 8 ans',
|
||||
'clothes': uniq(child),
|
||||
'accessories': uniq(child_acc),
|
||||
},
|
||||
'adult': {
|
||||
'title': 'Homme · 42 ans',
|
||||
'clothes': uniq(adult),
|
||||
'accessories': uniq(adult_acc),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _weather_signature(day: dict, outfits: dict) -> str:
|
||||
raw = json.dumps({'day': day, 'outfits': outfits}, ensure_ascii=False, sort_keys=True)
|
||||
return hashlib.sha256(raw.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def _extract_images(history: dict) -> list[dict]:
|
||||
images = []
|
||||
for node_id, output in history.get('outputs', {}).items():
|
||||
for image in output.get('images', []):
|
||||
filename = image.get('filename')
|
||||
if not filename:
|
||||
continue
|
||||
subfolder = image.get('subfolder', '')
|
||||
image_type = image.get('type', 'output')
|
||||
query = urlencode({'filename': filename, 'subfolder': subfolder, 'type': image_type})
|
||||
images.append({'filename': filename, 'url': f'{BASE}/view?{query}', 'node_id': str(node_id)})
|
||||
return images
|
||||
|
||||
|
||||
def _submit_workflow(prompt: str) -> str:
|
||||
workflow_path = WORKFLOWS_DIR / 'text_to_image.json'
|
||||
if not workflow_path.exists():
|
||||
raise RuntimeError(f'Workflow ComfyUI introuvable : {workflow_path}')
|
||||
workflow = json.loads(workflow_path.read_text(encoding='utf-8'))
|
||||
workflow[PROMPT_NODE]['inputs']['text'] = prompt
|
||||
response = requests.post(f'{BASE}/prompt', json={'prompt': workflow, 'client_id': str(uuid.uuid4())}, timeout=30)
|
||||
response.raise_for_status()
|
||||
return response.json()['prompt_id']
|
||||
|
||||
|
||||
def _wait_history(prompt_id: str) -> dict:
|
||||
deadline = time.monotonic() + TIMEOUT
|
||||
while time.monotonic() < deadline:
|
||||
response = requests.get(f'{BASE}/history/{prompt_id}', timeout=15)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
if prompt_id in result:
|
||||
return result[prompt_id]
|
||||
time.sleep(1)
|
||||
raise RuntimeError(f'ComfyUI timeout ({prompt_id})')
|
||||
|
||||
|
||||
def _illustration_prompt(day: dict, outfits: dict, focus_label: str) -> str:
|
||||
high = day.get('temperature')
|
||||
low = day.get('templow')
|
||||
pop = day.get('precipitation_probability')
|
||||
wind = day.get('wind_speed')
|
||||
weather_bits = [day.get('description', 'conditions du jour')]
|
||||
if high is not None:
|
||||
weather_bits.append(f'température maximale {high}°C')
|
||||
if low is not None:
|
||||
weather_bits.append(f'température minimale {low}°C')
|
||||
if pop is not None:
|
||||
weather_bits.append(f'probabilité de pluie {pop}%')
|
||||
if wind is not None:
|
||||
weather_bits.append(f'vent {wind} km/h')
|
||||
return (
|
||||
'Crée une illustration verticale propre pour un tableau météo d’un assistant domestique. '
|
||||
'Montre en pied deux personnages côte à côte, style illustration moderne semi-réaliste douce, lisible, familiale, fond simple et discret. '
|
||||
'À gauche : une fille de 8 ans. À droite : un homme adulte de 42 ans. '
|
||||
f'Contexte météo pour {focus_label}: ' + ', '.join(weather_bits) + '. '
|
||||
'Habille les personnages selon les recommandations suivantes. '
|
||||
f"Fille 8 ans: vêtements {', '.join(outfits['child']['clothes'])}; accessoires {', '.join(outfits['child']['accessories']) or 'aucun accessoire particulier'}. "
|
||||
f"Homme 42 ans: vêtements {', '.join(outfits['adult']['clothes'])}; accessoires {', '.join(outfits['adult']['accessories']) or 'aucun accessoire particulier'}. "
|
||||
'Aucun texte dans l’image. Montrer clairement les tenues et accessoires conseillés. '
|
||||
'Composition propre, adaptée à une interface d’assistant, haute qualité.'
|
||||
)
|
||||
|
||||
|
||||
def _generate_illustration(day: dict, outfits: dict, focus_label: str, refresh: bool = False) -> dict | None:
|
||||
if not ENABLED:
|
||||
return None
|
||||
sig = _weather_signature(day, outfits)
|
||||
cache = _load_cache()
|
||||
entry = cache.get(sig)
|
||||
now_ts = time.time()
|
||||
if entry and not refresh and (now_ts - float(entry.get('cached_at', 0))) < TTL_HOURS * 3600:
|
||||
entry['from_cache'] = True
|
||||
return entry
|
||||
|
||||
prompt = _illustration_prompt(day, outfits, focus_label)
|
||||
prompt_id = _submit_workflow(prompt)
|
||||
history = _wait_history(prompt_id)
|
||||
images = _extract_images(history)
|
||||
if not images:
|
||||
raise RuntimeError('ComfyUI a terminé sans image de sortie pour l’illustration météo.')
|
||||
entry = {
|
||||
'url': images[0]['url'],
|
||||
'filename': images[0]['filename'],
|
||||
'prompt_id': prompt_id,
|
||||
'prompt': prompt,
|
||||
'cached_at': now_ts,
|
||||
'from_cache': False,
|
||||
}
|
||||
cache[sig] = entry
|
||||
_save_cache(cache)
|
||||
return entry
|
||||
|
||||
|
||||
def build_payload(q: str = '', refresh: bool = False) -> dict:
|
||||
data = ha_weather.get_weather(q)
|
||||
day = _pick_day(data)
|
||||
outfits = _recommend(day)
|
||||
illustration = _generate_illustration(day, outfits, data.get('focus_label', 'aujourd’hui'), refresh=refresh)
|
||||
return {
|
||||
'type': 'weather_outfits',
|
||||
'focus_label': data.get('focus_label', 'aujourd’hui'),
|
||||
'date': day.get('date'),
|
||||
'condition': day.get('condition'),
|
||||
'description': day.get('description'),
|
||||
'temperature': day.get('temperature'),
|
||||
'templow': day.get('templow'),
|
||||
'precipitation_probability': day.get('precipitation_probability'),
|
||||
'wind_speed': day.get('wind_speed'),
|
||||
'summary': outfits.get('summary'),
|
||||
'child': outfits['child'],
|
||||
'adult': outfits['adult'],
|
||||
'image': illustration,
|
||||
}
|
||||
Reference in New Issue
Block a user