#!/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