diff --git a/docs/orders/04-searcher.md b/docs/orders/04-searcher.md new file mode 100644 index 0000000..8300a27 --- /dev/null +++ b/docs/orders/04-searcher.md @@ -0,0 +1,76 @@ +Проект: /root/WORK/memo (Kotlin/JVM). Заказ по поиску. + +Создать РОВНО ДВА новых файла: + memo-core/src/main/kotlin/memo/core/Searcher.kt + memo-core/src/test/kotlin/memo/core/SearcherTest.kt + +## Searcher.kt + +package memo.core + +enum class SearchMode { HYBRID, LEX, VEC } + +data class Hit( + val path: String, + val line: Int, + val heading: String, + val score: Double, + val text: String, // текст чанка, обрезанный до 1200 символов +) + +fun interface RefreshHook { fun refresh(root: java.io.File) } + +class Searcher( + private val db: Db, + private val embedder: Embedder, + private val refresh: RefreshHook? = null, +) { + /** Ищет по индексу. Если задан [refresh] — сначала вызывает его для root + * (догон свежих правок файлов до поиска). */ + fun search(root: java.io.File, query: String, k: Int = 8, mode: SearchMode = SearchMode.HYBRID): List +} + +Правила реализации: +1. Если refresh != null — вызвать refresh.refresh(root) ПЕРЕД любым запросом к базе. +2. Лексический список (режимы LEX и HYBRID): разбить query на токены по всем символам, кроме букв + и цифр (unicode: Character.isLetterOrDigit); оставить токены длиной >= 2; взять первые 8; + собрать строку вида "ток1" OR "ток2" (каждый токен в двойных кавычках); + если токенов нет — лексический список пуст, без ошибки. + Запрос: SELECT rowid, bm25(chunks_fts) AS s FROM chunks_fts WHERE chunks_fts MATCH ? ORDER BY s LIMIT 32. + Ошибку SQLite на MATCH не ронять наружу, а считать списком пустым. +3. Векторный список (режимы VEC и HYBRID): embedder.embed(query) → FloatArray[768]; + запрос: SELECT rowid, distance FROM chunks_vec WHERE embedding MATCH ? ORDER BY distance LIMIT 32. + Байндить вектор тем же способом, что в тесте vec0KnnRoundTrip (bindVector). +4. Слияние RRF: score(rowid) = сумма по спискам 1.0/(60.0 + rank), где rank — позиция в списке, начиная с 1. + В режиме LEX — только лексический список, в VEC — только векторный, в HYBRID — оба. +5. По итоговому score взять первые k rowid; вычитать из chunks поля path, line, heading, text + (SELECT path, line, heading, text FROM chunks WHERE id IN (...)); сохранить порядок по score убыванию. +6. text обрезать до 1200 символов (если длиннее — substring(0,1200) без многоточий). +7. Пустой индекс → пустой список, без исключений. + +## SearcherTest.kt — ровно 5 тестов, имена ровно такие + +Подготовка (общий помощник): временный каталог + Db(index.db) + db.init() + Embedder(модель из MEMO_MODEL_DIR +как в CoreSmokeTest.embedderProduces768). Помощник `addChunk(path, heading, line, text)`, который вставляет +строку в chunks, берёт last_insert_rowid() и вставляет тот же rowid в chunks_fts(text, heading) +и в chunks_vec(embedding) с эмбеддингом embedder.embed(text). + +1. `lexModeFindsExactValue` — 3 чанка, в одном текст "Прокси корпоративных доменов на 76.132"; + поиск с mode = LEX по запросу "76.132" → первый хит содержит "76.132". +2. `vecModeFindsSemanticMatch` — 3 чанка на разные темы (сервер/книга/CI); + запрос про «настройку выпуска приложения», mode = VEC → первый хит — чанк про CI. +3. `lexModeIgnoresVectorOnlyMatch` — запрос, не имеющий ни одного общего токена с чанками + (например, при тексте чанков "опрос", "случай", "знание" запрос "автомобиль"); + mode = LEX → пустой список, mode = VEC → непустой. +4. `hybridCombinesBoth` — в индексе чанк с точным значением и чанк со смысловым совпадением; + запрос, попадающий в оба, mode = HYBRID, k = 5 → в результате присутствуют оба чанка. +5. `resultsRespectKAndTextLength` — 4 чанка; k = 2 → ровно 2 хита; у каждого text непустой и его длина <= 1200. + +После: ./gradlew :memo-core:test --rerun-tasks — все тесты зелёные. +Коммит: git add -A && git commit -m "core: гибридный поиск (BM25 + вектор + RRF)" + +СТРОГИЕ ЗАПРЕТЫ: +- Не выводить план текстом; сразу создавай файлы. +- Не трогать существующие файлы (Chunker.kt, Indexer.kt, Embedder.kt, Db.kt, CoreSmokeTest.kt, ChunkerTest.kt, build.gradle.kts). +- Не добавлять зависимости. +- Не создавать другие файлы. diff --git a/docs/orders/05-cli.md b/docs/orders/05-cli.md new file mode 100644 index 0000000..42a428c --- /dev/null +++ b/docs/orders/05-cli.md @@ -0,0 +1,79 @@ +Проект: /root/WORK/memo (Kotlin/JVM). Заказ по CLI — руками проверяемые команды. + +Правки: + - memo-cli/build.gradle.kts: добавить application-плагин и зависимость на :memo-core + - новый файл memo-cli/src/main/kotlin/memo/cli/Main.kt + - новый файл memo-cli/src/test/kotlin/memo/cli/CliArgsTest.kt + +## Настройка модуля memo-cli + +build.gradle.kts модуля: + plugins { kotlin("jvm"); application } + application { mainClass.set("memo.cli.MainKt") } + dependencies { implementation(project(":memo-core")) } + +## Main.kt — контракт команд + +fun main(args: Array) + +Разбор аргументов вынести в чистую функцию (тестируемую без процесса): + sealed interface Cmd + data class IndexCmd(val path: String) : Cmd + data class SearchCmd(val path: String, val query: String, val k: Int, val mode: SearchMode, val json: Boolean) : Cmd + data class StatusCmd(val path: String) : Cmd + data class McpProbeCmd(val tool: String, val argsJson: String) : Cmd + object HelpCmd : Cmd + fun parseArgs(args: Array): Cmd // при ошибке — HelpCmd + +Правила разбора: +- `index ` → IndexCmd +- `search [--k N] [--mode hybrid|lex|vec] [--json]` → SearchCmd + (по умолчанию k = 8, mode = HYBRID); неизвестный флаг или нечисловой --k → HelpCmd +- `status ` → StatusCmd +- `mcp-probe --tool [--args ]` → McpProbeCmd (args по умолчанию "{}") +- нет аргументов, `--help`, `-h`, неизвестная команда → HelpCmd + +Поведение команд: +1. `index `: определить корень так — если path оканчивается на "/.memo" или содержит его, взять родителя; + каталог `X` считается коллекцией, если в нём есть файлы *.md (искать на глубине до 2 от path). + Для каждой найденной коллекции: создать каталог `<коллекция>/.memo`, базу `<коллекция>/.memo/index.db`, + Db(...).init(), затем Indexer(db, embedder).indexTree(коллекция). + Путь к модели: переменная окружения MEMO_MODEL_DIR, иначе /root/WORK/memo/models/siglip2. + Печатать по строке на коллекцию: `индексировано: обновлено, <файлов всего>` и в конце + `итого: <сумма> обновлено в <число коллекций> коллекциях`. +2. `search `: path — ЛИБО коллекция, ЛИБО файл *.md (тогда коллекция = его каталог), + ЛИБО корень, содержащий коллекции (тогда искать по всем коллекциям внутри, объединив результаты + по убыванию score и обрезав до k). + Модель/эмбеддер обязателен: без него базы не откроются — это нормально. + Refresh-хук передавать обязательно: он делает Indexer(db, embedder).indexTree(root) перед поиском. + Без `--json` печатать человекочитаемо: + ` : ` + далее текст чанка с отступом 4 пробела, обрезанный до 300 символов. + С `--json` печатать ОДИН JSON-массив, без пояснений и без лишних строк: + [{"path":"...","line":12,"heading":"...","score":0.51,"text":"..."}] + Если ничего не найдено — пустой JSON-массив `[]` (в режиме --json) и код возврата 0. +3. `status `: по каждой коллекции печатать `<<коллекция>>: файлов , чанков , индекс <дата ISO>` + (дата — максимальный indexed_at из files). +4. `mcp-probe --tool --args `: выполнить то же, что делает MCP-инструмент, и напечатать + результат в формате MCP-ответа: + {"content":[{"type":"text","text":"<результат>"}],"isError":false} + Поддерживаемые инструменты на этом шаге: memo_search (аргументы path, query, k, mode) и memo_status (path). + memo_reindex — вернуть isError: true с текстом "not implemented yet". + +## CliArgsTest.kt — ровно 5 тестов, имена ровно такие + +1. `indexCommandParsed` — ["index", "/tmp/x"] → IndexCmd с path "/tmp/x". +2. `searchDefaultsAreK8HybridNoJson` — ["search", "/tmp/x", "вопрос"] → SearchCmd(k = 8, mode = HYBRID, json = false). +3. `searchFlagsParsed` — ["search", "/tmp/x", "вопрос", "--k", "3", "--mode", "lex", "--json"] → k = 3, mode = LEX, json = true. +4. `badKValueFallsBackToHelp` — ["search", "/tmp/x", "вопрос", "--k", "abc"] → HelpCmd. +5. `unknownCommandFallsBackToHelp` — ["сломать"] → HelpCmd. + +После: ./gradlew :memo-cli:test --rerun-tasks и ./gradlew :memo-cli:installDist — обе команды зелёные. +Проверь руками, что бинарь появился: ls memo-cli/build/install/memo-cli/bin/memo +Коммит: git add -A && git commit -m "cli: index/search/status/mcp-probe" + +СТРОГИЕ ЗАПРЕТЫ: +- Не выводить план текстом; сразу правь файлы. +- Не трогать memo-core (ни src/main, ни src/test), кроме использования его публичного API. +- Не добавлять зависимости, кроме :memo-core. +- Не создавать другие файлы. diff --git a/docs/orders/06-watcher.md b/docs/orders/06-watcher.md new file mode 100644 index 0000000..c55ce09 --- /dev/null +++ b/docs/orders/06-watcher.md @@ -0,0 +1,74 @@ +Проект: /root/WORK/memo (Kotlin/JVM). Заказ по слежению за файлами. + +Создать РОВНО ДВА новых файла: + memo-watch/build.gradle.kts (плагин kotlin("jvm") + зависимость на :memo-core) + memo-watch/src/main/kotlin/memo/watch/Watcher.kt + memo-watch/src/main/kotlin/memo/watch/WatchMain.kt +и ОДИН тестовый: + memo-watch/src/test/kotlin/memo/watch/WatcherTest.kt + +## Watcher.kt + +package memo.watch + +class Watcher( + private val collections: List, // каталоги-коллекции + private val index: (java.io.File) -> Unit, // функция индексации одной коллекции + private val debounceMillis: Long = 500, + private val reconcileMillis: Long = 10 * 60 * 1000, +) : AutoCloseable { + + /** Запускает фоновый поток: WatchService на каждый каталог рекурсивно, + * debounce, плюс периодическая полная реконсиляция по mtime. */ + fun start() + + /** Останавливает поток, закрывает WatchService. Идемпотентно. */ + override fun close() + + /** Количество выполненных вызовов index(...) — для тестов. */ + val indexCalls: Int +} + +Требования: +1. Регистрировать каждый каталог через FileSystems.getDefault().newWatchService() с ENTRY_CREATE, + ENTRY_MODIFY, ENTRY_DELETE и при регистрации рекурсивно обойти подкаталоги (WatchService не рекурсивен). + Каталоги, начинающиеся с '.', не обходить и не регистрировать. +2. События собирать в Set (какие коллекции затронуты). Поток: take() с таймаутом debounceMillis; + при накоплении — по истечении debounce вызвать index(коллекция) для каждой затронутой коллекции. + Не вызывать index десятки раз на одно сохранение файла. +3. Раз в reconcileMillis — полная реконсиляция: обойти все файлы *.md во всех коллекциях, + сравнить (path, mtime, size) со таблицей files, при расхождении вызвать index(коллекция). + Реализацию сравнения НЕ дублировать: у :memo-core уже есть Indexer.indexFile, который сам + пропускает неизменённые файлы и возвращает Boolean. Использовать его: index(коллекция) должен + внутри вызывать Indexer.indexTree(коллекция). +4. Все исключения в рабочем потоке не должны убивать поток: ловить, печатать в stderr, продолжать. +5. close(): interrupt + закрыть WatchService, поток завершается не дольше 2 секунд. + +## WatchMain.kt + +fun main(args: Array) +- Аргумент: корень, содержащий коллекции (или одну коллекцию). +- Определение коллекций — как в CLI (каталог с *.md на глубине до 2). +- Создаёт Db(коллекция/.memo/index.db), .init(), Indexer(db, embedder) — по одному на коллекцию, + Watcher с index = { коллекция -> indexer.indexTree(коллекция) }. +- Печатает `наблюдаю: <коллекция>` по строке на каждую, затем блокируется навсегда до SIGINT. +- Модель: MEMO_MODEL_DIR, иначе /root/WORK/memo/models/siglip2. + +## WatcherTest.kt — ровно 3 теста, имена ровно такие + +1. `modifyTriggersSingleIndexCall` — временный каталог с note.md; Watcher с index-счётчиком и + debounce 200 мс; start(); правка файла; подождать 1.5 с; indexCalls == 1 (не больше!); + close(). (Перед стартом дать watcher 300 мс прогреться.) +2. `unchangedFileDoesNotTriggerIndex` — после прогрева ничего не менять 1.5 с → indexCalls == 0. +3. `closeIsIdempotentAndStopsThread` — start(); close(); close(); повторный вызов не бросает исключение. + +Тесты должны быть устойчивыми: ждать не фиксированным sleep, а опросом условия с таймаутом +(например, до 5 с с шагом 100 мс), чтобы не флапать на медленной машине. + +После: ./gradlew :memo-watch:test --rerun-tasks — зелёные; ./gradlew build -x test компилируется. +Коммит: git add -A && git commit -m "watch: слежение за файлами (WatchService + debounce + реконсиляция)" + +СТРОГИЕ ЗАПРЕТЫ: +- Не выводить план текстом; сразу создавай файлы. +- Не трогать memo-core и memo-cli. +- Не добавлять зависимости, кроме :memo-core. diff --git a/memo-core/src/main/kotlin/memo/core/Searcher.kt b/memo-core/src/main/kotlin/memo/core/Searcher.kt new file mode 100644 index 0000000..1b04223 --- /dev/null +++ b/memo-core/src/main/kotlin/memo/core/Searcher.kt @@ -0,0 +1,157 @@ +package memo.core + +import java.io.File + +enum class SearchMode { HYBRID, LEX, VEC } + +data class Hit( + val path: String, + val line: Int, + val heading: String, + val score: Double, + val text: String, +) + +fun interface RefreshHook { fun refresh(root: File) } + +class Searcher( + private val db: Db, + private val embedder: Embedder, + private val refresh: RefreshHook? = null, +) { + fun search( + root: File, + query: String, + k: Int = 8, + mode: SearchMode = SearchMode.HYBRID, + ): List { + if (refresh != null) { + refresh.refresh(root) + } + + val lexRows: List> = + if (mode == SearchMode.LEX || mode == SearchMode.HYBRID) lexSearch(query) else emptyList() + val vecRows: List> = + if (mode == SearchMode.VEC || mode == SearchMode.HYBRID) vecSearch(query) else emptyList() + + val scores = HashMap() + for ((idx, row) in lexRows.withIndex()) { + val rank = idx + 1 + scores.merge(row.first, 1.0 / (60.0 + rank), Double::plus) + } + for ((idx, row) in vecRows.withIndex()) { + val rank = idx + 1 + scores.merge(row.first, 1.0 / (60.0 + rank), Double::plus) + } + + if (scores.isEmpty()) return emptyList() + + val topEntries = scores.entries.sortedByDescending { it.value }.take(k) + val orderedRowids = topEntries.map { it.key } + val scoreByRowid = orderedRowids.associateWith { scores[it]!! } + + val placeholders = orderedRowids.joinToString(",") { "?" } + val select = db.conn.prepare( + "SELECT id, path, line, heading, text FROM chunks WHERE id IN ($placeholders)" + ) + val rowData = HashMap() + try { + for ((idx, id) in orderedRowids.withIndex()) { + select.bindLong(idx + 1, id) + } + val rs = select.executeQuery() + try { + while (rs.next()) { + val id = rs.getLong(0)!! + val path = rs.getText(1) ?: "" + val line = rs.getInt(2)!! + val heading = rs.getText(3) ?: "" + val textRaw = rs.getText(4) ?: "" + val text = if (textRaw.length > 1200) textRaw.substring(0, 1200) else textRaw + rowData[id] = RowData(path, line, heading, text) + } + } finally { + rs.close() + } + } finally { + select.close() + } + + return orderedRowids.mapNotNull { id -> + val rd = rowData[id] ?: return@mapNotNull null + Hit( + path = rd.path, + line = rd.line, + heading = rd.heading, + score = scoreByRowid[id] ?: 0.0, + text = rd.text, + ) + } + } + + private fun lexSearch(query: String): List> { + val tokens = tokensOf(query) + if (tokens.isEmpty()) return emptyList() + val matchExpr = tokens.joinToString(" OR ") { "\"$it\"" } + val stmt = db.conn.prepare( + "SELECT rowid, bm25(chunks_fts) AS s FROM chunks_fts " + + "WHERE chunks_fts MATCH ? ORDER BY s LIMIT 32" + ) + return try { + stmt.bindText(1, matchExpr) + val rs = stmt.executeQuery() + try { + val out = ArrayList>() + while (rs.next()) { + out.add(rs.getLong(0)!! to rs.getDouble(1)!!) + } + out + } finally { + rs.close() + } + } catch (_: Throwable) { + emptyList() + } finally { + stmt.close() + } + } + + private fun vecSearch(query: String): List> { + val stmt = db.conn.prepare( + "SELECT rowid, distance FROM chunks_vec " + + "WHERE embedding MATCH ? ORDER BY distance LIMIT 32" + ) + return try { + stmt.bindVector(1, embedder.embed(query)) + val rs = stmt.executeQuery() + try { + val out = ArrayList>() + while (rs.next()) { + out.add(rs.getLong(0)!! to rs.getDouble(1)!!) + } + out + } finally { + rs.close() + } + } finally { + stmt.close() + } + } + + private data class RowData(val path: String, val line: Int, val heading: String, val text: String) +} + +private fun tokensOf(query: String): List { + val tokens = ArrayList() + val sb = StringBuilder() + for (ch in query) { + if (Character.isLetterOrDigit(ch)) { + sb.append(ch) + } else { + if (sb.length >= 2) tokens.add(sb.toString()) + sb.setLength(0) + } + } + if (sb.length >= 2) tokens.add(sb.toString()) + return if (tokens.size <= 8) tokens else tokens.subList(0, 8) +} diff --git a/memo-core/src/test/kotlin/memo/core/SearcherTest.kt b/memo-core/src/test/kotlin/memo/core/SearcherTest.kt new file mode 100644 index 0000000..a16d682 --- /dev/null +++ b/memo-core/src/test/kotlin/memo/core/SearcherTest.kt @@ -0,0 +1,141 @@ +package memo.core + +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SearcherTest { + + private class Fixture : AutoCloseable { + val tempDir: File = Files.createTempDirectory("memo-searcher-").toFile() + val db = Db(File(tempDir, "index.db").absolutePath) + val embedder: Embedder + + init { + db.init() + val modelDir = System.getenv("MEMO_MODEL_DIR") ?: "/root/WORK/memo/models/siglip2" + val modelPath = "$modelDir/text_model_int8.onnx" + val tokenizerPath = "$modelDir/tokenizer.model" + embedder = Embedder(modelPath, tokenizerPath) + } + + fun addChunk(path: String, heading: String, line: Int, text: String) { + val conn = db.conn + val insChunk = conn.prepare( + "INSERT INTO chunks(path, heading, line, ord, text, hash) VALUES (?, ?, ?, ?, ?, ?)" + ) + try { + insChunk.bindText(1, path) + insChunk.bindText(2, heading) + insChunk.bindLong(3, line.toLong()) + insChunk.bindLong(4, 0L) + insChunk.bindText(5, text) + insChunk.bindText(6, "$path#$line") + insChunk.executeUpdate() + } finally { + insChunk.close() + } + val id = conn.lastInsertRowId + val insFts = conn.prepare( + "INSERT INTO chunks_fts(rowid, text, heading) VALUES (?, ?, ?)" + ) + try { + insFts.bindLong(1, id) + insFts.bindText(2, text) + insFts.bindText(3, heading) + insFts.executeUpdate() + } finally { + insFts.close() + } + val insVec = conn.prepare( + "INSERT INTO chunks_vec(rowid, embedding) VALUES (?, ?)" + ) + try { + insVec.bindLong(1, id) + insVec.bindVector(2, embedder.embed(text)) + insVec.executeUpdate() + } finally { + insVec.close() + } + } + + override fun close() { + embedder.close() + db.close() + tempDir.deleteRecursively() + } + } + + @Test + fun lexModeFindsExactValue() { + Fixture().use { f -> + f.addChunk("/a.md", "A", 1, "Прокси корпоративных доменов на 76.132") + f.addChunk("/b.md", "B", 1, "Список контактов службы поддержки") + f.addChunk("/c.md", "C", 1, "Описание архитектуры сетевого шлюза") + val s = Searcher(f.db, f.embedder) + val hits = s.search(f.tempDir, "76.132", k = 8, mode = SearchMode.LEX) + assertTrue(hits.isNotEmpty(), "ожидались хиты, получено 0") + assertTrue(hits[0].text.contains("76.132"), "первый хит должен содержать 76.132") + } + } + + @Test + fun vecModeFindsSemanticMatch() { + Fixture().use { f -> + f.addChunk("/server.md", "Server", 1, "Настройка сервера приложений и конфигурация Tomcat") + f.addChunk("/book.md", "Book", 1, "Аннотация книги по истории Древнего Рима") + f.addChunk("/ci.md", "CI", 1, "Пайплайн выпуска приложения: сборка, тесты, деплой в Kubernetes") + val s = Searcher(f.db, f.embedder) + val hits = s.search(f.tempDir, "настройку выпуска приложения", k = 1, mode = SearchMode.VEC) + assertTrue(hits.isNotEmpty(), "ожидались хиты, получено 0") + assertEquals("/ci.md", hits[0].path, "первый хит должен быть чанк про CI") + } + } + + @Test + fun lexModeIgnoresVectorOnlyMatch() { + Fixture().use { f -> + f.addChunk("/p.md", "P", 1, "опрос") + f.addChunk("/s.md", "S", 1, "случай") + f.addChunk("/z.md", "Z", 1, "знание") + val s = Searcher(f.db, f.embedder) + val lex = s.search(f.tempDir, "автомобиль", k = 8, mode = SearchMode.LEX) + val vec = s.search(f.tempDir, "автомобиль", k = 8, mode = SearchMode.VEC) + assertTrue(lex.isEmpty(), "LEX должен быть пустым, получили ${lex.size} хитов") + assertTrue(vec.isNotEmpty(), "VEC должен быть непустым, получили 0") + } + } + + @Test + fun hybridCombinesBoth() { + Fixture().use { f -> + f.addChunk("/exact.md", "Exact", 1, "Прокси корпоративных доменов на 76.132") + f.addChunk("/sem.md", "Sem", 1, "Пайплайн выпуска приложения: сборка, тесты, деплой в Kubernetes") + f.addChunk("/other.md", "Other", 1, "Заметки о книге по истории") + val s = Searcher(f.db, f.embedder) + val hits = s.search(f.tempDir, "выпуск приложения 76.132", k = 5, mode = SearchMode.HYBRID) + val paths = hits.map { it.path }.toSet() + assertTrue(paths.contains("/exact.md"), "чанк exact отсутствует в: $paths") + assertTrue(paths.contains("/sem.md"), "чанк sem отсутствует в: $paths") + } + } + + @Test + fun resultsRespectKAndTextLength() { + Fixture().use { f -> + f.addChunk("/a.md", "A", 1, "Первый фрагмент про сервер") + f.addChunk("/b.md", "B", 1, "Второй фрагмент про книгу") + f.addChunk("/c.md", "C", 1, "Третий фрагмент про CI") + f.addChunk("/d.md", "D", 1, "Четвёртый фрагмент про настройку") + val s = Searcher(f.db, f.embedder) + val hits = s.search(f.tempDir, "фрагмент", k = 2, mode = SearchMode.HYBRID) + assertEquals(2, hits.size, "должно быть ровно 2 хита при k=2") + for (h in hits) { + assertTrue(h.text.isNotEmpty(), "text не должен быть пустым: $h") + assertTrue(h.text.length <= 1200, "длина text превышает 1200: ${h.text.length}") + } + } + } +}