manual: инструкция «как пользоваться»; watch: первичный проход, коллекции по правилу ядра; приёмка: контур демона
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user