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:
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