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) — отдельно.
148 lines
6.4 KiB
TypeScript
148 lines
6.4 KiB
TypeScript
/**
|
||
* Подготовка текста для 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()
|
||
}
|