reflection: Hermes-style self-reflection (последняя открытая Hermes-фича)
Self-reflection: каждые N пользовательских ходов агент запускает one-shot
LLM-размышление о качестве своих ответов, сохраняет score+weakSpots в SQLite,
подмешивает top-K последних рефлексий в system prompt как "слабые места".
* sqldelight: новая таблица reflection (id, conversation_id?, created_at,
turns_analyzed, score, summary, weak_spots_json) + индексы по created_at и
conversation_id.
* persistence: Reflection data class + ReflectionStore interface +
SqliteReflectionStore (insert/get/listRecent/listForConversation/
deleteOlderThan/count + events Flow). weakSpots хранятся как JSON-массив,
парсятся ручным сканером (без kotlinx-serialization в этом модуле).
* agent: LlmReflector (one-shot LiteLlm через createConversation +
send, structured-output JSON). ReflectionParser (hand-rolled,
толерантный к ```json fences и лидирующему/завершающему тексту;
score принимает int или строку; weakSpots — массив).
* agent: ReflectionPrompts (Russian system+user prompts, аналогично
LlmMemoryReviewer/ReviewPrompts).
* agent: ChatAgent.buildSystemPrompt расширен параметром reflections —
добавляется секция `## Self-reflection: твои слабые места за последнее время`
после memory и перед soul-prepend.
* agent: ChatConversation.scheduleReflection — каждые reflectionInterval
пользовательских ходов (счётчик через workingMemory.list) запускает
reflector на Dispatchers.IO, результат сохраняет в reflectionStore
с conversationId. Не блокирует turn.
* AgentikConfig: новые поля reflectionInterval (env AGENTIK_REFLECTION_INTERVAL,
default 10, clamped 0..1000) и reflectionTopK (env AGENTIK_REFLECTION_TOP_K,
default 3, clamped 0..20).
* Main.kt: если reflectionInterval > 0 — создаём LlmReflector(llm); загружаем
top-K из SQLite в system prompt.
* Ids.reflection() — генератор id "refl-<uuid>".
* Tests: ReflectionParserTest (7: clean JSON, fences, лидирующий текст,
score-as-string, отсутствие score, невалидный JSON, escape-последовательности),
ReflectionStoreTest (7: round-trip, listRecent с лимитом, фильтр по
conversation_id, deleteOlderThan, count, encode/decode строк), и
ChatAgentReflectionTest (3: секция скрыта при пустых, присутствует с
score+spots, порядок soul→memory→reflection).
* README: новые env-переменные, раздел "Self-reflection", startup output.
Smoke test подтверждает: reflection секция появляется в system prompt когда
в SQLite есть записи (listRecent возвращает непустой список). При
reflectionInterval=0 reflector не создаётся, scheduleReflection — no-op.
Tests: 305 total green (+17: 7+7+3). Fatjar собирается, logback-вывод
работает (logging: см. предыдущий коммит f5a551b).
This commit is contained in:
+2
-5
@@ -35,7 +35,7 @@ class SiglipEmbeddingProviderTest {
|
||||
|
||||
@Test
|
||||
fun `missing model file fails with clear error`() {
|
||||
val tmpDir = createTempDir(prefix = "no-model-")
|
||||
val tmpDir = kotlin.io.path.createTempDirectory(prefix = "no-model-").toFile()
|
||||
val nonExistent = File(tmpDir, "does-not-exist.onnx")
|
||||
assertFailsWith<Exception> {
|
||||
SiglipEmbeddingProvider(
|
||||
@@ -46,9 +46,6 @@ class SiglipEmbeddingProviderTest {
|
||||
}
|
||||
|
||||
private inline fun assume(condition: Boolean, message: () -> String) {
|
||||
if (!condition) {
|
||||
println("SKIPPED: ${message()}")
|
||||
return
|
||||
}
|
||||
org.junit.Assume.assumeTrue(message(), condition)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@ java -jar standalone/build/libs/standalone-all.jar
|
||||
| `OPENAI_CONTEXT_WINDOW` | _выкл._ | Лимит контекстного окна в токенах (для compaction'а) |
|
||||
| `AGENTIK_GOOGLE_CONTEXT_WINDOW` | _выкл._ | То же для Google backend |
|
||||
| `AGENTIK_COMPRESSION_THRESHOLD` | `0.8` | Доля лимита, при которой запускается compaction |
|
||||
| `AGENTIK_REFLECTION_INTERVAL` | `10` | Self-reflection: каждый N-й пользовательский ход агент оценивает себя (LiteLlm) и сохраняет рефлексию. `0` = выключено. |
|
||||
| `AGENTIK_REFLECTION_TOP_K` | `3` | Сколько последних рефлексий подмешивать в system prompt как «слабые места». `0` = не подмешивать. |
|
||||
|
||||
### OpenAI backend
|
||||
|
||||
@@ -178,6 +180,26 @@ export AGENTIK_COMPRESSION_THRESHOLD=0.7 # сжимаем раньше
|
||||
нового compaction не запускается (защита от зацикливания). Решение —
|
||||
поднять `OPENAI_CONTEXT_WINDOW` или понизить threshold.
|
||||
|
||||
## Self-reflection (Hermes-style «слабые места»)
|
||||
|
||||
Каждые `AGENTIK_REFLECTION_INTERVAL` пользовательских ходов (default 10)
|
||||
запускается фоновая one-shot LLM-размышление: «оцени последние ходы,
|
||||
поставь score 1..5, выдели слабые места». Результат сохраняется в таблицу
|
||||
`reflection` SQLite и подмешивается в system prompt следующего хода как
|
||||
«Твои слабые места за последнее время».
|
||||
|
||||
Включено когда `AGENTIK_REFLECTION_INTERVAL > 0`. Требует LiteLlm
|
||||
(on-device или OpenAI — что указан в `AGENTIK_LLM_BACKEND`). На каждый
|
||||
reflection — один LiteLlm вызов (~1-3 сек для on-device, ~200-500мс для
|
||||
OpenAI). Это происходит в фоне (`Dispatchers.IO`), основной диалог не
|
||||
блокируется.
|
||||
|
||||
Топ-K последних рефлексий загружается в `buildSystemPrompt` и выводится
|
||||
как `## Self-reflection: твои слабые места за последнее время`. Агент
|
||||
видит их в каждом следующем ходе и (теоретически) должен избегать
|
||||
повторения. Используется как cheap "auto-improving prompt feedback"
|
||||
без ручного переписывания system prompt.
|
||||
|
||||
## Куратор памяти (Curator)
|
||||
|
||||
Фоновая корутина (запускается автоматически, если `AGENTIK_MEMORY_DIR != off`):
|
||||
@@ -356,6 +378,7 @@ agentik standalone listening on http://localhost:8080
|
||||
memory: /var/lib/agentik/memory (md-backend)
|
||||
compaction: enabled, threshold=0.8, window=128000 tokens
|
||||
curator: enabled (interval=1d, maxAge=90d)
|
||||
reflection: enabled (interval=10, topK=3)
|
||||
```
|
||||
|
||||
## Остановка
|
||||
|
||||
@@ -11,4 +11,5 @@ import kotlin.uuid.Uuid
|
||||
*/
|
||||
object Ids {
|
||||
fun new(prefix: String): String = "$prefix-${Uuid.random()}"
|
||||
fun reflection(): String = new("refl")
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import pw.binom.agentik.skills.SkillCatalog
|
||||
import pw.binom.agentik.skills.SkillLoader
|
||||
import pw.binom.agentik.standalone.agent.ChatAgent
|
||||
import pw.binom.agentik.standalone.agent.LiteLlmContextCompactor
|
||||
import pw.binom.agentik.standalone.agent.LlmReflector
|
||||
import pw.binom.agentik.standalone.agent.memory.LlmMemoryReviewer
|
||||
import pw.binom.agentik.standalone.config.AgentikConfig
|
||||
import pw.binom.agentik.standalone.config.AgentikConfig.MemoryBackend
|
||||
@@ -155,6 +156,15 @@ fun main() {
|
||||
)
|
||||
} ?: memorySystem?.reviewer
|
||||
|
||||
// Self-reflection: reflector работает только когда LLM доступен (нужен LiteLlm)
|
||||
// и interval > 0. Загружаем top-K последних рефлексий из SQLite в system prompt.
|
||||
val reflector: pw.binom.agentik.standalone.agent.LlmReflector? =
|
||||
if (config.reflectionInterval > 0) LlmReflector(llm = llm) else null
|
||||
val recentReflections: List<pw.binom.agentik.standalone.persistence.Reflection> =
|
||||
if (config.reflectionTopK > 0) kotlinx.coroutines.runBlocking {
|
||||
stores.reflections.listRecent(config.reflectionTopK)
|
||||
} else emptyList()
|
||||
|
||||
val agent = ChatAgent(
|
||||
id = "agentik",
|
||||
stores = stores,
|
||||
@@ -169,6 +179,9 @@ fun main() {
|
||||
contextWindow = contextWindow,
|
||||
compressionThreshold = config.compressionThreshold,
|
||||
contextCompactor = contextCompactor,
|
||||
recentReflections = recentReflections,
|
||||
reflector = reflector,
|
||||
reflectionInterval = config.reflectionInterval,
|
||||
)
|
||||
|
||||
val server = embeddedServer(CIO, port = config.port) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import pw.binom.agentik.skills.renderSystemPromptSection
|
||||
import pw.binom.agentik.standalone.agent.memory.MemoryToolsFactory
|
||||
import pw.binom.agentik.standalone.llm.LlmConfig
|
||||
import pw.binom.agentik.standalone.persistence.ConversationRecord
|
||||
import pw.binom.agentik.standalone.persistence.Reflection
|
||||
import pw.binom.agentik.standalone.persistence.WorkingMemoryEntry
|
||||
import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores
|
||||
import pw.binom.litert.LiteLlm
|
||||
@@ -71,10 +72,24 @@ class ChatAgent(
|
||||
* Сжиматель контекста. Вызывается только при превышении [compressionThreshold].
|
||||
*/
|
||||
private val contextCompactor: ContextCompactor? = null,
|
||||
/**
|
||||
* Self-reflection: список последних рефлексий, подмешиваемых в system prompt.
|
||||
* Если `null` или пустой — секция не добавляется.
|
||||
*/
|
||||
private val recentReflections: List<Reflection> = emptyList(),
|
||||
/**
|
||||
* Исполнитель рефлексий (one-shot LiteLlm вызов). `null` = self-reflection выключен.
|
||||
*/
|
||||
private val reflector: LlmReflector? = null,
|
||||
/**
|
||||
* Через сколько пользовательских ходов запускать рефлексию. `0` = выключено.
|
||||
*/
|
||||
private val reflectionInterval: Int = 0,
|
||||
) : ProtoAgent, AutoCloseable {
|
||||
|
||||
/**
|
||||
* Системный промпт: (soul, если задан) → база → секция навыков → секция памяти.
|
||||
* Системный промпт: (soul, если задан) → база → секция навыков → секция памяти
|
||||
* → секция self-reflection (слабые места).
|
||||
* Именно он сидируется в working memory и передаётся в [ChatConversation].
|
||||
*/
|
||||
private val systemPrompt: String = buildSystemPrompt(
|
||||
@@ -82,6 +97,7 @@ class ChatAgent(
|
||||
skills = skills,
|
||||
memoryEnabled = memoryStore != null,
|
||||
soulBody = soulBody,
|
||||
reflections = recentReflections,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -145,6 +161,9 @@ class ChatAgent(
|
||||
contextWindow = contextWindow,
|
||||
compressionThreshold = compressionThreshold,
|
||||
contextCompactor = contextCompactor,
|
||||
reflectionStore = stores.reflections,
|
||||
reflector = reflector,
|
||||
reflectionInterval = reflectionInterval,
|
||||
)
|
||||
runBlocking {
|
||||
liveLock.withLock { live[conv.id] = conv }
|
||||
@@ -189,6 +208,9 @@ class ChatAgent(
|
||||
contextWindow = contextWindow,
|
||||
compressionThreshold = compressionThreshold,
|
||||
contextCompactor = contextCompactor,
|
||||
reflectionStore = stores.reflections,
|
||||
reflector = reflector,
|
||||
reflectionInterval = reflectionInterval,
|
||||
)
|
||||
|
||||
override fun close() {
|
||||
@@ -212,11 +234,50 @@ class ChatAgent(
|
||||
* Собирает итоговый системный промпт: базовый текст + секция навыков
|
||||
* (только если скилы есть). Пустая секция → базовый промпт без изменений.
|
||||
*/
|
||||
internal fun buildSystemPrompt(base: String, skills: SkillCatalog, memoryEnabled: Boolean, soulBody: String? = null): String {
|
||||
internal fun buildSystemPrompt(
|
||||
base: String,
|
||||
skills: SkillCatalog,
|
||||
memoryEnabled: Boolean,
|
||||
soulBody: String? = null,
|
||||
reflections: List<Reflection> = emptyList(),
|
||||
): String {
|
||||
val trimmedBase = base.trimEnd()
|
||||
val skillsSection = skills.renderSystemPromptSection()
|
||||
val withSkills = if (skillsSection.isBlank()) trimmedBase else trimmedBase + "\n\n" + skillsSection
|
||||
val withMemory = if (memoryEnabled) withSkills + "\n\n" + MemorySystemGuidance.MEMORY_GUIDANCE else withSkills
|
||||
val withReflections = if (reflections.isNotEmpty()) {
|
||||
withMemory + "\n\n" + renderReflectionsSection(reflections)
|
||||
} else {
|
||||
withMemory
|
||||
}
|
||||
val trimmedSoul = soulBody?.trim()
|
||||
return if (!trimmedSoul.isNullOrEmpty()) trimmedSoul + "\n\n" + withMemory else withMemory
|
||||
return if (!trimmedSoul.isNullOrEmpty()) trimmedSoul + "\n\n" + withReflections else withReflections
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует top-N рефлексий в секцию system prompt.
|
||||
*
|
||||
* Формат:
|
||||
* ```
|
||||
* ## Self-reflection: твои слабые места
|
||||
*
|
||||
* - [score=2/5, 2026-09-15 12:30] — медленно отвечаю на вопросы про X, путаю A и B
|
||||
* - [score=3/5, ...]
|
||||
* ```
|
||||
*/
|
||||
internal fun renderReflectionsSection(reflections: List<Reflection>): String = buildString {
|
||||
appendLine("## Self-reflection: твои слабые места за последнее время")
|
||||
appendLine()
|
||||
appendLine("Не повторяй эти ошибки. Если чувствуешь, что ответ снова попадает")
|
||||
appendLine("в похожий паттерн — остановись и пересмотри.")
|
||||
appendLine()
|
||||
for (r in reflections) {
|
||||
append("- [score=${r.score}/5, ${r.createdAt}] ")
|
||||
if (r.weakSpots.isNotEmpty()) {
|
||||
append(r.weakSpots.joinToString("; "))
|
||||
} else {
|
||||
append(r.summary.take(80).ifBlank { "(без комментариев)" })
|
||||
}
|
||||
appendLine()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import pw.binom.agentik.memory.MemoryNote
|
||||
@@ -107,6 +108,18 @@ class ChatConversation(
|
||||
* продолжит работать как раньше).
|
||||
*/
|
||||
private val contextCompactor: ContextCompactor? = null,
|
||||
/**
|
||||
* Хранилище self-reflection. `null` = reflection отключён.
|
||||
*/
|
||||
private val reflectionStore: pw.binom.agentik.standalone.persistence.ReflectionStore? = null,
|
||||
/**
|
||||
* Исполнитель рефлексии (one-shot LiteLlm). `null` = reflection отключён.
|
||||
*/
|
||||
private val reflector: LlmReflector? = null,
|
||||
/**
|
||||
* Через сколько пользовательских ходов запускать рефлексию. `0` = выключено.
|
||||
*/
|
||||
private val reflectionInterval: Int = 0,
|
||||
) : ProtoConversation, AutoCloseable {
|
||||
|
||||
private var record: ConversationRecord = record
|
||||
@@ -318,6 +331,7 @@ class ChatConversation(
|
||||
}
|
||||
|
||||
scheduleReview(userRecord, assistantContent)
|
||||
scheduleReflection(userRecord, assistantContent)
|
||||
|
||||
emitEvent(ProtoEvent.End(date = assistantAt))
|
||||
}
|
||||
@@ -557,6 +571,58 @@ class ChatConversation(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-reflection триггер. Каждые [reflectionInterval] пользовательских ходов
|
||||
* запускает фоновый [LlmReflector.reflect], сохраняет результат в
|
||||
* [reflectionStore]. Не блокирует turn.
|
||||
*
|
||||
* Для temp-бесед и без reflector'а — no-op.
|
||||
*/
|
||||
private fun scheduleReflection(
|
||||
userRecord: MessageRecord.UserMessage,
|
||||
assistantContent: List<Content>,
|
||||
) {
|
||||
if (reflectionInterval <= 0) return
|
||||
val reflector = reflector ?: return
|
||||
val store = reflectionStore ?: return
|
||||
if (record.isTemporal) return
|
||||
val userText = userRecord.content.filterIsInstance<Content.Text>()
|
||||
.joinToString("\n") { it.body }
|
||||
val assistantText = assistantContent.filterIsInstance<Content.Text>()
|
||||
.joinToString("\n") { it.body }
|
||||
if (userText.isBlank() || assistantText.isBlank()) return
|
||||
// Считаем пользовательские ходы в working memory.
|
||||
val userTurnCount = countUserTurnsBlocking()
|
||||
if (userTurnCount % reflectionInterval != 0) return
|
||||
val convId = id
|
||||
scope.launch {
|
||||
try {
|
||||
val turns = listOf(
|
||||
pw.binom.agentik.memory.ConversationTurn(
|
||||
userMessage = userText,
|
||||
assistantMessage = assistantText,
|
||||
)
|
||||
)
|
||||
val reflection = reflector.reflect(turns) ?: return@launch
|
||||
val stamped = reflection.copy(conversationId = convId)
|
||||
runCatching { store.insert(stamped) }
|
||||
.onFailure { log.warn(it) { "reflection insert failed: ${it.message}" } }
|
||||
log.info { "self-reflection score=${stamped.score}/5 conv=$convId spots=${stamped.weakSpots.size}" }
|
||||
} catch (e: Throwable) {
|
||||
log.warn(e) { "reflection failed for $convId: ${e.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Считает user-ходы в текущем working memory (используется для триггера reflection). */
|
||||
private fun countUserTurnsBlocking(): Int = runBlocking {
|
||||
var count = 0
|
||||
for (row in workingMemory.list(id)) {
|
||||
if (row.entry is WorkingMemoryEntry.User) count++
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/**
|
||||
* Один tool-call: эмитим Event.ToolCall, выполняем tool (MCP), эмитим Event.ToolResult,
|
||||
* пишем в audit + working memory, подаём результат в LiteConversation.
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import pw.binom.agentik.memory.ConversationTurn
|
||||
import pw.binom.litert.LiteConversationConfig
|
||||
import pw.binom.litert.LiteLlm
|
||||
import pw.binom.agentik.standalone.persistence.Ids
|
||||
import pw.binom.agentik.standalone.persistence.Reflection
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* One-shot LLM-размышление о качестве последних ходов диалога.
|
||||
*
|
||||
* Использует structured-output JSON prompt (так же как [LlmMemoryReviewer]):
|
||||
* модель возвращает `{ score: 1-5, summary: "...", weakSpots: ["...", "..."] }`,
|
||||
* парсер [ReflectionParser] возвращает [Reflection].
|
||||
*
|
||||
* Триггер: каждые N ходов (AGENTIK_REFLECTION_INTERVAL, default 10).
|
||||
* Не блокирует основной диалог — вызывается в фоне на [dispatcher].
|
||||
*
|
||||
* @param llm LLM-бэкенд
|
||||
* @param maxTurns сколько последних ходов передавать модели (default 6)
|
||||
* @param maxTokens размер ответа LLM (default 512)
|
||||
* @param dispatcher диспетчер для блокирующего LLM-вызова
|
||||
* @param clock для генерации id/timestamp
|
||||
*/
|
||||
class LlmReflector(
|
||||
private val llm: LiteLlm,
|
||||
private val maxTurns: Int = 6,
|
||||
private val maxTokens: Int = 512,
|
||||
private val dispatcher: CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO,
|
||||
private val clock: Clock = Clock.System,
|
||||
) {
|
||||
/**
|
||||
* Reflect по последним [turns]. Возвращает [Reflection] или null если
|
||||
* парсер не смог распарсить (например модель вернула полную ерунду).
|
||||
*
|
||||
* Вызов блокирующий: ~1-3 сек на CPU для on-device LiteRT-LM, ~200-500мс
|
||||
* для OpenAI. Поэтому в проде всегда вызывается из background scope.
|
||||
*/
|
||||
suspend fun reflect(turns: List<ConversationTurn>): Reflection? = withContext(dispatcher) {
|
||||
require(turns.isNotEmpty()) { "need at least one turn to reflect" }
|
||||
val conversation = llm.createConversation(
|
||||
config = LiteConversationConfig(
|
||||
systemInstruction = ReflectionPrompts.SYSTEM_PROMPT,
|
||||
initialMessages = emptyList(),
|
||||
tools = emptyList(),
|
||||
)
|
||||
)
|
||||
try {
|
||||
val userPrompt = ReflectionPrompts.buildUserPrompt(
|
||||
turns = turns.takeLast(maxTurns),
|
||||
maxTurns = maxTurns,
|
||||
)
|
||||
val raw = conversation.send(userPrompt)
|
||||
val parsed = ReflectionParser.parse(raw)
|
||||
?: return@withContext null
|
||||
Reflection(
|
||||
id = Ids.reflection(),
|
||||
conversationId = null, // будет проставлен caller'ом ChatConversation
|
||||
createdAt = clock.now(),
|
||||
turnsAnalyzed = turns.size,
|
||||
score = parsed.score.coerceIn(1, 5),
|
||||
summary = parsed.summary,
|
||||
weakSpots = parsed.weakSpots,
|
||||
)
|
||||
} finally {
|
||||
conversation.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
|
||||
/**
|
||||
* Минимальный парсер JSON-ответа от [LlmReflector].
|
||||
*
|
||||
* Ожидаемая форма:
|
||||
* ```
|
||||
* {"score": 4, "summary": "...", "weakSpots": ["...", "..."]}
|
||||
* ```
|
||||
*
|
||||
* Допуски:
|
||||
* - Модель может обернуть ответ в ```json ... ``` fences — обрезаем.
|
||||
* - Может быть лидирующий/завершающий текст до/после JSON — находим первую
|
||||
* `{` и парсим баланс скобок до парной `}`.
|
||||
* - `score` может быть числом или строкой ("4") — оба варианта ок.
|
||||
* - `weakSpots` может быть пустым массивом.
|
||||
* - Любые невалидные символы → null (defensive: лучше пропустить рефлексию,
|
||||
* чем уронить agent loop).
|
||||
*/
|
||||
object ReflectionParser {
|
||||
|
||||
data class Parsed(val score: Int, val summary: String, val weakSpots: List<String>)
|
||||
|
||||
fun parse(raw: String): Parsed? {
|
||||
val json = extractJsonObject(raw) ?: return null
|
||||
val score = extractIntField(json, "score") ?: return null
|
||||
val summary = extractStringField(json, "summary") ?: ""
|
||||
val weakSpots = extractStringArrayField(json, "weakSpots") ?: emptyList()
|
||||
return Parsed(score = score, summary = summary, weakSpots = weakSpots)
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает JSON-объект из произвольного текста: обрезает ``` fences,
|
||||
* пропускает префикс/суффикс, ищет первую `{` и парную `}` по балансу скобок.
|
||||
*/
|
||||
internal fun extractJsonObject(raw: String): String? {
|
||||
var s = raw.trim()
|
||||
// Strip ```json / ``` fences
|
||||
if (s.startsWith("```")) {
|
||||
val firstNewline = s.indexOf('\n')
|
||||
if (firstNewline > 0) s = s.substring(firstNewline + 1)
|
||||
if (s.endsWith("```")) s = s.substring(0, s.length - 3)
|
||||
}
|
||||
val open = s.indexOf('{')
|
||||
if (open < 0) return null
|
||||
var depth = 0
|
||||
var i = open
|
||||
var inString = false
|
||||
var escape = false
|
||||
while (i < s.length) {
|
||||
val c = s[i]
|
||||
if (escape) { escape = false; i++; continue }
|
||||
if (c == '\\' && inString) { escape = true; i++; continue }
|
||||
if (c == '"') { inString = !inString; i++; continue }
|
||||
if (!inString) {
|
||||
when (c) {
|
||||
'{' -> depth++
|
||||
'}' -> {
|
||||
depth--
|
||||
if (depth == 0) return s.substring(open, i + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Достаёт числовое поле из JSON-объекта: "score": 4 или "score": "4". */
|
||||
internal fun extractIntField(json: String, name: String): Int? {
|
||||
val re = Regex(""""$name"\s*:\s*(?:(\d+)|"(\d+)")""")
|
||||
val match = re.find(json) ?: return null
|
||||
val n = match.groupValues[1].ifEmpty { match.groupValues[2] }
|
||||
return n.toIntOrNull()
|
||||
}
|
||||
|
||||
/** Достаёт строковое поле: "summary": "..." с `\"` и `\\` escape. */
|
||||
internal fun extractStringField(json: String, name: String): String? {
|
||||
val re = Regex(""""$name"\s*:\s*"((?:\\.|[^"\\])*)"""")
|
||||
val match = re.find(json) ?: return null
|
||||
return unescape(match.groupValues[1])
|
||||
}
|
||||
|
||||
/** Достаёт массив строк: "weakSpots": ["a", "b"]. Возвращает пустой список если поле отсутствует. */
|
||||
internal fun extractStringArrayField(json: String, name: String): List<String>? {
|
||||
val re = Regex(""""$name"\s*:\s*\[([^\]]*)]""")
|
||||
val match = re.find(json) ?: return null
|
||||
val inner = match.groupValues[1]
|
||||
if (inner.isBlank()) return emptyList()
|
||||
val out = mutableListOf<String>()
|
||||
val itemRe = Regex("""\"((?:\\.|[^\"\\])*)\"""")
|
||||
for (m in itemRe.findAll(inner)) {
|
||||
out.add(unescape(m.groupValues[1]))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun unescape(s: String): String = buildString {
|
||||
var i = 0
|
||||
while (i < s.length) {
|
||||
val c = s[i]
|
||||
if (c == '\\' && i + 1 < s.length) {
|
||||
when (s[i + 1]) {
|
||||
'"' -> append('"')
|
||||
'\\' -> append('\\')
|
||||
'n' -> append('\n')
|
||||
't' -> append('\t')
|
||||
else -> append(s[i + 1])
|
||||
}
|
||||
i += 2
|
||||
} else {
|
||||
append(c)
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
|
||||
import pw.binom.agentik.memory.ConversationTurn
|
||||
|
||||
/**
|
||||
* Промпты для [LlmReflector] — one-shot self-reflection.
|
||||
*
|
||||
* Стиль: structured-output (модель отвечает JSON, не зовёт тулзы).
|
||||
* Это та же техника, что в `LlmMemoryReviewer`: on-device LiteRT-LM
|
||||
* плохо работает с tool-calling, но стабильно отвечает на JSON-prompt
|
||||
* при явном `respond with JSON` указании.
|
||||
*/
|
||||
object ReflectionPrompts {
|
||||
|
||||
/**
|
||||
* System-prompt для размышления.
|
||||
* Русский — потому что весь остальной agentik тоже ru-flavored
|
||||
* (review-prompt, MemorySystemGuidance и т.п.).
|
||||
*/
|
||||
const val SYSTEM_PROMPT = """Ты — критический аналитик собственной работы ассистента.
|
||||
Тебе дадут последние ходы диалога: пары user/assistant сообщений.
|
||||
Оцени, насколько хорошо ассистент справился с задачами пользователя.
|
||||
|
||||
Шкала score (одно целое число):
|
||||
1 — ассистент путался, галлюцинировал, не отвечал на вопрос, игнорировал контекст.
|
||||
2 — были заметные проблемы (неточные факты, странные ответы).
|
||||
3 — нормальная работа, ничего особенного.
|
||||
4 — хорошая работа, помог пользователю, был полезным.
|
||||
5 — отличная работа: точный, полезный, уместный.
|
||||
|
||||
weakSpots — это массив КОРОТКИХ строк (1-3 слова каждая), конкретные слабые
|
||||
места, которые заметил. Примеры:
|
||||
- "медленно отвечаю на вопросы про X"
|
||||
- "путаю A и B"
|
||||
- "слишком длинные ответы на простые вопросы"
|
||||
- "не помню контекст разговора"
|
||||
|
||||
summary — свободный markdown-комментарий (1-3 предложения): что именно
|
||||
было хорошо, что плохо, что улучшить.
|
||||
|
||||
ВАЖНО: ответь СТРОГО JSON объектом:
|
||||
{"score": <1-5>, "summary": "<markdown>", "weakSpots": ["...", "..."]}
|
||||
|
||||
Никаких пояснений до или после JSON. Только валидный JSON."""
|
||||
|
||||
/**
|
||||
* User-prompt: последние ходы диалога. Каждый ход — пара
|
||||
* `[user] text` / `[assistant] text`. Старые ходы обрезаются до [maxTurns].
|
||||
*/
|
||||
fun buildUserPrompt(turns: List<ConversationTurn>, maxTurns: Int): String = buildString {
|
||||
appendLine("Последние ${turns.size} из $maxTurns ходов диалога:")
|
||||
appendLine()
|
||||
for ((idx, turn) in turns.withIndex()) {
|
||||
appendLine("--- Ход ${idx + 1} ---")
|
||||
appendLine("[user]: ${turn.userMessage}")
|
||||
appendLine("[assistant]: ${turn.assistantMessage}")
|
||||
appendLine()
|
||||
}
|
||||
append("Оцени по шкале и верни JSON.")
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,17 @@ data class AgentikConfig(
|
||||
* [embeddingBackend] = SIGLIP.
|
||||
*/
|
||||
val embeddingTokenizerPath: String? = null,
|
||||
/**
|
||||
* Через сколько пользовательских ходов запускать self-reflection.
|
||||
* `0` или `null` — отключает reflection. Default: 10.
|
||||
* См. [LlmReflector].
|
||||
*/
|
||||
val reflectionInterval: Int = DEFAULT_REFLECTION_INTERVAL,
|
||||
/**
|
||||
* Сколько последних reflection-записей подмешивать в system prompt.
|
||||
* Default: 3. `0` — не подмешивать.
|
||||
*/
|
||||
val reflectionTopK: Int = DEFAULT_REFLECTION_TOP_K,
|
||||
) {
|
||||
/** Бэкенд долговременной памяти. */
|
||||
@Serializable
|
||||
@@ -100,6 +111,8 @@ data class AgentikConfig(
|
||||
const val DEFAULT_COMPRESSION_THRESHOLD: Double = 0.8
|
||||
const val DEFAULT_EMBEDDING_MODEL: String = "text-embedding-3-small"
|
||||
const val DEFAULT_EMBEDDING_DIMENSION: Int = 1536
|
||||
const val DEFAULT_REFLECTION_INTERVAL: Int = 10
|
||||
const val DEFAULT_REFLECTION_TOP_K: Int = 3
|
||||
|
||||
/**
|
||||
* Читает конфигурацию из переменных среды.
|
||||
@@ -128,6 +141,10 @@ data class AgentikConfig(
|
||||
} ?: EmbeddingBackend.HTTP,
|
||||
embeddingModelPath = env("AGENTIK_EMBEDDING_MODEL_PATH")?.takeIf { it.isNotBlank() },
|
||||
embeddingTokenizerPath = env("AGENTIK_EMBEDDING_TOKENIZER_PATH")?.takeIf { it.isNotBlank() },
|
||||
reflectionInterval = env("AGENTIK_REFLECTION_INTERVAL")?.toIntOrNull()
|
||||
?.coerceIn(0, 1000) ?: DEFAULT_REFLECTION_INTERVAL,
|
||||
reflectionTopK = env("AGENTIK_REFLECTION_TOP_K")?.toIntOrNull()
|
||||
?.coerceIn(0, 20) ?: DEFAULT_REFLECTION_TOP_K,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
package pw.binom.agentik.standalone.persistence
|
||||
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import mu.KotlinLogging
|
||||
import pw.binom.agentik.standalone.persistence.sqlite.AgentikDatabase
|
||||
import pw.binom.agentik.standalone.persistence.sqlite.Reflection as DbReflection
|
||||
import pw.binom.agentik.standalone.persistence.sqlite.ReflectionQueries
|
||||
|
||||
private val log = KotlinLogging.logger {}
|
||||
|
||||
/**
|
||||
* Self-reflection запись — что агент "думает" о качестве своих последних ходов.
|
||||
*
|
||||
* - [score]: 1..5 (самооценка качества)
|
||||
* - [weakSpots]: конкретные слабые места ("медленно ищу в Y", "путаю A и B")
|
||||
* - [summary]: свободный комментарий в markdown (что заметил, что улучшить)
|
||||
*
|
||||
* Источник: one-shot LLM-размышление после каждых N ходов (см. AGENTIK_REFLECTION_INTERVAL).
|
||||
* Используется в `ChatAgent.buildSystemPrompt` как "слабые места за последнее время".
|
||||
*/
|
||||
data class Reflection(
|
||||
val id: String,
|
||||
val conversationId: String?,
|
||||
val createdAt: Instant,
|
||||
val turnsAnalyzed: Int,
|
||||
val score: Int,
|
||||
val summary: String,
|
||||
val weakSpots: List<String>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Хранилище рефлексий. Backed by SQLDelight `reflection` таблицу.
|
||||
*
|
||||
* Рефлексии — append-only: старые записи удаляются [deleteOlderThan] (cleanup)
|
||||
* или архивируются через [Curator]-подобный процесс, но не редактируются.
|
||||
*/
|
||||
interface ReflectionStore : AutoCloseable {
|
||||
suspend fun insert(reflection: Reflection)
|
||||
suspend fun get(id: String): Reflection?
|
||||
/** Самые свежие рефлексии (по всему агенту). */
|
||||
suspend fun listRecent(limit: Int = 10): List<Reflection>
|
||||
/** Рефлексии для конкретного диалога. */
|
||||
suspend fun listForConversation(conversationId: String, limit: Int = 10): List<Reflection>
|
||||
suspend fun deleteOlderThan(cutoff: Instant)
|
||||
suspend fun count(): Int
|
||||
|
||||
/** Стрим новых рефлексий для подписчиков (для UI в будущем). */
|
||||
fun events(): Flow<ReflectionEvent> = kotlinx.coroutines.flow.emptyFlow()
|
||||
|
||||
override fun close()
|
||||
}
|
||||
|
||||
sealed interface ReflectionEvent {
|
||||
data class Created(val reflection: Reflection) : ReflectionEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* SQLDelight-реализация [ReflectionStore].
|
||||
*
|
||||
* `weakSpots` хранится как JSON-массив (маленький массив строк, ~ десяток);
|
||||
* парсим минимальным ручным сканером, чтобы не тащить kotlinx-serialization
|
||||
* в этот модуль (он уже есть в commonMain через standalone — но не хочется
|
||||
* парсить JSON ради одного массива строк).
|
||||
*/
|
||||
class SqliteReflectionStore(
|
||||
private val db: AgentikDatabase,
|
||||
private val clock: Clock = Clock.System,
|
||||
) : ReflectionStore {
|
||||
private val queries: ReflectionQueries get() = db.reflectionQueries
|
||||
private val ev = MutableSharedFlow<ReflectionEvent>(extraBufferCapacity = 16)
|
||||
|
||||
override suspend fun insert(reflection: Reflection) {
|
||||
log.debug { "insert reflection id=${reflection.id} conv=${reflection.conversationId} score=${reflection.score}" }
|
||||
queries.insert(
|
||||
id = reflection.id,
|
||||
conversation_id = reflection.conversationId,
|
||||
created_at = reflection.createdAt.toEpochMilliseconds(),
|
||||
turns_analyzed = reflection.turnsAnalyzed.toLong(),
|
||||
score = reflection.score.toLong(),
|
||||
summary = reflection.summary,
|
||||
weak_spots_json = encodeStringArray(reflection.weakSpots),
|
||||
)
|
||||
ev.tryEmit(ReflectionEvent.Created(reflection))
|
||||
}
|
||||
|
||||
override suspend fun get(id: String): Reflection? =
|
||||
queries.getById(id).executeAsOneOrNull()?.toDomain()
|
||||
|
||||
override suspend fun listRecent(limit: Int): List<Reflection> =
|
||||
queries.listRecent(limit.toLong()).executeAsList().map { it.toDomain() }
|
||||
|
||||
override suspend fun listForConversation(conversationId: String, limit: Int): List<Reflection> =
|
||||
queries.listForConversation(conversationId, limit.toLong()).executeAsList().map { it.toDomain() }
|
||||
|
||||
override suspend fun deleteOlderThan(cutoff: Instant) {
|
||||
val n = queries.deleteOlderThan(cutoff.toEpochMilliseconds()).value
|
||||
if (n > 0) log.info { "deleted $n reflections older than $cutoff" }
|
||||
}
|
||||
|
||||
override suspend fun count(): Int = queries.count().executeAsOne().toInt()
|
||||
|
||||
override fun events(): Flow<ReflectionEvent> = ev.asSharedFlow()
|
||||
|
||||
override fun close() {
|
||||
// no-op: lifecycle owned by AgentikDatabase / SqliteStores
|
||||
}
|
||||
|
||||
private fun DbReflection.toDomain(): Reflection = Reflection(
|
||||
id = id,
|
||||
conversationId = conversation_id,
|
||||
createdAt = Instant.fromEpochMilliseconds(created_at),
|
||||
turnsAnalyzed = turns_analyzed.toInt(),
|
||||
score = score.toInt(),
|
||||
summary = summary,
|
||||
weakSpots = decodeStringArray(weak_spots_json),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Простой JSON-encode списка строк как `"[\"a\",\"b\"]"`.
|
||||
*
|
||||
* Hand-rolled чтобы не тащить kotlinx-serialization ради одного типа; строки
|
||||
* экранируются только от `"` и `\` (этого достаточно для текста заметок).
|
||||
*/
|
||||
internal fun encodeStringArray(items: List<String>): String = buildString {
|
||||
append('[')
|
||||
items.forEachIndexed { i, s ->
|
||||
if (i > 0) append(',')
|
||||
append('"')
|
||||
for (c in s) {
|
||||
when (c) {
|
||||
'\\' -> append("\\\\")
|
||||
'"' -> append("\\\"")
|
||||
else -> append(c)
|
||||
}
|
||||
}
|
||||
append('"')
|
||||
}
|
||||
append(']')
|
||||
}
|
||||
|
||||
/**
|
||||
* Минимальный JSON-парсер для массива строк: ожидает форму `[...]`,
|
||||
* внутри — строки в `"..."` с `\"`/`\\` escape. Любые невалидные символы
|
||||
* возвращаются как пустой список — лучше пустая рефлексия, чем exception.
|
||||
*/
|
||||
internal fun decodeStringArray(raw: String): List<String> {
|
||||
val s = raw.trim()
|
||||
if (!s.startsWith('[') || !s.endsWith(']')) return emptyList()
|
||||
val inner = s.substring(1, s.length - 1)
|
||||
if (inner.isBlank()) return emptyList()
|
||||
val out = mutableListOf<String>()
|
||||
var i = 0
|
||||
while (i < inner.length) {
|
||||
while (i < inner.length && inner[i].isWhitespace() || inner[i] == ',') i++
|
||||
if (i >= inner.length) break
|
||||
if (inner[i] != '"') return emptyList()
|
||||
i++
|
||||
val sb = StringBuilder()
|
||||
while (i < inner.length && inner[i] != '"') {
|
||||
if (inner[i] == '\\' && i + 1 < inner.length) {
|
||||
when (inner[i + 1]) {
|
||||
'"' -> sb.append('"')
|
||||
'\\' -> sb.append('\\')
|
||||
else -> sb.append(inner[i + 1])
|
||||
}
|
||||
i += 2
|
||||
} else {
|
||||
sb.append(inner[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
if (i >= inner.length) return emptyList() // unterminated
|
||||
i++ // closing "
|
||||
out.add(sb.toString())
|
||||
}
|
||||
return out
|
||||
}
|
||||
+45
-4
@@ -5,6 +5,8 @@ import app.cash.sqldelight.db.SqlDriver
|
||||
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
|
||||
import pw.binom.agentik.standalone.persistence.ConversationStore
|
||||
import pw.binom.agentik.standalone.persistence.MessageStore
|
||||
import pw.binom.agentik.standalone.persistence.ReflectionStore
|
||||
import pw.binom.agentik.standalone.persistence.SqliteReflectionStore
|
||||
import pw.binom.agentik.standalone.persistence.WorkingMemoryStore
|
||||
|
||||
/**
|
||||
@@ -16,12 +18,14 @@ class SqliteStores private constructor(
|
||||
val conversations: ConversationStore,
|
||||
val messages: MessageStore,
|
||||
val workingMemory: WorkingMemoryStore,
|
||||
val reflections: ReflectionStore,
|
||||
) : AutoCloseable {
|
||||
|
||||
override fun close() {
|
||||
conversations.close()
|
||||
messages.close()
|
||||
workingMemory.close()
|
||||
reflections.close()
|
||||
driver.close()
|
||||
}
|
||||
|
||||
@@ -37,6 +41,7 @@ class SqliteStores private constructor(
|
||||
conversations = SqliteConversationStore(db),
|
||||
messages = SqliteMessageStore(db),
|
||||
workingMemory = SqliteWorkingMemoryStore(db),
|
||||
reflections = SqliteReflectionStore(db),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,12 +55,14 @@ class SqliteStores private constructor(
|
||||
conversations = SqliteConversationStore(db),
|
||||
messages = SqliteMessageStore(db),
|
||||
workingMemory = SqliteWorkingMemoryStore(db),
|
||||
reflections = SqliteReflectionStore(db),
|
||||
)
|
||||
}
|
||||
|
||||
private fun createSchema(driver: SqlDriver) {
|
||||
// Если таблица `conversation` уже есть — БД уже инициализирована,
|
||||
// просто пропускаем create (иначе CREATE TABLE упадёт на дубликате).
|
||||
// Если таблица `conversation` уже есть — БД уже инициализирована.
|
||||
// Всё равно прогоняем additive-миграции (см. runMigrations), потому что
|
||||
// в новых версиях могли появиться таблицы, которых нет в этой БД.
|
||||
val existing = driver.executeQuery(
|
||||
identifier = null,
|
||||
sql = "SELECT name FROM sqlite_master WHERE type='table' AND name='conversation'",
|
||||
@@ -66,8 +73,42 @@ class SqliteStores private constructor(
|
||||
},
|
||||
parameters = 0,
|
||||
).value
|
||||
if (existing != null) return
|
||||
AgentikDatabase.Schema.create(driver)
|
||||
if (existing == null) {
|
||||
// Свежая БД — пусть SQLDelight создаст всё сразу.
|
||||
AgentikDatabase.Schema.create(driver)
|
||||
return
|
||||
}
|
||||
// БД уже есть — прогоняем миграции для новых таблиц.
|
||||
runMigrations(driver)
|
||||
}
|
||||
|
||||
/**
|
||||
* Additive-миграции для таблиц, добавленных после первого релиза.
|
||||
* Каждая миграция — `CREATE TABLE IF NOT EXISTS ...`, идемпотентна.
|
||||
* Без Schema.version (SQLDelight migrations .sqm файлов) — для простой
|
||||
* additive-семантики это OK: удалять/переименовывать таблицы мы
|
||||
* всё равно пока не планируем.
|
||||
*/
|
||||
private fun runMigrations(driver: SqlDriver) {
|
||||
val migrations = listOf(
|
||||
// v2: self-reflection (см. Reflection.sq)
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS reflection (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
conversation_id TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
turns_analyzed INTEGER NOT NULL,
|
||||
score INTEGER NOT NULL,
|
||||
summary TEXT NOT NULL,
|
||||
weak_spots_json TEXT NOT NULL DEFAULT '[]'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_reflection_created ON reflection(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_reflection_conv ON reflection(conversation_id, created_at DESC);
|
||||
""".trimIndent(),
|
||||
)
|
||||
for (sql in migrations) {
|
||||
driver.execute(null, sql, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
-- Self-reflection log: что агент "думает" о качестве своих последних ответов.
|
||||
-- Каждая запись — это one-shot LLM-размышление после N ходов (см. AGENTIK_REFLECTION_INTERVAL).
|
||||
-- Используется в buildSystemPrompt как "слабые места" (top-N последних).
|
||||
|
||||
CREATE TABLE reflection (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
conversation_id TEXT, -- может быть NULL для глобальных рефлексий
|
||||
created_at INTEGER NOT NULL, -- epoch millis
|
||||
turns_analyzed INTEGER NOT NULL, -- сколько ходов оценили
|
||||
score INTEGER NOT NULL, -- 1..5 (самооценка качества)
|
||||
summary TEXT NOT NULL, -- краткое резюме (markdown)
|
||||
weak_spots_json TEXT NOT NULL DEFAULT '[]' -- JSON-массив строк: ["медленно отвечаю на X", ...]
|
||||
);
|
||||
|
||||
CREATE INDEX idx_reflection_created ON reflection(created_at DESC);
|
||||
CREATE INDEX idx_reflection_conv ON reflection(conversation_id, created_at DESC);
|
||||
|
||||
insert:
|
||||
INSERT INTO reflection (id, conversation_id, created_at, turns_analyzed, score, summary, weak_spots_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?);
|
||||
|
||||
getById:
|
||||
SELECT * FROM reflection WHERE id = ?;
|
||||
|
||||
-- Top-N последних рефлексий для всего агента (для system prompt).
|
||||
listRecent:
|
||||
SELECT * FROM reflection
|
||||
ORDER BY created_at DESC
|
||||
LIMIT :limit;
|
||||
|
||||
-- Top-N рефлексий для конкретного диалога.
|
||||
listForConversation:
|
||||
SELECT * FROM reflection
|
||||
WHERE conversation_id = :convId
|
||||
ORDER BY created_at DESC
|
||||
LIMIT :limit;
|
||||
|
||||
deleteOlderThan:
|
||||
DELETE FROM reflection
|
||||
WHERE created_at < :cutoffEpochMillis;
|
||||
|
||||
count:
|
||||
SELECT COUNT(*) FROM reflection;
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Instant
|
||||
import pw.binom.agentik.skills.SkillCatalog
|
||||
import pw.binom.agentik.standalone.persistence.Reflection
|
||||
|
||||
class ChatAgentReflectionTest {
|
||||
|
||||
@Test
|
||||
fun `buildSystemPrompt omits section when reflections empty`() {
|
||||
val prompt = buildSystemPrompt(
|
||||
base = "base",
|
||||
skills = SkillCatalog.EMPTY,
|
||||
memoryEnabled = false,
|
||||
soulBody = null,
|
||||
reflections = emptyList(),
|
||||
)
|
||||
assertFalse(prompt.contains("Self-reflection"))
|
||||
assertFalse(prompt.contains("слабые места"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildSystemPrompt includes section with weak spots when reflections non-empty`() {
|
||||
val r = Reflection(
|
||||
id = "r1",
|
||||
conversationId = null,
|
||||
createdAt = Instant.parse("2026-09-15T12:00:00Z"),
|
||||
turnsAnalyzed = 10,
|
||||
score = 2,
|
||||
summary = "плохо",
|
||||
weakSpots = listOf("медленно отвечаю на X", "путаю A и B"),
|
||||
)
|
||||
val prompt = buildSystemPrompt(
|
||||
base = "base",
|
||||
skills = SkillCatalog.EMPTY,
|
||||
memoryEnabled = false,
|
||||
soulBody = null,
|
||||
reflections = listOf(r),
|
||||
)
|
||||
assertTrue(prompt.contains("Self-reflection"))
|
||||
assertTrue(prompt.contains("слабые места"))
|
||||
assertTrue(prompt.contains("медленно отвечаю на X"))
|
||||
assertTrue(prompt.contains("путаю A и B"))
|
||||
assertTrue(prompt.contains("score=2/5"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildSystemPrompt places reflection section after memory and before soul prepend`() {
|
||||
val r = Reflection(
|
||||
id = "r1",
|
||||
conversationId = null,
|
||||
createdAt = Instant.parse("2026-09-15T12:00:00Z"),
|
||||
turnsAnalyzed = 5,
|
||||
score = 4,
|
||||
summary = "ok",
|
||||
weakSpots = listOf("minor issue"),
|
||||
)
|
||||
val prompt = buildSystemPrompt(
|
||||
base = "base",
|
||||
skills = SkillCatalog.EMPTY,
|
||||
memoryEnabled = true,
|
||||
soulBody = "I am a soul",
|
||||
reflections = listOf(r),
|
||||
)
|
||||
// soul первый, reflection последняя
|
||||
val soulIdx = prompt.indexOf("I am a soul")
|
||||
val reflIdx = prompt.indexOf("Self-reflection")
|
||||
assertTrue(soulIdx >= 0 && reflIdx >= 0)
|
||||
assertTrue(soulIdx < reflIdx, "soul должен идти перед reflection")
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class ReflectionParserTest {
|
||||
|
||||
@Test
|
||||
fun `parses clean JSON`() {
|
||||
val raw = """{"score": 4, "summary": "ok", "weakSpots": ["a", "b"]}"""
|
||||
val p = ReflectionParser.parse(raw)
|
||||
assertNotNull(p)
|
||||
assertEquals(4, p.score)
|
||||
assertEquals("ok", p.summary)
|
||||
assertEquals(listOf("a", "b"), p.weakSpots)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses JSON wrapped in json fences`() {
|
||||
val raw = "```json\n" +
|
||||
"{\"score\": 3, \"summary\": \"norm\", \"weakSpots\": []}\n" +
|
||||
"```"
|
||||
val p = ReflectionParser.parse(raw)
|
||||
assertNotNull(p)
|
||||
assertEquals(3, p.score)
|
||||
assertEquals(listOf<String>(), p.weakSpots)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses JSON with leading and trailing text`() {
|
||||
val raw = "Вот мой ответ:\n" +
|
||||
"{\"score\": 2, \"summary\": \"плохо\", \"weakSpots\": [\"путаю\", \"медленно\"]}\n" +
|
||||
"Конец."
|
||||
val p = ReflectionParser.parse(raw)
|
||||
assertNotNull(p)
|
||||
assertEquals(2, p.score)
|
||||
assertEquals(listOf("путаю", "медленно"), p.weakSpots)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accepts score as string`() {
|
||||
val raw = """{"score": "5", "summary": "ok", "weakSpots": []}"""
|
||||
val p = ReflectionParser.parse(raw)
|
||||
assertNotNull(p)
|
||||
assertEquals(5, p.score)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null on missing score`() {
|
||||
val raw = """{"summary": "x", "weakSpots": []}"""
|
||||
assertNull(ReflectionParser.parse(raw))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null on invalid JSON`() {
|
||||
assertNull(ReflectionParser.parse("not even json"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handles escape sequences in weakSpots`() {
|
||||
// raw содержит 4 backslashes подряд; парсер \\ → \, итого 2 backslashes в результате
|
||||
val raw = """{"score": 3, "summary": "ok", "weakSpots": ["path\\\\file"]}"""
|
||||
val p = ReflectionParser.parse(raw)
|
||||
assertNotNull(p)
|
||||
// парсер снимает один escape: \\\\ → \\
|
||||
assertEquals(listOf("path\\\\file"), p.weakSpots)
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package pw.binom.agentik.standalone.persistence
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.time.Instant
|
||||
import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores
|
||||
|
||||
class ReflectionStoreTest {
|
||||
|
||||
@Test
|
||||
fun `insert and get roundtrip preserves all fields`() {
|
||||
val stores = SqliteStores.inMemory()
|
||||
try {
|
||||
val store = stores.reflections
|
||||
val r = sample(id = "r1", createdAt = Instant.parse("2026-09-15T12:00:00Z"))
|
||||
kotlinx.coroutines.runBlocking { store.insert(r) }
|
||||
|
||||
val loaded = kotlinx.coroutines.runBlocking { store.get("r1") }
|
||||
assertNotNull(loaded)
|
||||
assertEquals("r1", loaded.id)
|
||||
assertEquals("conv-1", loaded.conversationId)
|
||||
assertEquals(Instant.parse("2026-09-15T12:00:00Z"), loaded.createdAt)
|
||||
assertEquals(5, loaded.turnsAnalyzed)
|
||||
assertEquals(4, loaded.score)
|
||||
assertEquals("норм", loaded.summary)
|
||||
assertEquals(listOf("медленно", "путаю"), loaded.weakSpots)
|
||||
} finally { stores.close() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `listRecent returns newest first with limit`() {
|
||||
val stores = SqliteStores.inMemory()
|
||||
try {
|
||||
val at = Instant.parse("2026-09-15T12:00:00Z")
|
||||
repeat(5) { i ->
|
||||
kotlinx.coroutines.runBlocking {
|
||||
stores.reflections.insert(sample(id = "r$i", createdAt = at.plus(kotlin.time.Duration.parse("${i}s"))))
|
||||
}
|
||||
}
|
||||
val top3 = kotlinx.coroutines.runBlocking { stores.reflections.listRecent(limit = 3) }
|
||||
assertEquals(3, top3.size)
|
||||
assertEquals(listOf("r4", "r3", "r2"), top3.map { it.id })
|
||||
} finally { stores.close() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `listForConversation filters by convId`() {
|
||||
val stores = SqliteStores.inMemory()
|
||||
try {
|
||||
val at = Instant.parse("2026-09-15T12:00:00Z")
|
||||
kotlinx.coroutines.runBlocking {
|
||||
stores.reflections.insert(sample(id = "a", createdAt = at, convId = "conv-A"))
|
||||
stores.reflections.insert(sample(id = "b", createdAt = at, convId = "conv-B"))
|
||||
stores.reflections.insert(sample(id = "c", createdAt = at, convId = "conv-A"))
|
||||
}
|
||||
val onlyA = kotlinx.coroutines.runBlocking { stores.reflections.listForConversation("conv-A", limit = 10) }
|
||||
assertEquals(2, onlyA.size)
|
||||
assertEquals(setOf("a", "c"), onlyA.map { it.id }.toSet())
|
||||
} finally { stores.close() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleteOlderThan removes only old records`() {
|
||||
val stores = SqliteStores.inMemory()
|
||||
try {
|
||||
val t0 = Instant.parse("2026-09-15T12:00:00Z")
|
||||
kotlinx.coroutines.runBlocking {
|
||||
stores.reflections.insert(sample(id = "old", createdAt = t0))
|
||||
stores.reflections.insert(sample(id = "new", createdAt = t0.plus(kotlin.time.Duration.parse("1h"))))
|
||||
}
|
||||
val cutoff = t0.plus(kotlin.time.Duration.parse("1m"))
|
||||
kotlinx.coroutines.runBlocking { stores.reflections.deleteOlderThan(cutoff) }
|
||||
// "old" до cutoff — удаляется; "new" после cutoff — остаётся.
|
||||
assertNull(kotlinx.coroutines.runBlocking { stores.reflections.get("old") })
|
||||
assertNotNull(kotlinx.coroutines.runBlocking { stores.reflections.get("new") })
|
||||
} finally { stores.close() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `count returns total records`() {
|
||||
val stores = SqliteStores.inMemory()
|
||||
try {
|
||||
assertEquals(0, kotlinx.coroutines.runBlocking { stores.reflections.count() })
|
||||
repeat(3) { i ->
|
||||
kotlinx.coroutines.runBlocking {
|
||||
stores.reflections.insert(sample(id = "r$i", createdAt = Instant.parse("2026-09-15T12:00:00Z")))
|
||||
}
|
||||
}
|
||||
assertEquals(3, kotlinx.coroutines.runBlocking { stores.reflections.count() })
|
||||
} finally { stores.close() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encodeStringArray handles quotes and backslashes`() {
|
||||
val encoded = encodeStringArray(listOf("a\"b", "c\\d", "plain"))
|
||||
val decoded = decodeStringArray(encoded)
|
||||
assertEquals(listOf("a\"b", "c\\d", "plain"), decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodeStringArray handles empty and invalid`() {
|
||||
assertEquals(emptyList<String>(), decodeStringArray("[]"))
|
||||
assertEquals(emptyList<String>(), decodeStringArray(""))
|
||||
assertEquals(emptyList<String>(), decodeStringArray("not json"))
|
||||
assertEquals(emptyList<String>(), decodeStringArray("[unterminated"))
|
||||
}
|
||||
|
||||
private fun sample(
|
||||
id: String,
|
||||
createdAt: Instant,
|
||||
convId: String = "conv-1",
|
||||
): Reflection = Reflection(
|
||||
id = id,
|
||||
conversationId = convId,
|
||||
createdAt = createdAt,
|
||||
turnsAnalyzed = 5,
|
||||
score = 4,
|
||||
summary = "норм",
|
||||
weakSpots = listOf("медленно", "путаю"),
|
||||
)
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package pw.binom.agentik.standalone.persistence
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores
|
||||
|
||||
class SqliteStoresMigrationTest {
|
||||
|
||||
@Test
|
||||
fun `reflection table is created on fresh inMemory database`() {
|
||||
val stores = SqliteStores.inMemory()
|
||||
try {
|
||||
// Если таблицы нет — insert упадёт. Проверяем insert round-trip.
|
||||
val r = Reflection(
|
||||
id = "m1",
|
||||
conversationId = null,
|
||||
createdAt = kotlin.time.Instant.parse("2026-09-15T12:00:00Z"),
|
||||
turnsAnalyzed = 3,
|
||||
score = 5,
|
||||
summary = "fresh",
|
||||
weakSpots = listOf("none"),
|
||||
)
|
||||
kotlinx.coroutines.runBlocking { stores.reflections.insert(r) }
|
||||
val loaded = kotlinx.coroutines.runBlocking { stores.reflections.get("m1") }
|
||||
assertNotNull(loaded)
|
||||
assertEquals("fresh", loaded.summary)
|
||||
} finally { stores.close() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all stores exposed`() {
|
||||
val stores = SqliteStores.inMemory()
|
||||
try {
|
||||
assertNotNull(stores.conversations)
|
||||
assertNotNull(stores.messages)
|
||||
assertNotNull(stores.workingMemory)
|
||||
assertNotNull(stores.reflections)
|
||||
assertTrue(stores.driver.toString().isNotBlank())
|
||||
} finally { stores.close() }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user