voice: локальный агент (fast-path), tool control_light, смелая тема, RUNBOOK
All checks were successful
Deploy / deploy (push) Successful in 3m43s

- lib/agent-local.ts + route.ts: команды-действия исполняет локальная intent-служба
  (под AGENT_URL, с fallback на LLM); инфо-запросы остаются у LLM
- lib/tools/lights.ts: голосовое управление светом через HA (control_light)
- app/globals.css + page.tsx: космическая indigo/aurora тема (токены, kiosk-safe)
- agent/: sidecar-служба (Dockerfile, predict.py), веса монтируются томом
- RUNBOOK.md: вывод в продакшен на мини-ПК
This commit is contained in:
d.klimov
2026-07-22 21:48:24 +03:00
parent d30ed1bac1
commit e6dab09c38
13 changed files with 975 additions and 32 deletions

178
agent/joint_model_def.py Normal file
View File

@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""Joint-модель планшета: один энкодер ruBERT-tiny2, две головы — интент и слоты.
Адаптация из smart-chat/mlx-chat/ft под домен smart-home-tablet. Машинерия
(энкодер + две головы, сегментация, сбор слота срезом исходной строки) — та же,
что в оригинале; изменены только INTENTS/SLOTS под инструменты планшета.
Головы читают один и тот же last_hidden_state, форвард один — скорость на
уровне обычного классификатора (~1-2 мс на CPU):
intent — по [CLS], классификация всей фразы;
slots — по каждому токену, BIO-теги (извлечение куска текста).
Значение слота достаём по символьным offsets ИСХОДНОЙ строки, а не склейкой
subword-токенов: «Биг сити лайф» иначе пришлось бы собирать из кусков.
"""
import json
import os
import re
import torch
import torch.nn as nn
from transformers import AutoModel, AutoTokenizer
BASE = "cointegrated/rubert-tiny2"
# --- Интенты: закрытые enum-действия закодированы в САМ интент -----------------
# Принцип: BIO-слоты хороши для свободного текста (город, трек, длительность),
# но плохи для выбора из фиксированного набора (направление транспорта,
# действие с музыкой, режим). Такие enum надёжнее сделать отдельными интентами,
# чем тащить отдельной головой. Поэтому 14 «широких» tools планшета развёрнуты
# в узкие интенты. Карта интент → tool: см. tablet_map.py.
# none — ПОСЛЕДНИМ: predict() возвращает cfg["intents"][-1] на пустой вход.
INTENTS = [
"weather_get", # get_weather (+ city)
"transport_to_center", # get_transport{to_center}
"transport_from_center", # get_transport{from_center}
"transport_all", # get_transport{all}
"calendar_today", # get_today_events{today}
"calendar_week", # get_today_events{week}
"calendar_create", # create_event (+ title,date,time,owner)
"timer_set", # set_timer (+ duration)
"timer_add", # adjust_timer{+} (+ duration)
"timer_subtract", # adjust_timer{-} (+ duration)
"timer_cancel", # cancel_timer
"notes_get", # get_notes
"home_state", # get_smart_home_state
"purifier_on", # control_air_purifier{turn_on}
"purifier_off", # control_air_purifier{turn_off}
"purifier_mode", # control_air_purifier{set_mode} (+ mode)
"light_on", # control_light{on} (+ device) — НОВЫЙ tool
"light_off", # control_light{off} (+ device) — НОВЫЙ tool
"music_play", # control_spotify{play + query} (+ query)
"music_pause", # control_spotify{pause}
"music_resume", # control_spotify{play}
"music_next", # control_spotify{next}
"music_prev", # control_spotify{previous}
"music_volume", # control_spotify{volume} (+ level)
"now_playing", # get_now_playing
"none", # посторонняя речь → fallback на облачный LLM
]
# --- Слоты: только СВОБОДНЫЙ текст (закрытые значения ушли в интенты) ----------
SLOTS = [
"city", # город для погоды: «в Питере», «Чаща»
"title", # название события: «встреча с врачом»
"date", # дата события: «25 июля», «завтра»
"time", # время события: «13 часов», «в 15:30»
"owner", # чей календарь: «Дани», «Свете»
"duration", # длительность таймера: «15 минут», «полтора часа»
"mode", # режим очистителя: «ночной», «авто»
"device", # какая лампа: «свет у кровати», «лампу для чтения»
"query", # что включить: «Ваню Дмитриенко», «Биг сити лайф»
"level", # громкость: «50», «30 процентов»
]
TAGS = ["O"] + [f"{p}-{s}" for s in SLOTS for p in ("B", "I")]
# Режем текст на слова И цифры И знаки препинания по отдельности — ровно так же,
# как при обучении (gen_slots.seg). Разбор целиком делает модель: «13:30.» →
# токены «13», «:», «30», «.», которым она сама ставит теги. Никакой
# постобработки регуляркой. Граница буква/цифра тоже режет («15минут» слитно).
TOKEN_RE = re.compile(r"[^\W\d_]+|\d+|[^\w\s]")
def segment(text):
"""Строка → [(токен, начало, конец)] с позициями в исходной строке.
Позиции нужны, чтобы собрать значение слота срезом ИСХОДНОГО текста:
«свет у кровати» — три токена, склеивать вручную = гадать про пробелы.
"""
return [(m.group(), m.start(), m.end()) for m in TOKEN_RE.finditer(text)]
class JointClassifier(nn.Module):
def __init__(self, base=BASE, n_intents=len(INTENTS), n_tags=len(TAGS)):
super().__init__()
self.bert = AutoModel.from_pretrained(base)
h = self.bert.config.hidden_size
self.dropout = nn.Dropout(0.1)
self.intent_head = nn.Linear(h, n_intents)
self.slot_head = nn.Linear(h, n_tags)
def forward(self, input_ids, attention_mask):
h = self.bert(input_ids=input_ids, attention_mask=attention_mask).last_hidden_state
h = self.dropout(h)
return self.intent_head(h[:, 0]), self.slot_head(h)
def save(model, tok, path):
os.makedirs(path, exist_ok=True)
torch.save(model.state_dict(), os.path.join(path, "joint.pt"))
tok.save_pretrained(path)
with open(os.path.join(path, "joint_config.json"), "w", encoding="utf-8") as f:
json.dump({"base": BASE, "intents": INTENTS, "tags": TAGS}, f,
ensure_ascii=False, indent=2)
def load(path, device="cpu"):
with open(os.path.join(path, "joint_config.json"), encoding="utf-8") as f:
cfg = json.load(f)
tok = AutoTokenizer.from_pretrained(path)
model = JointClassifier(cfg["base"], len(cfg["intents"]), len(cfg["tags"]))
model.load_state_dict(torch.load(os.path.join(path, "joint.pt"), map_location=device))
return model.to(device).eval(), tok, cfg
@torch.no_grad()
def predict(model, tok, cfg, text, device="cpu"):
"""Строка → (intent, {slot: значение}, score).
score — вероятность интента (softmax по голове интента). Потребитель
(route.ts) сравнивает с порогом: score < 0.7 → трактовать как none и падать
на облачный LLM.
Сегментация на инференсе — ровно как при обучении (segment + split_words),
иначе «13:30» рвётся иначе и теги съезжают.
"""
segs = segment(text)
if not segs:
return cfg["intents"][-1], {}, 1.0
words = [s[0] for s in segs]
enc = tok([words], is_split_into_words=True, truncation=True,
max_length=48, return_tensors="pt")
word_ids = enc.word_ids(0)
feed = {k: v.to(device) for k, v in enc.items()}
intent_logits, slot_logits = model(feed["input_ids"], feed["attention_mask"])
probs = torch.softmax(intent_logits[0], -1)
best = int(probs.argmax())
intent, score = cfg["intents"][best], float(probs[best])
tag_ids = slot_logits[0].argmax(-1).tolist()
# тег на слово — с первого subword, хвост игнорируем (у него при обучении -100)
word_tags, prev = {}, None
for tid, wid in zip(tag_ids, word_ids):
if wid is not None and wid != prev:
word_tags[wid] = cfg["tags"][tid]
prev = wid
# BIO-склейка: значение слота — срез исходного текста от начала первого
# токена спана до конца последнего.
slots, cur, start, end = {}, None, 0, 0
for i, (_, s, e) in enumerate(segs):
tag = word_tags.get(i, "O")
if tag.startswith("B-"):
if cur:
slots.setdefault(cur, text[start:end])
cur, start, end = tag[2:], s, e
elif tag.startswith("I-") and cur == tag[2:]:
end = e
else:
if cur:
slots.setdefault(cur, text[start:end])
cur = None
if cur:
slots.setdefault(cur, text[start:end])
slots = {k: v for k, v in slots.items() if v}
return intent, slots, score