voice: локальный агент (fast-path), tool control_light, смелая тема, RUNBOOK
All checks were successful
Deploy / deploy (push) Successful in 3m43s
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:
4
agent/.gitignore
vendored
Normal file
4
agent/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# Веса дообученной модели (~115 МБ) — не в git. Переносятся на мини-ПК отдельно
|
||||
# (scp) и монтируются томом в контейнер. См. RUNBOOK.md, раздел «Агент».
|
||||
joint_model/
|
||||
__pycache__/
|
||||
29
agent/Dockerfile
Normal file
29
agent/Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
||||
# Sidecar-служба локального агента (intent+slots) для планшета.
|
||||
# route.ts зовёт её по HTTP (AGENT_URL) и исполняет команды-действия офлайн.
|
||||
#
|
||||
# Веса дообученной модели (joint_model/, ~115 МБ) в git НЕ идут и в образ НЕ
|
||||
# пекутся — они МОНТИРУЮТСЯ томом в /app/joint_model при запуске (см. RUNBOOK).
|
||||
# Базовая ruBERT-tiny2 запекается в образ при сборке, чтобы на старте не ходить
|
||||
# в интернет.
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
ENV HF_HUB_DISABLE_XET=1
|
||||
|
||||
# CPU-torch из cpu-индекса (без CUDA — образ легче).
|
||||
RUN pip install --no-cache-dir --index-url https://download.pytorch.org/whl/cpu torch \
|
||||
&& pip install --no-cache-dir transformers sentencepiece
|
||||
|
||||
# Запекаем базовую модель в кэш образа (runtime без сети).
|
||||
RUN python -c "from transformers import AutoModel, AutoTokenizer; \
|
||||
AutoModel.from_pretrained('cointegrated/rubert-tiny2'); \
|
||||
AutoTokenizer.from_pretrained('cointegrated/rubert-tiny2')"
|
||||
|
||||
COPY joint_model_def.py tablet_map.py predict.py ./
|
||||
|
||||
ENV THRESHOLD=0.7
|
||||
EXPOSE 8091
|
||||
|
||||
# Модель ждём в /app/joint_model (том). predict.py --serve поднимает HTTP:
|
||||
# POST /predict {"text": "..."} -> {intent, slots, score, tool}
|
||||
CMD ["python", "predict.py", "--serve", "--port", "8091"]
|
||||
178
agent/joint_model_def.py
Normal file
178
agent/joint_model_def.py
Normal 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
|
||||
104
agent/predict.py
Normal file
104
agent/predict.py
Normal file
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Инференс joint-модели планшета: CLI и HTTP-служба.
|
||||
|
||||
Разовый разбор:
|
||||
python predict.py "поставь таймер на 15 минут"
|
||||
python predict.py "включи Ваню Дмитриенко"
|
||||
|
||||
HTTP-служба (для app/api/voice/chat/route.ts как локальный «мозг»):
|
||||
python predict.py --serve --port 8091
|
||||
# POST /predict {"text": "..."} → {intent, slots, score, tool}
|
||||
# score < THRESHOLD → intent принудительно none (порог на стороне службы,
|
||||
# но потребитель всё равно решает сам падать ли на облачный LLM).
|
||||
|
||||
Порог THRESHOLD=0.7 по умолчанию (переопределяется env).
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import joint_model_def as jm
|
||||
import tablet_map
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
MODEL_DIR = os.path.join(HERE, "joint_model")
|
||||
THRESHOLD = float(os.environ.get("THRESHOLD", "0.7"))
|
||||
|
||||
_model = _tok = _cfg = None
|
||||
|
||||
|
||||
def _ensure_loaded():
|
||||
global _model, _tok, _cfg
|
||||
if _model is None:
|
||||
_model, _tok, _cfg = jm.load(MODEL_DIR)
|
||||
jm.predict(_model, _tok, _cfg, "прогрев") # первый форвард медленнее
|
||||
|
||||
|
||||
def analyze(text):
|
||||
_ensure_loaded()
|
||||
intent, slots, score = jm.predict(_model, _tok, _cfg, text)
|
||||
if score < THRESHOLD:
|
||||
intent = "none"
|
||||
tool = tablet_map.to_tool_call(intent, slots) if intent != "none" else None
|
||||
return {"intent": intent, "slots": slots, "score": round(score, 3), "tool": tool}
|
||||
|
||||
|
||||
def serve(port):
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def _send(self, code, obj):
|
||||
body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/health":
|
||||
self._send(200, {"ok": True})
|
||||
else:
|
||||
self._send(404, {"error": "not found"})
|
||||
|
||||
def do_POST(self):
|
||||
if self.path != "/predict":
|
||||
return self._send(404, {"error": "not found"})
|
||||
n = int(self.headers.get("Content-Length", 0))
|
||||
try:
|
||||
data = json.loads(self.rfile.read(n) or b"{}")
|
||||
text = (data.get("text") or "").strip()
|
||||
except Exception:
|
||||
return self._send(400, {"error": "bad json"})
|
||||
if not text:
|
||||
return self._send(400, {"error": "empty text"})
|
||||
self._send(200, analyze(text))
|
||||
|
||||
def log_message(self, *a):
|
||||
pass # тихо
|
||||
|
||||
_ensure_loaded()
|
||||
print(f"tablet-agent слушает http://127.0.0.1:{port} (порог {THRESHOLD})")
|
||||
HTTPServer(("127.0.0.1", port), Handler).serve_forever()
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("text", nargs="*", help="запрос для разбора")
|
||||
ap.add_argument("--serve", action="store_true", help="поднять HTTP-службу")
|
||||
ap.add_argument("--port", type=int, default=8091)
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.serve:
|
||||
serve(args.port)
|
||||
return
|
||||
|
||||
if not args.text:
|
||||
print("Использование: python predict.py \"текст запроса\"")
|
||||
sys.exit(1)
|
||||
print(json.dumps(analyze(" ".join(args.text)), ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
4
agent/requirements.txt
Normal file
4
agent/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
# Инференс joint-модели агента (CPU). Совпадает с tablet-agent/requirements.txt.
|
||||
torch>=2.2
|
||||
transformers>=4.40
|
||||
sentencepiece
|
||||
155
agent/tablet_map.py
Normal file
155
agent/tablet_map.py
Normal file
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Карта: интент joint-модели → вызов tool планшета (smart-home-tablet).
|
||||
|
||||
Модель отдаёт (intent, slots) — «что сделать» и «куски текста параметров».
|
||||
Приведение к боевому tool-call (направление, действие, режим в enum; «15 минут»
|
||||
→ секунды; «Дани» → daniil) — здесь, на стороне потребителя. Модель конвенций
|
||||
приложения не знает, она видела только текст запроса.
|
||||
|
||||
Это референс для интеграции в app/api/voice/chat/route.ts. На проде логика
|
||||
переписывается на TypeScript, но соответствие интент→tool берётся отсюда.
|
||||
"""
|
||||
import re
|
||||
|
||||
# intent → (tool_name, фиксированные аргументы). Слоты домешиваются отдельно.
|
||||
INTENT_TO_TOOL = {
|
||||
"weather_get": ("get_weather", {}),
|
||||
"transport_to_center": ("get_transport", {"direction": "to_center"}),
|
||||
"transport_from_center": ("get_transport", {"direction": "from_center"}),
|
||||
"transport_all": ("get_transport", {"direction": "all"}),
|
||||
"calendar_today": ("get_today_events", {"range": "today"}),
|
||||
"calendar_week": ("get_today_events", {"range": "week"}),
|
||||
"calendar_create": ("create_event", {}),
|
||||
"timer_set": ("set_timer", {}),
|
||||
"timer_add": ("adjust_timer", {"sign": +1}),
|
||||
"timer_subtract": ("adjust_timer", {"sign": -1}),
|
||||
"timer_cancel": ("cancel_timer", {}),
|
||||
"notes_get": ("get_notes", {}),
|
||||
"home_state": ("get_smart_home_state", {}),
|
||||
"purifier_on": ("control_air_purifier", {"action": "turn_on"}),
|
||||
"purifier_off": ("control_air_purifier", {"action": "turn_off"}),
|
||||
"purifier_mode": ("control_air_purifier", {"action": "set_mode"}),
|
||||
"light_on": ("control_light", {"action": "turn_on"}), # НОВЫЙ tool
|
||||
"light_off": ("control_light", {"action": "turn_off"}), # НОВЫЙ tool
|
||||
"music_play": ("control_spotify", {"action": "play"}),
|
||||
"music_pause": ("control_spotify", {"action": "pause"}),
|
||||
"music_resume": ("control_spotify", {"action": "play"}),
|
||||
"music_next": ("control_spotify", {"action": "next"}),
|
||||
"music_prev": ("control_spotify", {"action": "previous"}),
|
||||
"music_volume": ("control_spotify", {"action": "volume"}),
|
||||
"now_playing": ("get_now_playing", {}),
|
||||
}
|
||||
|
||||
OWNER_MAP = {
|
||||
"дани": "daniil", "даниил": "daniil", "даниила": "daniil",
|
||||
"даниилу": "daniil", "даня": "daniil", "мне": "daniil", "мой": "daniil",
|
||||
"света": "sveta", "свете": "sveta", "светы": "sveta", "свету": "sveta",
|
||||
}
|
||||
|
||||
MODE_MAP = {
|
||||
"авто": "Auto", "автоматический": "Auto",
|
||||
"ночной": "Night", "ночь": "Night",
|
||||
"высокий": "High", "максимальный": "High", "турбо": "High",
|
||||
"максимум": "High", "тихий": "Night", "низкий": "Night",
|
||||
}
|
||||
|
||||
_NUM_WORDS = {
|
||||
"полминуты": 30, "полчаса": 1800, "минуту": 60, "минуты": 60, "минут": 60,
|
||||
"полторы": 90, "полтора": 5400, "пару": 120, "несколько": 180,
|
||||
}
|
||||
|
||||
|
||||
def parse_duration_seconds(text):
|
||||
"""«15 минут» → 900, «полтора часа» → 5400, «30 секунд» → 30.
|
||||
|
||||
Грубый разбор для set/adjust таймера. На проде можно взять готовую
|
||||
JS-либу разбора длительности; здесь — референс.
|
||||
"""
|
||||
t = text.lower().strip()
|
||||
if t in ("полчаса",):
|
||||
return 1800
|
||||
if t in ("полминуты",):
|
||||
return 30
|
||||
if t in ("полтора часа", "полтора",):
|
||||
return 5400
|
||||
if t in ("полторы минуты",):
|
||||
return 90
|
||||
m = re.search(r"(\d+)", t)
|
||||
n = int(m.group(1)) if m else 1
|
||||
if "сек" in t:
|
||||
return n
|
||||
if "час" in t:
|
||||
return n * 3600
|
||||
if "мин" in t:
|
||||
return n * 60
|
||||
return n * 60 # по умолчанию минуты
|
||||
|
||||
|
||||
def to_tool_call(intent, slots):
|
||||
"""(intent, slots) → {tool, args} или None для none/неизвестного."""
|
||||
if intent not in INTENT_TO_TOOL:
|
||||
return None
|
||||
tool, args = INTENT_TO_TOOL[intent]
|
||||
args = dict(args)
|
||||
|
||||
if intent == "weather_get" and "city" in slots:
|
||||
# снять предлог: «в Питере» → «Питере» (город приложение нормализует само)
|
||||
args["city"] = re.sub(r"^в\s+", "", slots["city"], flags=re.I)
|
||||
|
||||
elif intent == "calendar_create":
|
||||
if "title" in slots:
|
||||
args["title"] = slots["title"]
|
||||
if "date" in slots:
|
||||
args["date"] = slots["date"] # «25 июля»/«завтра» → YYYY-MM-DD на стороне приложения
|
||||
if "time" in slots:
|
||||
args["start_time"] = slots["time"]
|
||||
args["owner"] = OWNER_MAP.get(slots.get("owner", "").lower().strip(), "daniil")
|
||||
|
||||
elif intent in ("timer_set",):
|
||||
if "duration" in slots:
|
||||
args["seconds"] = parse_duration_seconds(slots["duration"])
|
||||
args.setdefault("label", "таймер")
|
||||
|
||||
elif intent in ("timer_add", "timer_subtract"):
|
||||
sign = args.pop("sign", 1)
|
||||
secs = parse_duration_seconds(slots.get("duration", "1 минута"))
|
||||
args["delta_seconds"] = sign * secs
|
||||
args.setdefault("label", "таймер")
|
||||
|
||||
elif intent == "purifier_mode":
|
||||
raw = slots.get("mode", "").lower().strip()
|
||||
# точное совпадение, затем подстрока (страховка от «ночной режим» и т.п.)
|
||||
mode = MODE_MAP.get(raw)
|
||||
if mode is None:
|
||||
mode = next((v for k, v in MODE_MAP.items() if k in raw), "Auto")
|
||||
args["mode"] = mode
|
||||
|
||||
elif intent in ("light_on", "light_off"):
|
||||
if "device" in slots:
|
||||
args["device"] = slots["device"] # приложение сопоставит с entity HA
|
||||
|
||||
elif intent == "music_play":
|
||||
if "query" in slots:
|
||||
args["query"] = slots["query"]
|
||||
|
||||
elif intent == "music_volume":
|
||||
m = re.search(r"(\d+)", slots.get("level", ""))
|
||||
if m:
|
||||
args["volume"] = int(m.group(1))
|
||||
|
||||
return {"tool": tool, "args": args}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# быстрая проверка карты без модели
|
||||
demo = [
|
||||
("timer_set", {"duration": "15 минут"}),
|
||||
("timer_add", {"duration": "5 минут"}),
|
||||
("calendar_create", {"title": "встречу", "date": "25 июля",
|
||||
"time": "13 часов", "owner": "Дани"}),
|
||||
("purifier_mode", {"mode": "ночной"}),
|
||||
("music_volume", {"level": "50 процентов"}),
|
||||
("weather_get", {"city": "в Питере"}),
|
||||
]
|
||||
for intent, slots in demo:
|
||||
print(f"{intent:16s} {slots} -> {to_tool_call(intent, slots)}")
|
||||
Reference in New Issue
Block a user