core: чанкер markdown по заголовкам с перекрытием
This commit is contained in:
@@ -31,7 +31,7 @@ fun chunkMarkdown(text: String): List<Chunk>
|
|||||||
## ChunkerTest.kt — ровно 5 тестов, имена ровно такие
|
## ChunkerTest.kt — ровно 5 тестов, имена ровно такие
|
||||||
|
|
||||||
1. `splitsByHeadingLevels` — вход с '# A', '## B', '### C' → 3 чанка, heading = ["A","B","C"], line = [1,3,5], ord = [0,1,2].
|
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` — строка '#### не заголовок' остаётся частью текста предыдущего раздела.
|
3. `fourHashesIsNotAHeading` — строка '#### не заголовок' остаётся частью текста предыдущего раздела.
|
||||||
4. `emptySectionsAreDropped` — ввод с двумя заголовками подряд без текста между ними → 1 чанк.
|
4. `emptySectionsAreDropped` — ввод с двумя заголовками подряд без текста между ними → 1 чанк.
|
||||||
5. `longSectionIsSplitWithOverlap` — раздел из 6000 символов 'а' → 2 чанка, длина первого 4000,
|
5. `longSectionIsSplitWithOverlap` — раздел из 6000 символов 'а' → 2 чанка, длина первого 4000,
|
||||||
|
|||||||
@@ -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 и любые другие файлы.
|
||||||
|
- Не менять существующие тесты.
|
||||||
|
- Не добавлять зависимости.
|
||||||
@@ -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<Chunk> {
|
||||||
|
val sections = mutableListOf<Triple<String, Int, String>>()
|
||||||
|
|
||||||
|
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<Chunk>()
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user