feat: switch from Anthropic to Groq API (llama-3.3-70b-versatile)
All checks were successful
Deploy / deploy (push) Successful in 2m47s

- route.ts: replace @anthropic-ai/sdk with groq-sdk, rewrite chat loop
- voice-tool-schemas.ts: convert from Anthropic format to OpenAI/Groq function tools
- voice-history.ts: extend HistoryMessage type to include tool role, simplify cache stubs

No prompt caching (Groq does not support it), tool calling preserved.
This commit is contained in:
Cosmo
2026-04-30 20:43:30 +00:00
parent 96fa78bd5c
commit 04b7d1f104
3 changed files with 263 additions and 257 deletions

View File

@@ -3,6 +3,8 @@
* /data — это volume контейнера (на хосте /opt/digital-home/smart-home-tablet-data/).
*
* Fallback: если /data не существует (локальная разработка) — пишем в /tmp/voice-history.
*
* Формат обновлён под Groq/OpenAI: role может быть 'user' | 'assistant' | 'tool'.
*/
import { promises as fs } from 'node:fs'
import { existsSync } from 'node:fs'
@@ -10,15 +12,16 @@ import path from 'node:path'
const PRIMARY_DIR = process.env.VOICE_HISTORY_DIR || '/data/voice-history'
const DATA_DIR = (() => {
// Проверяем существование родителя (/data) — без него запись упадёт ENOENT.
const parent = path.dirname(PRIMARY_DIR)
return existsSync(parent) ? PRIMARY_DIR : '/tmp/voice-history'
})()
const MAX_HISTORY = parseInt(process.env.VOICE_MAX_HISTORY || '40', 10)
export type HistoryMessage = {
role: 'user' | 'assistant'
role: 'user' | 'assistant' | 'tool'
content: any
tool_calls?: any[]
tool_call_id?: string
}
function todayIso(): string {
@@ -60,7 +63,8 @@ export async function resetHistory(agent: string): Promise<void> {
}
/**
* Убирает cache_control из блоков (для записи в историю — следующий turn пересчитает границу).
* Заглушка для обратной совместимости — убирает cache_control из блоков.
* В Groq-режиме cache_control не используется, функция — no-op.
*/
export function stripCacheControl(content: any): any {
if (Array.isArray(content)) {
@@ -76,33 +80,9 @@ export function stripCacheControl(content: any): any {
}
/**
* Граница prompt-кеша: всё, кроме последних N сообщений, помечаем cache_control
* на последнем блоке последнего «старого» сообщения. Даёт cache hit на каждом turn.
* Заглушка для обратной совместимости.
* В Groq-режиме prompt-кеширование не используется — просто возвращаем историю как есть.
*/
export function buildMessagesWithCache(history: HistoryMessage[], cacheTailUncached = 2): HistoryMessage[] {
if (history.length <= cacheTailUncached) {
return history.map((m) => ({ role: m.role, content: m.content }))
}
const cacheBoundary = history.length - cacheTailUncached
return history.map((msg, i) => {
if (i === cacheBoundary - 1) {
return { role: msg.role, content: wrapLastBlockWithCache(msg.content) }
}
return { role: msg.role, content: msg.content }
})
}
function wrapLastBlockWithCache(content: any): any {
if (typeof content === 'string') {
return [{ type: 'text', text: content, cache_control: { type: 'ephemeral' } }]
}
if (Array.isArray(content) && content.length) {
const out = [...content]
const last = out[out.length - 1]
if (last && typeof last === 'object') {
out[out.length - 1] = { ...last, cache_control: { type: 'ephemeral' } }
}
return out
}
return content
export function buildMessagesWithCache(history: HistoryMessage[], _cacheTailUncached = 2): HistoryMessage[] {
return history.map((m) => ({ role: m.role, content: m.content }))
}

View File

@@ -1,203 +1,248 @@
/**
* Tool schemas для Anthropic API. Порт TOOL_SCHEMAS из satellite/tools.py.
* Формат — Anthropic native tools (name + description + input_schema).
* Tool schemas для Groq API (OpenAI-compatible format).
* Было: Anthropic.Tool[] (input_schema) → Стало: Groq/OpenAI function tools (parameters).
*/
import type Anthropic from '@anthropic-ai/sdk'
export const TOOL_SCHEMAS: Anthropic.Tool[] = [
export interface GroqTool {
type: 'function'
function: {
name: string
description: string
parameters: {
type: string
properties: Record<string, any>
required?: string[]
}
}
}
export const TOOL_SCHEMAS: GroqTool[] = [
{
name: 'get_weather',
description:
'Получить текущую погоду и короткий прогноз для города. ' +
'Для вопросов вроде «какая сегодня погода», «холодно ли на улице», «нужен ли зонт». ' +
'По умолчанию — Санкт-Петербург.',
input_schema: {
type: 'object',
properties: {
city: {
type: 'string',
description:
'Город на русском или шорткод (spb, msk, sochi, ekb, kzn, nsk, krd). ' +
'По умолчанию Санкт-Петербург.',
type: 'function',
function: {
name: 'get_weather',
description:
'Получить текущую погоду и короткий прогноз для города. ' +
'Для вопросов вроде «какая сегодня погода», «холодно ли на улице», «нужен ли зонт». ' +
'По умолчанию — Санкт-Петербург.',
parameters: {
type: 'object',
properties: {
city: {
type: 'string',
description:
'Город на русском или шорткод (spb, msk, sochi, ekb, kzn, nsk, krd). ' +
'По умолчанию Санкт-Петербург.',
},
},
},
},
},
{
name: 'get_transport',
description:
'Расписание ближайших трамваев на остановке Ул. Антонова-Овсеенко. ' +
'Для вопросов «когда следующий 23-й», «что ближайшее в центр», «пора идти на остановку».',
input_schema: {
type: 'object',
properties: {
direction: {
type: 'string',
enum: ['to_center', 'from_center', 'all'],
description:
'to_center = в центр (к Новочеркасской), ' +
'from_center = от центра (к Большевиков), all = оба направления',
},
routes: {
type: 'string',
description:
'Фильтр маршрутов через запятую, например «23» или «23,27». Пусто = все маршруты.',
type: 'function',
function: {
name: 'get_transport',
description:
'Расписание ближайших трамваев на остановке Ул. Антонова-Овсеенко. ' +
'Для вопросов «когда следующий 23-й», «что ближайшее в центр», «пора идти на остановку».',
parameters: {
type: 'object',
properties: {
direction: {
type: 'string',
enum: ['to_center', 'from_center', 'all'],
description:
'to_center = в центр (к Новочеркасской), ' +
'from_center = от центра (к Большевиков), all = оба направления',
},
routes: {
type: 'string',
description:
'Фильтр маршрутов через запятую, например «23» или «23,27». Пусто = все маршруты.',
},
},
},
},
},
{
name: 'get_today_events',
description:
'События из календаря (Даниил + Света). Вернёт id события, title, start, end, ' +
'owner («daniil» или «sveta»). ВАЖНО: для update_event / delete_event сначала ' +
'вызывай этот tool чтобы получить event_id.',
input_schema: {
type: 'object',
properties: {
range: {
type: 'string',
enum: ['today', 'week', 'month'],
description: 'today (по умолчанию), week (7 дней) или month (текущий месяц)',
type: 'function',
function: {
name: 'get_today_events',
description:
'События из календаря (Даниил + Света). Вернёт id события, title, start, end, ' +
'owner («daniil» или «sveta»). ВАЖНО: для update_event / delete_event сначала ' +
'вызывай этот tool чтобы получить event_id.',
parameters: {
type: 'object',
properties: {
range: {
type: 'string',
enum: ['today', 'week', 'month'],
description: 'today (по умолчанию), week (7 дней) или month (текущий месяц)',
},
},
},
},
},
{
name: 'create_event',
description:
'Создать событие в Google Calendar. ВАЖНО: параметр owner обязателен. ' +
'Если пользователь не сказал чей это календарь — СПРОСИ у него ' +
'(«в твой календарь или в Светин?») и только потом вызывай tool. Не угадывай.',
input_schema: {
type: 'object',
properties: {
title: { type: 'string', description: 'Название события' },
date: { type: 'string', description: 'Дата в формате YYYY-MM-DD' },
start_time: {
type: 'string',
description: 'Время начала в формате HH:MM (24-часовой). Обязательно если all_day=false.',
},
end_time: {
type: 'string',
description: 'Время окончания в формате HH:MM. По умолчанию start_time + 1 час.',
},
all_day: {
type: 'boolean',
description: 'Событие на весь день без времени. По умолчанию false.',
},
owner: {
type: 'string',
enum: ['daniil', 'sveta'],
description: 'Чей это календарь — Даниила или Светы',
type: 'function',
function: {
name: 'create_event',
description:
'Создать событие в Google Calendar. ВАЖНО: параметр owner обязателен. ' +
'Если пользователь не сказал чей это календарь — СПРОСИ у него ' +
'(«в твой календарь или в Светин?») и только потом вызывай tool. Не угадывай.',
parameters: {
type: 'object',
properties: {
title: { type: 'string', description: 'Название события' },
date: { type: 'string', description: 'Дата в формате YYYY-MM-DD' },
start_time: {
type: 'string',
description: 'Время начала в формате HH:MM (24-часовой). Обязательно если all_day=false.',
},
end_time: {
type: 'string',
description: 'Время окончания в формате HH:MM. По умолчанию start_time + 1 час.',
},
all_day: {
type: 'boolean',
description: 'Событие на весь день без времени. По умолчанию false.',
},
owner: {
type: 'string',
enum: ['daniil', 'sveta'],
description: 'Чей это календарь — Даниила или Светы',
},
},
required: ['title', 'date', 'owner'],
},
required: ['title', 'date', 'owner'],
},
},
{
name: 'update_event',
description:
'Изменить существующее событие. Сначала обязательно вызови get_today_events ' +
'чтобы получить event_id и owner нужного события. Передавай только те поля ' +
'которые меняешь.',
input_schema: {
type: 'object',
properties: {
event_id: { type: 'string' },
owner: {
type: 'string',
enum: ['daniil', 'sveta'],
description: 'Чей календарь (из get_today_events)',
type: 'function',
function: {
name: 'update_event',
description:
'Изменить существующее событие. Сначала обязательно вызови get_today_events ' +
'чтобы получить event_id и owner нужного события. Передавай только те поля ' +
'которые меняешь.',
parameters: {
type: 'object',
properties: {
event_id: { type: 'string' },
owner: {
type: 'string',
enum: ['daniil', 'sveta'],
description: 'Чей календарь (из get_today_events)',
},
title: { type: 'string' },
date: { type: 'string', description: 'YYYY-MM-DD' },
start_time: { type: 'string', description: 'HH:MM' },
end_time: { type: 'string', description: 'HH:MM' },
all_day: { type: 'boolean' },
},
title: { type: 'string' },
date: { type: 'string', description: 'YYYY-MM-DD' },
start_time: { type: 'string', description: 'HH:MM' },
end_time: { type: 'string', description: 'HH:MM' },
all_day: { type: 'boolean' },
required: ['event_id', 'owner'],
},
required: ['event_id', 'owner'],
},
},
{
name: 'delete_event',
description:
'Удалить событие из календаря. Сначала вызови get_today_events чтобы найти ' +
'event_id и определить owner. Подтверди удаление с пользователем если событие ' +
'важное (встреча, врач, работа).',
input_schema: {
type: 'object',
properties: {
event_id: { type: 'string' },
owner: { type: 'string', enum: ['daniil', 'sveta'] },
type: 'function',
function: {
name: 'delete_event',
description:
'Удалить событие из календаря. Сначала вызови get_today_events чтобы найти ' +
'event_id и определить owner. Подтверди удаление с пользователем если событие ' +
'важное (встреча, врач, работа).',
parameters: {
type: 'object',
properties: {
event_id: { type: 'string' },
owner: { type: 'string', enum: ['daniil', 'sveta'] },
},
required: ['event_id', 'owner'],
},
required: ['event_id', 'owner'],
},
},
{
name: 'get_notes',
description:
'Список заметок и списков покупок с планшета. Для «что мне купить», ' +
'«что в списке», «какие записи».',
input_schema: { type: 'object', properties: {} },
},
{
name: 'set_timer',
description:
'Запустить таймер на планшете. Показывает обратный отсчёт с названием и звенит ' +
'по окончании. Используй для «поставь таймер на 10 минут», «напомни через час», ' +
'«засеки 5 минут для чайника».',
input_schema: {
type: 'object',
properties: {
seconds: {
type: 'integer',
description: 'Длительность в секундах (1..86400)',
minimum: 1,
maximum: 86400,
},
label: {
type: 'string',
description: 'Короткое название таймера (например «Чайник», «Паста»)',
},
type: 'function',
function: {
name: 'get_notes',
description:
'Список заметок и списков покупок с планшета. Для «что мне купить», ' +
'«что в списке», «какие записи».',
parameters: {
type: 'object',
properties: {},
},
required: ['seconds', 'label'],
},
},
{
name: 'cancel_timer',
description:
'Отменить активный таймер по его названию. Для «отмени таймер чайник», ' +
'«убери таймер пасты», «останови отсчёт».',
input_schema: {
type: 'object',
properties: {
label: {
type: 'string',
description: 'Название таймера (примерное совпадение — можно частично).',
type: 'function',
function: {
name: 'set_timer',
description:
'Запустить таймер на планшете. Показывает обратный отсчёт с названием и звенит ' +
'по окончании. Используй для «поставь таймер на 10 минут», «напомни через час», ' +
'«засеки 5 минут для чайника».',
parameters: {
type: 'object',
properties: {
seconds: {
type: 'integer',
description: 'Длительность в секундах (1..86400)',
minimum: 1,
maximum: 86400,
},
label: {
type: 'string',
description: 'Короткое название таймера (например «Чайник», «Паста»)',
},
},
required: ['seconds', 'label'],
},
required: ['label'],
},
},
{
name: 'adjust_timer',
description:
'Изменить оставшееся время таймера. Для «добавь ещё 5 минут», «убавь на минуту», ' +
'«накинь времени чайнику». Положительный delta_seconds = добавить, отрицательный = уменьшить.',
input_schema: {
type: 'object',
properties: {
label: {
type: 'string',
description: 'Название таймера для которого меняем время.',
},
delta_seconds: {
type: 'integer',
description: 'Секунды (+ добавить, - уменьшить). Например 300 = +5 минут, -60 = -1 минута.',
type: 'function',
function: {
name: 'cancel_timer',
description:
'Отменить активный таймер по его названию. Для «отмени таймер чайник», ' +
'«убери таймер пасты», «останови отсчёт».',
parameters: {
type: 'object',
properties: {
label: {
type: 'string',
description: 'Название таймера (примерное совпадение — можно частично).',
},
},
required: ['label'],
},
},
},
{
type: 'function',
function: {
name: 'adjust_timer',
description:
'Изменить оставшееся время таймера. Для «добавь ещё 5 минут», «убавь на минуту», ' +
'«накинь времени чайнику». Положительный delta_seconds = добавить, отрицательный = уменьшить.',
parameters: {
type: 'object',
properties: {
label: {
type: 'string',
description: 'Название таймера для которого меняем время.',
},
delta_seconds: {
type: 'integer',
description: 'Секунды (+ добавить, - уменьшить). Например 300 = +5 минут, -60 = -1 минута.',
},
},
required: ['label', 'delta_seconds'],
},
required: ['label', 'delta_seconds'],
},
},
]