feat(voice): server-side LLM/STT — porting Python satellite into tablet
All checks were successful
Deploy / deploy (push) Successful in 5m44s

Шаг 1 миграции голосового стека из home-voice-assistant в сам tablet:

- /api/voice/chat — Claude Haiku 4.5 с tool-loop (max 4 раунда), prompt
  caching на system + старой истории, история в /data/voice-history/.
  Эмитит command/response/error в voice-bus → орб моргает как раньше.
- /api/voice/stt — Groq whisper-large-v3-turbo, multipart или raw audio.
- lib/voice-text.ts — порт clean_for_speech (без pymorphy3, время в
  именительном падеже) и strip_fillers + RESET_PATTERNS.
- lib/voice-executors.ts — tool executors через loopback fetch на
  существующие /api/voice/tools/* и /api/voice/timer.
- Поддержка ANTHROPIC_PROXY/GROQ_PROXY (fallback на HTTPS_PROXY).

После деплоя нужны GROQ_API_KEY и ANTHROPIC_API_KEY в tablet.env.
Шаги 2 (push-to-talk в браузере) и 3 (wake-word) — отдельно.
This commit is contained in:
Cosmo
2026-04-27 08:24:19 +00:00
parent a97dd11f25
commit eeac2eefb3
10 changed files with 1215 additions and 4 deletions

147
lib/voice-text.ts Normal file
View File

@@ -0,0 +1,147 @@
/**
* Подготовка текста для TTS и распознавание команд сброса.
* Порт satellite/text.py + satellite/llm.py (RESET_PATTERNS, FILLER_PATTERNS).
*
* Отличие от Python-версии: время «HH:MM» раскрывается в слова всегда в
* именительном падеже (без pymorphy3). Звучит чуть менее естественно в
* конструкциях вроде «встретимся в 14:30», но без зависимости от морфологии.
*/
const RESET_PATTERNS = new RegExp(
'(начни|начать|создай|открой|давай).{0,10}(новую|новый|чистую|чистый).{0,10}' +
'(сессию|сессия|диалог|разговор|чат)' +
'|' +
'(сбрось|очисти|обнови).{0,10}(сессию|диалог|разговор|чат|историю|контекст)',
'i'
)
const FILLER_PATTERNS = new RegExp(
'(?:(?:сейчас посмотрю|дай мне секунду|дай секунду|проверяю|загружаю|узнаю' +
'|смотрю|одну секунду|я сейчас посмотрю|я проверю|попробую другой источник' +
'|нужны конкретные числа|дай мне загрузить)[^.!?]*[.!?]?\\s*)+',
'gi'
)
export function isResetCommand(text: string): boolean {
return RESET_PATTERNS.test(text)
}
export function stripFillers(text: string): string {
return text.replace(FILLER_PATTERNS, '').trim()
}
// ── числа в слова (рус, именительный, кардинальные 0..99) ─────────────
const ONES_M = ['', 'один', 'два', 'три', 'четыре', 'пять', 'шесть', 'семь', 'восемь', 'девять']
const ONES_F = ['', 'одна', 'две', 'три', 'четыре', 'пять', 'шесть', 'семь', 'восемь', 'девять']
const TEENS = ['десять', 'одиннадцать', 'двенадцать', 'тринадцать', 'четырнадцать', 'пятнадцать',
'шестнадцать', 'семнадцать', 'восемнадцать', 'девятнадцать']
const TENS = ['', '', 'двадцать', 'тридцать', 'сорок', 'пятьдесят', 'шестьдесят', 'семьдесят',
'восемьдесят', 'девяносто']
function numToWords(n: number, gender: 'm' | 'f' = 'm'): string {
if (n === 0) return 'ноль'
if (n < 0 || n > 99) return String(n)
const ones = gender === 'f' ? ONES_F : ONES_M
if (n < 10) return ones[n]
if (n < 20) return TEENS[n - 10]
const t = Math.floor(n / 10)
const o = n % 10
return o === 0 ? TENS[t] : `${TENS[t]} ${ones[o]}`
}
function hoursWord(n: number): string {
const last2 = n % 100
const last1 = n % 10
if (last2 >= 11 && last2 <= 14) return 'часов'
if (last1 === 1) return 'час'
if (last1 >= 2 && last1 <= 4) return 'часа'
return 'часов'
}
function minutesWord(n: number): string {
const last2 = n % 100
const last1 = n % 10
if (last2 >= 11 && last2 <= 14) return 'минут'
if (last1 === 1) return 'минута'
if (last1 >= 2 && last1 <= 4) return 'минуты'
return 'минут'
}
function formatTime(h: number, mm: number): string {
let out = `${numToWords(h, 'm')} ${hoursWord(h)}`
if (mm > 0) out += ` ${numToWords(mm, 'f')} ${minutesWord(mm)}`
return out
}
// ── единицы измерения со слэшем ───────────────────────────────────────
const UNIT_SLASH: Array<[RegExp, string]> = [
[/\bкм\s*\/\s*ч\b/gi, 'километров в час'],
[/\bм\s*\/\s*с\b/gi, 'метров в секунду'],
[/\bкм\s*\/\s*с\b/gi, 'километров в секунду'],
[/\bмб\s*\/\s*с\b/gi, 'мегабит в секунду'],
[/\bгб\s*\/\s*с\b/gi, 'гигабит в секунду'],
[/\bруб\s*\/\s*мес\b/gi, 'рублей в месяц'],
[/\bр\s*\/\s*мес\b/gi, 'рублей в месяц'],
]
const ABBR: Array<[RegExp, string]> = [
[/\bт\.\s*е\./gi, 'то есть'],
[/\bт\.\s*к\./gi, 'так как'],
[/\bт\.\s*д\./gi, 'так далее'],
[/\bт\.\s*п\./gi, 'тому подобное'],
[/\bи\s*т\.\s*д\./gi, 'и так далее'],
[/\bи\s*т\.\s*п\./gi, 'и тому подобное'],
]
export function cleanForSpeech(input: string): string {
let t = input
// markdown — emojis: supplementary planes + symbols + dingbats + misc-symbols
t = t.replace(/[\u{1F000}-\u{1FFFF}-]/gu, '')
t = t.replace(/\*+/g, '')
t = t.replace(/#+\s/g, '')
t = t.replace(/^- /gm, '')
t = t.replace(/\[.*?\]\(.*?\)/g, '')
t = t.replace(/\n+/g, '. ')
for (const [pat, repl] of UNIT_SLASH) t = t.replace(pat, repl)
// знаки перед числом
t = t.replace(/(^|\s)\+(\d)/g, '$1плюс $2')
t = t.replace(/(^|\s)-(\d)/g, '$1минус $2')
t = t.replace(/±(\d)/g, 'плюс-минус $1')
// время HH:MM (опционально с предлогом — предлог сохраняем как есть, время в номинативе)
t = t.replace(
/(?:\b(с|со|до|от|после|около|к|ко|в|во|на|через|за|перед|между|о|об|при)\s+)?(\d{1,2}):(\d{2})\b/gi,
(_m, prep: string | undefined, hStr: string, mmStr: string) => {
const h = parseInt(hStr, 10)
const mm = parseInt(mmStr, 10)
if (h < 0 || h > 23 || mm < 0 || mm > 59) return _m
const words = formatTime(h, mm)
return prep ? `${prep} ${words}` : words
}
)
// дроби и слэш
t = t.replace(/(\d+)\s*\/\s*(\d+)/g, '$1 из $2')
t = t.replace(/\//g, ' или ')
// символы
t = t.replace(/°\s*C\b/gi, ' градусов')
t = t.replace(/°\s*F\b/gi, ' градусов Фаренгейта')
t = t.replace(/°/g, ' градусов')
t = t.replace(/%/g, ' процентов')
t = t.replace(/№/g, ' номер ')
t = t.replace(/&/g, ' и ')
t = t.replace(/@/g, ' собака ')
t = t.replace(/×/g, ' на ')
for (const [pat, repl] of ABBR) t = t.replace(pat, repl)
// схлопывание
t = t.replace(/([:;,!?])\s*\./g, '$1')
t = t.replace(/\.\s*\.+/g, '.')
t = t.replace(/\s+/g, ' ')
t = t.replace(/(\d+)\.(\s)/g, '$1$2')
return t.trim()
}