Plugin Home Assistant (scheda Casa) e motore interno: ragionamento a scelta, formato Spark, cache del prompt esatta

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Fable 5.1 committed 2026-09-26 12:35:38 +02:00
1 parent 209228aea0
commit 59027d1d5b
6 files changed
+444 -29

No files matched your search

+2 -2
View File
@@ -181,7 +181,7 @@ class Assistant:
s = self.settings s = self.settings
provider = s.get("provider") provider = s.get("provider")
self.name, self.user_name = _identity() self.name, self.user_name = _identity()
key = (provider, s.get("effort"), s.get("local_base_url"), s.get("local_model"), s.get("mlx_model"), s.get("search_api_key"), key = (provider, s.get("effort"), s.get("local_base_url"), s.get("local_model"), s.get("mlx_model"), s.get("mlx_thinking"), s.get("search_api_key"),
s.get("claudecode_model"), s.get("claudecode_access"), s.get("claudecode_config_dir"), s.get("claudecode_model"), s.get("claudecode_access"), s.get("claudecode_config_dir"),
s.get("claudecode_path"), self.name, self.user_name, s.get_secret("anthropic_api_key")[-6:], s.get("claudecode_path"), self.name, self.user_name, s.get_secret("anthropic_api_key")[-6:],
s.get_secret("local_api_key")[-4:]) s.get_secret("local_api_key")[-4:])
@@ -191,7 +191,7 @@ class Assistant:
if old is not None and hasattr(old, "close"): if old is not None and hasattr(old, "close"):
old.close() # libera il modello interno dalla memoria old.close() # libera il modello interno dalla memoria
if provider == "mlx": if provider == "mlx":
self._engine = MLXEngine(s.get("mlx_model") or "", s.get("search_api_key"), self.name, self.user_name) self._engine = MLXEngine(s.get("mlx_model") or "", s.get("search_api_key"), self.name, self.user_name, s.get("mlx_thinking") or "auto")
elif provider == "local": elif provider == "local":
if s.get("local_base_url") and s.get("local_model"): if s.get("local_base_url") and s.get("local_model"):
self._engine = OpenAICompatEngine(s.get("local_base_url"), s.get("local_model"), s.get_secret("local_api_key"), self._engine = OpenAICompatEngine(s.get("local_base_url"), s.get("local_model"), s.get_secret("local_api_key"),
+67 -24
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import copy import copy
import json import json
import os
import re import re
import threading import threading
from pathlib import Path from pathlib import Path
@@ -21,6 +22,8 @@ from .openai_compat import SEARCH_TOOL, Aborted
MAX_ROUNDS = 8 MAX_ROUNDS = 8
MAX_HISTORY = 30 MAX_HISTORY = 30
MAX_TOKENS = 3000 MAX_TOKENS = 3000
SAMPLING = {"temp": 0.6, "top_p": 0.8, "top_k": 20} # poco sotto i valori consigliati da Qwen: più costante nell'uso degli strumenti
DEBUG = bool(os.environ.get("MLX_DEBUG")) # stampa il testo grezzo generato (tag compresi)
DEFAULT_MODEL = "mlx-community/Qwen3-30B-A3B-Instruct-2507-4bit" DEFAULT_MODEL = "mlx-community/Qwen3-30B-A3B-Instruct-2507-4bit"
HUB = Path.home() / ".cache" / "huggingface" / "hub" HUB = Path.home() / ".cache" / "huggingface" / "hub"
_EXCLUDE = ("whisper", "tts", "embed", "rerank", "clip", "vl-", "-vl", "vision", "diffusion", "parakeet") _EXCLUDE = ("whisper", "tts", "embed", "rerank", "clip", "vl-", "-vl", "vision", "diffusion", "parakeet")
@@ -47,6 +50,10 @@ def load_model(name: str):
if _loaded["name"] == name and _loaded["model"] is not None: if _loaded["name"] == name and _loaded["model"] is not None:
return _loaded["model"], _loaded["tokenizer"] return _loaded["model"], _loaded["tokenizer"]
unload() unload()
try:
import optiq # noqa: F401 registra architetture extra (es. spark2_5) in mlx-lm, se installato
except Exception:
pass
from mlx_lm import load from mlx_lm import load
model, tokenizer = load(name) model, tokenizer = load(name)
_loaded.update(name=name, model=model, tokenizer=tokenizer, cache=None, tokens=[], snap=None) _loaded.update(name=name, model=model, tokenizer=tokenizer, cache=None, tokens=[], snap=None)
@@ -78,7 +85,7 @@ class TagFilter:
def _emit(self, piece: str) -> str: def _emit(self, piece: str) -> str:
if self.strip_next: if self.strip_next:
piece = piece.lstrip() piece = piece.lstrip().lstrip("-").lstrip()
if piece: if piece:
self.strip_next = False self.strip_next = False
return piece return piece
@@ -128,10 +135,14 @@ class TagFilter:
return self._emit(c) return self._emit(c)
# Formati di chiamata: Hermes/Qwen (JSON in <tool_call>) e Gemma 4 (call:nome{chiave:<|"|>testo<|"|>,...}). # Formati di chiamata: Hermes/Qwen (JSON in <tool_call>), Gemma 4 (call:nome{chiave:<|"|>testo<|"|>,...}),
# argkey/Spark-GLM (<tool_call>nome<arg_key>k</arg_key><arg_value>v</arg_value>...</tool_call>).
# "thinking": alcuni modelli (Spark) usano gli strumenti in modo affidabile solo col ragionamento acceso.
# "raw_args": il template riproduce gli argomenti come stringa grezza (prompt identico a ciò che il modello ha scritto).
FORMATS = { FORMATS = {
"hermes": {"think": ("<think>", "</think>"), "call": ("<tool_call>", "</tool_call>")}, "hermes": {"think": ("<think>", "</think>"), "call": ("<tool_call>", "</tool_call>"), "thinking": False, "raw_args": False},
"gemma4": {"think": ("<|channel>", "<channel|>"), "call": ("<|tool_call>", "<tool_call|>")}, "gemma4": {"think": ("<|channel>", "<channel|>"), "call": ("<|tool_call>", "<tool_call|>"), "thinking": False, "raw_args": True},
"argkey": {"think": ("<think>", "</think>"), "call": ("<tool_call>", "</tool_call>"), "thinking": True, "raw_args": False},
} }
GEMMA_ESC = '<|"|>' GEMMA_ESC = '<|"|>'
@@ -141,7 +152,11 @@ def detect_format(tokenizer) -> str:
vocab = tokenizer.get_vocab() vocab = tokenizer.get_vocab()
except Exception: except Exception:
vocab = {} vocab = {}
return "gemma4" if "<|tool_call>" in vocab else "hermes" if "<|tool_call>" in vocab:
return "gemma4"
if "<arg_key>" in str(getattr(tokenizer, "chat_template", "") or ""):
return "argkey"
return "hermes"
def _gemma_to_json(body: str) -> str: def _gemma_to_json(body: str) -> str:
@@ -169,6 +184,18 @@ def _parse_call(raw: str, fmt: str) -> tuple[str, dict, str] | None:
except Exception: except Exception:
return None return None
return m.group(1), dict(args or {}), body[1:-1] return m.group(1), dict(args or {}), body[1:-1]
if fmt == "argkey" or "<arg_key>" in raw:
m = re.match(r"\s*([\w.-]+)\s*(.*)$", raw, re.S)
if not m:
return None
args = {}
for k, v in re.findall(r"<arg_key>(.*?)</arg_key>\s*<arg_value>(.*?)</arg_value>", m.group(2), re.S):
v = v.strip()
try:
args[k.strip()] = json.loads(v)
except Exception:
args[k.strip()] = v
return m.group(1), args, json.dumps(args, ensure_ascii=False)
try: try:
d = json.loads(raw) d = json.loads(raw)
except Exception: except Exception:
@@ -190,8 +217,9 @@ def _parse_call(raw: str, fmt: str) -> tuple[str, dict, str] | None:
class MLXEngine: class MLXEngine:
name = "mlx" name = "mlx"
def __init__(self, model_name: str, search_api_key: str, assistant_name: str, user_name: str) -> None: def __init__(self, model_name: str, search_api_key: str, assistant_name: str, user_name: str, thinking: str = "auto") -> None:
self.model_name = model_name or DEFAULT_MODEL self.model_name = model_name or DEFAULT_MODEL
self.thinking = thinking # auto | on | off
self.search_api_key = search_api_key self.search_api_key = search_api_key
self.assistant_name, self.user_name = assistant_name, user_name self.assistant_name, self.user_name = assistant_name, user_name
self.history = History("mlx") self.history = History("mlx")
@@ -232,7 +260,6 @@ class MLXEngine:
def _prefill(self, tokens: list[int], cut: int): def _prefill(self, tokens: list[int], cut: int):
"""Cache che contiene esattamente `tokens`, riusando la fotografia precedente se ne è un'estensione. """Cache che contiene esattamente `tokens`, riusando la fotografia precedente se ne è un'estensione.
La nuova fotografia viene scattata a `cut` (fine dell'ultimo messaggio): il turno successivo riparte da lì.""" La nuova fotografia viene scattata a `cut` (fine dell'ultimo messaggio): il turno successivo riparte da lì."""
from mlx_lm import stream_generate
from mlx_lm.models.cache import make_prompt_cache, trim_prompt_cache, can_trim_prompt_cache from mlx_lm.models.cache import make_prompt_cache, trim_prompt_cache, can_trim_prompt_cache
snap, cache, fed = _loaded["snap"], None, 0 snap, cache, fed = _loaded["snap"], None, 0
if snap and len(tokens) >= len(snap["tokens"]) and tokens[: len(snap["tokens"])] == snap["tokens"]: if snap and len(tokens) >= len(snap["tokens"]) and tokens[: len(snap["tokens"])] == snap["tokens"]:
@@ -249,17 +276,22 @@ class MLXEngine:
_loaded.update(cache=None, tokens=[]) _loaded.update(cache=None, tokens=[])
if cache is None: if cache is None:
cache = make_prompt_cache(self.model) cache = make_prompt_cache(self.model)
# max_tokens=1: il token campionato non entra nella cache, che contiene quindi esattamente i token passati
if cut > fed: if cut > fed:
for _ in stream_generate(self.model, self.tokenizer, tokens[fed:cut], max_tokens=1, prompt_cache=cache): self._feed(cache, tokens[fed:cut])
pass
_loaded["snap"] = {"tokens": list(tokens[:cut]), "cache": copy.deepcopy(cache)} _loaded["snap"] = {"tokens": list(tokens[:cut]), "cache": copy.deepcopy(cache)}
fed = cut fed = cut
if len(tokens) > fed: if len(tokens) > fed:
for _ in stream_generate(self.model, self.tokenizer, tokens[fed:], max_tokens=1, prompt_cache=cache): self._feed(cache, tokens[fed:])
pass
return cache return cache
def _feed(self, cache, tokens: list[int], step: int = 2048) -> None:
"""Inserisce esattamente `tokens` nella cache, senza campionare (stream_generate aggiungerebbe un token in più)."""
import mlx.core as mx
for i in range(0, len(tokens), step):
self.model(mx.array(tokens[i:i + step])[None], cache=cache)
mx.eval([c.state for c in cache])
mx.clear_cache()
def close(self) -> None: def close(self) -> None:
self.model = self.tokenizer = None self.model = self.tokenizer = None
unload() unload()
@@ -275,12 +307,19 @@ class MLXEngine:
self.model, self.tokenizer = load_model(self.model_name) self.model, self.tokenizer = load_model(self.model_name)
self.fmt = detect_format(self.tokenizer) self.fmt = detect_format(self.tokenizer)
def _thinking(self) -> bool:
if self.thinking == "on":
return True
if self.thinking == "off":
return False
return FORMATS[self.fmt]["thinking"]
def _system(self) -> str: def _system(self) -> str:
note = ("\n\nRegole sugli strumenti (obbligatorie):\n" note = ("\n\nRegole sugli strumenti (obbligatorie):\n"
"- Quando l'utente ti chiede di ricordare qualcosa, o ti dice un fatto importante su di sé, DEVI chiamare salva_memoria prima di rispondere. Non dire mai di aver salvato senza averlo chiamato davvero.\n" "- Quando l'utente ti chiede di ricordare qualcosa, o ti dice un fatto importante su di sé, DEVI chiamare salva_memoria prima di rispondere. Non dire mai di aver salvato senza averlo chiamato davvero.\n"
"- Per cancellare una memoria chiama dimentica_memoria; per cercarne una non presente nel prompt chiama cerca_memoria.\n" "- Per cancellare una memoria chiama dimentica_memoria; per cercarne una non presente nel prompt chiama cerca_memoria.\n"
"- Per azioni sul Mac (per esempio il calendario) usa gli strumenti dedicati. Se uno risponde con [CONFIRMATION_PENDING], chiedi all'utente di confermare sul pannello e non dire che è fatto.\n" "- Per azioni sul Mac (per esempio il calendario) usa gli strumenti dedicati. Se uno risponde con [CONFIRMATION_PENDING], chiedi all'utente di confermare sul pannello e non dire che è fatto.\n"
"- Non inventare mai dati reali (meteo, ora, calendario, mail, messaggi, file, stato dei server): se esiste uno strumento che li fornisce, chiamalo prima di rispondere.\n" "- Non inventare mai dati reali: meteo, calendario, mail, messaggi, file, ora, server e memoria li ottieni SOLO chiamando lo strumento corrispondente, anche a metà conversazione. Rispondere senza averlo chiamato è un errore grave.\n"
"- Chiama gli strumenti solo nel formato previsto dal tuo template e mai descrivendoli a parole.") "- Chiama gli strumenti solo nel formato previsto dal tuo template e mai descrivendoli a parole.")
note += ("\n- Per informazioni aggiornate chiama cerca_web e rispondi in base ai risultati." if self.search_api_key note += ("\n- Per informazioni aggiornate chiama cerca_web e rispondi in base ai risultati." if self.search_api_key
else "\n- Non hai accesso al web: se ti chiedono informazioni aggiornate, dillo chiaramente.") else "\n- Non hai accesso al web: se ti chiedono informazioni aggiornate, dillo chiaramente.")
@@ -302,21 +341,22 @@ class MLXEngine:
"""Prompt completo; con generation=False si ferma alla fine dell'ultimo messaggio (parte stabile).""" """Prompt completo; con generation=False si ferma alla fine dell'ultimo messaggio (parte stabile)."""
messages = [{"role": "system", "content": self._system()}, *self._recent()] messages = [{"role": "system", "content": self._system()}, *self._recent()]
try: try:
return self.tokenizer.apply_chat_template(messages, tools=self._tools(), add_generation_prompt=generation, tokenize=False, enable_thinking=False) return self.tokenizer.apply_chat_template(messages, tools=self._tools(), add_generation_prompt=generation, tokenize=False, enable_thinking=self._thinking())
except TypeError: except TypeError:
# Template senza supporto strumenti: li descrive nel sistema. # Template senza supporto strumenti: li descrive nel sistema.
messages[0]["content"] += "\n\nStrumenti disponibili (JSON):\n" + json.dumps(self._tools(), ensure_ascii=False) + \ messages[0]["content"] += "\n\nStrumenti disponibili (JSON):\n" + json.dumps(self._tools(), ensure_ascii=False) + \
"\nPer usarne uno scrivi solo: <tool_call>{\"name\": \"…\", \"arguments\": {…}}</tool_call>" "\nPer usarne uno scrivi solo: <tool_call>{\"name\": \"…\", \"arguments\": {…}}</tool_call>"
return self.tokenizer.apply_chat_template(messages, add_generation_prompt=generation, tokenize=False, enable_thinking=False) return self.tokenizer.apply_chat_template(messages, add_generation_prompt=generation, tokenize=False, enable_thinking=self._thinking())
def _prompt_tokens(self) -> tuple[list[int], int]: def _prompt_tokens(self) -> tuple[list[int], int, str]:
"""Token del prompt e lunghezza della parte stabile (comune al turno successivo).""" """Token del prompt, lunghezza della parte stabile (comune al turno successivo) e testo del prompt."""
tokens = list(self.tokenizer.encode(self._prompt())) prompt = self._prompt()
tokens = list(self.tokenizer.encode(prompt))
stable = list(self.tokenizer.encode(self._prompt(generation=False))) stable = list(self.tokenizer.encode(self._prompt(generation=False)))
cut = 0 cut = 0
while cut < min(len(tokens), len(stable)) and tokens[cut] == stable[cut]: while cut < min(len(tokens), len(stable)) and tokens[cut] == stable[cut]:
cut += 1 cut += 1
return tokens, cut return tokens, cut, prompt
def _run_tool(self, name: str, args: dict, emit: Emit, sources: list) -> str: def _run_tool(self, name: str, args: dict, emit: Emit, sources: list) -> str:
if name == "cerca_web": if name == "cerca_web":
@@ -343,9 +383,10 @@ class MLXEngine:
from mlx_lm.sample_utils import make_sampler from mlx_lm.sample_utils import make_sampler
f = FORMATS[self.fmt] f = FORMATS[self.fmt]
think, tools, text, truncated = TagFilter(*f["think"], keep=False), TagFilter(*f["call"], keep=True), "", False think, tools, text, truncated = TagFilter(*f["think"], keep=False), TagFilter(*f["call"], keep=True), "", False
sampler = make_sampler(temp=0.7, top_p=0.8, top_k=20) sampler = make_sampler(**SAMPLING)
# Cache del prompt: la parte già vista (fotografia del turno precedente) non viene ricalcolata. # Cache del prompt: la parte già vista (fotografia del turno precedente) non viene ricalcolata.
tokens, cut = self._prompt_tokens() tokens, cut, prompt = self._prompt_tokens()
think.inside = prompt.rstrip().endswith(f["think"][0]) # il template ha già aperto il blocco di pensiero
cache = self._prefill(tokens[:-1], min(cut, len(tokens) - 1)) cache = self._prefill(tokens[:-1], min(cut, len(tokens) - 1))
generated: list[int] = [] generated: list[int] = []
try: try:
@@ -353,6 +394,8 @@ class MLXEngine:
if abort.is_set(): if abort.is_set():
raise Aborted() raise Aborted()
generated.append(r.token) generated.append(r.token)
if DEBUG:
print(r.text, end="", flush=True)
visible = tools.push(think.push(r.text)) visible = tools.push(think.push(r.text))
if visible: if visible:
if not announced: if not announced:
@@ -365,8 +408,8 @@ class MLXEngine:
except Aborted: except Aborted:
raise raise
else: else:
# generate_step inserisce nella cache ogni token emesso, tranne l'ultimo quando si ferma per limite. # generate_step inserisce nella cache ogni token emesso (anche l'ultimo, anche quando si ferma per limite).
_loaded.update(cache=cache, tokens=tokens + (generated[:-1] if truncated else generated)) _loaded.update(cache=cache, tokens=tokens + generated)
tail = tools.flush() tail = tools.flush()
if tail: if tail:
text += tail text += tail
@@ -389,7 +432,7 @@ class MLXEngine:
break break
self.history.messages.append({ self.history.messages.append({
"role": "assistant", "content": round_text.strip(), "role": "assistant", "content": round_text.strip(),
"tool_calls": [{"id": f"call_{rnd}_{i}", "type": "function", "function": {"name": n, "arguments": raw}} for i, (n, a, raw) in enumerate(calls)], "tool_calls": [{"id": f"call_{rnd}_{i}", "type": "function", "function": {"name": n, "arguments": raw if FORMATS[self.fmt]["raw_args"] else a}} for i, (n, a, raw) in enumerate(calls)],
}) })
for i, (n, a, raw) in enumerate(calls): for i, (n, a, raw) in enumerate(calls):
result = self._run_tool(n, a, emit, sources) result = self._run_tool(n, a, emit, sources)
+5
View File
@@ -37,3 +37,8 @@ Sei **Ava**, l'assistente personale di chi ti parla. Vivi in un'app sul suo Mac
- Con gli strumenti "server_…" tieni d'occhio i server Linux dell'utente: lo stato della flotta, gli allarmi e i backup li leggi subito dal monitoraggio; per diagnosi e interventi ("perché nginx dà 502?", "riavvia php-fpm su web1") inoltri la richiesta all'assistente sysadmin con server_chiedi e riferisci la sua risposta in breve. - Con gli strumenti "server_…" tieni d'occhio i server Linux dell'utente: lo stato della flotta, gli allarmi e i backup li leggi subito dal monitoraggio; per diagnosi e interventi ("perché nginx dà 502?", "riavvia php-fpm su web1") inoltri la richiesta all'assistente sysadmin con server_chiedi e riferisci la sua risposta in breve.
- Quando l'assistente sysadmin chiede l'approvazione di un comando, leggi all'utente cosa vuole fare e attendi la sua decisione prima di usare server_approva. Non approvare mai di tua iniziativa. - Quando l'assistente sysadmin chiede l'approvazione di un comando, leggi all'utente cosa vuole fare e attendi la sua decisione prima di usare server_approva. Non approvare mai di tua iniziativa.
- Se una risposta tarda, dillo e continua con server_attendi invece di inventare l'esito. - Se una risposta tarda, dillo e continua con server_attendi invece di inventare l'esito.
## Casa
- Con gli strumenti "casa_…" controlli la casa tramite Home Assistant: luci, prese, clima, tapparelle, media, scene e sensori. Se l'utente nomina un dispositivo che non trovi, cerca con casa_dispositivi prima di dire che non esiste; per comandi su più stanze usa casa_chiedi.
- Conferma a voce cosa hai fatto in poche parole ("Luce cucina accesa"). Serrature e allarme solo dopo la conferma esplicita dell'utente.
+3 -1
View File
@@ -18,6 +18,7 @@ DEFAULTS: dict[str, Any] = {
"local_base_url": "http://localhost:8000/v1", "local_base_url": "http://localhost:8000/v1",
"local_model": "", "local_model": "",
"mlx_model": "mlx-community/Qwen3-30B-A3B-Instruct-2507-4bit", # motore interno (mlx-lm), repo Hugging Face o cartella "mlx_model": "mlx-community/Qwen3-30B-A3B-Instruct-2507-4bit", # motore interno (mlx-lm), repo Hugging Face o cartella
"mlx_thinking": "auto", # ragionamento del modello interno: auto (secondo il modello) | on | off
"search_api_key": "", "search_api_key": "",
"claudecode_model": "sonnet", "claudecode_model": "sonnet",
"claudecode_access": "chat", # chat | read | full "claudecode_access": "chat", # chat | read | full
@@ -50,9 +51,10 @@ DEFAULTS: dict[str, Any] = {
"server_assist_ssh": "", # utente@host del server ponte (assistenza server), es. root@1.2.3.4 "server_assist_ssh": "", # utente@host del server ponte (assistenza server), es. root@1.2.3.4
"server_assist_dir": "/opt/aiserverassistance", # cartella del bot sul server ponte (contiene .env) "server_assist_dir": "/opt/aiserverassistance", # cartella del bot sul server ponte (contiene .env)
"server_assist_bot": "luzaserver_bot", # username Telegram del bot sysadmin "server_assist_bot": "luzaserver_bot", # username Telegram del bot sysadmin
"homeassistant_url": "http://homeassistant.local:8123", # Home Assistant (token nel portachiavi)
} }
SECRET_KEYS = ("anthropic_api_key", "local_api_key", "telegram_api_hash", "elevenlabs_api_key") SECRET_KEYS = ("anthropic_api_key", "local_api_key", "telegram_api_hash", "elevenlabs_api_key", "homeassistant_token")
class Settings: class Settings:
+41 -2
View File
@@ -1,7 +1,9 @@
"""Finestra impostazioni: motore, chiavi, voce e riconoscimento vocale.""" """Finestra impostazioni: motore, chiavi, voce e riconoscimento vocale."""
from __future__ import annotations from __future__ import annotations
import sys
import threading import threading
from pathlib import Path
from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtWidgets import (QApplication, QCheckBox, QComboBox, QDialog, QFormLayout, QFrame, QHBoxLayout, QLabel, QLineEdit, from PyQt6.QtWidgets import (QApplication, QCheckBox, QComboBox, QDialog, QFormLayout, QFrame, QHBoxLayout, QLabel, QLineEdit,
@@ -63,7 +65,7 @@ class SettingsDialog(QDialog):
self.tabs.addTab(sc, title) self.tabs.addTab(sc, title)
lay.addStretch(1) lay.addStretch(1)
return lay return lay
pg_motore, pg_voce, pg_msg, pg_mon = page("Motore"), page("Voce e avatar"), page("Messaggistica"), page("Monitor") pg_motore, pg_voce, pg_msg, pg_mon, pg_casa = page("Motore"), page("Voce e avatar"), page("Messaggistica"), page("Monitor"), page("Casa")
def add(lay, item): # inserisce prima dello stretch finale def add(lay, item): # inserisce prima dello stretch finale
if isinstance(item, QWidget): lay.insertWidget(lay.count() - 1, item) if isinstance(item, QWidget): lay.insertWidget(lay.count() - 1, item)
else: lay.insertLayout(lay.count() - 1, item) else: lay.insertLayout(lay.count() - 1, item)
@@ -117,6 +119,8 @@ class SettingsDialog(QDialog):
self.mlx_model.addItems(cached_models() or [DEFAULT_MODEL]) self.mlx_model.addItems(cached_models() or [DEFAULT_MODEL])
self.mlx_model.setEditText(s.get("mlx_model") or DEFAULT_MODEL) self.mlx_model.setEditText(s.get("mlx_model") or DEFAULT_MODEL)
f.addRow("Modello (repo Hugging Face mlx-community)", self.mlx_model) f.addRow("Modello (repo Hugging Face mlx-community)", self.mlx_model)
self.mlx_thinking = _combo([("auto", "Automatico (acceso solo dove serve, es. Spark)"), ("off", "Spento: risposte rapide"), ("on", "Acceso: più lento, meglio su domande complesse")], s.get("mlx_thinking") or "auto")
f.addRow("Ragionamento", self.mlx_thinking)
hint = QLabel("Elenco: modelli già scaricati. Un nome nuovo viene scaricato al primo uso. " hint = QLabel("Elenco: modelli già scaricati. Un nome nuovo viene scaricato al primo uso. "
"Con 32 GB di RAM: modelli fino a ~20 GB in 4 bit (Qwen3-30B-A3B è veloce e supporta gli strumenti). " "Con 32 GB di RAM: modelli fino a ~20 GB in 4 bit (Qwen3-30B-A3B è veloce e supporta gli strumenti). "
"Non tenere aperto anche VLLMac con lo stesso modello.") "Non tenere aperto anche VLLMac con lo stesso modello.")
@@ -243,6 +247,19 @@ class SettingsDialog(QDialog):
form5.addRow("Escludi dagli avvisi", self.mon_excl) form5.addRow("Escludi dagli avvisi", self.mon_excl)
add(pg_mon, form5) add(pg_mon, form5)
# ── Casa (Home Assistant) ─────────────────────────────────────────
form_ha = QFormLayout()
form_ha.addRow(QLabel("Home Assistant: crea un token di accesso a lunga durata dal tuo profilo (Sicurezza) e incollalo qui. Viene salvato nel portachiavi."))
self.ha_url = QLineEdit(str(s.get("homeassistant_url") or "")); self.ha_url.setPlaceholderText("http://homeassistant.local:8123")
form_ha.addRow("Indirizzo", self.ha_url)
row = QHBoxLayout()
self.ha_token = QLineEdit(); self.ha_token.setEchoMode(QLineEdit.EchoMode.Password)
self.ha_token.setPlaceholderText("•••••• (salvato)" if s.get_secret("homeassistant_token") else "token di accesso a lunga durata"); row.addWidget(self.ha_token, 1)
b = QPushButton("Verifica"); b.clicked.connect(self._ha_check); row.addWidget(b)
form_ha.addRow("Token", row)
self.ha_hint = QLabel(""); self.ha_hint.setWordWrap(True); form_ha.addRow("", self.ha_hint)
add(pg_casa, form_ha)
btns = QHBoxLayout(); btns.addStretch() btns = QHBoxLayout(); btns.addStretch()
cancel = QPushButton("Annulla"); cancel.clicked.connect(self.reject); btns.addWidget(cancel) cancel = QPushButton("Annulla"); cancel.clicked.connect(self.reject); btns.addWidget(cancel)
save = QPushButton("Salva"); save.setObjectName("primary"); save.clicked.connect(self._save); btns.addWidget(save) save = QPushButton("Salva"); save.setObjectName("primary"); save.clicked.connect(self._save); btns.addWidget(save)
@@ -390,7 +407,26 @@ class SettingsDialog(QDialog):
self._async.emit("el_error", str(err)[:120]) self._async.emit("el_error", str(err)[:120])
threading.Thread(target=work, daemon=True).start() threading.Thread(target=work, daemon=True).start()
def _ha_check(self) -> None:
vals = {"homeassistant_url": self.ha_url.text().strip().rstrip("/")}
if self.ha_token.text().strip():
vals["homeassistant_token"] = self.ha_token.text().strip()
self.settings.update(vals)
self.ha_hint.setText("Verifico…")
def go():
try:
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "plugins"))
import importlib
mod = importlib.import_module("home_assistant")
self._async.emit("ha", mod.verifica())
except Exception as err:
self._async.emit("ha", f"Errore: {err}")
threading.Thread(target=go, daemon=True).start()
def _on_async(self, kind: str, payload) -> None: def _on_async(self, kind: str, payload) -> None:
if kind == "ha":
self.ha_hint.setText(str(payload)); return
if kind == "vb": if kind == "vb":
self.vb_profile.clear() self.vb_profile.clear()
for pid, label in payload: for pid, label in payload:
@@ -456,7 +492,7 @@ class SettingsDialog(QDialog):
values = { values = {
"provider": self.provider.currentData(), "effort": self.effort.currentData(), "provider": self.provider.currentData(), "effort": self.effort.currentData(),
"local_base_url": self.local_url.text().strip().rstrip("/"), "local_model": self.local_model.currentText().strip(), "local_base_url": self.local_url.text().strip().rstrip("/"), "local_model": self.local_model.currentText().strip(),
"mlx_model": self.mlx_model.currentText().strip(), "mlx_model": self.mlx_model.currentText().strip(), "mlx_thinking": self.mlx_thinking.currentData() or "auto",
"search_api_key": self.search_key.text().strip(), "search_api_key": self.search_key.text().strip(),
"claudecode_model": self.cc_model.currentData(), "claudecode_access": self.cc_access.currentData(), "claudecode_model": self.cc_model.currentData(), "claudecode_access": self.cc_access.currentData(),
"claudecode_config_dir": self.cc_config.currentText().strip(), "claudecode_path": self.cc_path.text().strip(), "claudecode_config_dir": self.cc_config.currentText().strip(), "claudecode_path": self.cc_path.text().strip(),
@@ -480,7 +516,10 @@ class SettingsDialog(QDialog):
"server_assist_ssh": self.srv_ssh.text().strip(), "server_assist_ssh": self.srv_ssh.text().strip(),
"server_assist_dir": self.srv_dir.text().strip() or "/opt/aiserverassistance", "server_assist_dir": self.srv_dir.text().strip() or "/opt/aiserverassistance",
"server_assist_bot": self.srv_bot.text().strip().lstrip("@") or "luzaserver_bot", "server_assist_bot": self.srv_bot.text().strip().lstrip("@") or "luzaserver_bot",
"homeassistant_url": self.ha_url.text().strip().rstrip("/"),
} }
if self.ha_token.text().strip():
values["homeassistant_token"] = self.ha_token.text().strip()
if self.tg_hash.text().strip(): if self.tg_hash.text().strip():
values["telegram_api_hash"] = self.tg_hash.text().strip() values["telegram_api_hash"] = self.tg_hash.text().strip()
if self.api_key.text().strip(): if self.api_key.text().strip():
+326
View File
@@ -0,0 +1,326 @@
"""Home Assistant: dispositivi, stati, accensione/spegnimento, regolazioni, scene, servizi e Assist.
Usa l'API REST con un token di accesso a lunga durata (Profilo > Sicurezza in Home Assistant),
salvato nel portachiavi. Indirizzo e token si impostano nella scheda "Casa" delle impostazioni.
"""
from __future__ import annotations
import json
import re
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from avatar.macos import confirm_or_param, CONFERMATO # noqa: E402
from avatar.settings import Settings # noqa: E402
DOMINI_IT = {"light": "luce", "switch": "interruttore", "fan": "ventilatore", "climate": "clima", "cover": "tapparella",
"media_player": "media", "lock": "serratura", "sensor": "sensore", "binary_sensor": "sensore", "scene": "scena",
"script": "script", "automation": "automazione", "vacuum": "aspirapolvere", "camera": "telecamera",
"alarm_control_panel": "allarme", "humidifier": "umidificatore", "water_heater": "scaldabagno", "input_boolean": "opzione"}
AZIONABILI = ("light", "switch", "fan", "climate", "cover", "media_player", "lock", "scene", "script", "automation",
"vacuum", "humidifier", "water_heater", "input_boolean", "siren", "remote")
DELICATI = ("lock", "alarm_control_panel", "siren") # richiedono conferma
_cache: dict = {"t": 0.0, "items": []}
def _cfg() -> tuple[str, str]:
s = Settings()
url = str(s.get("homeassistant_url") or "").strip().rstrip("/")
tok = s.get_secret("homeassistant_token")
if not url or not tok:
raise RuntimeError("Home Assistant non configurato: indirizzo e token di accesso nella scheda Casa delle impostazioni.")
return url, tok
def api(path: str, data: dict | None = None, timeout: int = 15):
url, tok = _cfg()
req = urllib.request.Request(url + path, method="POST" if data is not None else "GET",
headers={"Authorization": f"Bearer {tok}", "Content-Type": "application/json"},
data=json.dumps(data).encode() if data is not None else None)
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
raw = r.read()
return json.loads(raw) if raw else None
except urllib.error.HTTPError as e:
if e.code == 401:
raise RuntimeError("Home Assistant rifiuta il token (401): rigeneralo dal tuo profilo e incollalo nelle impostazioni.")
raise RuntimeError(f"Home Assistant: HTTP {e.code} {e.read()[:200].decode(errors='replace')}")
except urllib.error.URLError as e:
raise RuntimeError(f"Home Assistant non raggiungibile ({e.reason}).")
def verifica() -> str:
c = api("/api/config")
return f"Collegato a «{c.get('location_name')}», Home Assistant {c.get('version')}, {len(entita(force=True))} entità."
def entita(force: bool = False) -> list[dict]:
"""Elenco entità con nome, stato e area (via template; ripiego su /api/states)."""
if not force and time.time() - _cache["t"] < 60 and _cache["items"]:
return _cache["items"]
items = []
try:
tpl = "{% for s in states %}{{ s.entity_id }}\t{{ s.name }}\t{{ s.state }}\t{{ area_name(s.entity_id) or '' }}\n{% endfor %}"
url, tok = _cfg()
req = urllib.request.Request(url + "/api/template", method="POST", data=json.dumps({"template": tpl}).encode(),
headers={"Authorization": f"Bearer {tok}", "Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=20) as r:
for line in r.read().decode().splitlines():
p = line.split("\t")
if len(p) >= 3:
items.append({"id": p[0], "nome": p[1], "stato": p[2], "area": p[3] if len(p) > 3 else ""})
except Exception:
for s in api("/api/states") or []:
items.append({"id": s["entity_id"], "nome": s.get("attributes", {}).get("friendly_name", s["entity_id"]), "stato": s.get("state", ""), "area": ""})
_cache.update(t=time.time(), items=items)
return items
def _norm(s: str) -> str:
return re.sub(r"[^a-z0-9]+", " ", (s or "").lower()).strip()
def trova(nome: str, dominio: str = "") -> dict | None:
"""Entità per id, nome esatto, nome parziale o parole in comune (nome + area)."""
nome = (nome or "").strip()
if not nome:
return None
items = entita()
if dominio:
items = [e for e in items if e["id"].startswith(dominio + ".")]
for e in items:
if e["id"] == nome:
return e
n = _norm(nome)
for e in items:
if _norm(e["nome"]) == n:
return e
words = set(n.split())
best, score = None, 0
for e in items:
hay = set(_norm(e["nome"] + " " + e["area"] + " " + e["id"].split(".", 1)[-1].replace("_", " ")).split())
dom_it = DOMINI_IT.get(e["id"].split(".")[0], "")
if dom_it:
hay.add(dom_it)
common = len(words & hay)
if n in _norm(e["nome"]):
common += 2
if common > score:
best, score = e, common
return best if score > 0 else None
def _lista(items: list[dict], limite: int) -> str:
out = []
for e in items[:limite]:
area = f" ({e['area']})" if e["area"] else ""
out.append(f"- {e['nome']}{area}: {e['stato']} [{e['id']}]")
if len(items) > limite:
out.append(f"… e altre {len(items) - limite}")
return "\n".join(out)
def dispositivi(params: dict, ctx: dict) -> str:
filtro = _norm(str(params.get("filtro", "")))
dominio = str(params.get("dominio", "")).strip().lower()
items = entita(force=True)
if dominio:
items = [e for e in items if e["id"].split(".")[0] == dominio or DOMINI_IT.get(e["id"].split(".")[0]) == dominio]
else:
items = [e for e in items if e["id"].split(".")[0] in AZIONABILI or e["id"].split(".")[0] in ("sensor", "binary_sensor", "person", "weather")]
if filtro:
items = [e for e in items if filtro in _norm(e["nome"] + " " + e["area"] + " " + e["id"])]
if not items:
return "Nessun dispositivo corrisponde." + ("" if filtro else " Home Assistant non espone entità azionabili.")
if not filtro and not dominio:
# panoramica compatta per area e dominio
by_area: dict[str, list] = {}
for e in items:
if e["id"].split(".")[0] in AZIONABILI:
by_area.setdefault(e["area"] or "senza area", []).append(e)
out = [f"Dispositivi azionabili: {sum(len(v) for v in by_area.values())} in {len(by_area)} aree."]
for area, lst in sorted(by_area.items()):
out.append(f"{area}: " + ", ".join(f"{e['nome']} ({e['stato']})" for e in lst[:12]) + (f", +{len(lst)-12}" if len(lst) > 12 else ""))
return "\n".join(out) + "\nPer i dettagli usa casa_dispositivi con filtro o dominio."
return _lista(items, max(5, min(int(params.get("limite") or 40), 100)))
def stato(params: dict, ctx: dict) -> str:
e = trova(str(params.get("entita", "")))
if not e:
return f"Non trovo '{params.get('entita')}' in Home Assistant. Prova casa_dispositivi con un filtro."
s = api(f"/api/states/{e['id']}") or {}
a = s.get("attributes", {})
utili = {k: v for k, v in a.items() if k in ("brightness", "color_temp", "rgb_color", "temperature", "current_temperature", "hvac_mode",
"humidity", "current_position", "volume_level", "media_title", "battery_level", "unit_of_measurement", "device_class", "percentage")}
stato_txt = s.get("state", "?")
if a.get("unit_of_measurement"):
stato_txt += " " + str(a["unit_of_measurement"])
if "brightness" in a and a["brightness"] is not None:
utili["luminosita_pct"] = round(int(a["brightness"]) * 100 / 255)
det = ", ".join(f"{k}={v}" for k, v in utili.items() if k not in ("unit_of_measurement",))
return f"{e['nome']}{' (' + e['area'] + ')' if e['area'] else ''}: {stato_txt}" + (f" — {det}" if det else "") + f"\nAggiornato: {str(s.get('last_updated', ''))[:19].replace('T', ' ')}"
def _call(dominio: str, servizio: str, dati: dict) -> str:
api(f"/api/services/{dominio}/{servizio}", dati)
return "ok"
def _accendi_spegni(params: dict, ctx: dict, on: bool) -> str:
nome = str(params.get("entita", ""))
e = trova(nome)
if not e:
return f"Non trovo '{nome}'. Prova casa_dispositivi con un filtro."
dom = e["id"].split(".")[0]
if dom not in AZIONABILI:
return f"{e['nome']} è un {DOMINI_IT.get(dom, dom)}: non si può accendere o spegnere."
verbo = "Accendere" if on else "Spegnere"
def do() -> str:
_cache["t"] = 0
if dom == "lock":
_call("lock", "unlock" if on else "lock", {"entity_id": e["id"]})
return f"{e['nome']}: {'sbloccata' if on else 'bloccata'}."
if dom == "cover":
_call("cover", "open_cover" if on else "close_cover", {"entity_id": e["id"]})
return f"{e['nome']}: {'apro' if on else 'chiudo'}."
_call("homeassistant", "turn_on" if on else "turn_off", {"entity_id": e["id"]})
return f"{e['nome']}: {'accesa' if on else 'spenta'}."
if dom in DELICATI:
return confirm_or_param(ctx, params, "casa_delicato", f"{verbo} {e['nome']}?", f"{DOMINI_IT.get(dom, dom)} [{e['id']}]", do)
return do()
def accendi(params: dict, ctx: dict) -> str:
return _accendi_spegni(params, ctx, True)
def spegni(params: dict, ctx: dict) -> str:
return _accendi_spegni(params, ctx, False)
def imposta(params: dict, ctx: dict) -> str:
e = trova(str(params.get("entita", "")))
if not e:
return f"Non trovo '{params.get('entita')}'."
dom = e["id"].split(".")[0]
fatto = []
_cache["t"] = 0
if dom == "light":
dati = {"entity_id": e["id"]}
if params.get("luminosita") is not None:
dati["brightness_pct"] = max(0, min(100, int(params["luminosita"])))
fatto.append(f"luminosità {dati['brightness_pct']}%")
if params.get("colore"):
dati["color_name"] = str(params["colore"]).strip()
fatto.append(f"colore {dati['color_name']}")
if params.get("temperatura") is not None:
dati["kelvin"] = int(params["temperatura"])
fatto.append(f"{dati['kelvin']} K")
_call("light", "turn_on", dati)
elif dom == "climate":
if params.get("temperatura") is not None:
_call("climate", "set_temperature", {"entity_id": e["id"], "temperature": float(params["temperatura"])})
fatto.append(f"temperatura {params['temperatura']}°")
if params.get("modo"):
_call("climate", "set_hvac_mode", {"entity_id": e["id"], "hvac_mode": str(params["modo"])})
fatto.append(f"modalità {params['modo']}")
elif dom == "cover":
if params.get("posizione") is not None:
_call("cover", "set_cover_position", {"entity_id": e["id"], "position": max(0, min(100, int(params["posizione"])))})
fatto.append(f"posizione {params['posizione']}%")
elif dom == "media_player":
if params.get("volume") is not None:
_call("media_player", "volume_set", {"entity_id": e["id"], "volume_level": max(0, min(100, int(params["volume"]))) / 100})
fatto.append(f"volume {params['volume']}%")
if params.get("modo") in ("play", "pause", "stop", "next", "previous"):
svc = {"play": "media_play", "pause": "media_pause", "stop": "media_stop", "next": "media_next_track", "previous": "media_previous_track"}[params["modo"]]
_call("media_player", svc, {"entity_id": e["id"]})
fatto.append(params["modo"])
elif dom == "fan":
if params.get("luminosita") is not None or params.get("posizione") is not None:
pct = int(params.get("luminosita") if params.get("luminosita") is not None else params.get("posizione"))
_call("fan", "set_percentage", {"entity_id": e["id"], "percentage": max(0, min(100, pct))})
fatto.append(f"velocità {pct}%")
if not fatto:
return f"Per {e['nome']} ({DOMINI_IT.get(dom, dom)}) non ho una regolazione applicabile con questi parametri."
return f"{e['nome']}: " + ", ".join(fatto) + "."
def scena(params: dict, ctx: dict) -> str:
nome = str(params.get("nome", ""))
e = trova(nome, "scene") or trova(nome, "script") or trova(nome, "automation")
if not e:
return f"Non trovo la scena o lo script '{nome}'."
dom = e["id"].split(".")[0]
_call(dom, "turn_on" if dom == "scene" else ("trigger" if dom == "automation" else "turn_on"), {"entity_id": e["id"]})
return f"{DOMINI_IT.get(dom, dom).capitalize()} «{e['nome']}» avviata."
def servizio(params: dict, ctx: dict) -> str:
dom = str(params.get("dominio", "")).strip()
svc = str(params.get("servizio", "")).strip()
if not dom or not svc:
return "Errore: servono dominio e servizio (es. light / turn_on)."
dati = params.get("dati") or {}
if isinstance(dati, str):
try:
dati = json.loads(dati)
except Exception:
return "Errore: 'dati' deve essere un oggetto JSON."
if params.get("entita"):
e = trova(str(params["entita"]))
if not e:
return f"Non trovo '{params['entita']}'."
dati["entity_id"] = e["id"]
def do() -> str:
_cache["t"] = 0
_call(dom, svc, dati)
return f"Servizio {dom}.{svc} eseguito."
if dom in DELICATI or dom in ("homeassistant", "hassio", "shell_command", "persistent_notification") and svc not in ("turn_on", "turn_off", "toggle"):
return confirm_or_param(ctx, params, "casa_servizio", f"Eseguire {dom}.{svc}?", json.dumps(dati, ensure_ascii=False)[:300], do)
return do()
def chiedi(params: dict, ctx: dict) -> str:
testo = str(params.get("testo", "")).strip()
if not testo:
return "Errore: serve il testo del comando."
r = api("/api/conversation/process", {"text": testo, "language": "it"}) or {}
_cache["t"] = 0
try:
speech = r["response"]["speech"]["plain"]["speech"]
except Exception:
speech = ""
kind = (r.get("response") or {}).get("response_type", "")
if kind == "error":
return f"Assist non ha capito: {speech or 'nessun dettaglio'}. Prova con casa_dispositivi e gli strumenti diretti."
return speech or "Assist ha eseguito il comando senza commento."
TOOLS = [
{"name": "casa_dispositivi", "description": "Elenca i dispositivi di casa (Home Assistant) con stato e stanza. Senza parametri: panoramica per stanza. Con filtro (parola nel nome/stanza) o dominio (luce, interruttore, clima, tapparella, media, sensore, scena, …) l'elenco dettagliato con gli id.",
"parameters": {"type": "object", "properties": {"filtro": {"type": "string"}, "dominio": {"type": "string"}, "limite": {"type": "integer"}}}, "run": dispositivi},
{"name": "casa_stato", "description": "Stato e dettagli di un dispositivo o sensore di casa, per nome (es. 'luce cucina', 'temperatura salotto') o id.",
"parameters": {"type": "object", "properties": {"entita": {"type": "string"}}, "required": ["entita"]}, "run": stato},
{"name": "casa_accendi", "description": "Accende un dispositivo di casa per nome (luce, presa, ventilatore, clima, media, apre una tapparella, sblocca una serratura con conferma).",
"parameters": {"type": "object", "properties": {"entita": {"type": "string"}, "confermato": CONFERMATO}, "required": ["entita"]}, "run": accendi},
{"name": "casa_spegni", "description": "Spegne un dispositivo di casa per nome (chiude una tapparella, blocca una serratura con conferma).",
"parameters": {"type": "object", "properties": {"entita": {"type": "string"}, "confermato": CONFERMATO}, "required": ["entita"]}, "run": spegni},
{"name": "casa_imposta", "description": "Regola un dispositivo: luminosita (0-100) e colore (nome inglese es. red, warm white) o temperatura in kelvin per le luci; temperatura in gradi e modo (heat/cool/off/auto) per il clima; posizione (0-100) per tapparelle; volume (0-100) e modo (play/pause/stop/next/previous) per i media; luminosita come velocità per i ventilatori.",
"parameters": {"type": "object", "properties": {"entita": {"type": "string"}, "luminosita": {"type": "integer"}, "colore": {"type": "string"}, "temperatura": {"type": "number"}, "modo": {"type": "string"}, "posizione": {"type": "integer"}, "volume": {"type": "integer"}}, "required": ["entita"]}, "run": imposta},
{"name": "casa_scena", "description": "Attiva una scena, uno script o un'automazione di Home Assistant per nome (es. 'serata film', 'buonanotte').",
"parameters": {"type": "object", "properties": {"nome": {"type": "string"}}, "required": ["nome"]}, "run": scena},
{"name": "casa_servizio", "description": "Chiama un servizio generico di Home Assistant (dominio + servizio, es. vacuum/start, notify/mobile_app) con dati JSON e opzionale entità per nome. Per casi non coperti dagli altri strumenti; serrature, allarmi e sirene chiedono conferma.",
"parameters": {"type": "object", "properties": {"dominio": {"type": "string"}, "servizio": {"type": "string"}, "entita": {"type": "string"}, "dati": {"type": "object"}, "confermato": CONFERMATO}, "required": ["dominio", "servizio"]}, "run": servizio},
{"name": "casa_chiedi", "description": "Manda una frase in italiano ad Assist di Home Assistant (es. 'spegni tutte le luci del piano di sopra') e riporta la sua risposta. Utile per comandi su più dispositivi o quando non trovi l'entità.",
"parameters": {"type": "object", "properties": {"testo": {"type": "string"}}, "required": ["testo"]}, "run": chiedi},
]