diff --git a/docs/orders/02-chunker.md b/docs/orders/02-chunker.md index aa3eb79..19a519b 100644 --- a/docs/orders/02-chunker.md +++ b/docs/orders/02-chunker.md @@ -31,7 +31,7 @@ fun chunkMarkdown(text: String): List ## ChunkerTest.kt — ровно 5 тестов, имена ровно такие 1. `splitsByHeadingLevels` — вход с '# A', '## B', '### C' → 3 чанка, heading = ["A","B","C"], line = [1,3,5], ord = [0,1,2]. -2. `textBeforeFirstHeadingBecomesChunkWithEmptyHeading` — ввод «преамбула\n# Заголовок\nтело» → 2 чанка, первый: heading "»", line 1; второй heading "Заголовок". +2. `textBeforeFirstHeadingBecomesChunkWithEmptyHeading` — ввод "преамбула\n# Заголовок\nтело" → 2 чанка, первый: heading = "" (пустая строка), line 1; второй heading "Заголовок". 3. `fourHashesIsNotAHeading` — строка '#### не заголовок' остаётся частью текста предыдущего раздела. 4. `emptySectionsAreDropped` — ввод с двумя заголовками подряд без текста между ними → 1 чанк. 5. `longSectionIsSplitWithOverlap` — раздел из 6000 символов 'а' → 2 чанка, длина первого 4000, diff --git a/docs/orders/03-indexer.md b/docs/orders/03-indexer.md new file mode 100644 index 0000000..1a02fac --- /dev/null +++ b/docs/orders/03-indexer.md @@ -0,0 +1,57 @@ +Проект: /root/WORK/memo (Kotlin/JVM). Заказ по индексации markdown-файлов в базу. + +Создать РОВНО ОДИН новый файл: memo-core/src/main/kotlin/memo/core/Indexer.kt +Дополнить существующий: memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt (добавить ОДИН новый тест-метод в конец класса, существующие не трогать) + +## Indexer.kt + +package memo.core + +class Indexer(private val db: Db, private val embedder: Embedder) { + + /** Индексирует один файл. Возвращает true, если файл был (пере)проиндексирован, + * false — если пропущен (не изменился с прошлой индексации). */ + fun indexFile(path: java.io.File): Boolean + + /** Индексирует все .md файлы в дереве каталога root (рекурсивно). Возвращает число + * реально переиндексированных файлов. */ + fun indexTree(root: java.io.File): Int +} + +Правила: +1. Перед индексацией файла сравнить с записью в таблице files по колонке path: + - если записи нет — индексировать; + - если mtime и size совпадают с сохранёнными — вернуть false, ничего не делать (НЕ считать хэш); + - если mtime/size изменились — посчитать sha-256 содержимого; если хэш совпал с сохранённым — + обновить в files поля mtime/size/indexed_at (файл тронули, содержимое то же) и вернуть false; + - иначе — полная переиндексация файла. +2. Полная переиндексация файла: удалить из chunks все строки с этим path; удалить из chunks_fts строки + с rowid из удалённых chunks (DELETE FROM chunks_fts WHERE rowid IN (...)); то же для chunks_vec. + Затем разбить текст через chunkMarkdown (см. memo-core/src/main/kotlin/memo/core/Chunker.kt), + для каждого чанка: посчитать эмбеддинг embedder.embed(chunk.text), вставить строку в chunks, + взять last_insert_rowid() и вставить те же rowid в chunks_fts (колонки text, heading) + и в chunks_vec (колонка embedding). Всё — в одной транзакции (conn.beginTransaction/commit/rollback). +3. В files писать: path (абсолютный), mtime (доля секунды, REAL), size, hash (hex sha-256), indexed_at (epoch секунды). +4. indexTree: обход рекурсивно, только файлы с расширением .md, скрытые каталоги (начинающиеся с '.') + и каталоги .memo не обходить. Возвращать число переиндексированных файлов. + +## Тест: добавить в CoreSmokeTest.kt метод + +fun indexerSkipsUnchangedFile() + +- создать временный каталог, в нём файл note.md с содержимым "# Заголовок\nтело заметки\n## Второй\nещё текст"; +- создать Db на временном index.db (не забыть db.init()) и Embedder на модели + (путь брать так же, как в существующем тесте embedderProduces768 — через MEMO_MODEL_DIR); +- первый indexFile(...) должен вернуть true; +- второй indexFile(...) сразу же — обязан вернуть false (файл не менялся); +- дописать в файл "\n## Третий\nновый текст\n", второй indexFile обязан вернуть true; +- в конце: assertTrue, что SELECT count(*) FROM chunks без условия больше нуля (запрос через db.conn). + +После: ./gradlew :memo-core:test --rerun-tasks — все тесты зелёные. +Коммит: git add -A && git commit -m "core: индексатор файлов с пропуском неизменённых" + +СТРОГИЕ ЗАПРЕТЫ: +- Не выводить план текстом; сразу создавай/правь файлы. +- Не трогать Chunker.kt, Embedder.kt, Db.kt, build.gradle.kts и любые другие файлы. +- Не менять существующие тесты. +- Не добавлять зависимости. diff --git a/memo-core/src/main/kotlin/memo/core/Chunker.kt b/memo-core/src/main/kotlin/memo/core/Chunker.kt new file mode 100644 index 0000000..09dd9ef --- /dev/null +++ b/memo-core/src/main/kotlin/memo/core/Chunker.kt @@ -0,0 +1,78 @@ +package memo.core + +data class Chunk( + val heading: String, + val line: Int, + val text: String, + val ord: Int, +) + +private const val CHUNK_SIZE = 4000 +private const val CHUNK_OVERLAP = 600 + +private val HEADING_REGEX = Regex("^(#{1,3}) (.*)$") + +fun chunkMarkdown(text: String): List { + val sections = mutableListOf>() + + var currentHeading = "" + var currentLine = 1 + val currentBody = StringBuilder() + + val preamble = StringBuilder() + var foundHeading = false + + fun flush(heading: String, line: Int, body: String) { + val trimmed = body.trim() + if (trimmed.isNotEmpty()) { + sections.add(Triple(heading, line, trimmed)) + } + } + + val lines = text.split('\n') + for ((idx, raw) in lines.withIndex()) { + val lineNo = idx + 1 + val match = HEADING_REGEX.matchEntire(raw) + if (match != null) { + if (!foundHeading) { + flush("", 1, preamble.toString()) + foundHeading = true + } else { + flush(currentHeading, currentLine, currentBody.toString()) + } + currentHeading = match.groupValues[2].trim() + currentLine = lineNo + currentBody.setLength(0) + } else { + if (foundHeading) { + currentBody.append(raw).append('\n') + } else { + preamble.append(raw).append('\n') + } + } + } + + if (foundHeading) { + flush(currentHeading, currentLine, currentBody.toString()) + } else { + flush("", 1, preamble.toString()) + } + + val result = mutableListOf() + var ord = 0 + for ((heading, line, body) in sections) { + if (body.length <= CHUNK_SIZE) { + result.add(Chunk(heading, line, body, ord++)) + } else { + val step = CHUNK_SIZE - CHUNK_OVERLAP + var i = 0 + while (i < body.length) { + val end = minOf(i + CHUNK_SIZE, body.length) + result.add(Chunk(heading, line, body.substring(i, end), ord++)) + if (end == body.length) break + i += step + } + } + } + return result +} diff --git a/memo-core/src/test/kotlin/memo/core/ChunkerTest.kt b/memo-core/src/test/kotlin/memo/core/ChunkerTest.kt new file mode 100644 index 0000000..ee0d955 --- /dev/null +++ b/memo-core/src/test/kotlin/memo/core/ChunkerTest.kt @@ -0,0 +1,60 @@ +package memo.core + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ChunkerTest { + + @Test + fun splitsByHeadingLevels() { + val input = "# A\nbodyA\n## B\nbodyB\n### C\nbodyC" + val chunks = chunkMarkdown(input) + assertEquals(3, chunks.size) + assertEquals(listOf("A", "B", "C"), chunks.map { it.heading }) + assertEquals(listOf(1, 3, 5), chunks.map { it.line }) + assertEquals(listOf(0, 1, 2), chunks.map { it.ord }) + } + + @Test + fun textBeforeFirstHeadingBecomesChunkWithEmptyHeading() { + val input = "преамбула\n# Заголовок\nтело" + val chunks = chunkMarkdown(input) + assertEquals(2, chunks.size) + assertEquals("", chunks[0].heading) + assertEquals(1, chunks[0].line) + assertEquals("Заголовок", chunks[1].heading) + } + + @Test + fun fourHashesIsNotAHeading() { + val input = "# A\nbody\n#### не заголовок\nmore" + val chunks = chunkMarkdown(input) + assertEquals(1, chunks.size) + assertEquals("A", chunks[0].heading) + assertTrue(chunks[0].text.contains("#### не заголовок")) + assertTrue(chunks[0].text.contains("more")) + } + + @Test + fun emptySectionsAreDropped() { + val input = "# A\nsome text\n## B\n## C" + val chunks = chunkMarkdown(input) + assertEquals(1, chunks.size) + assertEquals("A", chunks[0].heading) + } + + @Test + fun longSectionIsSplitWithOverlap() { + val body = "а".repeat(6000) + val input = "# A\n$body" + val chunks = chunkMarkdown(input) + assertEquals(2, chunks.size) + assertEquals(4000, chunks[0].text.length) + assertEquals(2600, chunks[1].text.length) + assertEquals( + chunks[0].text.substring(4000 - 600), + chunks[1].text.substring(0, 600), + ) + } +}