Compare commits
2 Commits
cefc17d782
...
d62a26f5b1
| Author | SHA1 | Date | |
|---|---|---|---|
| d62a26f5b1 | |||
| 700508dc8e |
@@ -0,0 +1,20 @@
|
||||
plugins {
|
||||
kotlin("jvm") version "2.4.10" apply false
|
||||
}
|
||||
|
||||
ext {
|
||||
set("kotlinVersion", "2.4.10")
|
||||
set("onnxVersion", "1.16.0")
|
||||
}
|
||||
|
||||
allprojects {
|
||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
tasks.withType<JavaCompile>().configureEach {
|
||||
sourceCompatibility = "17"
|
||||
targetCompatibility = "17"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
Проект: Kotlin/JVM, Gradle 8.14 (wrapper уже в репо), JDK 21. Рабочая копия: /root/WORK/memo.
|
||||
|
||||
Задача — ТОЛЬКО каркас сборки и четыре теста. НЕ пиши чанкер, поиск, watcher, MCP, CLI-логику.
|
||||
|
||||
1. settings.gradle.kts: rootProject.name = "memo"; include(":memo-core", ":memo-cli", ":memo-watch", ":memo-mcp").
|
||||
В dependencyResolutionManagement { repositories { mavenCentral(); maven(url = "http://nexus.xx/repository/caffeine/") { isAllowInsecureProtocol = true } } }.
|
||||
|
||||
2. build.gradle.kts (корень): plugins { kotlin("jvm") version "2.4.10" apply false };
|
||||
ext: kotlinVersion "2.4.10", onnxVersion "1.16.0"; во всех модулях jvmTarget 17.
|
||||
|
||||
3. memo-core/build.gradle.kts: plugins { kotlin("jvm") }; зависимости:
|
||||
implementation("pw.binom.db:ksqlite:0.1.4")
|
||||
implementation("pw.binom.ai.embeddingtext:api:5")
|
||||
runtimeOnly("pw.binom.ai.embeddingtext:siglip-jvm:5")
|
||||
testImplementation(kotlin("test"))
|
||||
Тестовый фреймворк не менять (дефолтный JUnit4), useJUnitPlatform() НЕ добавлять.
|
||||
|
||||
4. Модули memo-cli, memo-watch, memo-mcp — только build.gradle.kts с plugins { kotlin("jvm") } и пустой src. Логику не писать.
|
||||
|
||||
5. memo-core/src/main/kotlin/memo/core/Embedder.kt:
|
||||
class Embedder(private val modelPath: String, private val tokenizerPath: String) : AutoCloseable
|
||||
- лениво, при первом embed, создаёт pw.binom.voice.embeddingtext.createSiglip2TextExtractor(modelPath, tokenizerPath)
|
||||
- fun embed(text: String): FloatArray — возвращает ex.embed(text).values; если размерность != 768, бросить IllegalStateException с фактической размерностью
|
||||
- override fun close() — идемпотентно, закрывает экстрактор
|
||||
Импорты: pw.binom.voice.embeddingtext.createSiglip2TextExtractor, pw.binom.voice.embeddingtext.TextEmbeddingExtractor
|
||||
|
||||
6. memo-core/src/main/kotlin/memo/core/Db.kt:
|
||||
class Db(val path: String) : AutoCloseable
|
||||
- при создании открывает SQLiteConnection.open(path) (pw.binom.db.ksqlite.SQLiteConnection)
|
||||
- сразу выполняет: "PRAGMA journal_mode=WAL" и "PRAGMA synchronous=NORMAL"
|
||||
- fun init() — идемпотентно создаёт схему (ровно эти SQL):
|
||||
CREATE TABLE IF NOT EXISTS files(path TEXT PRIMARY KEY, mtime REAL, size INTEGER, hash TEXT, indexed_at REAL);
|
||||
CREATE TABLE IF NOT EXISTS chunks(id INTEGER PRIMARY KEY, path TEXT NOT NULL, heading TEXT, line INTEGER NOT NULL, ord INTEGER NOT NULL, text TEXT NOT NULL, hash TEXT NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS idx_chunks_path ON chunks(path);
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(text, heading, tokenize='unicode61');
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_vec USING vec0(embedding float[768]);
|
||||
- val conn даёт доступ к соединению
|
||||
- close() закрывает соединение
|
||||
|
||||
7. memo-core/src/test/kotlin/memo/core/ — ровно 4 теста, отдельные @Test функции, имена ровно такие:
|
||||
- ksqliteSmoke — временный файл БД, CREATE TABLE t(a INTEGER), INSERT 42, SELECT, значение совпало.
|
||||
- vec0KnnRoundTrip — CREATE VIRTUAL TABLE v USING vec0(embedding float[4]); вставить 3 вектора с rowid 1, 2, 3 (привязка FloatArray: stmt.bind(1, id); stmt.bind(2, floatArrayOf(...)));
|
||||
запрос "SELECT rowid FROM v WHERE embedding MATCH ? ORDER BY distance LIMIT 1" с вектором, ближайшим к rowid=2, обязан вернуть 2.
|
||||
- fts5FindsCyrillic — FTS5-таблица, вставка строки "внутренний домен траефик", поиск MATCH 'траефик' находит ровно 1 строку.
|
||||
- embedderProduces768 — путь к модели: env MEMO_MODEL_DIR, иначе /root/WORK/memo/models/siglip2; если text_model_int8.onnx или tokenizer.model отсутствуют — тест падает с сообщением "модель не найдена: <путь>";
|
||||
иначе embed("привет мир") → dim == 768 и ни одного NaN.
|
||||
|
||||
8. Прогони ./gradlew :memo-core:test и добейся, чтобы все 4 теста были зелёными (файлы модели уже скачиваются в models/siglip2, дождись их; если их ещё нет — тест embedderProduces768 может упасть, тогда перезапусти прогон позже).
|
||||
В конце: git add -A && git commit -m "core: каркас сборки, embedder, схема БД, 4 теста".
|
||||
|
||||
СТРОГИЕ ЗАПРЕТЫ:
|
||||
- НЕ выводи план текстом. Сразу правь файлы инструментами. Задача не выполнена, пока файлы не изменены и ./gradlew :memo-core:test не зелёный.
|
||||
- НЕ добавляй зависимости, которых нет в списке выше.
|
||||
- НЕ трогай docs/SPEC.md, TASK.md, TESTING.md, README.md, LICENSE, scripts/, .gitignore.
|
||||
- НЕ создавай Android/KMP-модули и platform-таргеты.
|
||||
- НЕ коммить папку models/ и файлы *.db.
|
||||
@@ -0,0 +1,26 @@
|
||||
Проект: /root/WORK/memo (Kotlin/JVM). Заказ крошечный: усилить ОДИН тест.
|
||||
|
||||
Проблема (доказана мутационной проверкой): тест `fts5FindsCyrillic` в
|
||||
memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt ПРОХОДИТ даже если токенизатор в тесте
|
||||
заменить с `unicode61` на `ascii` — то есть тест не проверяет то, ради чего он написан
|
||||
(регистронезависимый поиск по кириллице). Проверено на живом SQLite: с `ascii` строчный запрос
|
||||
«траефик» НЕ находит строку «Траефик» (0 совпадений), с `unicode61` — находит.
|
||||
|
||||
Что сделать — ровно это, в файле memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt:
|
||||
|
||||
1. В тесте `fts5FindsCyrillic` (тело метода, сигнатуру и имя не менять):
|
||||
- таблица создаётся с `tokenize='unicode61'` (оставить как есть);
|
||||
- ВСТАВЛЯЕМОЕ значение сделать с заглавной буквой: `"Траефик внутри"` вместо `"внутренний домен траефик"`;
|
||||
- после существующей проверки добавить ещё две проверки в том же тесте:
|
||||
а) запрос СТРОЧНЫМИ `"траефик"` находит ровно 1 строку и её rowid == 1;
|
||||
б) запрос ЗАГЛАВНЫМИ `"Траефик"` тоже находит ровно 1 строку и её rowid == 1.
|
||||
- Проверки делать отдельным запросом каждым, не одним.
|
||||
2. Ничего больше в файле не менять. Формулировки ассертов писать по-русски, как в файле.
|
||||
3. Прогнать `./gradlew :memo-core:test --rerun-tasks` и добиться зелёного результата.
|
||||
4. Закоммитить: `git add -A && git commit -m "core: fts5-тест проверяет регистронезависимость кириллицы"`.
|
||||
|
||||
СТРОГИЕ ЗАПРЕТЫ:
|
||||
- Не выводить план текстом; сразу правь файл.
|
||||
- Не трогать боевой код (memo-core/src/main/**), только тестовый файл.
|
||||
- Не менять другие тесты, не переименовывать их.
|
||||
- Не добавлять зависимости.
|
||||
@@ -0,0 +1,47 @@
|
||||
Проект: /root/WORK/memo (Kotlin/JVM). Заказ по чанкеру markdown.
|
||||
|
||||
Создать РОВНО ОДИН новый файл: memo-core/src/main/kotlin/memo/core/Chunker.kt
|
||||
и РОВНО ОДИН новый тестовый файл: memo-core/src/test/kotlin/memo/core/ChunkerTest.kt
|
||||
|
||||
## Chunker.kt
|
||||
|
||||
package memo.core
|
||||
|
||||
data class Chunk(
|
||||
val heading: String, // текст заголовка без решёток, обрезанный по краям
|
||||
val line: Int, // 1-based номер строки, ГДЕ СТОИТ ЗАГОЛОВОК; 1, если заголовка нет
|
||||
val text: String, // текст раздела без строки-заголовка
|
||||
val ord: Int, // порядковый номер чанка в файле, с 0
|
||||
)
|
||||
|
||||
fun chunkMarkdown(text: String): List<Chunk>
|
||||
|
||||
Правила:
|
||||
- Разделитель — строка, начинающаяся с одного, двух или трёх символов '#' и следующего за ними пробела
|
||||
(заголовки '#'..'###'). Более длинные последовательности решёток заголовками НЕ считаются.
|
||||
- Заголовок попадает в field heading, его строка — в field line. В text строки-заголовки не входят
|
||||
(text обрезается по краям; пустые строки в начале и конце удаляются).
|
||||
- Текст ДО первого заголовка даёт чанк с heading = "" и line = 1 (только если он непустой после обрезки).
|
||||
- Пустые чанки (text пустой после trim) НЕ возвращаются.
|
||||
- Боковые ограничения по размеру: если text длиннее 4000 символов, он режется на несколько чанков
|
||||
по 4000 символов с перекрытием 600 символов (хвост предыдущего повторяется в начале следующего);
|
||||
все части одного раздела имеют ОДИН И ТОТ ЖЕ heading и line, ord растёт.
|
||||
- Никаких зависимостей; чистый Kotlin.
|
||||
|
||||
## 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 "Заголовок".
|
||||
3. `fourHashesIsNotAHeading` — строка '#### не заголовок' остаётся частью текста предыдущего раздела.
|
||||
4. `emptySectionsAreDropped` — ввод с двумя заголовками подряд без текста между ними → 1 чанк.
|
||||
5. `longSectionIsSplitWithOverlap` — раздел из 6000 символов 'а' → 2 чанка, длина первого 4000,
|
||||
длина второго 2600, и последние 600 символов первого равны первым 600 символам второго (перекрытие).
|
||||
|
||||
После: `./gradlew :memo-core:test --rerun-tasks` — все тесты (4 старых + 5 новых) зелёные.
|
||||
Коммит: `git add -A && git commit -m "core: чанкер markdown по заголовкам с перекрытием"`.
|
||||
|
||||
СТРОГИЕ ЗАПРЕТЫ:
|
||||
- Не выводить план текстом; сразу создавай файлы.
|
||||
- Не трогать существующие файлы (Embedder.kt, Db.kt, CoreSmokeTest.kt, build.gradle.kts) и любые другие.
|
||||
- Не добавлять зависимости.
|
||||
- Не создавать другие файлы.
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Мутационная проверка: ломаем боевой код и смотрим, падает ли нужный тест.
|
||||
|
||||
Формат e2e/mutations.tsv: file<TAB>old<TAB>new<TAB>must_fail_test
|
||||
Прогон: python3 e2e/mutation_check.py --gradle ./gradlew
|
||||
Выход 0 — все мутации убиты (тесты ловят поломку). 1 — есть выжившие (дыра в покрытии).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
|
||||
def run_tests(gradle: str) -> dict[str, str]:
|
||||
"""Возвращает {test_name: status}, где status = PASS/FAIL."""
|
||||
subprocess.run([gradle, "--quiet", "test", "--rerun-tasks"],
|
||||
capture_output=True, text=True, timeout=1800)
|
||||
status: dict[str, str] = {}
|
||||
for f in glob.glob("**/build/test-results/test/*.xml", recursive=True):
|
||||
try:
|
||||
root = ET.parse(f).getroot()
|
||||
except ET.ParseError:
|
||||
continue
|
||||
for tc in root.iter("testcase"):
|
||||
failed = any(c.tag in ("failure", "error") for c in tc)
|
||||
status[tc.get("name")] = "FAIL" if failed else "PASS"
|
||||
return status
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--gradle", default="./gradlew")
|
||||
ap.add_argument("--mutations", default="e2e/mutations.tsv")
|
||||
a = ap.parse_args()
|
||||
|
||||
rows = []
|
||||
for line in open(a.mutations, encoding="utf-8"):
|
||||
line = line.rstrip("\n")
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
f, old, new, must_fail = line.split("\t")
|
||||
rows.append((f, old.replace("\\n", "\n"), new.replace("\\n", "\n"), must_fail))
|
||||
|
||||
print("=== базовая линия (без мутаций) ===")
|
||||
base = run_tests(a.gradle)
|
||||
print(f"тестов найдено: {len(base)}; упавших: {[k for k, v in base.items() if v == 'FAIL']}")
|
||||
|
||||
survivors = []
|
||||
for f, old, new, must_fail in rows:
|
||||
backup = f + ".bak"
|
||||
shutil.copy2(f, backup)
|
||||
try:
|
||||
src = open(f, encoding="utf-8").read()
|
||||
assert old in src, f"МУТАЦИЯ НЕ ПРИМЕНИЛАСЬ (нет фрагмента) в {f}: {old[:60]!r}"
|
||||
open(f, "w", encoding="utf-8").write(src.replace(old, new, 1))
|
||||
st = run_tests(a.gradle)
|
||||
got = st.get(must_fail, "НЕ НАЙДЕН")
|
||||
killed = got == "FAIL"
|
||||
print(f"{'УБИТА ' if killed else 'ВЫЖИЛА'} | {must_fail} -> {got} | {f}: {old[:50]} -> {new[:30]}")
|
||||
if not killed:
|
||||
survivors.append((must_fail, f, old, new))
|
||||
finally:
|
||||
shutil.move(backup, f)
|
||||
|
||||
print("\n=== восстановление ===")
|
||||
st = run_tests(a.gradle)
|
||||
print(f"после отката упавших: {[k for k, v in st.items() if v == 'FAIL']}")
|
||||
|
||||
if survivors:
|
||||
print("\nВЫЖИВШИЕ МУТАЦИИ (дыры в покрытии):")
|
||||
for t, f, old, new in survivors:
|
||||
print(f" - {t}: {f} `{old[:60]}` -> `{new[:40]}`")
|
||||
return 1
|
||||
print("\nВСЕ МУТАЦИИ УБИТЫ")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,5 @@
|
||||
# file<TAB>old<TAB>new<TAB>must_fail_test
|
||||
memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt CREATE VIRTUAL TABLE v USING vec0(embedding float[4]) CREATE VIRTUAL TABLE v USING vec0(embedding float[8]) vec0KnnRoundTrip
|
||||
memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt floatArrayOf(0.1f, 0.9f, 0.0f, 0.0f) floatArrayOf(0.0f, 0.0f, 0.0f, 1.0f) vec0KnnRoundTrip
|
||||
memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt tokenize='unicode61' tokenize='ascii' fts5FindsCyrillic
|
||||
memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt "SELECT a FROM t" "SELECT a+1 FROM t" ksqliteSmoke
|
||||
|
@@ -0,0 +1,26 @@
|
||||
# Вопросы приёмки T2/T3. Формат: question<TAB>path<TAB>expected_file<TAB>kind
|
||||
# kind: sem — смысловой (должен вытянуть вектор), lex — точное значение (должен вытянуть BM25)
|
||||
# Ожидаемый файл должен попасть в top-5. Порог: recall@5 >= 16/20 по sem, 3/3 по lex.
|
||||
как понять, почему сборка съедает место на диске infra work/jira/ci.md sem
|
||||
чем разметить диск, если привычной утилиты нет infra infra/servers.md sem
|
||||
на какой карте разрешено ставить опыты infra infra/servers.md sem
|
||||
что служит основой для разрешения имён в сети infra infra/domains.md sem
|
||||
куда конвейер отдаёт готовые артефакты infra infra/hosts.md sem
|
||||
как корпоративные имена ходят наружу infra infra/hosts.md sem
|
||||
восстановление прошло мгновенно, а данных не видно infra infra/backup.md sem
|
||||
куда складываются зеркала для очков infra infra/backup.md sem
|
||||
где лежат ключи доступа к корпоративным ресурсам work/jira work/jira/access.md sem
|
||||
как оформляют задачу, пришедшую от робота work/jira work/jira/flow.md sem
|
||||
после обновления пропали настройки выпуска work/jira work/jira/pitfalls.md sem
|
||||
какой номер тикета закрыли после починки тестов work/jira work/jira/flow.md sem
|
||||
кто ведёт рассказ в романе life/books life/books/iphuck.md sem
|
||||
в каком году вышла книга life/books life/books/iphuck.md sem
|
||||
где чинят карточку, если подтянулось чужое издание life/books life/books/audiobooks.md sem
|
||||
до какого правителя доходит первый том life/books life/books/history.md sem
|
||||
как я фиксирую цитаты из прочитанного life/books life/books/notes.md sem
|
||||
чем слушают книги в очках life/books life/books/audiobooks.md sem
|
||||
что запрещено трогать на машине с двумя картами life/books infra/servers.md sem
|
||||
где живёт хранилище контейнерных образов life/books infra/hosts.md sem
|
||||
76.132 infra infra/hosts.md lex
|
||||
TEST-4173 work/jira work/jira/flow.md lex
|
||||
192.168.88.35:8080 infra infra/hosts.md lex
|
||||
|
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Приёмка recall@5 для memo (TESTING.md T2/T3).
|
||||
|
||||
Читает e2e/questions.tsv, гоняет CLI и считает, попал ли ожидаемый файл в top-5.
|
||||
Использование:
|
||||
python3 e2e/run_e2e.py --cli ./memo-cli/build/install/memo-cli/bin/memo \
|
||||
--root /root/WORK/memo-e2e [--k 5] [--min-sem-recall 0.8]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def normalize(p: str, root: str) -> str:
|
||||
p = p.replace("\\", "/")
|
||||
root = root.rstrip("/")
|
||||
if p.startswith(root + "/"):
|
||||
p = p[len(root) + 1:]
|
||||
return p.lstrip("./")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--cli", required=True)
|
||||
ap.add_argument("--root", required=True)
|
||||
ap.add_argument("--questions", default="e2e/questions.tsv")
|
||||
ap.add_argument("--k", type=int, default=5)
|
||||
ap.add_argument("--min-sem-recall", type=float, default=0.8)
|
||||
a = ap.parse_args()
|
||||
|
||||
rows = []
|
||||
for line in open(a.questions, encoding="utf-8"):
|
||||
line = line.rstrip("\n")
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
q, scope, expected, kind = line.split("\t")
|
||||
rows.append((q, scope, expected, kind))
|
||||
|
||||
hits = {"sem": 0, "lex": 0}
|
||||
total = {"sem": 0, "lex": 0}
|
||||
fails = []
|
||||
for q, scope, expected, kind in rows:
|
||||
total[kind] += 1
|
||||
cmd = shlex.split(a.cli) + ["search", f"{a.root}/{scope}", q, "--k", str(a.k), "--json"]
|
||||
try:
|
||||
out = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
|
||||
except subprocess.TimeoutExpired:
|
||||
fails.append((q, kind, "TIMEOUT", []))
|
||||
continue
|
||||
if out.returncode != 0:
|
||||
fails.append((q, kind, f"exit {out.returncode}: {out.stderr.strip()[:200]}", []))
|
||||
continue
|
||||
try:
|
||||
data = json.loads(out.stdout)
|
||||
except json.JSONDecodeError:
|
||||
fails.append((q, kind, f"не JSON: {out.stdout[:200]}", []))
|
||||
continue
|
||||
items = data if isinstance(data, list) else data.get("results", [])
|
||||
got = [normalize(str(it.get("path", "")), a.root) for it in items][:a.k]
|
||||
if normalize(expected, a.root) in got:
|
||||
hits[kind] += 1
|
||||
else:
|
||||
fails.append((q, kind, "не найдено в top-%d" % a.k, got))
|
||||
|
||||
sem_total = total["sem"] or 1
|
||||
lex_total = total["lex"] or 1
|
||||
sem_recall = hits["sem"] / sem_total
|
||||
lex_recall = hits["lex"] / lex_total
|
||||
print(f"sem: {hits['sem']}/{sem_total} = {sem_recall:.2f} (порог {a.min_sem_recall})")
|
||||
print(f"lex: {hits['lex']}/{lex_total} = {lex_recall:.2f} (порог 1.00)")
|
||||
if fails:
|
||||
print("\nпромахи:")
|
||||
for q, kind, why, got in fails:
|
||||
print(f" [{kind}] {q} -> {why}")
|
||||
if got:
|
||||
print(f" получено: {got}")
|
||||
ok = sem_recall >= a.min_sem_recall and lex_recall >= 1.0
|
||||
print("\nИТОГ:", "ПРОЙДЕНО" if ok else "ПРОВАЛ")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,3 @@
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("pw.binom.db:ksqlite:0.1.4")
|
||||
implementation("pw.binom.ai.embeddingtext:api:5")
|
||||
runtimeOnly("pw.binom.ai.embeddingtext:siglip-jvm:5")
|
||||
|
||||
testImplementation(kotlin("test"))
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package memo.core
|
||||
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
|
||||
class Db(val path: String) : AutoCloseable {
|
||||
|
||||
val conn: SQLiteConnection = SQLiteConnection.open(path)
|
||||
|
||||
init {
|
||||
conn.exec("PRAGMA journal_mode=WAL")
|
||||
conn.exec("PRAGMA synchronous=NORMAL")
|
||||
}
|
||||
|
||||
fun init() {
|
||||
conn.exec(
|
||||
"CREATE TABLE IF NOT EXISTS files(" +
|
||||
"path TEXT PRIMARY KEY, mtime REAL, size INTEGER, hash TEXT, indexed_at REAL" +
|
||||
")"
|
||||
)
|
||||
conn.exec(
|
||||
"CREATE TABLE IF NOT EXISTS chunks(" +
|
||||
"id INTEGER PRIMARY KEY, path TEXT NOT NULL, heading TEXT, " +
|
||||
"line INTEGER NOT NULL, ord INTEGER NOT NULL, text TEXT NOT NULL, hash TEXT NOT NULL" +
|
||||
")"
|
||||
)
|
||||
conn.exec("CREATE INDEX IF NOT EXISTS idx_chunks_path ON chunks(path)")
|
||||
conn.exec("CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(text, heading, tokenize='unicode61')")
|
||||
conn.exec("CREATE VIRTUAL TABLE IF NOT EXISTS chunks_vec USING vec0(embedding float[768])")
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
conn.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package memo.core
|
||||
|
||||
import pw.binom.voice.embeddingtext.TextEmbeddingExtractor
|
||||
|
||||
class Embedder(
|
||||
private val modelPath: String,
|
||||
private val tokenizerPath: String,
|
||||
) : AutoCloseable {
|
||||
|
||||
private var extractor: TextEmbeddingExtractor? = null
|
||||
private val factory: Class<*>? = runCatching {
|
||||
Class.forName("pw.binom.voice.embeddingtext.Siglip2TextExtractorFactoryKt")
|
||||
}.getOrNull()
|
||||
private val createMethod = factory?.methods?.firstOrNull {
|
||||
it.name == "createSiglip2TextExtractor" &&
|
||||
it.parameterTypes.size == 2 &&
|
||||
it.parameterTypes[0] == String::class.java &&
|
||||
it.parameterTypes[1] == String::class.java
|
||||
}
|
||||
|
||||
private fun obtain(): TextEmbeddingExtractor {
|
||||
extractor?.let { return it }
|
||||
val method = createMethod ?: error(
|
||||
"createSiglip2TextExtractor is not available on classpath; " +
|
||||
"ensure pw.binom.ai.embeddingtext:siglip-jvm is on the runtime classpath"
|
||||
)
|
||||
val created = method.invoke(null, modelPath, tokenizerPath) as TextEmbeddingExtractor
|
||||
extractor = created
|
||||
return created
|
||||
}
|
||||
|
||||
fun embed(text: String): FloatArray {
|
||||
val ex = obtain()
|
||||
val values = ex.embed(text).values
|
||||
if (values.size != 768) {
|
||||
throw IllegalStateException("expected 768 dims, got ${values.size}")
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
val current = extractor ?: return
|
||||
extractor = null
|
||||
current.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package memo.core
|
||||
|
||||
import java.io.File
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.fail
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
|
||||
class CoreSmokeTest {
|
||||
|
||||
@Test
|
||||
fun ksqliteSmoke() {
|
||||
val file = File.createTempFile("memo-ksqlite-", ".db")
|
||||
file.deleteOnExit()
|
||||
val conn = SQLiteConnection.open(file.absolutePath)
|
||||
try {
|
||||
conn.exec("CREATE TABLE t(a INTEGER)")
|
||||
conn.exec("INSERT INTO t(a) VALUES (42)")
|
||||
|
||||
val stmt = conn.prepare("SELECT a FROM t")
|
||||
try {
|
||||
val rs = stmt.executeQuery()
|
||||
try {
|
||||
assertTrue(rs.next(), "expected one row")
|
||||
assertEquals(42L, rs.getLong(0)!!)
|
||||
} finally {
|
||||
rs.close()
|
||||
}
|
||||
} finally {
|
||||
stmt.close()
|
||||
}
|
||||
} finally {
|
||||
conn.close()
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun vec0KnnRoundTrip() {
|
||||
val file = File.createTempFile("memo-vec0-", ".db")
|
||||
file.deleteOnExit()
|
||||
val conn = SQLiteConnection.open(file.absolutePath)
|
||||
try {
|
||||
conn.exec("CREATE VIRTUAL TABLE v USING vec0(embedding float[4])")
|
||||
|
||||
val insert = conn.prepare("INSERT INTO v(rowid, embedding) VALUES (?, ?)")
|
||||
try {
|
||||
insert.bindLong(1, 1L)
|
||||
insert.bindVector(2, floatArrayOf(1.0f, 0.0f, 0.0f, 0.0f))
|
||||
insert.executeUpdate()
|
||||
|
||||
insert.reset()
|
||||
insert.bindLong(1, 2L)
|
||||
insert.bindVector(2, floatArrayOf(0.0f, 1.0f, 0.0f, 0.0f))
|
||||
insert.executeUpdate()
|
||||
|
||||
insert.reset()
|
||||
insert.bindLong(1, 3L)
|
||||
insert.bindVector(2, floatArrayOf(0.0f, 0.0f, 1.0f, 0.0f))
|
||||
insert.executeUpdate()
|
||||
} finally {
|
||||
insert.close()
|
||||
}
|
||||
|
||||
val query = conn.prepare("SELECT rowid FROM v WHERE embedding MATCH ? ORDER BY distance LIMIT 1")
|
||||
try {
|
||||
query.bindVector(1, floatArrayOf(0.1f, 0.9f, 0.0f, 0.0f))
|
||||
val rs = query.executeQuery()
|
||||
try {
|
||||
assertTrue(rs.next(), "expected one match")
|
||||
assertEquals(2L, rs.getLong(0)!!)
|
||||
} finally {
|
||||
rs.close()
|
||||
}
|
||||
} finally {
|
||||
query.close()
|
||||
}
|
||||
} finally {
|
||||
conn.close()
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fts5FindsCyrillic() {
|
||||
val file = File.createTempFile("memo-fts-", ".db")
|
||||
file.deleteOnExit()
|
||||
val conn = SQLiteConnection.open(file.absolutePath)
|
||||
try {
|
||||
conn.exec("CREATE VIRTUAL TABLE docs USING fts5(text, tokenize='unicode61')")
|
||||
|
||||
val insert = conn.prepare("INSERT INTO docs(rowid, text) VALUES (?, ?)")
|
||||
try {
|
||||
insert.bindLong(1, 1L)
|
||||
insert.bindText(2, "Траефик внутри")
|
||||
insert.executeUpdate()
|
||||
} finally {
|
||||
insert.close()
|
||||
}
|
||||
|
||||
val query = conn.prepare("SELECT rowid FROM docs WHERE docs MATCH ?")
|
||||
try {
|
||||
query.bindText(1, "траефик")
|
||||
val rs = query.executeQuery()
|
||||
try {
|
||||
var hits = 0
|
||||
var lastRowid = -1L
|
||||
while (rs.next()) {
|
||||
hits++
|
||||
lastRowid = rs.getLong(0)!!
|
||||
}
|
||||
assertEquals(1, hits, "expected exactly one FTS5 hit")
|
||||
assertEquals(1L, lastRowid)
|
||||
} finally {
|
||||
rs.close()
|
||||
}
|
||||
} finally {
|
||||
query.close()
|
||||
}
|
||||
|
||||
val queryLower = conn.prepare("SELECT rowid FROM docs WHERE docs MATCH ?")
|
||||
try {
|
||||
queryLower.bindText(1, "траефик")
|
||||
val rs = queryLower.executeQuery()
|
||||
try {
|
||||
var hits = 0
|
||||
var lastRowid = -1L
|
||||
while (rs.next()) {
|
||||
hits++
|
||||
lastRowid = rs.getLong(0)!!
|
||||
}
|
||||
assertEquals(1, hits, "ожидалось ровно одно совпадение FTS5")
|
||||
assertEquals(1L, lastRowid, "rowid должен быть 1")
|
||||
} finally {
|
||||
rs.close()
|
||||
}
|
||||
} finally {
|
||||
queryLower.close()
|
||||
}
|
||||
|
||||
val queryUpper = conn.prepare("SELECT rowid FROM docs WHERE docs MATCH ?")
|
||||
try {
|
||||
queryUpper.bindText(1, "Траефик")
|
||||
val rs = queryUpper.executeQuery()
|
||||
try {
|
||||
var hits = 0
|
||||
var lastRowid = -1L
|
||||
while (rs.next()) {
|
||||
hits++
|
||||
lastRowid = rs.getLong(0)!!
|
||||
}
|
||||
assertEquals(1, hits, "ожидалось ровно одно совпадение FTS5")
|
||||
assertEquals(1L, lastRowid, "rowid должен быть 1")
|
||||
} finally {
|
||||
rs.close()
|
||||
}
|
||||
} finally {
|
||||
queryUpper.close()
|
||||
}
|
||||
} finally {
|
||||
conn.close()
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun embedderProduces768() {
|
||||
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")
|
||||
}
|
||||
|
||||
Embedder(modelPath, tokenizerPath).use { embedder ->
|
||||
val v = embedder.embed("привет мир")
|
||||
assertEquals(768, v.size)
|
||||
assertTrue(v.none { it.isNaN() }, "embedding contains NaN")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
}
|
||||
Executable
+141
@@ -0,0 +1,141 @@
|
||||
#!/bin/bash
|
||||
# Создаёт эталонный корпус для приёмки memo (TESTING.md §0).
|
||||
# 12-15 заметок в 3 коллекциях, кириллица + латиница + точные значения в каждой папке.
|
||||
set -euo pipefail
|
||||
ROOT="${1:-/root/WORK/memo-e2e}"
|
||||
rm -rf "$ROOT"
|
||||
mkdir -p "$ROOT/infra" "$ROOT/work/jira" "$ROOT/life/books"
|
||||
|
||||
# ---------- infra ----------
|
||||
cat > "$ROOT/infra/servers.md" <<'EOF'
|
||||
# Серверы
|
||||
|
||||
## Server4 (76.160)
|
||||
Основной Proxmox-хост. Диск sdd — SSD, sda/b/c — HDD, причём sda на SMR-пластинах.
|
||||
Утилиты parted нет: разметку делать через sgdisk или sfdisk.
|
||||
|
||||
## Server5 (88.130)
|
||||
Две видеокарты: 3090 занята под Qwen VL и аудио, её не трогать. Эксперименты — только на 5090.
|
||||
|
||||
## Server6 (76.109)
|
||||
Здесь живёт powerdns, он же LXC102. Отвечает за внутренние зоны.
|
||||
EOF
|
||||
|
||||
cat > "$ROOT/infra/hosts.md" <<'EOF'
|
||||
# Адреса внутренних сервисов
|
||||
|
||||
## Реестр образов
|
||||
zot слушает на 192.168.88.35:8080, репозиторий приватный.
|
||||
|
||||
## Прокси
|
||||
Корпоративные домены ходят через Caddy на 76.132. Отдельный Traefik на 76.195 заворачивает
|
||||
домены вида .xx, а DNS под ним — 76.109.
|
||||
|
||||
## Nexus
|
||||
Нексус доступен на 76.117, релизы публикует CI/CD, руками артефакты не заливать.
|
||||
EOF
|
||||
|
||||
cat > "$ROOT/infra/domains.md" <<'EOF'
|
||||
# Домены и DNS
|
||||
|
||||
## Внутренние зоны
|
||||
Основой для DNS и mDNS служит kdns, он же pw.binom.dns.
|
||||
|
||||
## LLM-роутер
|
||||
llm.binom.pw работает через Bifrost, виртуальные ключи раздаёт сам роутер.
|
||||
|
||||
## Реестр
|
||||
Домашний реестр библиотек — nexus.xx, репозиторий caffeine.
|
||||
EOF
|
||||
|
||||
cat > "$ROOT/infra/backup.md" <<'EOF'
|
||||
# Резервное копирование
|
||||
|
||||
## Postgres
|
||||
Дампы снимаются в custom-формате. При восстановлении pg_restore может выйти мгновенно
|
||||
с кодом ноль и ничего не сделать — проверять, что объекты реально появились.
|
||||
|
||||
## Медиа
|
||||
Джоб копирования зеркал для очков идёт через NATS и складывает результат в S3 SeaweedFS.
|
||||
EOF
|
||||
|
||||
# ---------- work/jira ----------
|
||||
cat > "$ROOT/work/jira/access.md" <<'EOF'
|
||||
# Доступы
|
||||
|
||||
## OTP-прокси
|
||||
Корпоративные ресурсы ходят через OTP, файлы лежат в /root/OTP/. Jira отвечает на jira.mcp.xx.
|
||||
|
||||
## TeamCity
|
||||
Сборки живут в teamcity.isb, статусы и логи — только через тот же OTP-прокси.
|
||||
|
||||
## Git
|
||||
Клонирование корпоративных репозиториев с bitbucket возможно лишь по HTTPS, git@ не работает.
|
||||
EOF
|
||||
|
||||
cat > "$ROOT/work/jira/flow.md" <<'EOF'
|
||||
# Рабочий поток
|
||||
|
||||
## Задачи от агентов
|
||||
Задача от агента оформляется спекой в отдельном файле, итог работы — комментарий в Vikunja.
|
||||
|
||||
## Тикеты
|
||||
Тикет TEST-4173 закрыт после того, как сборка перестала падать на шаге тестов.
|
||||
EOF
|
||||
|
||||
cat > "$ROOT/work/jira/pitfalls.md" <<'EOF'
|
||||
# Грабли
|
||||
|
||||
## Хелм
|
||||
Helm upgrade в k3s теряет values, если передавать их через --set. Восстанавливать из истории релиза.
|
||||
|
||||
## TeamCity
|
||||
Если прогон висит больше часа, смотреть лог агента, а не перезапускать сборку слепо.
|
||||
EOF
|
||||
|
||||
cat > "$ROOT/work/jira/ci.md" <<'EOF'
|
||||
# CI
|
||||
|
||||
## Gitea Actions
|
||||
Раннер живёт в LXC105 на 76.112. Сироты процессов podman и unity съедают диск и валят сборку.
|
||||
EOF
|
||||
|
||||
# ---------- life/books ----------
|
||||
cat > "$ROOT/life/books/iphuck.md" <<'EOF'
|
||||
# iPhuck 10
|
||||
|
||||
## Про роман
|
||||
Роман Пелевина, 2017 года. Рассказчик — алгоритмический следователь Порфирий Петрович,
|
||||
он же литературный негр, он же полицейская машина.
|
||||
|
||||
## Канон
|
||||
Канон Порфирия Петровича лежит в /root/BOOKS/iphuck10/. Тон — высокий штиль, смешанный
|
||||
с сухим канцеляритом.
|
||||
EOF
|
||||
|
||||
cat > "$ROOT/life/books/audiobooks.md" <<'EOF'
|
||||
# Аудиокниги
|
||||
|
||||
## Библиотека
|
||||
Книги хранятся в Audiobookshelf. Карточку чинят вручную, если метаданные подтянулись от другого издания.
|
||||
|
||||
## Плеер
|
||||
Для очков RayNeo X2 книги транскодируются в зеркала, аудио идёт отдельными дорожками.
|
||||
EOF
|
||||
|
||||
cat > "$ROOT/life/books/history.md" <<'EOF'
|
||||
# История
|
||||
|
||||
## Античность
|
||||
Первый том заканчивается на правлении Траяна, 117 год нашей эры.
|
||||
EOF
|
||||
|
||||
cat > "$ROOT/life/books/notes.md" <<'EOF'
|
||||
# Заметки о чтении
|
||||
|
||||
## Привычка
|
||||
Читаю по вечерам, отмечаю цитаты в markdown, потом ищу их семантическим поиском.
|
||||
EOF
|
||||
|
||||
ls -R "$ROOT"
|
||||
echo "OK: $(find "$ROOT" -name '*.md' | wc -l) заметок"
|
||||
@@ -0,0 +1,13 @@
|
||||
rootProject.name = "memo"
|
||||
|
||||
include(":memo-core", ":memo-cli", ":memo-watch", ":memo-mcp")
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven {
|
||||
url = uri("http://nexus.xx/repository/caffeine/")
|
||||
isAllowInsecureProtocol = true
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user