manual: инструкция «как пользоваться»; watch: первичный проход, коллекции по правилу ядра; приёмка: контур демона

This commit is contained in:
2026-10-02 03:43:25 +03:00
parent 2c7bca0d82
commit 3054705176
6 changed files with 648 additions and 27 deletions
@@ -3,6 +3,8 @@ package memo.watch
import memo.core.Db
import memo.core.Embedder
import memo.core.Indexer
import memo.core.findCollections
import memo.core.resolveCollection
import java.io.File
import java.util.concurrent.CountDownLatch
import kotlin.system.exitProcess
@@ -13,8 +15,8 @@ fun main(args: Array<String>) {
exitProcess(2)
}
val raw = File(args[0])
val base = if (raw.name == ".memo") raw.parentFile ?: raw else raw
val collections = discoverCollections(base)
val base = resolveCollection(raw)
val collections = findCollections(base)
if (collections.isEmpty()) {
System.err.println("коллекции не найдены в ${base.absolutePath}")
exitProcess(1)
@@ -34,10 +36,14 @@ fun main(args: Array<String>) {
CollCtx(coll, db, embedder, indexer)
}
for (ctx in ctxList) {
safeIndexTree(ctx.indexer, ctx.root)
}
val indexersByCollection = ctxList.associateBy { it.root }
val watcher = Watcher(
collections = ctxList.map { it.root },
index = { coll -> indexersByCollection.getValue(coll).indexer.indexTree(coll) },
index = { coll -> safeIndexTree(indexersByCollection.getValue(coll).indexer, coll) },
)
Runtime.getRuntime().addShutdownHook(
@@ -65,26 +71,25 @@ private data class CollCtx(
val indexer: Indexer,
)
private fun discoverCollections(base: File): List<File> {
if (!base.isDirectory) return emptyList()
val candidates = LinkedHashSet<File>()
candidates.add(base)
val q = ArrayDeque<Pair<File, Int>>()
q.addLast(base to 0)
while (q.isNotEmpty()) {
val (d, depth) = q.removeFirst()
if (depth >= 2) continue
val children = d.listFiles() ?: continue
for (c in children) {
if (c.isDirectory && !c.name.startsWith(".")) {
candidates.add(c)
q.addLast(c to depth + 1)
}
private fun safeIndexTree(indexer: Indexer, root: File): Int {
return try {
val n = indexer.indexTree(root)
println("индексирую: ${root.absolutePath} -> обновлено $n")
n
} catch (t: Throwable) {
System.err.println(
"watcher: indexTree ${root.absolutePath}: ${t.message}, повтор через 1с"
)
Thread.sleep(1000)
try {
val n = indexer.indexTree(root)
println("индексирую: ${root.absolutePath} -> обновлено $n")
n
} catch (t2: Throwable) {
System.err.println(
"watcher: повтор indexTree ${root.absolutePath} провалился: ${t2.message}"
)
0
}
}
return candidates.filter { d ->
d.walkTopDown()
.maxDepth(8)
.any { it.isFile && it.extension == "md" }
}
}
}
@@ -0,0 +1,135 @@
package memo.watch
import memo.core.Db
import memo.core.Embedder
import memo.core.Indexer
import memo.core.findCollections
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.test.fail
class WatchMainLogicTest {
private fun newRoot(prefix: String): File {
val root = File.createTempFile(prefix, "")
assertTrue(root.delete(), "temp cleanup")
assertTrue(root.mkdirs(), "temp mkdir")
root.deleteOnExit()
return root
}
private fun touchMd(parent: File, name: String, body: String = "# $name\n"): File {
val f = File(parent, name)
f.writeText(body)
f.deleteOnExit()
return f
}
@Test
fun collectionsFoundByCoreRule() {
val root = newRoot("memo-watchlogic-coll-")
touchMd(root, "a.md")
val sub = File(root, "sub"); sub.mkdirs()
touchMd(sub, "b.md")
val nested = File(root, "nested"); nested.mkdirs()
val only = File(nested, "only"); only.mkdirs()
val deep = File(only, "deep"); deep.mkdirs()
touchMd(deep, "d.md")
File(root, "empty").mkdirs()
val got = findCollections(root).map { it.absolutePath }.toSet()
val want = setOf(
root.absolutePath,
File(root, "sub").absolutePath,
File(root, "nested/only/deep").absolutePath,
)
assertEquals(3, got.size, "ожидалось ровно 3 коллекции, получено ${got.size}")
assertEquals(want, got, "набор коллекций не совпадает с правилом ядра")
assertTrue(
File(root, "nested").absolutePath !in got,
"nested без .md напрямую не должен быть коллекцией",
)
assertTrue(
File(root, "nested/only").absolutePath !in got,
"nested/only без .md напрямую не должен быть коллекцией",
)
assertTrue(
File(root, "empty").absolutePath !in got,
"пустой каталог не должен быть коллекцией",
)
root.deleteRecursively()
}
@Test
fun startupIndexPassFillsEveryCollection() {
val root = newRoot("memo-watchlogic-startup-")
val c1 = File(root, "c1"); c1.mkdirs()
touchMd(c1, "x.md", "# x\nпривет мир\n## Второй\nещё немного текста\n")
val c2 = File(root, "c2"); c2.mkdirs()
touchMd(c2, "y.md", "# y\nдругой текст\n## Третий\nещё строка\n")
val modelDir = System.getenv("MEMO_MODEL_DIR") ?: "/root/WORK/memo/models/siglip2"
val modelPath = "$modelDir/text_model_int8.onnx"
val tokenizerPath = "$modelDir/tokenizer.model"
if (!File(modelPath).exists() || !File(tokenizerPath).exists()) {
fail("модель не найдена: $modelDir")
}
data class Ctx(val db: Db, val embedder: Embedder, val indexer: Indexer)
val ctxByColl: Map<File, Ctx> = listOf(c1, c2).associateWith { coll ->
val memoDir = File(coll, ".memo"); memoDir.mkdirs()
val db = Db(File(memoDir, "index.db").absolutePath)
db.init()
val embedder = Embedder(modelPath, tokenizerPath)
val indexer = Indexer(db, embedder)
Ctx(db, embedder, indexer)
}
try {
for ((coll, ctx) in ctxByColl) {
val updated = ctx.indexer.indexTree(coll)
assertTrue(
updated > 0,
"indexTree обязан переиндексировать хотя бы один файл в ${coll.absolutePath}",
)
}
for ((coll, ctx) in ctxByColl) {
val filesStmt = ctx.db.conn.prepare("SELECT COUNT(*) FROM files")
val chunksStmt = ctx.db.conn.prepare("SELECT COUNT(*) FROM chunks")
try {
val rsF = filesStmt.executeQuery()
val filesCount = try {
assertTrue(rsF.next(), "SELECT COUNT(*) FROM files должен вернуть строку")
rsF.getLong(0)!!
} finally { rsF.close() }
val rsC = chunksStmt.executeQuery()
val chunksCount = try {
assertTrue(rsC.next(), "SELECT COUNT(*) FROM chunks должен вернуть строку")
rsC.getLong(0)!!
} finally { rsC.close() }
assertEquals(
1L, filesCount,
"ожидался ровно 1 файл в ${coll.absolutePath}, получено $filesCount",
)
assertTrue(
chunksCount > 0,
"ожидались чанки в ${coll.absolutePath}, получено $chunksCount",
)
} finally {
filesStmt.close()
chunksStmt.close()
}
}
} finally {
for ((_, ctx) in ctxByColl) {
try { ctx.embedder.close() } catch (_: Throwable) {}
try { ctx.db.close() } catch (_: Throwable) {}
}
root.deleteRecursively()
}
}
}