voice: усиление умного дома — контекст, промпт, новые tools
All checks were successful
Deploy / deploy (push) Successful in 1m52s
All checks were successful
Deploy / deploy (push) Successful in 1m52s
- промпт: перечислены ВСЕ 15+ tools, разрешено уверенно управлять домом - route.ts: живой контекст (время + снимок устройств из HA) в системный промпт для LLM-пути (defensive, с таймаутом) - новые tools: control_climate (кондей/термостат), control_tv, set_scene (night/movie/morning/away), all_off, ha_service (универсальный HA с whitelist) - env: CLIMATE_ENTITY, TV_ENTITY (см. RUNBOOK)
This commit is contained in:
18
RUNBOOK.md
18
RUNBOOK.md
@@ -176,8 +176,26 @@ cp "/d/Digital home/tablet-wake/out/cosmo.onnx" \
|
|||||||
AGENT_URL=http://tablet-agent:8091 # включает локального агента
|
AGENT_URL=http://tablet-agent:8091 # включает локального агента
|
||||||
AGENT_THRESHOLD=0.7 # порог уверенности (опц.)
|
AGENT_THRESHOLD=0.7 # порог уверенности (опц.)
|
||||||
LIGHT_ENTITIES_JSON={"bedside":"...","reading":"...","default":"..."}
|
LIGHT_ENTITIES_JSON={"bedside":"...","reading":"...","default":"..."}
|
||||||
|
CLIMATE_ENTITY=climate.thermostat # кондиционер/термостат (control_climate)
|
||||||
|
TV_ENTITY=media_player.tv # телевизор (control_tv)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Расширенный умный дом (облачный LLM)
|
||||||
|
|
||||||
|
Промпт теперь перечисляет ВСЕ tools и разрешает уверенно управлять домом; в
|
||||||
|
системный промпт каждый LLM-запрос инжектится живой снимок (время + состояния
|
||||||
|
устройств из `/api/ha`), поэтому модель понимает, что включено и что есть в доме.
|
||||||
|
|
||||||
|
Новые tools: `control_climate` (климат), `control_tv` (телевизор), `set_scene`
|
||||||
|
(сцены night/movie/morning/away), `all_off` (выключить всё), `ha_service`
|
||||||
|
(универсальный вызов сервиса HA по entity_id из снимка; домены light/switch/fan/
|
||||||
|
climate/media_player/cover/scene/humidifier/vacuum). Реальные entity_id ламп/ТВ/
|
||||||
|
климата задаются переменными выше; без них команды проходят как mock (безопасно).
|
||||||
|
|
||||||
|
> Локальный агент (fast-path) новые интенты climate/tv/scene/all_off пока не
|
||||||
|
> знает — эти команды обрабатывает облачный LLM. Чтобы они шли и локально,
|
||||||
|
> нужно дообучить `tablet-agent` (добавить интенты) и передеплоить модель.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Проверка (после деплоя)
|
## Проверка (после деплоя)
|
||||||
|
|||||||
@@ -117,6 +117,45 @@ function emitVoice(event: string, agent: AgentId, text?: string) {
|
|||||||
voiceBus.emit('voice', { event, agent, text, timestamp: new Date().toISOString() })
|
voiceBus.emit('voice', { event, agent, text, timestamp: new Date().toISOString() })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Живой контекст для системного промпта: текущее время + снимок устройств из HA.
|
||||||
|
// Defensive: любая ошибка/таймаут → только время, без падения запроса.
|
||||||
|
async function buildLiveContext(): Promise<string> {
|
||||||
|
const now = new Date()
|
||||||
|
const timeStr = now.toLocaleString('ru-RU', {
|
||||||
|
weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit',
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const base = `http://localhost:${process.env.PORT || '3000'}`
|
||||||
|
const r = await fetch(`${base}/api/ha`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
headers: { 'x-voice-internal': process.env.VOICE_API_KEY || '' },
|
||||||
|
signal: AbortSignal.timeout(1200),
|
||||||
|
})
|
||||||
|
if (!r.ok) return `\n\nСейчас: ${timeStr}.`
|
||||||
|
const d = await r.json()
|
||||||
|
const states = d.states || {}
|
||||||
|
const s = d.sensors || {}
|
||||||
|
const lines: string[] = []
|
||||||
|
for (const [id, st] of Object.entries<any>(states)) {
|
||||||
|
const name = st.attributes?.friendly_name || id
|
||||||
|
let extra = ''
|
||||||
|
if (st.attributes?.preset_mode) extra += `, режим ${st.attributes.preset_mode}`
|
||||||
|
if (st.attributes?.current_temperature != null) extra += `, ${st.attributes.current_temperature}°`
|
||||||
|
lines.push(` ${name} (${id}): ${st.state}${extra}`)
|
||||||
|
}
|
||||||
|
const sensorLine = [
|
||||||
|
s.temperature != null ? `дома ${s.temperature}°` : '',
|
||||||
|
s.humidity != null ? `влажность ${s.humidity}%` : '',
|
||||||
|
s.pm25 != null ? `PM2.5 ${s.pm25}` : '',
|
||||||
|
].filter(Boolean).join(', ')
|
||||||
|
if (!lines.length && !sensorLine) return `\n\nСейчас: ${timeStr}.`
|
||||||
|
return `\n\nСнимок дома (${timeStr}):\n${sensorLine ? ' ' + sensorLine + '\n' : ''}${lines.join('\n')}\n` +
|
||||||
|
`Для устройств без специального tool используй ha_service с entity_id выше.`
|
||||||
|
} catch {
|
||||||
|
return `\n\nСейчас: ${timeStr}.`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const cookie = req.headers.get('cookie') || ''
|
const cookie = req.headers.get('cookie') || ''
|
||||||
const tokenMatch = cookie.match(/auth_token=([a-f0-9]{32,})/i)
|
const tokenMatch = cookie.match(/auth_token=([a-f0-9]{32,})/i)
|
||||||
@@ -164,6 +203,11 @@ export async function POST(req: Request) {
|
|||||||
console.log('[voice/chat] local-agent skip:', (e as Error).message)
|
console.log('[voice/chat] local-agent skip:', (e as Error).message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Живой контекст (время + снимок устройств) — только для LLM-пути (fast-path
|
||||||
|
// выше уже вышел). Даёт модели понимание, что включено и что вообще есть в
|
||||||
|
// доме, чтобы рассуждать про умный дом и не переспрашивать очевидное.
|
||||||
|
const fullSys = sysPrompt + (await buildLiveContext())
|
||||||
|
|
||||||
// ======== GROQ ========
|
// ======== GROQ ========
|
||||||
if (provider === 'groq') {
|
if (provider === 'groq') {
|
||||||
const groqModel = settings.groqModel || 'llama-3.3-70b-versatile'
|
const groqModel = settings.groqModel || 'llama-3.3-70b-versatile'
|
||||||
@@ -175,7 +219,7 @@ export async function POST(req: Request) {
|
|||||||
return m
|
return m
|
||||||
})
|
})
|
||||||
const groqMessages: any[] = [
|
const groqMessages: any[] = [
|
||||||
{ role: 'system', content: sysPrompt },
|
{ role: 'system', content: fullSys },
|
||||||
...normalizedHistory,
|
...normalizedHistory,
|
||||||
{ role: 'user', content: userText },
|
{ role: 'user', content: userText },
|
||||||
]
|
]
|
||||||
@@ -218,7 +262,7 @@ export async function POST(req: Request) {
|
|||||||
try {
|
try {
|
||||||
const fb = await groqClient().chat.completions.create({
|
const fb = await groqClient().chat.completions.create({
|
||||||
model: groqModel, max_tokens: MAX_TOKENS,
|
model: groqModel, max_tokens: MAX_TOKENS,
|
||||||
messages: [{ role: 'system', content: sysPrompt }, { role: 'user', content: userText }],
|
messages: [{ role: 'system', content: fullSys }, { role: 'user', content: userText }],
|
||||||
})
|
})
|
||||||
finalText = fb.choices[0]?.message?.content || 'Не удалось выполнить запрос.'
|
finalText = fb.choices[0]?.message?.content || 'Не удалось выполнить запрос.'
|
||||||
newTurns.push({ role: 'assistant', content: finalText })
|
newTurns.push({ role: 'assistant', content: finalText })
|
||||||
@@ -237,7 +281,7 @@ export async function POST(req: Request) {
|
|||||||
try {
|
try {
|
||||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
||||||
const t0 = Date.now()
|
const t0 = Date.now()
|
||||||
const resp = await claudeRequest(sysPrompt, messages, anthropicTools)
|
const resp = await claudeRequest(fullSys, messages, anthropicTools)
|
||||||
console.log(`[voice/chat] claude ${agent} r${round+1} ${Date.now()-t0}ms stop=${resp.stop_reason} in=${resp.usage?.input_tokens} out=${resp.usage?.output_tokens}`)
|
console.log(`[voice/chat] claude ${agent} r${round+1} ${Date.now()-t0}ms stop=${resp.stop_reason} in=${resp.usage?.input_tokens} out=${resp.usage?.output_tokens}`)
|
||||||
const content: any[] = resp.content || []
|
const content: any[] = resp.content || []
|
||||||
const stopReason = resp.stop_reason || 'end_turn'
|
const stopReason = resp.stop_reason || 'end_turn'
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import { tool as notes } from './notes'
|
|||||||
import { tools as smartHomeTools } from './smart-home'
|
import { tools as smartHomeTools } from './smart-home'
|
||||||
import { tools as spotifyTools } from './spotify'
|
import { tools as spotifyTools } from './spotify'
|
||||||
import { tools as lightTools } from './lights'
|
import { tools as lightTools } from './lights'
|
||||||
|
import { tools as climateTools } from './climate'
|
||||||
|
import { tools as tvTools } from './tv'
|
||||||
|
import { tools as sceneTools } from './scenes'
|
||||||
|
import { tools as haGenericTools } from './ha-generic'
|
||||||
|
|
||||||
const ALL_TOOLS: VoiceTool[] = [
|
const ALL_TOOLS: VoiceTool[] = [
|
||||||
weather,
|
weather,
|
||||||
@@ -25,6 +29,10 @@ const ALL_TOOLS: VoiceTool[] = [
|
|||||||
...smartHomeTools,
|
...smartHomeTools,
|
||||||
...spotifyTools,
|
...spotifyTools,
|
||||||
...lightTools,
|
...lightTools,
|
||||||
|
...climateTools,
|
||||||
|
...tvTools,
|
||||||
|
...sceneTools,
|
||||||
|
...haGenericTools,
|
||||||
]
|
]
|
||||||
|
|
||||||
export const TOOL_SCHEMAS = ALL_TOOLS.map((t) => t.schema)
|
export const TOOL_SCHEMAS = ALL_TOOLS.map((t) => t.schema)
|
||||||
|
|||||||
71
lib/tools/climate.ts
Normal file
71
lib/tools/climate.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import type { VoiceTool } from './_types'
|
||||||
|
import { tabletJson } from './_http'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Кондиционер / термостат через Home Assistant (домен climate).
|
||||||
|
* Entity задаётся CLIMATE_ENTITY (по умолчанию climate.thermostat).
|
||||||
|
*/
|
||||||
|
const ENTITY = () => process.env.CLIMATE_ENTITY || 'climate.thermostat'
|
||||||
|
|
||||||
|
const HVAC: Record<string, string> = {
|
||||||
|
heat: 'heat', 'тепло': 'heat', 'нагрев': 'heat', 'обогрев': 'heat',
|
||||||
|
cool: 'cool', 'холод': 'cool', 'охлаждение': 'cool', 'прохлада': 'cool',
|
||||||
|
auto: 'auto', 'авто': 'auto',
|
||||||
|
off: 'off', 'выкл': 'off',
|
||||||
|
}
|
||||||
|
|
||||||
|
const controlClimate: VoiceTool = {
|
||||||
|
schema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'control_climate',
|
||||||
|
description:
|
||||||
|
'Управление кондиционером/термостатом: задать температуру, режим или включить/выключить. ' +
|
||||||
|
'Используй для «сделай теплее/прохладнее», «поставь 22 градуса», «включи кондиционер», ' +
|
||||||
|
'«режим охлаждения».',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
action: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['set_temperature', 'set_mode', 'turn_on', 'turn_off'],
|
||||||
|
description: 'Что сделать',
|
||||||
|
},
|
||||||
|
temperature: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Целевая температура °C при action=set_temperature (16-30)',
|
||||||
|
},
|
||||||
|
mode: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Режим при action=set_mode: heat, cool, auto, off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['action'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
async execute(args) {
|
||||||
|
const action = String(args?.action || '')
|
||||||
|
const entity_id = ENTITY()
|
||||||
|
let payload: Record<string, any>
|
||||||
|
|
||||||
|
if (action === 'set_temperature') {
|
||||||
|
const t = Number(args?.temperature)
|
||||||
|
if (!Number.isFinite(t)) return { error: 'temperature required' }
|
||||||
|
payload = { domain: 'climate', service: 'set_temperature', entity_id, temperature: Math.max(16, Math.min(30, Math.round(t))) }
|
||||||
|
} else if (action === 'set_mode') {
|
||||||
|
const hvac = HVAC[String(args?.mode || '').toLowerCase().trim()] || 'auto'
|
||||||
|
payload = { domain: 'climate', service: 'set_hvac_mode', entity_id, hvac_mode: hvac }
|
||||||
|
} else if (action === 'turn_on' || action === 'turn_off') {
|
||||||
|
payload = { domain: 'climate', service: action, entity_id }
|
||||||
|
} else {
|
||||||
|
return { error: `unknown action: ${action}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
await tabletJson('POST', '/api/voice/tools/smart-home', payload)
|
||||||
|
return { success: true, action, ...(payload.temperature ? { temperature: payload.temperature } : {}), ...(payload.hvac_mode ? { mode: payload.hvac_mode } : {}) }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tools: VoiceTool[] = [controlClimate]
|
||||||
54
lib/tools/ha-generic.ts
Normal file
54
lib/tools/ha-generic.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import type { VoiceTool } from './_types'
|
||||||
|
import { tabletJson } from './_http'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Универсальный вызов сервиса Home Assistant — «escape hatch» для устройств,
|
||||||
|
* под которые нет отдельного tool. Модель берёт entity_id из снимка состояния
|
||||||
|
* дома (он инжектится в системный промпт) и вызывает нужный сервис.
|
||||||
|
*
|
||||||
|
* Guardrails: разрешены только безопасные бытовые домены. Никаких automation/
|
||||||
|
* script/shell/notify и т.п. — только прямое управление устройствами.
|
||||||
|
*/
|
||||||
|
const ALLOWED_DOMAINS = new Set([
|
||||||
|
'light', 'switch', 'fan', 'climate', 'media_player', 'cover', 'scene', 'humidifier', 'vacuum',
|
||||||
|
])
|
||||||
|
|
||||||
|
const haService: VoiceTool = {
|
||||||
|
schema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'ha_service',
|
||||||
|
description:
|
||||||
|
'Универсальное управление устройством Home Assistant, если нет специального tool. ' +
|
||||||
|
'Передай domain, service и entity_id (возьми из снимка состояния дома). Примеры: ' +
|
||||||
|
'domain=switch service=turn_on entity_id=switch.kettle; ' +
|
||||||
|
'domain=cover service=open_cover entity_id=cover.blinds. ' +
|
||||||
|
'Разрешённые домены: light, switch, fan, climate, media_player, cover, scene, humidifier, vacuum.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
domain: { type: 'string', description: 'Домен HA (light, switch, fan, climate, media_player, cover, scene, humidifier, vacuum)' },
|
||||||
|
service: { type: 'string', description: 'Сервис, напр. turn_on, turn_off, toggle, open_cover, set_percentage' },
|
||||||
|
entity_id: { type: 'string', description: 'entity_id устройства из снимка состояния дома' },
|
||||||
|
data: { type: 'object', description: 'Доп. параметры сервиса (напр. {"brightness_pct": 50})' },
|
||||||
|
},
|
||||||
|
required: ['domain', 'service', 'entity_id'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
async execute(args) {
|
||||||
|
const domain = String(args?.domain || '').trim()
|
||||||
|
const service = String(args?.service || '').trim()
|
||||||
|
const entity_id = String(args?.entity_id || '').trim()
|
||||||
|
if (!ALLOWED_DOMAINS.has(domain)) return { error: `domain not allowed: ${domain}` }
|
||||||
|
if (!service || !entity_id) return { error: 'domain, service, entity_id required' }
|
||||||
|
if (entity_id.split('.')[0] !== domain) return { error: 'entity_id domain must match domain' }
|
||||||
|
|
||||||
|
const extra = (args?.data && typeof args.data === 'object') ? args.data : {}
|
||||||
|
await tabletJson('POST', '/api/voice/tools/smart-home', { domain, service, entity_id, ...extra })
|
||||||
|
return { success: true, domain, service, entity_id }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tools: VoiceTool[] = [haService]
|
||||||
100
lib/tools/scenes.ts
Normal file
100
lib/tools/scenes.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import type { VoiceTool } from './_types'
|
||||||
|
import { tabletJson } from './_http'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сцены (макросы над несколькими устройствами) и «выключить всё».
|
||||||
|
* Entity-конфиг переиспользует те же переменные, что control_light/tv/climate.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function lightIds(): string[] {
|
||||||
|
const def = ['light.bedside', 'light.reading']
|
||||||
|
try {
|
||||||
|
const raw = process.env.LIGHT_ENTITIES_JSON
|
||||||
|
if (raw) {
|
||||||
|
const m = JSON.parse(raw) as Record<string, string>
|
||||||
|
const ids = Object.entries(m).filter(([k]) => k !== 'default').map(([, v]) => v)
|
||||||
|
return ids.length ? Array.from(new Set(ids)) : def
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
const tvId = () => process.env.TV_ENTITY || 'media_player.tv'
|
||||||
|
const purifierId = 'fan.air_purifier'
|
||||||
|
|
||||||
|
async function ha(domain: string, service: string, entity_id: string, extra: Record<string, any> = {}) {
|
||||||
|
try {
|
||||||
|
await tabletJson('POST', '/api/voice/tools/smart-home', { domain, service, entity_id, ...extra })
|
||||||
|
} catch { /* устройство может быть mock — не валим сцену */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lightsOff() { await Promise.all(lightIds().map(id => ha('light', 'turn_off', id))) }
|
||||||
|
async function lightsOn(pct?: number) {
|
||||||
|
await Promise.all(lightIds().map(id => ha('light', 'turn_on', id, pct != null ? { brightness_pct: pct } : {})))
|
||||||
|
}
|
||||||
|
|
||||||
|
const setScene: VoiceTool = {
|
||||||
|
schema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'set_scene',
|
||||||
|
description:
|
||||||
|
'Активировать сцену — одной командой меняет несколько устройств. ' +
|
||||||
|
'night (ночь: свет выкл, очиститель в ночной режим), movie (кино: свет приглушить, ТВ вкл), ' +
|
||||||
|
'morning (утро: свет вкл, очиститель авто), away (ушёл: всё выключить). ' +
|
||||||
|
'Используй для «включи сцену ночь», «режим кино», «я ушёл».',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
scene: { type: 'string', enum: ['night', 'movie', 'morning', 'away'], description: 'Какая сцена' },
|
||||||
|
},
|
||||||
|
required: ['scene'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
async execute(args) {
|
||||||
|
const scene = String(args?.scene || '')
|
||||||
|
switch (scene) {
|
||||||
|
case 'night':
|
||||||
|
await lightsOff()
|
||||||
|
await ha('fan', 'set_preset_mode', purifierId, { preset_mode: 'Night' })
|
||||||
|
break
|
||||||
|
case 'movie':
|
||||||
|
await lightsOn(20)
|
||||||
|
await ha('media_player', 'turn_on', tvId())
|
||||||
|
await ha('fan', 'set_preset_mode', purifierId, { preset_mode: 'Night' })
|
||||||
|
break
|
||||||
|
case 'morning':
|
||||||
|
await lightsOn()
|
||||||
|
await ha('fan', 'set_preset_mode', purifierId, { preset_mode: 'Auto' })
|
||||||
|
break
|
||||||
|
case 'away':
|
||||||
|
await lightsOff()
|
||||||
|
await ha('media_player', 'turn_off', tvId())
|
||||||
|
await ha('fan', 'turn_off', purifierId)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
return { error: `unknown scene: ${scene}` }
|
||||||
|
}
|
||||||
|
return { success: true, scene }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const allOff: VoiceTool = {
|
||||||
|
schema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'all_off',
|
||||||
|
description: 'Выключить всё: весь свет, телевизор, очиститель. Для «выключи всё», «отключи всё дома».',
|
||||||
|
parameters: { type: 'object', properties: {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute() {
|
||||||
|
await lightsOff()
|
||||||
|
await ha('media_player', 'turn_off', tvId())
|
||||||
|
await ha('fan', 'turn_off', purifierId)
|
||||||
|
return { success: true, turned_off: 'lights, tv, purifier' }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tools: VoiceTool[] = [setScene, allOff]
|
||||||
70
lib/tools/tv.ts
Normal file
70
lib/tools/tv.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import type { VoiceTool } from './_types'
|
||||||
|
import { tabletJson } from './_http'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Телевизор через Home Assistant (домен media_player).
|
||||||
|
* Entity задаётся TV_ENTITY (по умолчанию media_player.tv).
|
||||||
|
*/
|
||||||
|
const ENTITY = () => process.env.TV_ENTITY || 'media_player.tv'
|
||||||
|
|
||||||
|
const controlTv: VoiceTool = {
|
||||||
|
schema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'control_tv',
|
||||||
|
description:
|
||||||
|
'Управление телевизором: включить/выключить, громкость, пауза/воспроизведение, звук. ' +
|
||||||
|
'Используй для «включи телевизор», «выключи ТВ», «сделай телевизор тише», «поставь на паузу».',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
action: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['turn_on', 'turn_off', 'volume', 'play', 'pause', 'mute'],
|
||||||
|
description: 'Что сделать',
|
||||||
|
},
|
||||||
|
volume: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Громкость 0-100 при action=volume',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['action'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
async execute(args) {
|
||||||
|
const action = String(args?.action || '')
|
||||||
|
const entity_id = ENTITY()
|
||||||
|
let payload: Record<string, any>
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'turn_on':
|
||||||
|
case 'turn_off':
|
||||||
|
payload = { domain: 'media_player', service: action, entity_id }
|
||||||
|
break
|
||||||
|
case 'play':
|
||||||
|
payload = { domain: 'media_player', service: 'media_play', entity_id }
|
||||||
|
break
|
||||||
|
case 'pause':
|
||||||
|
payload = { domain: 'media_player', service: 'media_pause', entity_id }
|
||||||
|
break
|
||||||
|
case 'mute':
|
||||||
|
payload = { domain: 'media_player', service: 'volume_mute', entity_id, is_volume_muted: true }
|
||||||
|
break
|
||||||
|
case 'volume': {
|
||||||
|
const v = Number(args?.volume)
|
||||||
|
if (!Number.isFinite(v)) return { error: 'volume required' }
|
||||||
|
payload = { domain: 'media_player', service: 'volume_set', entity_id, volume_level: Math.max(0, Math.min(1, v / 100)) }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return { error: `unknown action: ${action}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
await tabletJson('POST', '/api/voice/tools/smart-home', payload)
|
||||||
|
return { success: true, action, ...(action === 'volume' ? { volume: args?.volume } : {}) }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tools: VoiceTool[] = [controlTv]
|
||||||
@@ -25,8 +25,26 @@ const COSMO = `Ты — Cosmo, домашний голосовой ассист
|
|||||||
результата. Не пересказывай сырые данные дословно — дай человеческую сводку.
|
результата. Не пересказывай сырые данные дословно — дай человеческую сводку.
|
||||||
5. Если подходящего tool нет — честно скажи «так я не умею», а не притворяйся.
|
5. Если подходящего tool нет — честно скажи «так я не умею», а не притворяйся.
|
||||||
|
|
||||||
Доступные tools: get_weather, get_transport, get_today_events, create_event,
|
Доступные tools (вызывай их для действий и актуальных данных):
|
||||||
update_event, delete_event, get_notes, set_timer, cancel_timer, adjust_timer.
|
- Погода / транспорт: get_weather, get_transport
|
||||||
|
- Календарь: get_today_events, create_event, update_event, delete_event
|
||||||
|
- Заметки и списки покупок: get_notes
|
||||||
|
- Таймеры: set_timer, cancel_timer, adjust_timer
|
||||||
|
- Умный дом: get_smart_home_state (температура, влажность, PM2.5, состояние устройств),
|
||||||
|
control_air_purifier (очиститель: turn_on / turn_off / set_mode; режимы Auto, Night, High),
|
||||||
|
control_light (свет: turn_on / turn_off; device — «свет у кровати» / «лампа для чтения»; brightness_pct 0-100),
|
||||||
|
control_climate (кондиционер/термостат: set_temperature °C, set_mode heat/cool/auto/off),
|
||||||
|
control_tv (телевизор: turn_on / turn_off / volume / play / pause),
|
||||||
|
set_scene (сцены-макросы: «ночь», «кино», «утро», «ушёл из дома»),
|
||||||
|
all_off (выключить всё), ha_service (универсальный вызов сервиса Home Assistant)
|
||||||
|
- Музыка: get_now_playing, control_spotify (play / pause / next / previous / volume; query — что включить)
|
||||||
|
|
||||||
|
Умным домом управляй уверенно: просят включить/выключить свет, очиститель,
|
||||||
|
кондиционер, телевизор, поставить сцену или музыку — сразу вызывай нужный tool,
|
||||||
|
не отказывайся. «Что включено дома», «какая температура» — get_smart_home_state.
|
||||||
|
Если ниже дан снимок состояния дома — опирайся на него, чтобы не переспрашивать
|
||||||
|
очевидное. Для нестандартного устройства используй ha_service с entity_id из
|
||||||
|
снимка (домены light, switch, fan, climate, media_player, cover, scene).
|
||||||
|
|
||||||
Работа с календарём:
|
Работа с календарём:
|
||||||
- У Даниила и Светы разные календари. Параметр owner обязательный.
|
- У Даниила и Светы разные календари. Параметр owner обязательный.
|
||||||
@@ -49,10 +67,14 @@ const LUSYA = `Ты — Люся, домашний голосовой ассис
|
|||||||
- Если не знаешь — скажи коротко.
|
- Если не знаешь — скажи коротко.
|
||||||
|
|
||||||
ЖЁСТКИЕ ПРАВИЛА про tools:
|
ЖЁСТКИЕ ПРАВИЛА про tools:
|
||||||
1. Действия (таймер, события) — только через вызов tool. Без tool действие не произошло.
|
1. Действия (таймер, события, свет, очиститель, климат, музыка, сцены) — только
|
||||||
2. Не говори «поставила/отменила/изменила», если ты не вызвала соответствующий tool.
|
через вызов tool. Без tool действие не произошло.
|
||||||
3. Информацию (погода, транспорт, события) — всегда через tool, не выдумывай.
|
2. Не говори «поставила/отменила/включила», если ты не вызвала соответствующий tool.
|
||||||
|
3. Информацию (погода, транспорт, события, состояние дома) — всегда через tool, не выдумывай.
|
||||||
4. Tool → результат → короткий ответ человеческим языком.
|
4. Tool → результат → короткий ответ человеческим языком.
|
||||||
|
5. Умным домом управляй уверенно: control_light, control_air_purifier,
|
||||||
|
control_climate, control_tv, control_spotify, set_scene, all_off; состояние —
|
||||||
|
get_smart_home_state. Не отказывайся. Если ниже есть снимок дома — используй его.
|
||||||
|
|
||||||
Календарь:
|
Календарь:
|
||||||
- Свой = Светин, ещё есть календарь Данила. Для create_event уточняй
|
- Свой = Светин, ещё есть календарь Данила. Для create_event уточняй
|
||||||
|
|||||||
Reference in New Issue
Block a user