Внедряем единый тул-канал для локального LiteRT и удалённого LLM-клиента
This commit is contained in:
@@ -246,8 +246,11 @@ class PhoneApp : Application() {
|
|||||||
).also { toolkit = it }
|
).also { toolkit = it }
|
||||||
return Assistant(
|
return Assistant(
|
||||||
chatDao = chatDao,
|
chatDao = chatDao,
|
||||||
chat = { history, system -> llm.chat(history, system) },
|
chat = { history, toolSpecs, system -> llm.chatWithTools(history, toolSpecs, system) },
|
||||||
toolkit = tk,
|
toolkit = tk,
|
||||||
|
// Нативный тул-канал LiteRT: только локальная модель видит схемы
|
||||||
|
// тул у движка; системный промпт тогда без текстовых TOOL_CALL-линий.
|
||||||
|
nativeTools = choice == LlmModelChoice.LOCAL,
|
||||||
// Локальной модели нужно время: инициализация движка ~17с на
|
// Локальной модели нужно время: инициализация движка ~17с на
|
||||||
// CPU + генерация. Серверная укладывается в 30с, локальной
|
// CPU + генерация. Серверная укладывается в 30с, локальной
|
||||||
// даём 5 минут (иначе withTimeoutOrNull режет генерацию).
|
// даём 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 note = pendingNotes.remove(sessionId)
|
||||||
val activeSets = toolsets.activeNames(sessionId)
|
val activeSets = toolsets.activeNames(sessionId)
|
||||||
val sb = StringBuilder()
|
val sb = StringBuilder()
|
||||||
sb.append("Ты — ассистент Viewmate: краткий помощник на телефоне, связанный с очками Viewmate. ")
|
sb.append("Ты — ассистент Viewmate: краткий помощник на телефоне, связанный с очками Viewmate. ")
|
||||||
.append("Делай то, что просит пользователь, отвечай кратко (1-3 предложения), по-русски.")
|
.append("Делай то, что просит пользователь, отвечай кратко (1-3 предложения), по-русски.")
|
||||||
|
if (nativeTools) {
|
||||||
|
sb.append("\n\nДля тула используй вызов инструмента из списка доступных тул (function calling). ")
|
||||||
|
.append("После получения результата тула ответь пользователю текстом.")
|
||||||
|
} else {
|
||||||
sb.append("\n\nДля тула ответь ровно одной строкой TOOL_CALL: имя_тула|арг1=знач1,арг2=знач2 (без кавычек, без текста вокруг). ")
|
sb.append("\n\nДля тула ответь ровно одной строкой TOOL_CALL: имя_тула|арг1=знач1,арг2=знач2 (без кавычек, без текста вокруг). ")
|
||||||
.append("После получения результата тула ответь пользователю текстом.")
|
.append("После получения результата тула ответь пользователю текстом.")
|
||||||
|
}
|
||||||
sb.append("\n\nЯдро:")
|
sb.append("\n\nЯдро:")
|
||||||
CORE_LINES.forEach { sb.append('\n').append(it) }
|
CORE_LINES.forEach { sb.append('\n').append(it) }
|
||||||
val catalog = toolsets.catalogLines()
|
val catalog = toolsets.catalogLines()
|
||||||
@@ -85,6 +94,27 @@ class AgentToolkit(
|
|||||||
return sb.toString()
|
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` (или «голое» значение для
|
* Аргументы протокола `TOOL_CALL: имя|a=b,c=d` (или «голое» значение для
|
||||||
* одноаргументных тул) → карта. Прощающий: пары — через запятую/`&`,
|
* одноаргументных тул) → карта. Логика — в топовой [parseToolCallArgs]
|
||||||
* лишние ключи не ошибка. Пустая строка/нет аргументов — пустая карта.
|
* (общая с RemoteLlmClient), метод оставлен для API/тестов.
|
||||||
*/
|
*/
|
||||||
fun parseArgs(raw: String?, expected: List<String>): Map<String, String> {
|
fun parseArgs(raw: String?, expected: List<String>): Map<String, String> =
|
||||||
val s = raw?.trim()
|
parseToolCallArgs(raw, expected)
|
||||||
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("[,&]")
|
|
||||||
|
|
||||||
// ---------- диспач вызовов ----------
|
// ---------- диспач вызовов ----------
|
||||||
|
|
||||||
|
|||||||
@@ -9,19 +9,22 @@ import kotlinx.coroutines.withTimeoutOrNull
|
|||||||
* (сериализация фраз — LLM-вызовы не пересекаются).
|
* (сериализация фраз — LLM-вызовы не пересекаются).
|
||||||
*
|
*
|
||||||
* Тулы: агент видит их в системном промпте (механика — [AgentToolkit], если
|
* Тулы: агент видит их в системном промпте (механика — [AgentToolkit], если
|
||||||
* передана, иначе старый [AgentTools]); если тула нужна — агент отвечает одной
|
* передана, иначе старый [AgentTools]). Вызовы тул нормализованы на границе
|
||||||
* строкой `TOOL_CALL: имя|арг1=знач1,арг2=знач2` (аргументы — опционально).
|
* [LlmClient]: локальный бэкенд декларует их нативным LiteRT-каналом,
|
||||||
|
* удалённый — текстовым маркером `TOOL_CALL: имя|арг1=знач1`; ассистент
|
||||||
|
* получает единый [LlmResult.toolCalls] и не знает, откуда приехали.
|
||||||
* Ассистент выполняет тул и возвращает результат как tool-сообщение в историю,
|
* Ассистент выполняет тул и возвращает результат как tool-сообщение в историю,
|
||||||
* затем делает повторный вызов LLM. Невидимые системные тулы
|
* затем делает повторный вызов LLM. Невидимые системные тулы
|
||||||
* (enable_toolset / disable_toolset) в историю НЕ пишутся — просто
|
* (enable_toolset / disable_toolset) в историю НЕ пишутся — просто
|
||||||
* пересобирается системный промпт (состояние 3). Обычный текст (без маркера)
|
* пересобирается системный промпт (состояние 3). Обычный текст (без тул-вызовов)
|
||||||
* возвращается пользователю как финальный ответ.
|
* возвращается пользователю как финальный ответ.
|
||||||
*/
|
*/
|
||||||
class Assistant(
|
class Assistant(
|
||||||
private val chatDao: ChatDao,
|
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 toolkit: AgentToolkit? = null,
|
||||||
private val tools: List<AgentTool> = AgentTools.ALL,
|
private val tools: List<AgentTool> = AgentTools.ALL,
|
||||||
|
private val nativeTools: Boolean = false,
|
||||||
private val timeoutMs: Long = 30_000,
|
private val timeoutMs: Long = 30_000,
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
@@ -50,7 +53,7 @@ class Assistant(
|
|||||||
|
|
||||||
private suspend fun processInSession(sessionId: Long): String {
|
private suspend fun processInSession(sessionId: Long): String {
|
||||||
val history = chatDao.messagesForSession(sessionId).takeLast(20)
|
val history = chatDao.messagesForSession(sessionId).takeLast(20)
|
||||||
var answer = callLlm(history, sessionId)
|
var result = callLlm(history, sessionId)
|
||||||
var lastToolResult: String? = null
|
var lastToolResult: String? = null
|
||||||
var visibleCalls = 0
|
var visibleCalls = 0
|
||||||
var llmCalls = 1
|
var llmCalls = 1
|
||||||
@@ -58,31 +61,31 @@ class Assistant(
|
|||||||
// зацикливания агента). Невидимые (silent) вызовы лимит не съедают,
|
// зацикливания агента). Невидимые (silent) вызовы лимит не съедают,
|
||||||
// но общее число LLM-вызовов ограничено [MAX_LLM_CALLS].
|
// но общее число LLM-вызовов ограничено [MAX_LLM_CALLS].
|
||||||
while (true) {
|
while (true) {
|
||||||
val parsed = parseToolCall(answer)
|
val call = result.toolCalls.firstOrNull()
|
||||||
?: return answer // callLlm уже сохранил ASSISTANT-сообщение
|
?: return result.text // callLlm уже сохранил ASSISTANT-сообщение
|
||||||
val toolName = parsed.name
|
val toolName = call.name
|
||||||
if (toolkit == null) {
|
if (toolkit == null) {
|
||||||
// Наследственный путь (без механики тулсетов): простые тулы без аргументов.
|
// Наследственный путь (без механики тулсетов): простые тулы без аргументов.
|
||||||
val tool = tools.firstOrNull { it.name == toolName }
|
val tool = tools.firstOrNull { it.name == toolName }
|
||||||
if (tool == null) {
|
if (tool == null) {
|
||||||
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer)
|
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = result.text)
|
||||||
return "Тул $toolName не найден"
|
return "Тул $toolName не найден"
|
||||||
}
|
}
|
||||||
lastToolResult = runCatching { tool.invoke("{}") }.getOrElse { "ошибка тула: ${it.message}" }
|
lastToolResult = runCatching { tool.invoke("{}") }.getOrElse { "ошибка тула: ${it.message}" }
|
||||||
chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = lastToolResult, toolCallId = toolName)
|
chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = lastToolResult, toolCallId = toolName)
|
||||||
logTool(sessionId, toolName, lastToolResult)
|
logTool(sessionId, toolName, lastToolResult)
|
||||||
if (++visibleCalls >= MAX_TOOL_CALLS) break
|
if (++visibleCalls >= MAX_TOOL_CALLS) break
|
||||||
answer = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
|
result = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
|
||||||
llmCalls++
|
llmCalls++
|
||||||
continue
|
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 -> {
|
is AgentToolkit.Dispatch.Run -> {
|
||||||
lastToolResult = result.result
|
lastToolResult = dispatch.result
|
||||||
chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = result.result, toolCallId = toolName)
|
chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = dispatch.result, toolCallId = toolName)
|
||||||
logTool(sessionId, toolName, result.result)
|
logTool(sessionId, toolName, dispatch.result)
|
||||||
if (++visibleCalls >= MAX_TOOL_CALLS) break
|
if (++visibleCalls >= MAX_TOOL_CALLS) break
|
||||||
answer = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
|
result = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
|
||||||
llmCalls++
|
llmCalls++
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,29 +95,34 @@ class Assistant(
|
|||||||
// (переход в состояние 3 — «Активные тулсеты»).
|
// (переход в состояние 3 — «Активные тулсеты»).
|
||||||
logTool(sessionId, toolName, "невидимый вызов (пересборка промпта)")
|
logTool(sessionId, toolName, "невидимый вызов (пересборка промпта)")
|
||||||
if (llmCalls++ >= MAX_LLM_CALLS) break
|
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 — отдаём последний результат тула как ответ,
|
// LLM зациклилась на тул-вызовах — отдаём последний результат тула как
|
||||||
// чтобы пользователь получил данные, а не сырой маркер.
|
// ответ, чтобы пользователь получил данные, а не сырой маркер.
|
||||||
val stillTool = parseToolCall(answer)
|
val stillTool = result.toolCalls.firstOrNull()
|
||||||
return if (stillTool != null && lastToolResult != null) {
|
return if (stillTool != null && lastToolResult != null) {
|
||||||
"TOOL_CALL ${stillTool.name} (повтор) — $lastToolResult"
|
"TOOL_CALL ${stillTool.name} (повтор) — $lastToolResult"
|
||||||
} else {
|
} else {
|
||||||
answer
|
result.text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun callLlm(history: List<ChatMessage>, sessionId: Long): String {
|
private suspend fun callLlm(history: List<ChatMessage>, sessionId: Long): LlmResult {
|
||||||
val system = toolkit?.systemPrompt(sessionId) ?: legacySystemPrompt()
|
val system = toolkit?.systemPrompt(sessionId, nativeTools) ?: legacySystemPrompt()
|
||||||
val answer = withTimeoutOrNull(timeoutMs) { chat(history, system) }
|
val specs = if (toolkit != null) {
|
||||||
?: return "Ассистент не ответил (таймаут)"
|
toolkit.toolSpecs(sessionId)
|
||||||
if (!isToolCall(answer)) {
|
} else {
|
||||||
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer)
|
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)
|
chatDao.touchSession(sessionId)
|
||||||
}
|
}
|
||||||
return answer
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Системный промпт (наследственный путь, без [AgentToolkit]). */
|
/** Системный промпт (наследственный путь, без [AgentToolkit]). */
|
||||||
@@ -131,23 +139,6 @@ class Assistant(
|
|||||||
/** Общий лимит LLM-вызовов на фразу (включая невидимые). */
|
/** Общий лимит LLM-вызовов на фразу (включая невидимые). */
|
||||||
const val MAX_LLM_CALLS = 8
|
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 символов). */
|
/** Случайное имя сессии (UUID-подобное, 8 символов). */
|
||||||
private fun randomTitle(): String =
|
private fun randomTitle(): String =
|
||||||
"сессия-" + (1..8).map { "0123456789abcdef"[kotlin.random.Random.nextInt(16)] }.joinToString("")
|
"сессия-" + (1..8).map { "0123456789abcdef"[kotlin.random.Random.nextInt(16)] }.joinToString("")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import okhttp3.MediaType.Companion.toMediaType
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import pw.binom.viewmate.phone.log
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,6 +27,106 @@ interface LlmClient {
|
|||||||
* [system] — системный промпт тулкита/настроек или null (не обязателен).
|
* [system] — системный промпт тулкита/настроек или null (не обязателен).
|
||||||
*/
|
*/
|
||||||
suspend fun chat(messages: List<ChatMessage>, system: String? = null): String
|
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", "Продолжай.")
|
if (dto.none { it.role == "user" }) dto += ChatMessageDto("user", "Продолжай.")
|
||||||
|
|
||||||
val body = json.encodeToString(ChatRequest.serializer(), ChatRequest(model = model, messages = dto))
|
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()
|
val request = Request.Builder()
|
||||||
.url("$baseUrl/v1/chat/completions")
|
.url("$baseUrl/v1/chat/completions")
|
||||||
.header("Authorization", "Bearer $apiKey")
|
.header("Authorization", "Bearer $apiKey")
|
||||||
@@ -115,11 +218,34 @@ class RemoteLlmClient(
|
|||||||
val err = runCatching { json.decodeFromString<ChatResponse>(raw).error?.message }.getOrNull()
|
val err = runCatching { json.decodeFromString<ChatResponse>(raw).error?.message }.getOrNull()
|
||||||
throw IllegalStateException("HTTP ${resp.code}: ${err ?: raw.take(200)}")
|
throw IllegalStateException("HTTP ${resp.code}: ${err ?: raw.take(200)}")
|
||||||
}
|
}
|
||||||
val text = runCatching {
|
val reply = runCatching {
|
||||||
json.decodeFromString<ChatResponse>(raw).choices.firstOrNull()?.message?.content
|
json.decodeFromString<ChatResponse>(raw).choices.firstOrNull()?.message?.content
|
||||||
}.getOrNull()
|
}.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.LiteBackend
|
||||||
import pw.binom.litert.LiteConfig
|
import pw.binom.litert.LiteConfig
|
||||||
|
import pw.binom.litert.LiteConversation
|
||||||
|
import pw.binom.litert.LiteConversationConfig
|
||||||
import pw.binom.litert.LiteExperimental
|
import pw.binom.litert.LiteExperimental
|
||||||
import pw.binom.litert.LiteLlm
|
import pw.binom.litert.LiteLlm
|
||||||
import pw.binom.litert.LiteMessage
|
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 pw.binom.litert.google.googleLiteLlm
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.flow.takeWhile
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.withTimeout
|
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.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import java.io.File
|
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). */
|
/** Состояние локальной модели для UI настроек (StateFlow в PhoneApp). */
|
||||||
@@ -289,9 +348,9 @@ class GemmaModelDownloader(
|
|||||||
*
|
*
|
||||||
* Жизненный цикл: [LiteLlm] создаётся один раз (лениво, при первом [chat]),
|
* Жизненный цикл: [LiteLlm] создаётся один раз (лениво, при первом [chat]),
|
||||||
* переиспользуется — модель заново не грузится. Каждый чатовый вызов —
|
* переиспользуется — модель заново не грузится. Каждый чатовый вызов —
|
||||||
* новый RAW-инференс: [LiteRequest] несёт полный промпт (системная
|
* новый RAW-инференс: [LiteConversationConfig] несёт полный промпт
|
||||||
* инструкция + вся история + входящее), движок не хранит состояние между
|
* (системная инструкция + вся история + входящее) и схемы тул,
|
||||||
* вызовами. Ошибки — [IllegalStateException] с читаемым русским
|
* движок не хранит состояние между вызовами. Ошибки — [IllegalStateException] с читаемым русским
|
||||||
* сообщением («локальная модель не скачана…») — [Assistant] покажет его
|
* сообщением («локальная модель не скачана…») — [Assistant] покажет его
|
||||||
* пользователю, падение приложения исключено.
|
* пользователю, падение приложения исключено.
|
||||||
*
|
*
|
||||||
@@ -310,26 +369,57 @@ class LocalLlmClient(
|
|||||||
/** Модель на месте (по имени файла; SHA-контроль — в [ensureLlm]). */
|
/** Модель на месте (по имени файла; SHA-контроль — в [ensureLlm]). */
|
||||||
fun modelDownloaded(): Boolean = modelFile.isFile
|
fun modelDownloaded(): Boolean = modelFile.isFile
|
||||||
|
|
||||||
override suspend fun chat(messages: List<ChatMessage>, system: String?): String {
|
override suspend fun chat(messages: List<ChatMessage>, system: String?): String =
|
||||||
log("llm", "chat: вызов (messages=${messages.size}, system=${system?.length ?: 0} симв)")
|
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()
|
val llm = ensureLlm()
|
||||||
log("llm", "chat: движок готов, строю промпт")
|
log("llm", "chat: движок готов, строю промпт")
|
||||||
val prompt = buildLocalLlmPrompt(messages, system)
|
val prompt = buildLocalLlmPrompt(messages, system)
|
||||||
log("llm", "chat: промпт: system=${prompt.systemInstruction?.length ?: 0} симв, initial=${prompt.initialMessages.size}, incoming=${prompt.incoming.text.take(60)}")
|
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,
|
systemInstruction = prompt.systemInstruction,
|
||||||
// RAW-режим: владелец истории — мы; каждый инференс — новый полный промпт
|
// RAW-режим: владелец истории — мы; каждый инференс — новый полный промпт
|
||||||
messages = prompt.initialMessages + prompt.incoming,
|
initialMessages = prompt.initialMessages + prompt.incoming,
|
||||||
|
tools = tools.map { SpecLiteTool(it) },
|
||||||
|
// Ручной режим: исполняют тулы не движок, а Assistant (см. KDoc метода)
|
||||||
|
automaticToolCalling = false,
|
||||||
)
|
)
|
||||||
var text = ""
|
var text = ""
|
||||||
|
var toolCalls = emptyList<LiteToolCall>()
|
||||||
var chunks = 0
|
var chunks = 0
|
||||||
var tokens = 0
|
var tokens = 0
|
||||||
val t0 = System.currentTimeMillis()
|
val t0 = System.currentTimeMillis()
|
||||||
var firstTokenAt = 0L
|
var firstTokenAt = 0L
|
||||||
|
var conversation: LiteConversation? = null
|
||||||
try {
|
try {
|
||||||
withTimeout(120_000) {
|
withTimeout(120_000) {
|
||||||
// либа сама закрывает беседу по завершении/отмене потока
|
conversation = llm.createConversation(config)
|
||||||
llm.inferStream(request).collect { delta ->
|
// Поток либы сам себя не завершает (callbackFlow живёт до отмены):
|
||||||
|
// остановка — по терминальной дельте isDone (см. KDoc LiteDelta).
|
||||||
|
conversation.sendStream(prompt.incoming.text)
|
||||||
|
.onEach { delta ->
|
||||||
chunks++
|
chunks++
|
||||||
val chunkText = delta.text
|
val chunkText = delta.text
|
||||||
text += chunkText
|
text += chunkText
|
||||||
@@ -337,16 +427,29 @@ class LocalLlmClient(
|
|||||||
if (firstTokenAt == 0L) firstTokenAt = System.currentTimeMillis()
|
if (firstTokenAt == 0L) firstTokenAt = System.currentTimeMillis()
|
||||||
tokens += 1
|
tokens += 1
|
||||||
}
|
}
|
||||||
|
if (delta.toolCalls.isNotEmpty()) toolCalls = delta.toolCalls
|
||||||
if (chunks <= 3 || chunks % 20 == 0) {
|
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 totalMs = System.currentTimeMillis() - t0
|
||||||
val ttftMs = if (firstTokenAt > 0L) firstTokenAt - t0 else totalMs
|
val ttftMs = if (firstTokenAt > 0L) firstTokenAt - t0 else totalMs
|
||||||
val tokPerSec = if (totalMs > 0) (tokens * 1000.0 / totalMs) else 0.0
|
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}мс)")
|
log("llm", "chat: готово, чанков=$chunks, симв=${text.length}, TTFB=${ttftMs}мс, ~${String.format(java.util.Locale.US, "%.1f", tokPerSec)} чанк/с, тул-вызовов=${toolCalls.size} (всего ${totalMs}мс)")
|
||||||
return text.trim()
|
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) {
|
} catch (e: CancellationException) {
|
||||||
log("llm", "chat: отменено")
|
log("llm", "chat: отменено")
|
||||||
throw e
|
throw e
|
||||||
@@ -354,6 +457,10 @@ class LocalLlmClient(
|
|||||||
log("llm", "chat: EXC class=${e::class.simpleName} msgLen=${e.message?.length}")
|
log("llm", "chat: EXC class=${e::class.simpleName} msgLen=${e.message?.length}")
|
||||||
log("llm", "chat: EXC msg=${sanitize(e.message)}")
|
log("llm", "chat: EXC msg=${sanitize(e.message)}")
|
||||||
throw IllegalStateException("Локальная модель не ответила: ${e.message}", e)
|
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"
|
exoplayer = "2.17.1"
|
||||||
media3 = "1.6.1"
|
media3 = "1.6.1"
|
||||||
koog = "1.1.1"
|
koog = "1.1.1"
|
||||||
litert-google = "3"
|
litert-google = "5"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
|
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" }
|
media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
|
||||||
|
|
||||||
# LiteRT-LM (Google) — локальный LLM (Gemma 4 E2B, .litertlm) на телефоне.
|
# 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-инференс, владелец истории —
|
# универсальный контракт (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" }
|
litert-google = { module = "pw.binom.litert:litert-google", version.ref = "litert-google" }
|
||||||
# Универсальный контракт (LiteLlm/LiteRequest и др.) — типы импортируются
|
# Универсальный контракт (LiteLlm/LiteRequest и др.) — типы импортируются
|
||||||
# приложением напрямую; в litert-google он runtime-only, поэтому явный.
|
# приложением напрямую; в litert-google он runtime-only, поэтому явный.
|
||||||
|
|||||||
Reference in New Issue
Block a user