Files
smart-home-tablet/lib/voice-text.ts
Cosmo eeac2eefb3
All checks were successful
Deploy / deploy (push) Successful in 5m44s
feat(voice): server-side LLM/STT — porting Python satellite into tablet
Шаг 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) — отдельно.
2026-04-27 08:24:19 +00:00

148 lines
6.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Подготовка текста для 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()
}