Внедряем единый тул-канал для локального LiteRT и удалённого LLM-клиента
This commit is contained in:
@@ -246,8 +246,11 @@ class PhoneApp : Application() {
|
||||
).also { toolkit = it }
|
||||
return Assistant(
|
||||
chatDao = chatDao,
|
||||
chat = { history, system -> llm.chat(history, system) },
|
||||
chat = { history, toolSpecs, system -> llm.chatWithTools(history, toolSpecs, system) },
|
||||
toolkit = tk,
|
||||
// Нативный тул-канал LiteRT: только локальная модель видит схемы
|
||||
// тул у движка; системный промпт тогда без текстовых TOOL_CALL-линий.
|
||||
nativeTools = choice == LlmModelChoice.LOCAL,
|
||||
// Локальной модели нужно время: инициализация движка ~17с на
|
||||
// CPU + генерация. Серверная укладывается в 30с, локальной
|
||||
// даём 5 минут (иначе withTimeoutOrNull режет генерацию).
|
||||
|
||||
@@ -54,15 +54,24 @@ class AgentToolkit(
|
||||
* Системный промпт для сессии: ядро + каталог + активные тулсеты (с
|
||||
* описаниями тул) + индекс скиллов (свои скиллы активных тулсетов убраны).
|
||||
* Просроченные тулсеты чистятся здесь же — фоновых таймеров нет.
|
||||
*
|
||||
* [nativeTools] — нативный тул-канал (локальная модель, LiteRT): тулы
|
||||
* модель видит через JSON-схемы в движке, строку текстового протокола
|
||||
* TOOL_CALL в промпт НЕ кладем (иначе 2B-модель путает каналы).
|
||||
*/
|
||||
fun systemPrompt(sessionId: Long): String {
|
||||
fun systemPrompt(sessionId: Long, nativeTools: Boolean = false): String {
|
||||
val note = pendingNotes.remove(sessionId)
|
||||
val activeSets = toolsets.activeNames(sessionId)
|
||||
val sb = StringBuilder()
|
||||
sb.append("Ты — ассистент Viewmate: краткий помощник на телефоне, связанный с очками Viewmate. ")
|
||||
.append("Делай то, что просит пользователь, отвечай кратко (1-3 предложения), по-русски.")
|
||||
if (nativeTools) {
|
||||
sb.append("\n\nДля тула используй вызов инструмента из списка доступных тул (function calling). ")
|
||||
.append("После получения результата тула ответь пользователю текстом.")
|
||||
} else {
|
||||
sb.append("\n\nДля тула ответь ровно одной строкой TOOL_CALL: имя_тула|арг1=знач1,арг2=знач2 (без кавычек, без текста вокруг). ")
|
||||
.append("После получения результата тула ответь пользователю текстом.")
|
||||
}
|
||||
sb.append("\n\nЯдро:")
|
||||
CORE_LINES.forEach { sb.append('\n').append(it) }
|
||||
val catalog = toolsets.catalogLines()
|
||||
@@ -85,6 +94,27 @@ class AgentToolkit(
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Нейтральные схемы тул для [LlmClient.chatWithTools] (нативный канал):
|
||||
* ядро (5 тул) + тулы АКТИВНЫХ тулсетов (с полными именами `набор_тул`).
|
||||
* Неактивные тулсеты сюда не попадают — модель увидит их только после
|
||||
* enable_toolset (в следующем callLlm схема пересобирается).
|
||||
*/
|
||||
fun toolSpecs(sessionId: Long): List<ToolSpec> {
|
||||
val out = mutableListOf(
|
||||
ToolSpec(TOOL_TIME, "Текущие дата, время и часовой пояс (ISO 8601)"),
|
||||
ToolSpec(TOOL_SHOW_TEXT, "Показать текст на очках", listOf("text")),
|
||||
ToolSpec(TOOL_READ_SKILL, "Прочитать полное содержимое скилла по имени", listOf("name")),
|
||||
ToolSpec(TOOL_ENABLE, "Активировать тулсет (живёт 10 мин от последнего вызова его тул)", listOf("name")),
|
||||
ToolSpec(TOOL_DISABLE, "Деактивировать тулсет", listOf("name")),
|
||||
)
|
||||
for (setName in toolsets.activeNames(sessionId)) {
|
||||
val ts = toolsets.toolset(setName) ?: continue
|
||||
ts.tools.forEach { s -> out += ToolSpec(ts.toolFull(s), s.description, s.args) }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------- парсинг аргументов ----------
|
||||
|
||||
/** Схема (имена аргументов) для тула — чтобы распознать «голое» значение. */
|
||||
@@ -98,31 +128,11 @@ class AgentToolkit(
|
||||
|
||||
/**
|
||||
* Аргументы протокола `TOOL_CALL: имя|a=b,c=d` (или «голое» значение для
|
||||
* одноаргументных тул) → карта. Прощающий: пары — через запятую/`&`,
|
||||
* лишние ключи не ошибка. Пустая строка/нет аргументов — пустая карта.
|
||||
* одноаргументных тул) → карта. Логика — в топовой [parseToolCallArgs]
|
||||
* (общая с RemoteLlmClient), метод оставлен для API/тестов.
|
||||
*/
|
||||
fun parseArgs(raw: String?, expected: List<String>): Map<String, String> {
|
||||
val s = raw?.trim()
|
||||
if (s.isNullOrEmpty()) return emptyMap()
|
||||
val required = expected.filterNot { it.endsWith("?") }.distinct()
|
||||
val out = LinkedHashMap<String, String>()
|
||||
var bareIndex = 0
|
||||
for (field in s.split(ALTER)) {
|
||||
val f = field.trim()
|
||||
if (f.isEmpty()) continue
|
||||
val eq = f.indexOf('=')
|
||||
if (eq > 0) {
|
||||
val key = f.substring(0, eq).trim()
|
||||
if (key.isNotBlank()) out[key] = f.substring(eq + 1).trim()
|
||||
} else if (bareIndex < required.size) {
|
||||
// «Голое» значение (media_search|Название) идёт первому обязательному аргументу.
|
||||
out[required[bareIndex++]] = f
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private val ALTER = Regex("[,&]")
|
||||
fun parseArgs(raw: String?, expected: List<String>): Map<String, String> =
|
||||
parseToolCallArgs(raw, expected)
|
||||
|
||||
// ---------- диспач вызовов ----------
|
||||
|
||||
|
||||
@@ -9,19 +9,22 @@ import kotlinx.coroutines.withTimeoutOrNull
|
||||
* (сериализация фраз — LLM-вызовы не пересекаются).
|
||||
*
|
||||
* Тулы: агент видит их в системном промпте (механика — [AgentToolkit], если
|
||||
* передана, иначе старый [AgentTools]); если тула нужна — агент отвечает одной
|
||||
* строкой `TOOL_CALL: имя|арг1=знач1,арг2=знач2` (аргументы — опционально).
|
||||
* передана, иначе старый [AgentTools]). Вызовы тул нормализованы на границе
|
||||
* [LlmClient]: локальный бэкенд декларует их нативным LiteRT-каналом,
|
||||
* удалённый — текстовым маркером `TOOL_CALL: имя|арг1=знач1`; ассистент
|
||||
* получает единый [LlmResult.toolCalls] и не знает, откуда приехали.
|
||||
* Ассистент выполняет тул и возвращает результат как tool-сообщение в историю,
|
||||
* затем делает повторный вызов LLM. Невидимые системные тулы
|
||||
* (enable_toolset / disable_toolset) в историю НЕ пишутся — просто
|
||||
* пересобирается системный промпт (состояние 3). Обычный текст (без маркера)
|
||||
* пересобирается системный промпт (состояние 3). Обычный текст (без тул-вызовов)
|
||||
* возвращается пользователю как финальный ответ.
|
||||
*/
|
||||
class Assistant(
|
||||
private val chatDao: ChatDao,
|
||||
private val chat: suspend (history: List<ChatMessage>, system: String?) -> String,
|
||||
private val chat: suspend (history: List<ChatMessage>, tools: List<ToolSpec>, system: String?) -> LlmResult,
|
||||
private val toolkit: AgentToolkit? = null,
|
||||
private val tools: List<AgentTool> = AgentTools.ALL,
|
||||
private val nativeTools: Boolean = false,
|
||||
private val timeoutMs: Long = 30_000,
|
||||
) {
|
||||
/**
|
||||
@@ -50,7 +53,7 @@ class Assistant(
|
||||
|
||||
private suspend fun processInSession(sessionId: Long): String {
|
||||
val history = chatDao.messagesForSession(sessionId).takeLast(20)
|
||||
var answer = callLlm(history, sessionId)
|
||||
var result = callLlm(history, sessionId)
|
||||
var lastToolResult: String? = null
|
||||
var visibleCalls = 0
|
||||
var llmCalls = 1
|
||||
@@ -58,31 +61,31 @@ class Assistant(
|
||||
// зацикливания агента). Невидимые (silent) вызовы лимит не съедают,
|
||||
// но общее число LLM-вызовов ограничено [MAX_LLM_CALLS].
|
||||
while (true) {
|
||||
val parsed = parseToolCall(answer)
|
||||
?: return answer // callLlm уже сохранил ASSISTANT-сообщение
|
||||
val toolName = parsed.name
|
||||
val call = result.toolCalls.firstOrNull()
|
||||
?: return result.text // callLlm уже сохранил ASSISTANT-сообщение
|
||||
val toolName = call.name
|
||||
if (toolkit == null) {
|
||||
// Наследственный путь (без механики тулсетов): простые тулы без аргументов.
|
||||
val tool = tools.firstOrNull { it.name == toolName }
|
||||
if (tool == null) {
|
||||
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer)
|
||||
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = result.text)
|
||||
return "Тул $toolName не найден"
|
||||
}
|
||||
lastToolResult = runCatching { tool.invoke("{}") }.getOrElse { "ошибка тула: ${it.message}" }
|
||||
chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = lastToolResult, toolCallId = toolName)
|
||||
logTool(sessionId, toolName, lastToolResult)
|
||||
if (++visibleCalls >= MAX_TOOL_CALLS) break
|
||||
answer = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
|
||||
result = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
|
||||
llmCalls++
|
||||
continue
|
||||
}
|
||||
when (val result = toolkit.dispatch(sessionId, toolName, toolkit.parseArgs(parsed.argsRaw, toolkit.schemaFor(toolName)))) {
|
||||
when (val dispatch = toolkit.dispatch(sessionId, toolName, call.arguments)) {
|
||||
is AgentToolkit.Dispatch.Run -> {
|
||||
lastToolResult = result.result
|
||||
chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = result.result, toolCallId = toolName)
|
||||
logTool(sessionId, toolName, result.result)
|
||||
lastToolResult = dispatch.result
|
||||
chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = dispatch.result, toolCallId = toolName)
|
||||
logTool(sessionId, toolName, dispatch.result)
|
||||
if (++visibleCalls >= MAX_TOOL_CALLS) break
|
||||
answer = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
|
||||
result = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
|
||||
llmCalls++
|
||||
}
|
||||
|
||||
@@ -92,29 +95,34 @@ class Assistant(
|
||||
// (переход в состояние 3 — «Активные тулсеты»).
|
||||
logTool(sessionId, toolName, "невидимый вызов (пересборка промпта)")
|
||||
if (llmCalls++ >= MAX_LLM_CALLS) break
|
||||
answer = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
|
||||
result = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
// LLM зациклилась на TOOL_CALL — отдаём последний результат тула как ответ,
|
||||
// чтобы пользователь получил данные, а не сырой маркер.
|
||||
val stillTool = parseToolCall(answer)
|
||||
// LLM зациклилась на тул-вызовах — отдаём последний результат тула как
|
||||
// ответ, чтобы пользователь получил данные, а не сырой маркер.
|
||||
val stillTool = result.toolCalls.firstOrNull()
|
||||
return if (stillTool != null && lastToolResult != null) {
|
||||
"TOOL_CALL ${stillTool.name} (повтор) — $lastToolResult"
|
||||
} else {
|
||||
answer
|
||||
result.text
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun callLlm(history: List<ChatMessage>, sessionId: Long): String {
|
||||
val system = toolkit?.systemPrompt(sessionId) ?: legacySystemPrompt()
|
||||
val answer = withTimeoutOrNull(timeoutMs) { chat(history, system) }
|
||||
?: return "Ассистент не ответил (таймаут)"
|
||||
if (!isToolCall(answer)) {
|
||||
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer)
|
||||
private suspend fun callLlm(history: List<ChatMessage>, sessionId: Long): LlmResult {
|
||||
val system = toolkit?.systemPrompt(sessionId, nativeTools) ?: legacySystemPrompt()
|
||||
val specs = if (toolkit != null) {
|
||||
toolkit.toolSpecs(sessionId)
|
||||
} else {
|
||||
tools.map { ToolSpec(name = it.name, description = it.description) }
|
||||
}
|
||||
val result = withTimeoutOrNull(timeoutMs) { chat(history, specs, system) }
|
||||
?: return LlmResult("Ассистент не ответил (таймаут)")
|
||||
if (result.toolCalls.isEmpty()) {
|
||||
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = result.text)
|
||||
chatDao.touchSession(sessionId)
|
||||
}
|
||||
return answer
|
||||
return result
|
||||
}
|
||||
|
||||
/** Системный промпт (наследственный путь, без [AgentToolkit]). */
|
||||
@@ -131,23 +139,6 @@ class Assistant(
|
||||
/** Общий лимит LLM-вызовов на фразу (включая невидимые). */
|
||||
const val MAX_LLM_CALLS = 8
|
||||
|
||||
/**
|
||||
* Протокол вызова тула: строка вида `TOOL_CALL: имя_тула|арг1=знач1,арг2=знач2`
|
||||
* (аргументы — опционально; простое `TOOL_CALL: имя` — без аргументов).
|
||||
*/
|
||||
private val TOOL_CALL_RE =
|
||||
Regex("""TOOL_CALL\s*:\s*([A-Za-z0-9_]+)(?:\s*\|\s*([^\n]*))?""", RegexOption.IGNORE_CASE)
|
||||
|
||||
data class ParsedToolCall(val name: String, val argsRaw: String?)
|
||||
|
||||
private fun parseToolCall(answer: String): ParsedToolCall? {
|
||||
val m = TOOL_CALL_RE.find(answer) ?: return null
|
||||
val argsRaw = m.groupValues[2].trim().ifBlank { null }
|
||||
return ParsedToolCall(m.groupValues[1].lowercase(), argsRaw)
|
||||
}
|
||||
|
||||
private fun isToolCall(answer: String): Boolean = parseToolCall(answer) != null
|
||||
|
||||
/** Случайное имя сессии (UUID-подобное, 8 символов). */
|
||||
private fun randomTitle(): String =
|
||||
"сессия-" + (1..8).map { "0123456789abcdef"[kotlin.random.Random.nextInt(16)] }.joinToString("")
|
||||
|
||||
@@ -9,6 +9,7 @@ import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import pw.binom.viewmate.phone.log
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
@@ -26,6 +27,106 @@ interface LlmClient {
|
||||
* [system] — системный промпт тулкита/настроек или null (не обязателен).
|
||||
*/
|
||||
suspend fun chat(messages: List<ChatMessage>, system: String? = null): String
|
||||
|
||||
/**
|
||||
* Чат с тул-каналом: [tools] — схемы доступных тул ([ToolSpec]).
|
||||
* Локальный клиент регистрирует их в движке (нативный LiteRT-канал),
|
||||
* удалённый игнорирует список (тулы описывает текстовый протокол в промпте).
|
||||
* Результат [LlmResult] несёт И текст, И структурированные вызовы
|
||||
* [LlmToolCall] — клиент нормализует источник (нативный / текстовый
|
||||
* маркер), [Assistant] потребляет оба бэкенда одинаково.
|
||||
*/
|
||||
suspend fun chatWithTools(
|
||||
messages: List<ChatMessage>,
|
||||
tools: List<ToolSpec> = emptyList(),
|
||||
system: String? = null,
|
||||
): LlmResult = LlmResult(text = chat(messages, system))
|
||||
}
|
||||
|
||||
/**
|
||||
* Нейтральная схема тула для [LlmClient.chatWithTools]: имя, описание и
|
||||
* аргументы (та же нотация, что у [ToolSignature]: «query», «seek_ms?» —
|
||||
* «?» = опциональный).
|
||||
*/
|
||||
data class ToolSpec(
|
||||
val name: String,
|
||||
val description: String,
|
||||
val args: List<String> = emptyList(),
|
||||
) {
|
||||
/** Обязательные аргументы (без маркера «?»). */
|
||||
fun requiredArgs(): List<String> = args.filterNot { it.endsWith("?") }.map { it.substringBefore('?') }
|
||||
|
||||
/** Все имена аргументов (обязательные и опциональные, без маркера «?»). */
|
||||
fun argNames(): List<String> = args.map { it.substringBefore('?') }
|
||||
}
|
||||
|
||||
/** Вызов тула, декларированный моделью: имя + распарсенные именованные аргументы. */
|
||||
data class LlmToolCall(
|
||||
val name: String,
|
||||
val arguments: Map<String, String> = emptyMap(),
|
||||
)
|
||||
|
||||
/** Ответ [LlmClient.chatWithTools]: текст + декларированные вызовы тул (могут быть пусты). */
|
||||
data class LlmResult(
|
||||
val text: String,
|
||||
val toolCalls: List<LlmToolCall> = emptyList(),
|
||||
)
|
||||
|
||||
// ---------- Текстовый протокол вызова тула (RemoteLlmClient) ----------
|
||||
|
||||
/**
|
||||
* Строка вызова тула в текстовом протоколе: `TOOL_CALL: имя_тула|арг1=знач1,арг2=знач2`
|
||||
* (аргументы — опционально; простое `TOOL_CALL: имя` — без аргументов).
|
||||
*/
|
||||
val TOOL_CALL_RE =
|
||||
Regex("""TOOL_CALL\s*:\s*([A-Za-z0-9_]+)(?:\s*\|\s*([^\n]*))?""", RegexOption.IGNORE_CASE)
|
||||
|
||||
/**
|
||||
* Аргументы протокола `TOOL_CALL: имя|a=b,c=d` (или «голое» значение для
|
||||
* одноаргументных тул) → карта. Прощающий: пары — через запятую/`&`,
|
||||
* лишние ключи не ошибка. Пустая строка/нет аргументов — пустая карта.
|
||||
* [expected] — имена аргументов схемы ([ToolSpec.argNames]).
|
||||
*/
|
||||
fun parseToolCallArgs(raw: String?, expected: List<String>): Map<String, String> {
|
||||
val s = raw?.trim()
|
||||
if (s.isNullOrEmpty()) return emptyMap()
|
||||
val required = expected.filterNot { it.endsWith("?") }.distinct()
|
||||
val out = LinkedHashMap<String, String>()
|
||||
var bareIndex = 0
|
||||
for (field in s.split(Regex("[,&]"))) {
|
||||
val f = field.trim()
|
||||
if (f.isEmpty()) continue
|
||||
val eq = f.indexOf('=')
|
||||
if (eq > 0) {
|
||||
val key = f.substring(0, eq).trim()
|
||||
if (key.isNotBlank()) out[key] = f.substring(eq + 1).trim()
|
||||
} else if (bareIndex < required.size) {
|
||||
// «Голое» значение (media_search|Название) идёт первому обязательному аргументу.
|
||||
out[required[bareIndex++]] = f
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Лог длинного текста (тела запроса, промпта, ответа) в канал [log]
|
||||
* кусками ≤3500 символов — лимит записи logcat ~4096, длиннее обрезалось бы.
|
||||
*/
|
||||
internal fun logChunked(tag: String, label: String, text: String) {
|
||||
if (text.length <= 3500) {
|
||||
log(tag, "$label: $text")
|
||||
return
|
||||
}
|
||||
val size = 3500
|
||||
val total = (text.length + size - 1) / size
|
||||
var from = 0
|
||||
var i = 0
|
||||
while (from < text.length && i < total) {
|
||||
val to = minOf(from + size, text.length)
|
||||
log(tag, "$label [${i + 1}/$total]: ${text.substring(from, to)}")
|
||||
from = to
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,6 +202,8 @@ class RemoteLlmClient(
|
||||
if (dto.none { it.role == "user" }) dto += ChatMessageDto("user", "Продолжай.")
|
||||
|
||||
val body = json.encodeToString(ChatRequest.serializer(), ChatRequest(model = model, messages = dto))
|
||||
log("llm", "remote: запрос: модель=$model, сообщений=${dto.size}")
|
||||
logChunked("llm", "remote: тело запроса", body)
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl/v1/chat/completions")
|
||||
.header("Authorization", "Bearer $apiKey")
|
||||
@@ -115,11 +218,34 @@ class RemoteLlmClient(
|
||||
val err = runCatching { json.decodeFromString<ChatResponse>(raw).error?.message }.getOrNull()
|
||||
throw IllegalStateException("HTTP ${resp.code}: ${err ?: raw.take(200)}")
|
||||
}
|
||||
val text = runCatching {
|
||||
val reply = runCatching {
|
||||
json.decodeFromString<ChatResponse>(raw).choices.firstOrNull()?.message?.content
|
||||
}.getOrNull()
|
||||
text?.trim() ?: ""
|
||||
val text = reply?.trim() ?: ""
|
||||
logChunked("llm", "remote: ответ", text.ifEmpty { "(пусто)" })
|
||||
text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Текстовый протокол: тулы описываются в системном промпте, модель отвечает
|
||||
* строкой `TOOL_CALL: имя|арг1=знач1` — маркер разбираем здесь и
|
||||
* нормализуем в [LlmToolCall] (схема аргументов — из [tools], «голое»
|
||||
* значение прилетает первому обязательному аргументу).
|
||||
*/
|
||||
override suspend fun chatWithTools(
|
||||
messages: List<ChatMessage>,
|
||||
tools: List<ToolSpec>,
|
||||
system: String?,
|
||||
): LlmResult {
|
||||
val text = chat(messages, system)
|
||||
val m = TOOL_CALL_RE.find(text) ?: return LlmResult(text)
|
||||
val name = m.groupValues[1].lowercase()
|
||||
val argsRaw = m.groupValues[2].trim().ifBlank { null }
|
||||
val expected = tools.firstOrNull { it.name == name }?.argNames() ?: emptyList()
|
||||
val args = parseToolCallArgs(argsRaw, expected)
|
||||
log("llm", "remote: toolCall: $name (сырое='${argsRaw.orEmpty()}') → $args")
|
||||
return LlmResult(text, listOf(LlmToolCall(name, args)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,25 @@ package pw.binom.viewmate.phone.agent
|
||||
|
||||
import pw.binom.litert.LiteBackend
|
||||
import pw.binom.litert.LiteConfig
|
||||
import pw.binom.litert.LiteConversation
|
||||
import pw.binom.litert.LiteConversationConfig
|
||||
import pw.binom.litert.LiteExperimental
|
||||
import pw.binom.litert.LiteLlm
|
||||
import pw.binom.litert.LiteMessage
|
||||
import pw.binom.litert.LiteRequest
|
||||
import pw.binom.litert.LiteTool
|
||||
import pw.binom.litert.LiteToolCall
|
||||
import pw.binom.litert.google.googleLiteLlm
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.takeWhile
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonArray
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
@@ -172,6 +182,55 @@ fun buildLocalLlmPrompt(messages: List<ChatMessage>, system: String? = null): Lo
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAPI-JSON-дескриптор [ToolSpec] для LiteRT-LM: ПЛОСКИЙ объект
|
||||
* `{"name","description","parameters"}` — так его читает движок
|
||||
* (litertlm 0.16.1, ToolKt.tool: `json.get("name").getAsString()` на
|
||||
* верхнем уровне; OpenAI-обёртка `{"type":"function","function":{…}}`
|
||||
* падает ToolException «Failed to parse field name»). Движок передаёт
|
||||
* строку модели КАК ЕСТЬ — без репарсинга и валидации (контракт тул-слоя
|
||||
* pw.binom.litert, tag 5), поэтому JSON строим сами.
|
||||
* Все аргументы у нас строковые (текстовый протокол тулкита), `type` — «string».
|
||||
*/
|
||||
fun toolJson(spec: ToolSpec): String = buildJsonObject {
|
||||
put("name", spec.name)
|
||||
put("description", spec.description)
|
||||
putJsonObject("parameters") {
|
||||
put("type", "object")
|
||||
if (spec.argNames().isNotEmpty()) {
|
||||
putJsonObject("properties") {
|
||||
spec.args.forEach { a ->
|
||||
putJsonObject(a.substringBefore('?')) {
|
||||
put("type", "string")
|
||||
}
|
||||
}
|
||||
}
|
||||
val required = spec.requiredArgs()
|
||||
if (required.isNotEmpty()) {
|
||||
putJsonArray("required") {
|
||||
required.forEach { add(JsonPrimitive(it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.toString()
|
||||
|
||||
/**
|
||||
* Наш [ToolSpec] в виде [LiteTool] LiteRT-слоя: `describe()` — самособранное
|
||||
* OpenAPI-JSON (плоский, для LiteRT-LM) из [toolJson]. `invoke()` недостижим: в ручном режиме
|
||||
* (automaticToolCalling = false) тул-цикл ведёт [Assistant], движок не
|
||||
* исполняет тулы — если движок всё же позвал `invoke`, это баг движка,
|
||||
* падаем с понятным сообщением.
|
||||
*/
|
||||
private class SpecLiteTool(spec: ToolSpec) : LiteTool {
|
||||
private val descriptor: String = toolJson(spec)
|
||||
|
||||
override fun describe(): String = descriptor
|
||||
|
||||
override fun invoke(arguments: String): String =
|
||||
error("LiteTool.invoke: тул-цикл ведёт Assistant (automaticToolCalling = false), движок не должен исполнять тулы")
|
||||
}
|
||||
|
||||
// ---------- Скачивание модели ----------
|
||||
|
||||
/** Состояние локальной модели для UI настроек (StateFlow в PhoneApp). */
|
||||
@@ -289,9 +348,9 @@ class GemmaModelDownloader(
|
||||
*
|
||||
* Жизненный цикл: [LiteLlm] создаётся один раз (лениво, при первом [chat]),
|
||||
* переиспользуется — модель заново не грузится. Каждый чатовый вызов —
|
||||
* новый RAW-инференс: [LiteRequest] несёт полный промпт (системная
|
||||
* инструкция + вся история + входящее), движок не хранит состояние между
|
||||
* вызовами. Ошибки — [IllegalStateException] с читаемым русским
|
||||
* новый RAW-инференс: [LiteConversationConfig] несёт полный промпт
|
||||
* (системная инструкция + вся история + входящее) и схемы тул,
|
||||
* движок не хранит состояние между вызовами. Ошибки — [IllegalStateException] с читаемым русским
|
||||
* сообщением («локальная модель не скачана…») — [Assistant] покажет его
|
||||
* пользователю, падение приложения исключено.
|
||||
*
|
||||
@@ -310,26 +369,57 @@ class LocalLlmClient(
|
||||
/** Модель на месте (по имени файла; SHA-контроль — в [ensureLlm]). */
|
||||
fun modelDownloaded(): Boolean = modelFile.isFile
|
||||
|
||||
override suspend fun chat(messages: List<ChatMessage>, system: String?): String {
|
||||
log("llm", "chat: вызов (messages=${messages.size}, system=${system?.length ?: 0} симв)")
|
||||
override suspend fun chat(messages: List<ChatMessage>, system: String?): String =
|
||||
chatWithTools(messages, emptyList(), system).text
|
||||
|
||||
/**
|
||||
* Чат с тул-каналом: [tools] регистрируются в движке нативным LiteRT-каналом
|
||||
* (JSON-схемы [toolJson] → модели; вызовы → [LlmResult.toolCalls]).
|
||||
* Ручной режим: движок тулы НЕ исполняет (automaticToolCalling = false) —
|
||||
* цикл «вызов → dispatch → результат в историю → повторный инференс»
|
||||
* ведёт [Assistant]; каждый круг — новый RAW-инференс.
|
||||
*/
|
||||
override suspend fun chatWithTools(
|
||||
messages: List<ChatMessage>,
|
||||
tools: List<ToolSpec>,
|
||||
system: String?,
|
||||
): LlmResult {
|
||||
log("llm", "chat: вызов (messages=${messages.size}, tools=${tools.size}, system=${system?.length ?: 0} симв)")
|
||||
val llm = ensureLlm()
|
||||
log("llm", "chat: движок готов, строю промпт")
|
||||
val prompt = buildLocalLlmPrompt(messages, system)
|
||||
log("llm", "chat: промпт: system=${prompt.systemInstruction?.length ?: 0} симв, initial=${prompt.initialMessages.size}, incoming=${prompt.incoming.text.take(60)}")
|
||||
val request = LiteRequest(
|
||||
// Полный контент, уходящий в модель: системный промпт, история, входящее, схемы тул
|
||||
logChunked("llm", "local: system", prompt.systemInstruction ?: "(нет)")
|
||||
prompt.initialMessages.forEachIndexed { i, m ->
|
||||
logChunked("llm", "local: msg[$i/${prompt.initialMessages.size}] ${m.role}", m.text)
|
||||
}
|
||||
logChunked("llm", "local: incoming", prompt.incoming.text)
|
||||
tools.forEachIndexed { i, spec ->
|
||||
log("llm", "local: tool[$i/${tools.size}] ${spec.name}: ${toolJson(spec)}")
|
||||
}
|
||||
val config = LiteConversationConfig(
|
||||
systemInstruction = prompt.systemInstruction,
|
||||
// RAW-режим: владелец истории — мы; каждый инференс — новый полный промпт
|
||||
messages = prompt.initialMessages + prompt.incoming,
|
||||
initialMessages = prompt.initialMessages + prompt.incoming,
|
||||
tools = tools.map { SpecLiteTool(it) },
|
||||
// Ручной режим: исполняют тулы не движок, а Assistant (см. KDoc метода)
|
||||
automaticToolCalling = false,
|
||||
)
|
||||
var text = ""
|
||||
var toolCalls = emptyList<LiteToolCall>()
|
||||
var chunks = 0
|
||||
var tokens = 0
|
||||
val t0 = System.currentTimeMillis()
|
||||
var firstTokenAt = 0L
|
||||
var conversation: LiteConversation? = null
|
||||
try {
|
||||
withTimeout(120_000) {
|
||||
// либа сама закрывает беседу по завершении/отмене потока
|
||||
llm.inferStream(request).collect { delta ->
|
||||
conversation = llm.createConversation(config)
|
||||
// Поток либы сам себя не завершает (callbackFlow живёт до отмены):
|
||||
// остановка — по терминальной дельте isDone (см. KDoc LiteDelta).
|
||||
conversation.sendStream(prompt.incoming.text)
|
||||
.onEach { delta ->
|
||||
chunks++
|
||||
val chunkText = delta.text
|
||||
text += chunkText
|
||||
@@ -337,16 +427,29 @@ class LocalLlmClient(
|
||||
if (firstTokenAt == 0L) firstTokenAt = System.currentTimeMillis()
|
||||
tokens += 1
|
||||
}
|
||||
if (delta.toolCalls.isNotEmpty()) toolCalls = delta.toolCalls
|
||||
if (chunks <= 3 || chunks % 20 == 0) {
|
||||
log("llm", "chat: чанк #$chunks: ${chunkText.take(50)}")
|
||||
log("llm", "chat: чанк #$chunks: ${chunkText.take(50)}${if (delta.toolCalls.isNotEmpty()) " [тулы: ${delta.toolCalls.map { it.name }}]" else ""}")
|
||||
}
|
||||
}
|
||||
.takeWhile { !it.isDone }
|
||||
.collect { }
|
||||
}
|
||||
val totalMs = System.currentTimeMillis() - t0
|
||||
val ttftMs = if (firstTokenAt > 0L) firstTokenAt - t0 else totalMs
|
||||
val tokPerSec = if (totalMs > 0) (tokens * 1000.0 / totalMs) else 0.0
|
||||
log("llm", "chat: готово, чанков=$chunks, симв=${text.length}, TTFB=${ttftMs}мс, ~${String.format(java.util.Locale.US, "%.1f", tokPerSec)} чанк/с (всего ${totalMs}мс)")
|
||||
return text.trim()
|
||||
log("llm", "chat: готово, чанков=$chunks, симв=${text.length}, TTFB=${ttftMs}мс, ~${String.format(java.util.Locale.US, "%.1f", tokPerSec)} чанк/с, тул-вызовов=${toolCalls.size} (всего ${totalMs}мс)")
|
||||
val normalized = toolCalls.map { call ->
|
||||
LlmToolCall(
|
||||
name = call.name,
|
||||
arguments = call.arguments.mapValues { entry -> entry.value?.toString() ?: "" },
|
||||
)
|
||||
}
|
||||
logChunked("llm", "local: ответ", text.trim().ifEmpty { "(пусто)" })
|
||||
normalized.forEach { call ->
|
||||
log("llm", "local: toolCall: ${call.name} аргументы=${call.arguments}")
|
||||
}
|
||||
return LlmResult(text.trim(), normalized)
|
||||
} catch (e: CancellationException) {
|
||||
log("llm", "chat: отменено")
|
||||
throw e
|
||||
@@ -354,6 +457,10 @@ class LocalLlmClient(
|
||||
log("llm", "chat: EXC class=${e::class.simpleName} msgLen=${e.message?.length}")
|
||||
log("llm", "chat: EXC msg=${sanitize(e.message)}")
|
||||
throw IllegalStateException("Локальная модель не ответила: ${e.message}", e)
|
||||
} finally {
|
||||
// либа (RAW-путь) закрывает беседу сама, здесь — явный close для
|
||||
// прямого createConversation: каждый вызов — своя беседа
|
||||
runCatching { conversation?.close() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package pw.binom.viewmate.phone
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import pw.binom.viewmate.phone.agent.ChatMessage
|
||||
import pw.binom.viewmate.phone.agent.ChatRole
|
||||
import pw.binom.viewmate.phone.agent.LlmClient
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/** OpenAI-HTTP-сервер (Ktor): /v1/chat/completions (stream=false|true), маппинг ролей, ошибки. */
|
||||
class OpenAiHttpServerTest {
|
||||
|
||||
private val json = Json
|
||||
|
||||
private class FakeLlm(
|
||||
private val text: String = "Привет, мир!",
|
||||
private val failure: String? = null,
|
||||
) : LlmClient {
|
||||
var calls = 0
|
||||
var lastSystem: String? = null
|
||||
var lastMessages: List<ChatMessage> = emptyList()
|
||||
|
||||
override suspend fun chat(messages: List<ChatMessage>, system: String?): String {
|
||||
calls++
|
||||
lastSystem = system
|
||||
lastMessages = messages
|
||||
failure?.let { throw IllegalStateException(it) }
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
private val chatBody =
|
||||
"""{"model":"any","stream":false,"messages":[{"role":"user","content":"Привет"}]}"""
|
||||
|
||||
private fun call(body: String, llm: LlmClient?): OpenAiHttpServer.ChatOutcome =
|
||||
runBlocking { OpenAiHttpServer.handleChat(llm, body) }
|
||||
|
||||
@Test
|
||||
fun nonStream_returnsCompletionJson() {
|
||||
val llm = FakeLlm()
|
||||
val outcome = call(chatBody, llm) as OpenAiHttpServer.ChatOutcome.Json
|
||||
assertEquals(200, outcome.code)
|
||||
val body = outcome.body
|
||||
assertTrue(body.contains("\"object\":\"chat.completion\""))
|
||||
assertTrue(body.contains("\"content\":\"Привет, мир!\""))
|
||||
assertTrue(body.contains("\"finish_reason\":\"stop\""))
|
||||
assertTrue(body.contains("\"id\":\"chatcmpl-"))
|
||||
assertEquals(1, llm.calls)
|
||||
assertEquals(listOf(ChatMessage.of(ChatRole.USER, "Привет")), llm.lastMessages)
|
||||
assertEquals(null, llm.lastSystem)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun systemAndDeveloperRoles_goToSystem() {
|
||||
val llm = FakeLlm()
|
||||
call(
|
||||
"""{"messages":[{"role":"system","content":"S1"},{"role":"developer","content":"S2"},{"role":"user","content":"U"},{"role":"assistant","content":"A"}]}""",
|
||||
llm,
|
||||
)
|
||||
assertEquals("S1\nS2", llm.lastSystem)
|
||||
assertEquals(listOf(ChatRole.USER, ChatRole.ASSISTANT), llm.lastMessages.map { it.role })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toolRole_mapsToToolMessage() {
|
||||
val llm = FakeLlm()
|
||||
call(
|
||||
"""{"messages":[{"role":"user","content":"U"},{"role":"tool","tool_call_id":"t1","content":"результат"}]}""",
|
||||
llm,
|
||||
)
|
||||
val tool = llm.lastMessages.last()
|
||||
assertEquals(ChatRole.TOOL, tool.role)
|
||||
assertEquals("t1", tool.toolCallId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contentAsArrayOfTextParts_joined() {
|
||||
val llm = FakeLlm()
|
||||
call(
|
||||
"""{"messages":[{"role":"user","content":[{"type":"text","text":"часть1"},{"type":"text","text":"часть2"}]}]}""",
|
||||
llm,
|
||||
)
|
||||
assertEquals("часть1\nчасть2", llm.lastMessages.last().content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun assistantNullContentWithToolCalls_decodes() {
|
||||
val llm = FakeLlm()
|
||||
call(
|
||||
"""{"messages":[{"role":"user","content":"U"},{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"f","arguments":"{}"}}]}]}""",
|
||||
llm,
|
||||
)
|
||||
val roles = llm.lastMessages.map { it.role }
|
||||
assertEquals(listOf(ChatRole.USER, ChatRole.ASSISTANT), roles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stream_emitsSseChunksAndDone() {
|
||||
val llm = FakeLlm()
|
||||
val outcome = call(
|
||||
"""{"model":"any","stream":true,"messages":[{"role":"user","content":"Привет"}]}""",
|
||||
llm,
|
||||
) as OpenAiHttpServer.ChatOutcome.Sse
|
||||
val data = outcome.frames
|
||||
assertTrue(data.any { it.endsWith("[DONE]\n\n") || it == "data: [DONE]\n\n" })
|
||||
val chunks = data.filter { !it.contains("[DONE]") }.map {
|
||||
json.decodeFromString(JsonElement.serializer(), it.removePrefix("data: ").trim())
|
||||
}
|
||||
val choices = chunks.map { chunk ->
|
||||
chunk.jsonObject["choices"]!!.jsonArray[0].jsonObject
|
||||
}
|
||||
val deltas = choices.map { it["delta"]!!.jsonObject }
|
||||
assertEquals("assistant", deltas.first()["role"]!!.jsonPrimitive.content)
|
||||
val full = deltas.mapNotNull { it["content"]?.jsonPrimitive?.content }.joinToString("")
|
||||
assertEquals("Привет, мир!", full)
|
||||
assertEquals("stop", choices.last()["finish_reason"]!!.jsonPrimitive.content)
|
||||
assertTrue(chunks.all { it.jsonObject["object"]!!.jsonPrimitive.content == "chat.completion.chunk" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidBody_400() {
|
||||
val outcome = call("не json", FakeLlm()) as OpenAiHttpServer.ChatOutcome.Json
|
||||
assertEquals(400, outcome.code)
|
||||
assertTrue(outcome.body.contains("\"error\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyMessages_400() {
|
||||
val outcome = call("""{"messages":[]}""", FakeLlm()) as OpenAiHttpServer.ChatOutcome.Json
|
||||
assertEquals(400, outcome.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownRole_400() {
|
||||
val outcome = call("""{"messages":[{"role":"alien","content":"hi"}]}""", FakeLlm()) as OpenAiHttpServer.ChatOutcome.Json
|
||||
assertEquals(400, outcome.code)
|
||||
assertTrue(outcome.body.contains("\"error\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noLlm_503() {
|
||||
val outcome = call(chatBody, null) as OpenAiHttpServer.ChatOutcome.Json
|
||||
assertEquals(503, outcome.code)
|
||||
assertTrue(outcome.body.contains("\"error\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun llmFailure_502() {
|
||||
val outcome = call(chatBody, FakeLlm(failure = "boom")) as OpenAiHttpServer.ChatOutcome.Json
|
||||
assertEquals(502, outcome.code)
|
||||
assertTrue(outcome.body.contains("boom"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allIps_containsLoopback() {
|
||||
assertTrue(OpenAiHttpServer.allIps().contains("127.0.0.1"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package pw.binom.viewmate.phone.agent
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/**
|
||||
* Тесты тул-канала на границе клиента: парсер текстового маркера
|
||||
* (remote) и OpenAI-JSON-описатель тула (local, нативный LiteRT-канал).
|
||||
*/
|
||||
class LlmClientTest {
|
||||
|
||||
// ---------- parseToolCallArgs (текстовый протокол) ----------
|
||||
|
||||
@Test
|
||||
fun parseArgsEmptyWhenNoArgs() {
|
||||
assertEquals(emptyMap<String, String>(), parseToolCallArgs(null, emptyList()))
|
||||
assertEquals(emptyMap<String, String>(), parseToolCallArgs("", emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseArgsNamedAndBare() {
|
||||
val args = parseToolCallArgs("query=old songs,seek_ms=30000", listOf("query", "seek_ms?"))
|
||||
assertEquals("old songs", args["query"])
|
||||
assertEquals("30000", args["seek_ms"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseArgsBareValueGoesToFirstExpected() {
|
||||
val args = parseToolCallArgs("old songs", listOf("query"))
|
||||
assertEquals("old songs", args["query"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseArgsDuplicateNameLastWins() {
|
||||
val args = parseToolCallArgs("id=1,id=2", listOf("id"))
|
||||
assertEquals("2", args["id"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseArgsUnknownNamePassedThrough() {
|
||||
val args = parseToolCallArgs("id=1,foo=bar", listOf("id"))
|
||||
assertEquals("1", args["id"])
|
||||
assertTrue(args.containsKey("foo"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseArgsQuotedValueKeepsQuotes() {
|
||||
// Парсер прощающий: кавычки — литеральные символы, пары режутся запятой
|
||||
val args = parseToolCallArgs("""a="hello world",b=1""", listOf("a", "b"))
|
||||
assertEquals("\"hello world\"", args["a"])
|
||||
assertEquals("1", args["b"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseArgsUnknownFormatGoesToFirstRequired() {
|
||||
// Неизвестный формат (без `=`) не падает: всё сырое значение
|
||||
// уходит первому обязательному аргументу
|
||||
val args = parseToolCallArgs("""{"id":"c42"}""", listOf("id"))
|
||||
assertEquals("""{"id":"c42"}""", args["id"])
|
||||
}
|
||||
|
||||
// ---------- TOOL_CALL-маркер (текстовый протокол, remote) ----------
|
||||
|
||||
@Test
|
||||
fun markerFoundWithArgs() {
|
||||
val m = TOOL_CALL_RE.find("хорошо, TOOL_CALL: media_search|query=old songs,seek_ms=30000")
|
||||
assertNotNull(m)
|
||||
assertEquals("media_search", m.groupValues[1])
|
||||
assertEquals("query=old songs,seek_ms=30000", m.groupValues[2].trim())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markerFoundWithoutArgs() {
|
||||
val m = TOOL_CALL_RE.find("TOOL_CALL: get_current_time")
|
||||
assertNotNull(m)
|
||||
assertEquals("get_current_time", m.groupValues[1])
|
||||
assertTrue(m.groupValues[2].isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markerNotFoundInPlainText() {
|
||||
assertNull(TOOL_CALL_RE.find("Какое сейчас время?"))
|
||||
}
|
||||
|
||||
// ---------- ToolSpec ----------
|
||||
|
||||
@Test
|
||||
fun specArgNamesAndRequired() {
|
||||
val spec = ToolSpec("media_search", "поиск", listOf("query", "seek_ms?"))
|
||||
assertEquals(listOf("query", "seek_ms"), spec.argNames())
|
||||
assertEquals(listOf("query"), spec.requiredArgs())
|
||||
}
|
||||
|
||||
// ---------- toolJson (нативный LiteRT-канал) ----------
|
||||
|
||||
@Test
|
||||
fun toolJsonHasFunctionFields() {
|
||||
val json = toolJson(ToolSpec("media_search", "поиск трека", listOf("query", "seek_ms?")))
|
||||
val obj = Json.parseToJsonElement(json) as JsonObject
|
||||
assertEquals("media_search", (obj["name"] as JsonPrimitive).content)
|
||||
val params = obj["parameters"] as JsonObject
|
||||
val props = params["properties"] as JsonObject
|
||||
assertTrue(props.containsKey("query"))
|
||||
assertTrue(props.containsKey("seek_ms"))
|
||||
val required = params["required"] as JsonArray
|
||||
assertEquals(listOf("query"), required.map { (it as JsonPrimitive).content })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toolJsonWithoutArgsHasNoProperties() {
|
||||
val json = toolJson(ToolSpec("get_current_time", "текущее время"))
|
||||
val obj = Json.parseToJsonElement(json) as JsonObject
|
||||
val params = obj["parameters"] as JsonObject
|
||||
assertFalse(params.containsKey("properties"))
|
||||
assertFalse(params.containsKey("required"))
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ androidx-activity-compose = "1.10.1"
|
||||
exoplayer = "2.17.1"
|
||||
media3 = "1.6.1"
|
||||
koog = "1.1.1"
|
||||
litert-google = "3"
|
||||
litert-google = "5"
|
||||
|
||||
[libraries]
|
||||
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
|
||||
@@ -25,9 +25,10 @@ exoplayer = { module = "com.google.android.exoplayer:exoplayer", version.ref = "
|
||||
media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
|
||||
|
||||
# LiteRT-LM (Google) — локальный LLM (Gemma 4 E2B, .litertlm) на телефоне.
|
||||
# КMP-обёртка pw.binom.litert:litert-google (git.binom.pw/subochev/litert-kmp):
|
||||
# KMP-обёртка pw.binom.litert:litert-google (git.binom.pw/subochev/litert-kmp):
|
||||
# универсальный контракт (LiteLlm/LiteRequest — RAW-инференс, владелец истории —
|
||||
# потребитель) + google-бэкенд над официальным litertlm:0.16.1 (транзитивно).
|
||||
# потребитель) + нативный тул-слой (LiteTool/LiteToolCall, tag 5) + google-бэкенд
|
||||
# над официальным litertlm:0.16.1 (транзитивно).
|
||||
litert-google = { module = "pw.binom.litert:litert-google", version.ref = "litert-google" }
|
||||
# Универсальный контракт (LiteLlm/LiteRequest и др.) — типы импортируются
|
||||
# приложением напрямую; в litert-google он runtime-only, поэтому явный.
|
||||
|
||||
Reference in New Issue
Block a user