LLM: интерфейс + две реализации (Qwen удалённо / Gemma 4 E2B локально) + переключатель
- LlmClient интерфейс; RemoteLlmClient (OkHttp, llm.binom.pw) и LocalLlmClient (LiteRT-LM, Gemma 4 E2B) - LocalLlm: Gemma4E2B (SHA-256, GPU/CPU-бэкенды), buildLocalLlmPrompt (SYSTEM→systemInstruction, TOOL→user), GemmaModelDownloader (стриминг, прогресс, фолбэк источников), спекулятивное декодирование - LlmPrefs (персистентность выбора), PhoneApp: фабрика по llmChoice, пересоздание ассистента, скачивание/отмена - UI: GlassesInfoScreen — выбор сервер/локальная + прогресс; ChatScreen — индикатор активной модели - Тесты: +12 (LlmLocalTest) — phone 154 зелёных
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
package pw.binom.viewmate.phone.agent
|
||||
|
||||
import com.google.ai.edge.litertlm.Contents
|
||||
import com.google.ai.edge.litertlm.Message
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import kotlin.io.path.createTempFile
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
/** Тесты LLM-интерфейса без модели/сети: парсинг выбора, маппинг промпта, SHA-256, фабрика. */
|
||||
class LlmLocalTest {
|
||||
|
||||
// ---------- LlmModelChoice.parse ----------
|
||||
|
||||
@Test
|
||||
fun choiceParseDefaultsToRemote() {
|
||||
assertEquals(LlmModelChoice.REMOTE, LlmModelChoice.parse(null))
|
||||
assertEquals(LlmModelChoice.REMOTE, LlmModelChoice.parse(""))
|
||||
assertEquals(LlmModelChoice.REMOTE, LlmModelChoice.parse("unknown"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun choiceParseByNameInsensitive() {
|
||||
assertEquals(LlmModelChoice.LOCAL, LlmModelChoice.parse("LOCAL"))
|
||||
assertEquals(LlmModelChoice.LOCAL, LlmModelChoice.parse("local"))
|
||||
assertEquals(LlmModelChoice.REMOTE, LlmModelChoice.parse("remote"))
|
||||
}
|
||||
|
||||
// ---------- buildLocalLlmPrompt ----------
|
||||
|
||||
private fun chatHistory() = listOf(
|
||||
ChatMessage.of(ChatRole.SYSTEM, "Ты Порфирий — голосовой ассистент."),
|
||||
ChatMessage.of(ChatRole.USER, "включи свет"),
|
||||
ChatMessage.of(ChatRole.ASSISTANT, "Свет включён."),
|
||||
ChatMessage.of(ChatRole.TOOL, "выполнено", toolCallId = "c42"),
|
||||
ChatMessage.of(ChatRole.USER, "погоди, выключи"),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun promptMapsRolesToLiteRtLm() {
|
||||
val p = buildLocalLlmPrompt(chatHistory())
|
||||
// системный промпт = SYSTEM-сообщения (системный параметр не задан)
|
||||
assertEquals("Ты Порфирий — голосовой ассистент.", p.systemInstruction)
|
||||
// входящее — последнее непустое USER
|
||||
assertEquals("погоди, выключи", p.incoming.text())
|
||||
// история: user, assistant, tool-результат в «Результат тула…» формате
|
||||
assertEquals(3, p.initialMessages.size)
|
||||
assertEquals("включи свет", p.initialMessages[0].text())
|
||||
assertEquals("Свет включён.", p.initialMessages[1].text())
|
||||
assertEquals("Результат тула c42: выполнено", p.initialMessages[2].text())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun promptFoldsSystemParamAndSystemMessages() {
|
||||
val history = listOf(
|
||||
ChatMessage.of(ChatRole.SYSTEM, "контекст A"),
|
||||
ChatMessage.of(ChatRole.SYSTEM, "контекст B"),
|
||||
ChatMessage.of(ChatRole.USER, "привет"),
|
||||
)
|
||||
val p = buildLocalLlmPrompt(history, system = "Ты Порфирий.")
|
||||
assertEquals("Ты Порфирий.\nконтекст A\nконтекст B", p.systemInstruction)
|
||||
assertEquals("привет", p.incoming.text())
|
||||
assertTrue(p.initialMessages.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun promptToolLastIsIncoming() {
|
||||
// последняя непустая — TOOL (результат): входящим идёт именно он
|
||||
val history = listOf(
|
||||
ChatMessage.of(ChatRole.USER, "что в каталоге?"),
|
||||
ChatMessage.of(ChatRole.ASSISTANT, "", toolCalls = listOf(ToolCall("c7", "media_search", "{}"))),
|
||||
ChatMessage.of(ChatRole.TOOL, "найдено 5", toolCallId = "c7"),
|
||||
)
|
||||
val p = buildLocalLlmPrompt(history)
|
||||
assertEquals("Результат тула c7: найдено 5", p.incoming.text())
|
||||
// assistant с пустым текстом в историю не попадает
|
||||
assertEquals(1, p.initialMessages.size)
|
||||
assertEquals("что в каталоге?", p.initialMessages[0].text())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun promptLastIsModelMessageFallsBackToContinue() {
|
||||
val history = listOf(
|
||||
ChatMessage.of(ChatRole.USER, "привет"),
|
||||
ChatMessage.of(ChatRole.ASSISTANT, "здравствуйте"),
|
||||
)
|
||||
val p = buildLocalLlmPrompt(history)
|
||||
assertEquals("Продолжай.", p.incoming.text())
|
||||
assertEquals(2, p.initialMessages.size)
|
||||
assertTrue(p.systemInstruction == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun promptSkipsEmptyContents() {
|
||||
val history = listOf(
|
||||
ChatMessage.of(ChatRole.USER, ""),
|
||||
ChatMessage.of(ChatRole.ASSISTANT, "привет"),
|
||||
ChatMessage.of(ChatRole.USER, "как дела"),
|
||||
)
|
||||
val p = buildLocalLlmPrompt(history)
|
||||
// пустое user-сообщение отброшено; входящее — «как дела»
|
||||
assertEquals(1, p.initialMessages.size)
|
||||
assertEquals("как дела", p.incoming.text())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun messageTextExtractsTextParts() {
|
||||
val m = Message.user(Contents.of("привет"))
|
||||
assertEquals("привет", m.text())
|
||||
}
|
||||
|
||||
// ---------- SHA-256 ----------
|
||||
|
||||
@Test
|
||||
fun sha256OfFileMatchesKnownContent() {
|
||||
val file = createTempFile("sha-test").toFile()
|
||||
try {
|
||||
file.writeBytes("hello world".toByteArray())
|
||||
val expected = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
|
||||
assertEquals(expected, sha256HexOfFile(file))
|
||||
assertTrue(sha256OfFileMatches(file, expected))
|
||||
assertTrue(sha256OfFileMatches(file, expected.uppercase()))
|
||||
assertFalse(sha256OfFileMatches(file, "deadbeef"))
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sha256OfFileMatchesFalseOnMissingFile() {
|
||||
assertFalse(sha256OfFileMatches(File("/root/WORK/view-mate/definitely-not-here-llm"), ""))
|
||||
}
|
||||
|
||||
// ---------- LocalLlmClient ----------
|
||||
|
||||
@Test
|
||||
fun localClientWithoutModelFailsReadably() {
|
||||
val missing = File(createTempFile("model").toFile().parentFile, "missing.litertlm")
|
||||
val client = LocalLlmClient(modelFile = missing, cacheDir = File(missing.parentFile, "cache"))
|
||||
assertFalse(client.modelDownloaded())
|
||||
runBlocking {
|
||||
val e = assertFailsWith<IllegalStateException> {
|
||||
client.chat(listOf(ChatMessage.of(ChatRole.USER, "привет")))
|
||||
}
|
||||
assertTrue(e.message!!.contains("не скачана"), e.message)
|
||||
}
|
||||
client.close()
|
||||
}
|
||||
|
||||
// ---------- GemmaModelDownloader ----------
|
||||
|
||||
@Test
|
||||
fun downloaderAllSourcesFailThrows() {
|
||||
val target = File(createTempFile("model-dl").toFile().parentFile, "model.litertlm")
|
||||
val dl = GemmaModelDownloader(target, urls = listOf("http://127.0.0.1:1/model.litertlm"))
|
||||
val e = assertFailsWith<IOException> {
|
||||
runBlocking { dl.download { } }
|
||||
}
|
||||
assertTrue(e.message!!.startsWith("Модель не скачалась (все источники)"), e.message)
|
||||
assertFalse(target.exists())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user