feat: потоковый локальный ассистент — фраза → LLM → ответ на очки

- agent/Assistant.kt: оркестратор диалога (SQLite-история + Koog), таймаут 30с,
  сессия одна живая, история — последние 20 сообщений
- ChatDao.latestSessionId(); SYSTEM_PROMPT нейтральный (TODO: персона)
- PhoneApp: очередь фраз (Channel UNLIMITED), assistantLoop (THINKING/ShowText/IDLE),
  ensureAssistant ленивый; ключ/модель из BuildConfig (local.properties)
- Очки: assistantText StateFlow, ShowText → оверлей (белый текст речи + серый ответ),
  оверлей остаётся после Click до новой сессии
- AssistantTest (androidTest, 3 теста: сессия/история/таймаут) — OK (6 tests всего)
- README: строка про ассистента
This commit is contained in:
Hermes Agent
2026-08-20 12:35:49 +03:00
parent d9d34e9277
commit a434d5bb12
9 changed files with 221 additions and 3 deletions
@@ -0,0 +1,100 @@
package pw.binom.viewmate.phone.agent
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class AssistantTest {
private lateinit var dao: ChatDao
private val context: Context = ApplicationProvider.getApplicationContext()
@Before
fun setUp() {
context.deleteDatabase("assistant.db")
dao = ChatDao(ChatDb(context))
}
@After
fun tearDown() {
context.deleteDatabase("assistant.db")
}
@Test
fun firstPhraseCreatesSessionAndTwoMessages() = runBlocking {
val histories = mutableListOf<List<ChatMessage>>()
val fake: suspend (List<ChatMessage>, String?) -> String = { history, _ ->
histories += history
"Привет! Чем помочь?"
}
val assistant = Assistant(chatDao = dao, chat = fake, timeoutMs = 1_000)
val answer = assistant.process("привет")
// ответ = то, что вернул фейк
assertEquals("Привет! Чем помочь?", answer)
// создана сессия, в ней 2 сообщения (user + assistant)
val sessionId = dao.latestSessionId()
assertTrue("сессия должна создаться", sessionId != null)
val messages = dao.messagesForSession(sessionId!!)
assertEquals(2, messages.size)
assertEquals(ChatRole.USER, messages[0].role)
assertEquals("привет", messages[0].content)
assertEquals(ChatRole.ASSISTANT, messages[1].role)
assertEquals("Привет! Чем помочь?", messages[1].content)
}
@Test
fun secondPhraseReusesSessionAndFakeGetsHistory() = runBlocking {
val histories = mutableListOf<List<ChatMessage>>()
val fake: suspend (List<ChatMessage>, String?) -> String = { history, _ ->
histories += history
"ответ #${history.size}"
}
val assistant = Assistant(chatDao = dao, chat = fake, timeoutMs = 1_000)
assistant.process("первый")
val session1 = dao.latestSessionId()
assistant.process("второй")
val session2 = dao.latestSessionId()
// сессия та же
assertEquals(session1, session2)
// фейк получил историю из 2+ сообщений (user1 + assistant1 + user2)
assertTrue("фейк получил историю из 2+ сообщений", histories.last().size >= 2)
// в сессии 4 сообщения
assertEquals(4, dao.messagesForSession(session1!!).size)
}
@Test
fun timeoutReturnsMessageAndOnlyUserMessagePersisted() = runBlocking {
val fake: suspend (List<ChatMessage>, String?) -> String = { _, _ ->
delay(150)
"опоздал"
}
val assistant = Assistant(chatDao = dao, chat = fake, timeoutMs = 100)
val answer = assistant.process("вопрос")
assertEquals("Ассистент не ответил (таймаут)", answer)
// в БД ТОЛЬКО user-сообщение — assistant не пишется
val sessionId = dao.latestSessionId()
val messages = dao.messagesForSession(sessionId!!)
assertEquals(1, messages.size)
assertEquals(ChatRole.USER, messages[0].role)
assertEquals("вопрос", messages[0].content)
}
}