Files
smart-home-tablet/agent/joint_model_def.py
d.klimov e6dab09c38
All checks were successful
Deploy / deploy (push) Successful in 3m43s
voice: локальный агент (fast-path), tool control_light, смелая тема, RUNBOOK
- 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: вывод в продакшен на мини-ПК
2026-07-22 21:48:24 +03:00

179 lines
9.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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