Compare commits
14 Commits
cefc17d782
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 607137fdf7 | |||
| 69a0ebe0fa | |||
| 3054705176 | |||
| 2c7bca0d82 | |||
| dc8d9bc598 | |||
| 2ed736a9bc | |||
| f2956e3cef | |||
| f1d497d336 | |||
| 8b78309c56 | |||
| a2f09fffb6 | |||
| c33f94fa13 | |||
| de3cb24edc | |||
| d62a26f5b1 | |||
| 700508dc8e |
@@ -0,0 +1,366 @@
|
||||
# Как пользоваться memo
|
||||
|
||||
Инструкция для человека. Без внутренностей — только что делать руками.
|
||||
|
||||
Смысл одной фразой: **вы пишете обычные markdown-файлы, а `memo` рядом с ними отвечает на вопросы
|
||||
по смыслу** — не по словам, а по содержанию, и не отправляя ничего в интернет.
|
||||
|
||||
---
|
||||
|
||||
## 1. Что это и зачем
|
||||
|
||||
У вас есть папка с заметками. Обычные `.md`-файлы, обычные подпапки-темы. `memo` строит рядом
|
||||
с каждой папкой-темой маленький индекс (`SQLite`) и умеет искать по нему:
|
||||
|
||||
- **по смыслу** — «как чинят карточку в jellyfin» найдёт заметку, где написано «удаление элемента
|
||||
из медиатеки», хотя слова «карточка» там нет;
|
||||
- **по точному значению** — `76.117`, номер тикета, версия — найдёт точную строку;
|
||||
- **сразу и то, и другое** (по умолчанию) — режим называется гибридный.
|
||||
|
||||
Что важно понять сразу:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Ваши файлы — главные** | `memo` только читает `.md`. Он никогда ничего в них не пишет и не переименовывает. |
|
||||
| **Индекс — расходник** | `.memo/index.db` внутри папки можно удалить в любой момент. Он пересоберётся. Это не ваши данные. |
|
||||
| **Всё локально** | Никаких облаков и API. Модель лежит файлом на диске, считает на процессоре. |
|
||||
| **Никакого «формата memo»** | Никаких спец-тегов, баз данных для правки, экспортов. Только markdown. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Структура вашей библиотеки
|
||||
|
||||
`memo` не навязывает вам схему — он просто следует за вашими папками.
|
||||
|
||||
**Коллекция** = папка, в которой лежат `.md` **напрямую**. Одна папка = одна тема = одна независимая
|
||||
база. Папки друг о друге не знают.
|
||||
|
||||
Пример нормальной библиотеки:
|
||||
|
||||
```
|
||||
~/notes/ <- корень библиотеки (не коллекция: тут нет .md напрямую)
|
||||
├── infra/ <- КОЛЛЕКЦИЯ (тут *.md)
|
||||
│ ├── servers.md
|
||||
│ ├── hosts.md
|
||||
│ └── .memo/index.db <- индекс, создастся сам
|
||||
├── work/
|
||||
│ └── jira/ <- КОЛЛЕКЦИЯ (тут *.md)
|
||||
│ ├── access.md
|
||||
│ └── ci.md
|
||||
└── life/
|
||||
└── books/ <- КОЛЛЕКЦИЯ (тут *.md)
|
||||
├── history.md
|
||||
└── notes.md
|
||||
```
|
||||
|
||||
Тут три коллекции: `infra`, `work/jira`, `life/books`. Папки `work` и `life` — просто группировка,
|
||||
коллекциями они не считаются (в них нет `.md` напрямую), и это правильно: иначе одни и те же заметки
|
||||
индексировались бы по нескольку раз.
|
||||
|
||||
**Правило простое:** завёл папку, положил туда `.md` — это новая коллекция. Ничего регистрировать
|
||||
не надо.
|
||||
|
||||
---
|
||||
|
||||
## 3. Установка (один раз)
|
||||
|
||||
Нужен JDK 21.
|
||||
|
||||
```bash
|
||||
cd /root/WORK/memo
|
||||
./gradlew installDist
|
||||
```
|
||||
|
||||
**Больше ничего делать не надо.** Модель (287 МБ) скачается сама при первом использовании —
|
||||
при первом `index`, `search` или `status`. Выглядит это так:
|
||||
|
||||
```
|
||||
модель не найдена в /root/WORK/memo/models/siglip2, скачиваю с http://static.binom.pw/models/siglip2 (≈287 МБ, один раз)
|
||||
скачиваю text_model_int8.onnx: 45% (128 МБ / 283 МБ)
|
||||
скачано text_model_int8.onnx: 283 МБ
|
||||
скачано tokenizer.model: 4 МБ
|
||||
```
|
||||
|
||||
Качается один раз: если файлы на месте, в сеть никто не ходит — поиск и индексация работают офлайн.
|
||||
|
||||
### Скачать заранее (необязательно)
|
||||
|
||||
Если хотите подготовить модель до первого запуска:
|
||||
|
||||
```bash
|
||||
CLI=/root/WORK/memo/memo-cli/build/install/memo/bin/memo
|
||||
$CLI model
|
||||
#> скачано: text_model_int8.onnx, tokenizer.model (287679278 байт)
|
||||
|
||||
$CLI model # повторный запуск: ничего не качает
|
||||
#> модель уже на месте в /root/WORK/memo/models/siglip2: text_model_int8.onnx, tokenizer.model
|
||||
|
||||
$CLI model --force # перекачать принудительно
|
||||
$CLI model --dir /другой/путь
|
||||
```
|
||||
|
||||
Скачивание можно прервать и продолжить: файл пишется как `<имя>.part`, а при следующем запуске
|
||||
закачка **дописывается с места обрыва** (сервер поддерживает докачку). Целевой файл появляется
|
||||
только после того, как размер сошёлся, — «битого, но выглядящего рабочим» файла не будет.
|
||||
|
||||
### Где живёт модель и как это менять
|
||||
|
||||
По умолчанию — `/root/WORK/memo/models/siglip2`. Одна копия на все три программы, поэтому
|
||||
в дистрибутивы она не кладётся (иначе было бы 287 МБ × 3).
|
||||
|
||||
| Переменная | Смысл |
|
||||
|---|---|
|
||||
| `MEMO_MODEL_DIR` | где лежит модель (иначе `/root/WORK/memo/models/siglip2`) |
|
||||
| `MEMO_MODEL_AUTO_DOWNLOAD=0` | запретить автоматическое скачивание. Тогда при отсутствии модели будет ошибка `модель не найдена в <путь>; запустите: memo model` |
|
||||
|
||||
```bash
|
||||
export MEMO_MODEL_DIR=/root/WORK/memo/models/siglip2 # добавьте в ~/.bashrc, чтобы не повторять
|
||||
```
|
||||
|
||||
После сборки появятся три программы:
|
||||
|
||||
```
|
||||
memo-cli/build/install/memo/bin/memo <- для человека и для скриптов
|
||||
memo-mcp/build/install/memo-mcp/bin/memo-mcp <- для агента (MCP)
|
||||
memo-watch/build/install/memo-watch/bin/memo-watch <- демон слежения (необязателен)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Первый запуск: проиндексировать и найти
|
||||
|
||||
```bash
|
||||
CLI=/root/WORK/memo/memo-cli/build/install/memo/bin/memo
|
||||
|
||||
# Проиндексировать всю библиотеку (коллекции найдутся сами)
|
||||
$CLI index ~/notes
|
||||
#> индексировано: 4 обновлено, 4 файлов всего
|
||||
#> индексировано: 4 обновлено, 4 файлов всего
|
||||
#> индексировано: 4 обновлено, 4 файлов всего
|
||||
#> итого: 12 обновлено в 3 коллекциях
|
||||
|
||||
# Спросить по смыслу
|
||||
$CLI search ~/notes/infra "где публикуются релизы"
|
||||
#> 0.016 /root/notes/infra/hosts.md:6 Прокси
|
||||
#> Корпоративные домены проксируются кади на 76.132.
|
||||
#> 0.016 /root/notes/infra/hosts.md:3 Nexus
|
||||
#> Нексус доступен на 76.117, публикация — только через CI.
|
||||
```
|
||||
|
||||
Первая строка в каждой паре — `похожесть`, адрес `файл:строка` и заголовок раздела; ниже — сам
|
||||
текст куска.
|
||||
|
||||
Обратите внимание: первая команда была `index`, но она **не обязательна**. `search` сам до-индексирует
|
||||
то, что изменилось, прямо перед поиском. Можно просто писать заметки и сразу спрашивать.
|
||||
|
||||
### Команды целиком
|
||||
|
||||
```bash
|
||||
$CLI index <путь> # проиндексировать (повторный прогон — быстрый)
|
||||
$CLI model # скачать модель (обычно не нужно — сама скачается)
|
||||
$CLI search <путь> "<вопрос>" # искать: и смысл, и точные слова
|
||||
$CLI search <путь> "<вопрос>" --k 5 # вернуть 5 результатов (по умолчанию 8)
|
||||
$CLI search <путь> "<вопрос>" --json # машинный вывод (для скриптов и агента)
|
||||
$CLI search <путь> "<вопрос>" --mode lex # только точные слова (BM25)
|
||||
$CLI search <путь> "<вопрос>" --mode vec # только по смыслу (вектор)
|
||||
$CLI status <путь> # что проиндексировано и когда
|
||||
```
|
||||
|
||||
Что писать в `<путь>`:
|
||||
|
||||
- **корень библиотеки** (`~/notes`) — поиск по всем коллекциям сразу, ответы перемешиваются и
|
||||
сортируются по релевантности;
|
||||
- **одну папку-коллекцию** (`~/notes/infra`) — поиск только внутри неё.
|
||||
|
||||
Область видно по адресам в результатах: `.../infra/hosts.md` — значит, нашли в `infra`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Подключить к агенту (Hermes)
|
||||
|
||||
Здесь `memo` и живёт по-настоящему: агент получает **инструмент поиска** и перестаёт перечитывать
|
||||
всю библиотеку целиком.
|
||||
|
||||
Добавьте в конфиг Hermes (`~/.hermes/config.yaml`, секция `mcp_servers`):
|
||||
|
||||
```yaml
|
||||
mcp_servers:
|
||||
memo:
|
||||
command: /root/WORK/memo/memo-mcp/build/install/memo-mcp/bin/memo-mcp
|
||||
env:
|
||||
MEMO_MODEL_DIR: /root/WORK/memo/models/siglip2
|
||||
enabled: true
|
||||
```
|
||||
|
||||
**Инструменты подхватятся только в новой сессии Hermes** — как и любой MCP-сервер, они читаются
|
||||
при старте процесса. В текущей беседе их не будет; начните новую и проверьте, что появились
|
||||
`memo_search`, `memo_status`, `memo_reindex`.
|
||||
|
||||
Агенту после этого можно говорить просто: «поищи в заметках, чем мы чинили карточку в jellyfin».
|
||||
Он вызовет `memo_search` и получит пути, строки и текст найденных кусков.
|
||||
|
||||
### Три инструмента
|
||||
|
||||
| Инструмент | Что делает |
|
||||
|---|---|
|
||||
| `memo_search(path, query, k, mode)` | Гибридный поиск. **Если индекса нет — создаёт его сам.** |
|
||||
| `memo_status(path)` | Что проиндексировано, сколько файлов и чанков, когда обновлялось. |
|
||||
| `memo_reindex(path)` | Полная переиндексация (нужна редко — обычный поиск и так до-индексирует). |
|
||||
|
||||
### Проверить без агента
|
||||
|
||||
Тот же код инструмента, но из командной строки — удобно, когда что-то не работает:
|
||||
|
||||
```bash
|
||||
$CLI mcp-probe --tool memo_search --args '{"path":"/root/notes/infra","query":"домены","k":3}'
|
||||
$CLI mcp-probe --tool memo_status --args '{"path":"/root/notes"}'
|
||||
```
|
||||
|
||||
Ответ печатается ровно в той форме, которую получил бы агент.
|
||||
|
||||
---
|
||||
|
||||
## 6. Демон слежения — нужен ли он вам
|
||||
|
||||
**Короткий ответ: для работы с агентом — не нужен.** Поиск сам замечает изменения: перед каждым
|
||||
запросом он сверяет дату и размер файлов и до-индексирует то, что поменялось. Написали заметку —
|
||||
сразу нашли её.
|
||||
|
||||
Демон `memo-watch` нужен ровно в одном случае: **заметки правят мимо агента** — вы сами в редакторе,
|
||||
через `git pull`, синхронизацию, чужой скрипт, — и вы хотите, чтобы индекс был свежим *заранее*,
|
||||
а не в момент вопроса.
|
||||
|
||||
```bash
|
||||
memo-watch/build/install/memo-watch/bin/memo-watch ~/notes
|
||||
#> индексирую: /root/notes/infra -> обновлено 4
|
||||
#> индексирую: /root/notes/life/books -> обновлено 4
|
||||
#> индексирую: /root/notes/work/jira -> обновлено 4
|
||||
#> наблюдаю: /root/notes/infra
|
||||
#> наблюдаю: /root/notes/life/books
|
||||
#> наблюдаю: /root/notes/work/jira
|
||||
```
|
||||
|
||||
При старте он **сразу проходит по всем коллекциям** — индекс полный с первой секунды, ждать
|
||||
изменений не надо. Дальше печатает `наблюдаю:` по каждой и доиндексирует по факту правок:
|
||||
|
||||
```
|
||||
#> индексирую: /root/notes/infra -> обновлено 1
|
||||
```
|
||||
|
||||
### Какие папки он мониторит
|
||||
|
||||
Ровно те, что вы передали аргументом, — по тому же правилу, что и остальные команды:
|
||||
|
||||
- передали **корень** (`~/notes`) — следит за всеми коллекциями внутри него (в примере — `infra`,
|
||||
`work/jira`, `life/books`) и держит индекс по каждой;
|
||||
- передали **одну папку** (`~/notes/infra`) — следит только за ней.
|
||||
|
||||
Внутрь служебных папок (`.memo`, `.git`, любые `.`-папки) он не заглядывает. Список наблюдаемых
|
||||
папок печатается при старте строками `наблюдаю: <путь>` — это и есть ответ на вопрос «что он мониторит».
|
||||
|
||||
Демон переживает правки так: ловит изменения (с задержкой 0.5 с, чтобы не дёргаться на каждое
|
||||
нажатие), раз в 10 минут делает полный сверочный проход — это страховка для случаев, когда система
|
||||
о событиях файлов не сообщила (сетевые диски, sshfs).
|
||||
|
||||
Пока это ручной запуск: автостарта (systemd) нет, сам он нигде в системе не прописан и **сейчас не
|
||||
работает** — его надо запускать руками, когда понадобится.
|
||||
|
||||
---
|
||||
|
||||
## 7. Порядок работы: как это выглядит в жизни
|
||||
|
||||
```bash
|
||||
# 1. Завести тему — просто папка с markdown
|
||||
mkdir -p ~/notes/infra
|
||||
cat > ~/notes/infra/hosts.md <<'EOF'
|
||||
# Хосты
|
||||
|
||||
## Nexus
|
||||
Нексус доступен на 76.117, публикация — только через CI.
|
||||
|
||||
## Прокси
|
||||
Корпоративные домены проксируются кади на 76.132.
|
||||
EOF
|
||||
|
||||
# 2. Спросить
|
||||
$CLI search ~/notes/infra "где публикуются релизы"
|
||||
#> 0.031 /root/notes/infra/hosts.md:10 Nexus
|
||||
#> Нексус доступен на 76.117, публикация — только через CI.
|
||||
|
||||
# 3. Дописать заметку — и сразу спросить про неё, без переиндексации
|
||||
printf '\n## Свежий раздел\nуникальное_слово_дзынь_47\n' >> ~/notes/infra/hosts.md
|
||||
$CLI search ~/notes/infra "уникальное слово дзынь"
|
||||
#> 0.016 /root/notes/infra/hosts.md:10 Свежий раздел
|
||||
#> уникальное_слово_дзынь_47
|
||||
#> 0.016 /root/notes/infra/hosts.md:6 Прокси
|
||||
#> Корпоративные домены проксируются кади на 76.132.
|
||||
|
||||
# 4. Периодически освежать всё скопом (обычно не нужно — бывает после git pull)
|
||||
$CLI index ~/notes
|
||||
```
|
||||
|
||||
Записывать заметки можно **любым способом**: редактором, `>>`, агентом, `git pull`. `memo` не
|
||||
требует, чтобы записи шли через него.
|
||||
|
||||
---
|
||||
|
||||
## 8. Если что-то не так
|
||||
|
||||
**Поиск ничего не находит, хотя файлы на месте.**
|
||||
Проверьте, что папка — коллекция, то есть `.md` лежат в ней напрямую:
|
||||
|
||||
```bash
|
||||
$CLI status ~/notes # должны быть видны коллекции и число файлов
|
||||
find ~/notes -name '*.md' | head # файлы на месте?
|
||||
|
||||
$CLI status ~/notes/emptygroup
|
||||
#> коллекции не найдены
|
||||
```
|
||||
|
||||
Если `status` пишет `коллекции не найдены` — вы указали папку, в которой нет `.md` напрямую
|
||||
(например, общую группировку вроде `work`, где лежат только подпапки). Укажите корень библиотеки
|
||||
или папку-коллекцию.
|
||||
|
||||
**Свежая правка не находится.**
|
||||
Проверьте, что ищете в той же папке, где файл: поиск по `~/notes/infra` не увидит заметку из
|
||||
`~/notes/work`. Поиск по корню видит всё.
|
||||
|
||||
**Хочу начать с чистого листа.**
|
||||
Удалите индексы — данные не пострадают:
|
||||
|
||||
```bash
|
||||
find ~/notes -name .memo -type d -prune -exec rm -rf {} +
|
||||
$CLI index ~/notes
|
||||
```
|
||||
|
||||
**`database is locked`.**
|
||||
Значит, одновременно пишут два процесса. Подождите минуту и повторите; если повторяется — пришлите
|
||||
текст ошибки, это повод для отдельного разбирательства.
|
||||
|
||||
**Инструменты `memo_*` не появились у агента.**
|
||||
MCP-серверы читаются при старте Hermes: начните новую сессию. Проверьте, что путь к `memo-mcp`
|
||||
в конфиге верный и файл исполняемый.
|
||||
|
||||
**Агент долго не отвечает на первый запрос.**
|
||||
Скорее всего, скачивается модель (287 МБ) — в stderr `memo-mcp` идёт строка
|
||||
`модель не найдена ..., скачиваю ...`. Это разовое: скачайте заранее командой `$CLI model`
|
||||
или подложите файлы в `MEMO_MODEL_DIR`.
|
||||
|
||||
**Где посмотреть, что вообще происходит.**
|
||||
```bash
|
||||
$CLI status ~/notes # по каждой коллекции: файлов, чанков, когда индексировали
|
||||
find ~/notes -name index.db # где лежат индексы
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Что можно удалять без страха
|
||||
|
||||
| Путь | Что это | Удалять? |
|
||||
|---|---|---|
|
||||
| `~/notes/**/*.md` | **ваши заметки** | Нет — это ваши данные |
|
||||
| `~/notes/**/.memo/` | индексы | **Да**, пересоберутся командой `index` |
|
||||
| `models/siglip2/` | модель | Да, но придётся скачать заново |
|
||||
| `build/` | сборка | Да, но придётся пересобрать |
|
||||
|
||||
Правило: **всё, что не `.md`, — расходник.**
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
Локальная библиотека заметок с семантическим поиском.
|
||||
|
||||
> **Как этим пользоваться — [`MANUAL.md`](MANUAL.md).** Пошаговая инструкция для человека:
|
||||
> установка, команды, подключение к агенту, что мониторит демон, что можно удалять.
|
||||
> Остальной README — для разработки.
|
||||
|
||||
**Markdown на диске — истина, `.db` рядом — пересобираемый кэш.** Агент (или человек) пишет и
|
||||
читает обычные `.md`-файлы своими штатными средствами: `memo` не требует ни специального API записи,
|
||||
ни изменения привычек. Он следит за файловой системой, держит рядом с каждой папкой-темой
|
||||
@@ -10,17 +14,21 @@
|
||||
|
||||
## Статус
|
||||
|
||||
Проектирование завершено. Код не написан.
|
||||
Реализовано и принято на эталонном корпусе. 38 тестов, мутационная проверка, сквозной recall@5 = 20/20.
|
||||
|
||||
| Документ | Что внутри |
|
||||
| Что | Вердикт приёмки |
|
||||
|---|---|
|
||||
| [`docs/SPEC.md`](docs/SPEC.md) | полная спека: модель данных, индекс, маршрутизация, watcher, интерфейс, приёмка |
|
||||
| [`TASK.md`](TASK.md) | ТЗ для исполнителя (opencode): стек, схема БД, контракты, чего не делать |
|
||||
| [`TESTING.md`](TESTING.md) | тест-план: 6 приёмочных проверок, команды, признаки провала |
|
||||
| юнит-тесты всех модулей | ✅ |
|
||||
| мутационная проверка (13 мутаций) | ✅ все убиты |
|
||||
| индексация: 3 коллекции, повторный прогон — 0 обновлений | ✅ |
|
||||
| recall@5 на эталонном корпусе (sem 20/20, lex 3/3) | ✅ |
|
||||
| MCP-протокол живым клиентом + холодный старт | ✅ |
|
||||
|
||||
## Идея
|
||||
|
||||
- Коллекций (папок-тем) — сколько угодно; они не мешают друг другу.
|
||||
- **Коллекция — каталог, в котором есть `*.md` напрямую** (не в подкаталогах). Вложенные папки
|
||||
без своих заметок коллекциями не считаются — иначе одни и те же файлы индексируются по нескольку раз.
|
||||
- Индекс каждой коллекции живёт в её `.memo/index.db` и **удаляется без потерь** — пересобирается.
|
||||
- Поиск — обычный read-only инструмент; записи в markdown он не делает никогда.
|
||||
- Только локально: эмбеддинг на CPU, сеть не нужна ни при индексации, ни при поиске.
|
||||
@@ -36,6 +44,63 @@ memo-mcp # MCP-сервер (stdio): memo_search / memo_status / memo_reinde
|
||||
|
||||
`memo-core` не знает ни про watcher, ни про MCP — это драйверы поверх ядра.
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
```bash
|
||||
./gradlew installDist # собрать все дистрибутивы
|
||||
|
||||
CLI=memo-cli/build/install/memo/bin/memo
|
||||
$CLI model # скачать модель (необязательно — скачается сама при первом запуске)
|
||||
$CLI index ~/notes # проиндексировать дерево (коллекции найдутся сами)
|
||||
$CLI search ~/notes "чем чинят карточку в jellyfin"
|
||||
$CLI search ~/notes "76.132" --mode lex --json
|
||||
$CLI status ~/notes
|
||||
```
|
||||
|
||||
MCP-сервер (stdio), регистрируется как обычный MCP-сервер:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"memo": {
|
||||
"command": "/opt/memo/memo-mcp",
|
||||
"env": { "MEMO_MODEL_DIR": "/opt/memo/models/siglip2" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Инструменты: `memo_search(path, query, k, mode)`, `memo_status(path)`, `memo_reindex(path)`.
|
||||
`memo_search` **сам создаёт индекс**, если его ещё нет — можно просто писать `.md` и сразу искать.
|
||||
|
||||
Демон слежения (для правок мимо агента — людьми, git, сторонними редакторами):
|
||||
|
||||
```bash
|
||||
memo-watch/build/install/memo-watch/bin/memo-watch ~/notes
|
||||
```
|
||||
|
||||
## Приёмка
|
||||
|
||||
```bash
|
||||
bash scripts/accept.sh # полный прогон: сборка, тесты, мутации, индекс, recall, MCP
|
||||
bash scripts/accept.sh --fast # то же без мутационной проверки (самая долгая)
|
||||
```
|
||||
|
||||
Правило проекта: **«зелёная сборка» ничего не доказывает.** Тесты проверяются мутациями
|
||||
(`e2e/mutations.tsv`): боевой код ломается, и нужный тест обязан упасть. Выжившая мутация —
|
||||
дыра в покрытии, а не удача. Приёмка идёт на реальном корпусе (`scripts/make_e2e_corpus.sh`),
|
||||
а не на моках.
|
||||
|
||||
## Документы
|
||||
|
||||
| Документ | Что внутри |
|
||||
|---|---|
|
||||
| [`MANUAL.md`](MANUAL.md) | **инструкция для человека**: как пользоваться, команды, подключение к агенту |
|
||||
| [`docs/SPEC.md`](docs/SPEC.md) | полная спека: модель данных, индекс, маршрутизация, watcher, интерфейс, приёмка |
|
||||
| [`TASK.md`](TASK.md) | ТЗ для исполнителя: стек, схема БД, контракты, чего не делать |
|
||||
| [`TESTING.md`](TESTING.md) | тест-план: приёмочные проверки, команды, признаки провала |
|
||||
| [`docs/orders/`](docs/orders/) | журнал заказов исполнителю — с доказательствами дефектов, а не «сделай хорошо» |
|
||||
|
||||
## Зависимости (проверено 02.10.2026)
|
||||
|
||||
| Что | Координата | Откуда |
|
||||
|
||||
+7
-5
@@ -23,16 +23,18 @@ export MEMO_ROOT=/root/WORK/memo-e2e
|
||||
## T1. Индексация и её повтор
|
||||
|
||||
```bash
|
||||
cd /root/WORK/memo && ./gradlew :memo-cli:installDist -q
|
||||
CLI=./memo-cli/build/install/memo-cli/bin/memo
|
||||
cd /root/WORK/memo && ./gradlew installDist -q
|
||||
CLI=./memo-cli/build/install/memo/bin/memo
|
||||
$CLI index "$MEMO_ROOT"; $CLI status "$MEMO_ROOT"
|
||||
ls -l "$MEMO_ROOT"/*/.memo/index.db
|
||||
find "$MEMO_ROOT" -name index.db | sort # ожидается 3 пути, по одному на коллекцию
|
||||
$CLI index "$MEMO_ROOT" # второй прогон
|
||||
```
|
||||
|
||||
Ожидается: в каждой папке появился `.memo/index.db`; второй прогон **не переэмбеддивает** ни одного файла
|
||||
Ожидается: в каждой коллекции появился `.memo/index.db` (**ровно три** — вложенные папки без своих
|
||||
заметок коллекциями не считаются); второй прогон **не переэмбеддивает** ни одного файла
|
||||
(в выводе 0 обновлённых, `status` показывает те же `indexed_at`). Провал: нет `.memo/index.db`;
|
||||
второй прогон молча переиндексирует всё.
|
||||
баз больше трёх (значит, коллекцией считается каждая папка с .md внутри); второй прогон молча
|
||||
переиндексирует всё.
|
||||
|
||||
## T2. Смысловой поиск (вектор работает)
|
||||
|
||||
|
||||
@@ -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,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,76 @@
|
||||
Проект: /root/WORK/memo (Kotlin/JVM). Заказ по поиску.
|
||||
|
||||
Создать РОВНО ДВА новых файла:
|
||||
memo-core/src/main/kotlin/memo/core/Searcher.kt
|
||||
memo-core/src/test/kotlin/memo/core/SearcherTest.kt
|
||||
|
||||
## Searcher.kt
|
||||
|
||||
package memo.core
|
||||
|
||||
enum class SearchMode { HYBRID, LEX, VEC }
|
||||
|
||||
data class Hit(
|
||||
val path: String,
|
||||
val line: Int,
|
||||
val heading: String,
|
||||
val score: Double,
|
||||
val text: String, // текст чанка, обрезанный до 1200 символов
|
||||
)
|
||||
|
||||
fun interface RefreshHook { fun refresh(root: java.io.File) }
|
||||
|
||||
class Searcher(
|
||||
private val db: Db,
|
||||
private val embedder: Embedder,
|
||||
private val refresh: RefreshHook? = null,
|
||||
) {
|
||||
/** Ищет по индексу. Если задан [refresh] — сначала вызывает его для root
|
||||
* (догон свежих правок файлов до поиска). */
|
||||
fun search(root: java.io.File, query: String, k: Int = 8, mode: SearchMode = SearchMode.HYBRID): List<Hit>
|
||||
}
|
||||
|
||||
Правила реализации:
|
||||
1. Если refresh != null — вызвать refresh.refresh(root) ПЕРЕД любым запросом к базе.
|
||||
2. Лексический список (режимы LEX и HYBRID): разбить query на токены по всем символам, кроме букв
|
||||
и цифр (unicode: Character.isLetterOrDigit); оставить токены длиной >= 2; взять первые 8;
|
||||
собрать строку вида "ток1" OR "ток2" (каждый токен в двойных кавычках);
|
||||
если токенов нет — лексический список пуст, без ошибки.
|
||||
Запрос: SELECT rowid, bm25(chunks_fts) AS s FROM chunks_fts WHERE chunks_fts MATCH ? ORDER BY s LIMIT 32.
|
||||
Ошибку SQLite на MATCH не ронять наружу, а считать списком пустым.
|
||||
3. Векторный список (режимы VEC и HYBRID): embedder.embed(query) → FloatArray[768];
|
||||
запрос: SELECT rowid, distance FROM chunks_vec WHERE embedding MATCH ? ORDER BY distance LIMIT 32.
|
||||
Байндить вектор тем же способом, что в тесте vec0KnnRoundTrip (bindVector).
|
||||
4. Слияние RRF: score(rowid) = сумма по спискам 1.0/(60.0 + rank), где rank — позиция в списке, начиная с 1.
|
||||
В режиме LEX — только лексический список, в VEC — только векторный, в HYBRID — оба.
|
||||
5. По итоговому score взять первые k rowid; вычитать из chunks поля path, line, heading, text
|
||||
(SELECT path, line, heading, text FROM chunks WHERE id IN (...)); сохранить порядок по score убыванию.
|
||||
6. text обрезать до 1200 символов (если длиннее — substring(0,1200) без многоточий).
|
||||
7. Пустой индекс → пустой список, без исключений.
|
||||
|
||||
## SearcherTest.kt — ровно 5 тестов, имена ровно такие
|
||||
|
||||
Подготовка (общий помощник): временный каталог + Db(index.db) + db.init() + Embedder(модель из MEMO_MODEL_DIR
|
||||
как в CoreSmokeTest.embedderProduces768). Помощник `addChunk(path, heading, line, text)`, который вставляет
|
||||
строку в chunks, берёт last_insert_rowid() и вставляет тот же rowid в chunks_fts(text, heading)
|
||||
и в chunks_vec(embedding) с эмбеддингом embedder.embed(text).
|
||||
|
||||
1. `lexModeFindsExactValue` — 3 чанка, в одном текст "Прокси корпоративных доменов на 76.132";
|
||||
поиск с mode = LEX по запросу "76.132" → первый хит содержит "76.132".
|
||||
2. `vecModeFindsSemanticMatch` — 3 чанка на разные темы (сервер/книга/CI);
|
||||
запрос про «настройку выпуска приложения», mode = VEC → первый хит — чанк про CI.
|
||||
3. `lexModeIgnoresVectorOnlyMatch` — запрос, не имеющий ни одного общего токена с чанками
|
||||
(например, при тексте чанков "опрос", "случай", "знание" запрос "автомобиль");
|
||||
mode = LEX → пустой список, mode = VEC → непустой.
|
||||
4. `hybridCombinesBoth` — в индексе чанк с точным значением и чанк со смысловым совпадением;
|
||||
запрос, попадающий в оба, mode = HYBRID, k = 5 → в результате присутствуют оба чанка.
|
||||
5. `resultsRespectKAndTextLength` — 4 чанка; k = 2 → ровно 2 хита; у каждого text непустой и его длина <= 1200.
|
||||
|
||||
После: ./gradlew :memo-core:test --rerun-tasks — все тесты зелёные.
|
||||
Коммит: git add -A && git commit -m "core: гибридный поиск (BM25 + вектор + RRF)"
|
||||
|
||||
СТРОГИЕ ЗАПРЕТЫ:
|
||||
- Не выводить план текстом; сразу создавай файлы.
|
||||
- Не трогать существующие файлы (Chunker.kt, Indexer.kt, Embedder.kt, Db.kt, CoreSmokeTest.kt, ChunkerTest.kt, build.gradle.kts).
|
||||
- Не добавлять зависимости.
|
||||
- Не создавать другие файлы.
|
||||
@@ -0,0 +1,79 @@
|
||||
Проект: /root/WORK/memo (Kotlin/JVM). Заказ по CLI — руками проверяемые команды.
|
||||
|
||||
Правки:
|
||||
- memo-cli/build.gradle.kts: добавить application-плагин и зависимость на :memo-core
|
||||
- новый файл memo-cli/src/main/kotlin/memo/cli/Main.kt
|
||||
- новый файл memo-cli/src/test/kotlin/memo/cli/CliArgsTest.kt
|
||||
|
||||
## Настройка модуля memo-cli
|
||||
|
||||
build.gradle.kts модуля:
|
||||
plugins { kotlin("jvm"); application }
|
||||
application { mainClass.set("memo.cli.MainKt") }
|
||||
dependencies { implementation(project(":memo-core")) }
|
||||
|
||||
## Main.kt — контракт команд
|
||||
|
||||
fun main(args: Array<String>)
|
||||
|
||||
Разбор аргументов вынести в чистую функцию (тестируемую без процесса):
|
||||
sealed interface Cmd
|
||||
data class IndexCmd(val path: String) : Cmd
|
||||
data class SearchCmd(val path: String, val query: String, val k: Int, val mode: SearchMode, val json: Boolean) : Cmd
|
||||
data class StatusCmd(val path: String) : Cmd
|
||||
data class McpProbeCmd(val tool: String, val argsJson: String) : Cmd
|
||||
object HelpCmd : Cmd
|
||||
fun parseArgs(args: Array<String>): Cmd // при ошибке — HelpCmd
|
||||
|
||||
Правила разбора:
|
||||
- `index <path>` → IndexCmd
|
||||
- `search <path> <query> [--k N] [--mode hybrid|lex|vec] [--json]` → SearchCmd
|
||||
(по умолчанию k = 8, mode = HYBRID); неизвестный флаг или нечисловой --k → HelpCmd
|
||||
- `status <path>` → StatusCmd
|
||||
- `mcp-probe --tool <name> [--args <json>]` → McpProbeCmd (args по умолчанию "{}")
|
||||
- нет аргументов, `--help`, `-h`, неизвестная команда → HelpCmd
|
||||
|
||||
Поведение команд:
|
||||
1. `index <path>`: определить корень так — если path оканчивается на "/.memo" или содержит его, взять родителя;
|
||||
каталог `X` считается коллекцией, если в нём есть файлы *.md (искать на глубине до 2 от path).
|
||||
Для каждой найденной коллекции: создать каталог `<коллекция>/.memo`, базу `<коллекция>/.memo/index.db`,
|
||||
Db(...).init(), затем Indexer(db, embedder).indexTree(коллекция).
|
||||
Путь к модели: переменная окружения MEMO_MODEL_DIR, иначе /root/WORK/memo/models/siglip2.
|
||||
Печатать по строке на коллекцию: `индексировано: <N> обновлено, <файлов всего>` и в конце
|
||||
`итого: <сумма> обновлено в <число коллекций> коллекциях`.
|
||||
2. `search <path> <query>`: path — ЛИБО коллекция, ЛИБО файл *.md (тогда коллекция = его каталог),
|
||||
ЛИБО корень, содержащий коллекции (тогда искать по всем коллекциям внутри, объединив результаты
|
||||
по убыванию score и обрезав до k).
|
||||
Модель/эмбеддер обязателен: без него базы не откроются — это нормально.
|
||||
Refresh-хук передавать обязательно: он делает Indexer(db, embedder).indexTree(root) перед поиском.
|
||||
Без `--json` печатать человекочитаемо:
|
||||
`<score с 3 знаками> <path>:<line> <heading>`
|
||||
далее текст чанка с отступом 4 пробела, обрезанный до 300 символов.
|
||||
С `--json` печатать ОДИН JSON-массив, без пояснений и без лишних строк:
|
||||
[{"path":"...","line":12,"heading":"...","score":0.51,"text":"..."}]
|
||||
Если ничего не найдено — пустой JSON-массив `[]` (в режиме --json) и код возврата 0.
|
||||
3. `status <path>`: по каждой коллекции печатать `<<коллекция>>: файлов <N>, чанков <M>, индекс <дата ISO>`
|
||||
(дата — максимальный indexed_at из files).
|
||||
4. `mcp-probe --tool <name> --args <json>`: выполнить то же, что делает MCP-инструмент, и напечатать
|
||||
результат в формате MCP-ответа:
|
||||
{"content":[{"type":"text","text":"<результат>"}],"isError":false}
|
||||
Поддерживаемые инструменты на этом шаге: memo_search (аргументы path, query, k, mode) и memo_status (path).
|
||||
memo_reindex — вернуть isError: true с текстом "not implemented yet".
|
||||
|
||||
## CliArgsTest.kt — ровно 5 тестов, имена ровно такие
|
||||
|
||||
1. `indexCommandParsed` — ["index", "/tmp/x"] → IndexCmd с path "/tmp/x".
|
||||
2. `searchDefaultsAreK8HybridNoJson` — ["search", "/tmp/x", "вопрос"] → SearchCmd(k = 8, mode = HYBRID, json = false).
|
||||
3. `searchFlagsParsed` — ["search", "/tmp/x", "вопрос", "--k", "3", "--mode", "lex", "--json"] → k = 3, mode = LEX, json = true.
|
||||
4. `badKValueFallsBackToHelp` — ["search", "/tmp/x", "вопрос", "--k", "abc"] → HelpCmd.
|
||||
5. `unknownCommandFallsBackToHelp` — ["сломать"] → HelpCmd.
|
||||
|
||||
После: ./gradlew :memo-cli:test --rerun-tasks и ./gradlew :memo-cli:installDist — обе команды зелёные.
|
||||
Проверь руками, что бинарь появился: ls memo-cli/build/install/memo-cli/bin/memo
|
||||
Коммит: git add -A && git commit -m "cli: index/search/status/mcp-probe"
|
||||
|
||||
СТРОГИЕ ЗАПРЕТЫ:
|
||||
- Не выводить план текстом; сразу правь файлы.
|
||||
- Не трогать memo-core (ни src/main, ни src/test), кроме использования его публичного API.
|
||||
- Не добавлять зависимости, кроме :memo-core.
|
||||
- Не создавать другие файлы.
|
||||
@@ -0,0 +1,74 @@
|
||||
Проект: /root/WORK/memo (Kotlin/JVM). Заказ по слежению за файлами.
|
||||
|
||||
Создать РОВНО ДВА новых файла:
|
||||
memo-watch/build.gradle.kts (плагин kotlin("jvm") + зависимость на :memo-core)
|
||||
memo-watch/src/main/kotlin/memo/watch/Watcher.kt
|
||||
memo-watch/src/main/kotlin/memo/watch/WatchMain.kt
|
||||
и ОДИН тестовый:
|
||||
memo-watch/src/test/kotlin/memo/watch/WatcherTest.kt
|
||||
|
||||
## Watcher.kt
|
||||
|
||||
package memo.watch
|
||||
|
||||
class Watcher(
|
||||
private val collections: List<java.io.File>, // каталоги-коллекции
|
||||
private val index: (java.io.File) -> Unit, // функция индексации одной коллекции
|
||||
private val debounceMillis: Long = 500,
|
||||
private val reconcileMillis: Long = 10 * 60 * 1000,
|
||||
) : AutoCloseable {
|
||||
|
||||
/** Запускает фоновый поток: WatchService на каждый каталог рекурсивно,
|
||||
* debounce, плюс периодическая полная реконсиляция по mtime. */
|
||||
fun start()
|
||||
|
||||
/** Останавливает поток, закрывает WatchService. Идемпотентно. */
|
||||
override fun close()
|
||||
|
||||
/** Количество выполненных вызовов index(...) — для тестов. */
|
||||
val indexCalls: Int
|
||||
}
|
||||
|
||||
Требования:
|
||||
1. Регистрировать каждый каталог через FileSystems.getDefault().newWatchService() с ENTRY_CREATE,
|
||||
ENTRY_MODIFY, ENTRY_DELETE и при регистрации рекурсивно обойти подкаталоги (WatchService не рекурсивен).
|
||||
Каталоги, начинающиеся с '.', не обходить и не регистрировать.
|
||||
2. События собирать в Set<File> (какие коллекции затронуты). Поток: take() с таймаутом debounceMillis;
|
||||
при накоплении — по истечении debounce вызвать index(коллекция) для каждой затронутой коллекции.
|
||||
Не вызывать index десятки раз на одно сохранение файла.
|
||||
3. Раз в reconcileMillis — полная реконсиляция: обойти все файлы *.md во всех коллекциях,
|
||||
сравнить (path, mtime, size) со таблицей files, при расхождении вызвать index(коллекция).
|
||||
Реализацию сравнения НЕ дублировать: у :memo-core уже есть Indexer.indexFile, который сам
|
||||
пропускает неизменённые файлы и возвращает Boolean. Использовать его: index(коллекция) должен
|
||||
внутри вызывать Indexer.indexTree(коллекция).
|
||||
4. Все исключения в рабочем потоке не должны убивать поток: ловить, печатать в stderr, продолжать.
|
||||
5. close(): interrupt + закрыть WatchService, поток завершается не дольше 2 секунд.
|
||||
|
||||
## WatchMain.kt
|
||||
|
||||
fun main(args: Array<String>)
|
||||
- Аргумент: корень, содержащий коллекции (или одну коллекцию).
|
||||
- Определение коллекций — как в CLI (каталог с *.md на глубине до 2).
|
||||
- Создаёт Db(коллекция/.memo/index.db), .init(), Indexer(db, embedder) — по одному на коллекцию,
|
||||
Watcher с index = { коллекция -> indexer.indexTree(коллекция) }.
|
||||
- Печатает `наблюдаю: <коллекция>` по строке на каждую, затем блокируется навсегда до SIGINT.
|
||||
- Модель: MEMO_MODEL_DIR, иначе /root/WORK/memo/models/siglip2.
|
||||
|
||||
## WatcherTest.kt — ровно 3 теста, имена ровно такие
|
||||
|
||||
1. `modifyTriggersSingleIndexCall` — временный каталог с note.md; Watcher с index-счётчиком и
|
||||
debounce 200 мс; start(); правка файла; подождать 1.5 с; indexCalls == 1 (не больше!);
|
||||
close(). (Перед стартом дать watcher 300 мс прогреться.)
|
||||
2. `unchangedFileDoesNotTriggerIndex` — после прогрева ничего не менять 1.5 с → indexCalls == 0.
|
||||
3. `closeIsIdempotentAndStopsThread` — start(); close(); close(); повторный вызов не бросает исключение.
|
||||
|
||||
Тесты должны быть устойчивыми: ждать не фиксированным sleep, а опросом условия с таймаутом
|
||||
(например, до 5 с с шагом 100 мс), чтобы не флапать на медленной машине.
|
||||
|
||||
После: ./gradlew :memo-watch:test --rerun-tasks — зелёные; ./gradlew build -x test компилируется.
|
||||
Коммит: git add -A && git commit -m "watch: слежение за файлами (WatchService + debounce + реконсиляция)"
|
||||
|
||||
СТРОГИЕ ЗАПРЕТЫ:
|
||||
- Не выводить план текстом; сразу создавай файлы.
|
||||
- Не трогать memo-core и memo-cli.
|
||||
- Не добавлять зависимости, кроме :memo-core.
|
||||
@@ -0,0 +1,62 @@
|
||||
Проект: /root/WORK/memo (Kotlin/JVM). Заказ по MCP-серверу. Контракт уже в docs/orders/07-mcp.md —
|
||||
читать его как исходное ТЗ, а здесь перечислены обязательные требования и известные грабли.
|
||||
|
||||
Создать:
|
||||
memo-mcp/build.gradle.kts
|
||||
memo-mcp/src/main/kotlin/memo/mcp/McpServer.kt
|
||||
memo-mcp/src/test/kotlin/memo/mcp/McpProtocolTest.kt
|
||||
|
||||
## Зависимости модуля memo-mcp
|
||||
|
||||
plugins { kotlin("jvm"); application; kotlin("plugin.serialization") version "2.4.10" }
|
||||
application { mainClass.set("memo.mcp.McpServerKt") }
|
||||
dependencies {
|
||||
implementation(project(":memo-core"))
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||
}
|
||||
ВАЖНО: в kotlin("plugin.serialization") версию указать обязательно 2.4.10, иначе сборка упадёт на несовместимости версий плагина и Kotlin.
|
||||
Если сериализация начнёт мешать — НЕ добавляй её, собери JSON строками вручную (ручной JSON-рендер допустим и проще).
|
||||
|
||||
## McpServer.kt
|
||||
|
||||
fun main(args: Array<String>) — читает stdin построчно, на каждый запрос печатает в stdout РОВНО одну строку
|
||||
JSON-RPC 2.0. В stdout НИЧЕГО кроме JSON (включая баннеры/логи) — диагностика только в stderr.
|
||||
|
||||
Методы:
|
||||
- initialize → {"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"memo","version":"0.1.0"}}
|
||||
- notifications/initialized → ответ не отправлять (вернуть null)
|
||||
- tools/list → 3 инструмента: memo_search {path, query, k=8, mode="hybrid"}, memo_status {path}, memo_reindex {path}
|
||||
- tools/call {"name":..., "arguments":{...}} → {"content":[{"type":"text","text":"..."}],"isError":false}
|
||||
Ошибки (нет обязательного аргумента, неизвестный инструмент, исключение) → isError:true + текст, НЕ JSON-RPC error.
|
||||
- неизвестный метод → {"jsonrpc":"2.0","id":<id>,"error":{"code":-32601,"message":"Method not found"}}
|
||||
- уведомление (нет "id" в запросе) → ответ не отправлять
|
||||
|
||||
Тестируемые без процесса функции: handleRequest(line: String): String?, toolSearch(path, query, k, mode): String,
|
||||
toolStatus(path): String, toolReindex(path): String.
|
||||
|
||||
Поведение инструментов:
|
||||
- Коллекции определять ТАК ЖЕ, как CLI: см. memo-cli/src/main/kotlin/memo/cli/Main.kt — переиспользуй ту
|
||||
логику определения корня и коллекций (если это private — сделай в memo-core отдельный файл
|
||||
memo-core/src/main/kotlin/memo/core/Collections.kt с функциями
|
||||
`fun resolveCollection(path: java.io.File): java.io.File` и `fun findCollections(root: java.io.File): List<java.io.File>`,
|
||||
и переключи CLI и MCP на них; CLI при этом должен остаться с зелёными тестами).
|
||||
- toolSearch: Searcher с refresh-хуком (indexTree перед поиском), текст ответа — по строке на хит:
|
||||
`<path>:<line> <heading>` и следом текст чанка; пусто → "ничего не найдено".
|
||||
- toolStatus: `<коллекция>: файлов <N>, чанков <M>, индекс <ISO>`.
|
||||
- toolReindex: удалить `<коллекция>/.memo` целиком и переиндексировать; вернуть `переиндексировано файлов: <N>`.
|
||||
|
||||
## McpProtocolTest.kt — ровно 5 тестов, имена ровно такие
|
||||
|
||||
1. `initializeHandshake` — ответ содержит "2024-11-05" и "memo".
|
||||
2. `toolsListHasThreeTools` — ответ содержит memo_search, memo_status, memo_reindex.
|
||||
3. `unknownMethodReturns32601` — ответ содержит "-32601".
|
||||
4. `searchWithoutPathIsError` — tools/call memo_search с arguments {} → содержит "isError":true.
|
||||
5. `notificationProducesNoResponse` — handleRequest(уведомление notifications/initialized) == null.
|
||||
|
||||
После: ./gradlew :memo-mcp:test --rerun-tasks и ./gradlew :memo-mcp:installDist — зелёные.
|
||||
Коммит: git add -A && git commit -m "mcp: сервер memo_search/memo_status/memo_reindex"
|
||||
|
||||
СТРОГИЕ ЗАПРЕТЫ:
|
||||
- Не выводить план текстом; сразу создавай файлы.
|
||||
- Не трогать логику memo-core, кроме добавления Collections.kt и переключения CLI на него.
|
||||
- Ничего в stdout, кроме JSON-RPC строк.
|
||||
@@ -0,0 +1,78 @@
|
||||
Проект: /root/WORK/memo (Kotlin/JVM). Заказ на ИСПРАВЛЕНИЕ дефекта определения коллекций.
|
||||
|
||||
## Доказанный дефект
|
||||
|
||||
На корпусе /root/WORK/memo-e2e (3 папки с заметками: `infra`, `work/jira`, `life/books`,
|
||||
всего 12 .md) команда
|
||||
|
||||
memo index /root/WORK/memo-e2e
|
||||
|
||||
создаёт ШЕСТЬ баз индекса вместо трёх:
|
||||
|
||||
/root/WORK/memo-e2e/.memo/index.db
|
||||
/root/WORK/memo-e2e/infra/.memo/index.db
|
||||
/root/WORK/memo-e2e/life/.memo/index.db
|
||||
/root/WORK/memo-e2e/life/books/.memo/index.db
|
||||
/root/WORK/memo-e2e/work/.memo/index.db
|
||||
/root/WORK/memo-e2e/work/jira/.memo/index.db
|
||||
|
||||
и «итого: 24 обновлено в 6 коллекциях» — одни и те же заметки проиндексированы по 2-3 раза.
|
||||
Причина: каталог считается коллекцией, если .md-файлы есть где-то на глубине до 2 ВНУТРИ него.
|
||||
|
||||
## Правило, которое надо реализовать
|
||||
|
||||
Коллекция — это каталог, в котором есть хотя бы один файл `*.md` **непосредственно в нём самом**
|
||||
(не в подкаталогах). Всё остальное коллекцией не является.
|
||||
|
||||
Правки только в memo-core/src/main/kotlin/memo/core/Collections.kt (и, при необходимости,
|
||||
в местах его вызова в memo-cli и memo-mcp — но менять надо только вызовы, не логику):
|
||||
|
||||
1. `fun findCollections(root: java.io.File): List<java.io.File>` — рекурсивный обход root,
|
||||
возвращает ВСЕ каталоги (включая сам root, если подходит), в которых есть `*.md` напрямую.
|
||||
Скрытые каталоги (имя начинается с '.') пропускать, каталог `.memo` не обходить.
|
||||
Порядок — по пути, детерминированный.
|
||||
2. `fun resolveCollection(path: java.io.File): java.io.File` — если path каталог, вернуть ближайший
|
||||
каталог, содержащий .md напрямую: сам path, иначе подняться вверх по родителям до первого такого
|
||||
(но не выше, чем файловая система); если не найден — вернуть сам path.
|
||||
Если path — файл, вернуть его родительский каталог (file.parentFile).
|
||||
3. `fun isCollection(dir: java.io.File): Boolean` — вспомогательная, реализует правило из абзаца выше
|
||||
(ровно один уровень, без рекурсии).
|
||||
|
||||
## Тесты: добавить в memo-core/src/test/kotlin/memo/core/ новый файл CollectionsTest.kt
|
||||
|
||||
Ровно 4 теста, имена ровно такие:
|
||||
1. `findCollectionsReturnsOnlyDirsWithDirectMarkdown` — временный корень с такой структурой:
|
||||
`root/a.md`, `root/sub/b.md`, `root/sub/deep/c.md`, `root/empty/` (без .md), `root/nested/only/deep/d.md`
|
||||
→ ровно 4 коллекции: root, root/sub, root/sub/deep, root/nested/only/deep. Каталогов `empty`, `nested`,
|
||||
`nested/only` в результате быть НЕ должно.
|
||||
2. `parentDirWithoutDirectMarkdownIsNotCollection` — структура `root/top/inner/x.md` →
|
||||
findCollections даёт ровно один элемент — `root/top/inner`.
|
||||
3. `resolveCollectionRaisesToNearestWithMarkdown` — для файла `root/top/inner/x.md` resolveCollection
|
||||
возвращает `root/top/inner`; для каталога `root/top` (внутри .md нет, есть только в inner) —
|
||||
тоже `root/top/inner`.
|
||||
4. `hiddenDirsAreSkipped` — структура `root/.hidden/a.md`, `root/vis/b.md` → ровно одна коллекция `root/vis`.
|
||||
Плюс: если в корне лежит `root/.memo/index.db`, это ничего не меняет и не ломает результат.
|
||||
|
||||
## Обязательная сквозная проверка (сделать самому, приложить вывод)
|
||||
|
||||
cd /root/WORK/memo
|
||||
./gradlew :memo-cli:installDist -q
|
||||
rm -rf /root/WORK/memo-e2e/infra/.memo /root/WORK/memo-e2e/work/jira/.memo /root/WORK/memo-e2e/life/books/.memo \
|
||||
/root/WORK/memo-e2e/.memo /root/WORK/memo-e2e/work/.memo /root/WORK/memo-e2e/life/.memo
|
||||
memo-cli/build/install/memo/bin/memo index /root/WORK/memo-e2e
|
||||
find /root/WORK/memo-e2e -name index.db | sort
|
||||
|
||||
Ожидается РОВНО три пути:
|
||||
/root/WORK/memo-e2e/infra/.memo/index.db
|
||||
/root/WORK/memo-e2e/life/books/.memo/index.db
|
||||
/root/WORK/memo-e2e/work/jira/.memo/index.db
|
||||
и в выводе index — «итого: 12 обновлено в 3 коллекциях». Второй прогон index — «итого: 0 обновлено».
|
||||
|
||||
После: ./gradlew test --rerun-tasks — ВСЕ тесты проекта зелёные (включая те 5+memo-cli+memo-mcp).
|
||||
Коммит: git add -A && git commit -m "core: коллекция — каталог с markdown напрямую; исправлен тройной обход"
|
||||
|
||||
СТРОГИЕ ЗАПРЕТЫ:
|
||||
- Не выводить план текстом; сразу правь файлы.
|
||||
- Не менять логику Searcher, Indexer, Chunker, Embedder, Db.
|
||||
- Не менять контракт CLI (имена команд и флагов) и MCP-инструментов.
|
||||
- Не добавлять зависимости.
|
||||
@@ -0,0 +1,72 @@
|
||||
Проект: /root/WORK/memo (Kotlin/JVM). Заказ по холодному старту MCP-поиска.
|
||||
|
||||
## Доказанные дефекты (воспроизведено зондом)
|
||||
|
||||
1. **Пустой старт не работает.** Команда
|
||||
rm -rf /root/WORK/memo-e2e/*/.memo
|
||||
python3 scripts/mcp_probe.py --cmd "./memo-mcp/build/install/memo-mcp/bin/memo-mcp" \
|
||||
--call memo_search --args '{"path":"/root/WORK/memo-e2e/infra","query":"основа для разрешения имён в сети","k":3}'
|
||||
возвращает текст `коллекции не найдены`, потому что `selectTargets()` оставляет только коллекции,
|
||||
у которых УЖЕ существует `.memo/index.db`. То есть главный сценарий — агент пишет .md штатными
|
||||
инструментами и сразу ищет — не работает: сначала надо вручную звать CLI `index`.
|
||||
Ожидаемое поведение: `memo_search` сам создаёт `.memo/index.db` и индексирует коллекцию,
|
||||
если индекса ещё нет, и возвращает результаты.
|
||||
|
||||
2. **Модель грузится по разу на каждую коллекцию.** В `toolSearch` `Embedder(...)` создаётся внутри цикла
|
||||
по коллекциям. На трёх коллекциях это три загрузки модели.
|
||||
|
||||
## Что сделать
|
||||
|
||||
Правки ТОЛЬКО в `memo-mcp/src/main/kotlin/memo/mcp/McpServer.kt` (+ новый тестовый файл). Логику memo-core не менять.
|
||||
|
||||
1. `toolSearch(path, query, k, mode)`:
|
||||
- Определить цели так: если `File(path)` сам является коллекцией (в нём есть `*.md` напрямую) —
|
||||
цель ровно он; иначе — все коллекции внутри `findCollections(File(path))`; если и их нет —
|
||||
вернуть `коллекции не найдены`.
|
||||
- Для каждой цели: если `<коллекция>/.memo/index.db` не существует — создать каталог `.memo`,
|
||||
`Db(...)`, `db.init()`, проиндексировать `Indexer(db, embedder).indexTree(коллекция)`.
|
||||
Затем обычный поиск с refresh-хуком.
|
||||
- `Embedder` создать ОДИН РАЗ до цикла по коллекциям и закрыть один раз после (try/finally).
|
||||
`Db` — по одному на коллекцию, как сейчас.
|
||||
- Не глотать ошибки молча: если по коллекции поиск упал, дописать в результат строку
|
||||
`ошибка в <коллекция>: <текст>` (в конец возвращаемого текста), остальные коллекции всё равно обрабатывать.
|
||||
2. `toolStatus` — оставить как есть (статус по несуществующему индексу законно говорит «нет индекса»).
|
||||
3. `toolReindex` — оставить как есть, но `Embedder` тоже вынести из цикла (один на все коллекции).
|
||||
|
||||
## Тест: новый файл memo-mcp/src/test/kotlin/memo/mcp/McpColdStartTest.kt
|
||||
|
||||
Ровно 2 теста, имена ровно такие:
|
||||
|
||||
1. `searchIndexesCollectionOnColdStart` — временный каталог-коллекция с двумя .md (например
|
||||
"# Прокси\nвнутренние домены ходят через шлюз 76.1" и "# Прочее\nсовсем другая заметка про книгу"),
|
||||
модель берётся из MEMO_MODEL_DIR (как в CoreSmokeTest). До вызова `.memo` НЕ существует.
|
||||
Вызвать `toolSearch(dir.absolutePath, "внутренние домены шлюз", 5, SearchMode.HYBRID)`.
|
||||
Проверить: (а) в ответе нет подстроки "коллекции не найдены"; (б) ответ содержит "76.1";
|
||||
(в) после вызова `File(dir, ".memo/index.db").exists()` == true.
|
||||
2. `searchOnEmptyDirReportsNoCollections` — пустой временный каталог без .md:
|
||||
ответ содержит "коллекции не найдены", каталог `.memo` не создан.
|
||||
|
||||
Тесты должны работать без запуска процесса (через `toolSearch` напрямую).
|
||||
|
||||
## Обязательная сквозная проверка (приложить вывод)
|
||||
|
||||
cd /root/WORK/memo
|
||||
./gradlew :memo-mcp:installDist -q
|
||||
cp -r /root/WORK/memo-e2e /tmp/memo-cold && rm -rf /tmp/memo-cold/*/.memo /tmp/memo-cold/.memo
|
||||
MEMO_MODEL_DIR=/root/WORK/memo/models/siglip2 python3 scripts/mcp_probe.py \
|
||||
--cmd "./memo-mcp/build/install/memo-mcp/bin/memo-mcp" \
|
||||
--call memo_search --args '{"path":"/tmp/memo-cold/infra","query":"основа для разрешения имён в сети","k":3}'
|
||||
MEMO_MODEL_DIR=/root/WORK/memo/models/siglip2 python3 scripts/mcp_probe.py \
|
||||
--cmd "./memo-mcp/build/install/memo-mcp/bin/memo-mcp" \
|
||||
--call memo_status --args '{"path":"/tmp/memo-cold/infra"}'
|
||||
|
||||
Ожидается: поиск отдаёт непустой текст с `infra/domains.md` (или infra/hosts.md) и без "коллекции не найдены";
|
||||
status после этого показывает ненулевые файлы и чанки. Также приложить `find /tmp/memo-cold -name index.db`.
|
||||
|
||||
После: ./gradlew test --rerun-tasks — ВСЕ тесты проекта зелёные.
|
||||
Коммит: git add -A && git commit -m "mcp: поиск индексирует коллекцию на холодном старте"
|
||||
|
||||
СТРОГИЕ ЗАПРЕТЫ:
|
||||
- Не выводить план текстом; сразу правь файлы.
|
||||
- Не менять memo-core, memo-cli, memo-watch.
|
||||
- Не добавлять зависимости.
|
||||
@@ -0,0 +1,61 @@
|
||||
Проект: /root/WORK/memo (Kotlin/JVM). Заказ на закрытие ТРЁХ дыр в покрытии, доказанных мутационной проверкой
|
||||
(`python3 e2e/mutation_check.py --gradle ./gradlew`). Мутация ломает боевой код, нужный тест обязан упасть.
|
||||
Сейчас три мутации выживают — тесты не проверяют то, ради чего написаны.
|
||||
|
||||
Правки — ТОЛЬКО в тестовых файлах. Боевой код (src/main) НЕ менять ни в одном модуле.
|
||||
|
||||
## Дыра 1. memo-core/src/test/kotlin/memo/core/CollectionsTest.kt
|
||||
|
||||
Мутация `it.isFile && it.extension == "md"` -> `it.name.endsWith("md")` в Collections.kt ВЫЖИВАЕТ:
|
||||
в тесте все файлы — .md, поэтому подмена незаметна.
|
||||
|
||||
Дополнить тест `findCollectionsReturnsOnlyDirsWithDirectMarkdown` (имя и остальные проверки не менять):
|
||||
- добавить каталог `File(root, "fakemd").mkdirs()` — имя каталога заканчивается на "md", но .md-файлов
|
||||
внутри него нет; этот каталог НЕ должен попасть в результат (добавить проверку);
|
||||
- добавить файл `File(root, "readme.txt").writeText("не markdown")` — не должен ничего менять;
|
||||
- в конце теста добавить проверку: результат НЕ содержит `File(root, "fakemd").absolutePath`.
|
||||
|
||||
## Дыра 2. memo-core/src/test/kotlin/memo/core/SearcherTest.kt
|
||||
|
||||
Мутация `if (mode == SearchMode.LEX || mode == SearchMode.HYBRID)` -> `if (mode == SearchMode.LEX)` ВЫЖИВАЕТ:
|
||||
в тесте `hybridCombinesBoth` «смысловой» чанк лексически содержит слова запроса («выпуск приложения»),
|
||||
поэтому его находит и чистый BM25, и вклад вектора ничем не доказан.
|
||||
|
||||
Дополнить тест `hybridCombinesBoth`:
|
||||
- заменить текст второго чанка на такой, у которого НЕТ общих токенов с запросом:
|
||||
запрос оставить `"выпуск приложения 76.132"` (то есть токены: выпуск, приложения, 76, 132),
|
||||
а чанк `/sem.md` сделать: `"Конвейер поставки продуктов: компиляция, испытания, выкладка в кластер"`.
|
||||
Убедиться, что ни один токен запроса (кроме служебных длиной < 2) не встречается в этом тексте;
|
||||
- оставить проверку `paths.contains("/exact.md")` и `paths.contains("/sem.md")` — теперь вторая проверка
|
||||
выполнима ТОЛЬКО если векторный список участвует в слиянии.
|
||||
Имя теста и остальные тесты не менять.
|
||||
|
||||
## Дыра 3. memo-watch/src/test/kotlin/memo/watch/WatcherTest.kt
|
||||
|
||||
Мутация `_indexCalls.incrementAndGet()` -> `_indexCalls.get()` в Watcher.kt ВЫЖИВАЕТ:
|
||||
тест `modifyTriggersSingleIndexCall` считает вызовы своим собственным AtomicInteger в лямбде `index = {...}`
|
||||
и встроенный счётчик `watcher.indexCalls` не проверяет вообще.
|
||||
|
||||
Дополнить тест `modifyTriggersSingleIndexCall` (имя и существующие проверки не менять):
|
||||
- в конце, рядом с `assertEquals(1, counter.get(), ...)`, добавить:
|
||||
`assertTrue(watcher.indexCalls >= 1, "встроенный счётчик indexCalls не растёт, получено ${watcher.indexCalls}")`.
|
||||
|
||||
## Обязательная проверка (приложить вывод)
|
||||
|
||||
cd /root/WORK/memo
|
||||
python3 e2e/mutation_check.py --gradle ./gradlew
|
||||
|
||||
Ожидается строка `ВСЕ МУТАЦИИ УБИТЫ` в конце. Если какая-то мутация выжила — доработать тест,
|
||||
пока все 13 не будут убиты. Затем:
|
||||
|
||||
./gradlew test --rerun-tasks
|
||||
|
||||
— все тесты зелёные.
|
||||
|
||||
Коммит: git add -A && git commit -m "tests: закрыты три дыры в покрытии, найденные мутационным анализом"
|
||||
|
||||
СТРОГИЕ ЗАПРЕТЫ:
|
||||
- Не выводить план текстом; сразу правь файлы.
|
||||
- НЕ менять боевой код ни в memo-core, memo-cli, memo-mcp, memo-watch.
|
||||
- Не менять имена тестов, не удалять существующие проверки.
|
||||
- Не добавлять зависимости.
|
||||
@@ -0,0 +1,93 @@
|
||||
Проект: /root/WORK/memo (Kotlin/JVM). Заказ на исправление ТРЁХ доказанных дефектов демона `memo-watch`.
|
||||
|
||||
## Доказанные дефекты (воспроизведено на живом корпусе)
|
||||
|
||||
Корпус `/root/WORK/memo-e2e-watch2` — копия эталонного (`infra`, `life/books`, `work/jira`, 12 .md).
|
||||
Все `.memo` предварительно удалены. Запуск:
|
||||
MEMO_MODEL_DIR=/root/WORK/memo/models/siglip2 \
|
||||
memo-watch/build/install/memo-watch/bin/memo-watch /root/WORK/memo-e2e-watch2
|
||||
|
||||
1. **Создаёт 6 баз вместо 3.** Вывод `наблюдаю:` перечисляет корень, `infra`, `life`, `work`,
|
||||
`life/books`, `work/jira` и всем заводит `.memo/index.db`. Причина — своя локальная копия
|
||||
`discoverCollections()` в `WatchMain.kt` (walkTopDown + depth 2 «есть .md где-то внутри»);
|
||||
в `memo-core` эту же ошибку уже вылечили (`findCollections` = только каталог с *.md НАПРЯМУЮ),
|
||||
но watcher остался на старой копии.
|
||||
2. **Не индексирует при старте.** Сразу после запуска (без правок файлов) в базах 0 файлов и 0 чанков;
|
||||
наполняется только та коллекция, в которой потом что-то изменилось. Проверка:
|
||||
правка одного файла в `infra` → в `infra/.memo` появились файлов 4, чанков 12, а `life/books`
|
||||
и `work/jira` остались с нулями. Никакого первичного прохода при старте нет.
|
||||
3. **Держит все базы открытыми постоянно.** `WatchMain` открывает `Db` и `Embedder` на каждую
|
||||
коллекцию и не закрывает до SIGINT; тот же `.db` в это время открывают CLI и MCP.
|
||||
Это надо проверить на отсутствие «database is locked» (одновременные watcher + поиск).
|
||||
|
||||
## Что сделать
|
||||
|
||||
Правки ТОЛЬКО в модуле `memo-watch` (`src/main` и `src/test`). `memo-core` не менять.
|
||||
|
||||
1. **Убрать копию логики.** В `WatchMain.kt` удалить локальную `discoverCollections` и функцию
|
||||
определения корня по `.memo`; использовать из ядра:
|
||||
`memo.core.findCollections(base)` и `memo.core.resolveCollection(raw)`.
|
||||
Поведение: передан один каталог-коллекция → он один; передан корень → все коллекции внутри.
|
||||
2. **Первичный проход при старте.** После `watcher.start()` (или до него) выполнить для каждой
|
||||
коллекции `indexer.indexTree(coll)` один раз, чтобы индекс был полным сразу, без ожидания событий.
|
||||
Вывести в stdout по строке: `индексирую: <путь> -> обновлено <N>`. Затем уже строки `наблюдаю:`.
|
||||
3. **Устойчивость к параллельному доступу.** Включить в `Db` уже есть WAL — не менять. Вместо этого
|
||||
добавить в `WatchMain` обработку ошибок записи так, чтобы `database is locked` не убивал демон:
|
||||
при ошибке печатать в stderr и повторять попытку один раз через 1 секунду (достаточно локальной
|
||||
обёртки вокруг `indexer.indexTree`). Не менять код ядра.
|
||||
|
||||
## Тесты: дополнить memo-watch/src/test/kotlin/memo/watch/WatcherTest.kt и добавить новый файл
|
||||
|
||||
Новый файл `memo-watch/src/test/kotlin/memo/watch/WatchMainLogicTest.kt`, ровно 2 теста:
|
||||
1. `collectionsFoundByCoreRule` — дерево: `root/a.md`, `root/sub/b.md`, `root/nested/only/deep/d.md`,
|
||||
`root/empty/` → `findCollections(root)` даёт ровно три каталога (root, root/sub, root/nested/only/deep),
|
||||
и среди них НЕТ `root/nested` и `root/nested/only`.
|
||||
2. `startupIndexPassFillsEveryCollection` — временный корень с двумя коллекциями (`root/c1/x.md`,
|
||||
`root/c2/y.md`), у каждой создаётся `Db` + `.memo`, вызывается `Indexer.indexTree(coll)` по разу
|
||||
(как это делает первичный проход), после чего в обеих базах
|
||||
`SELECT COUNT(*) FROM chunks` > 0 и `SELECT COUNT(*) FROM files` == 1.
|
||||
Модель брать из `MEMO_MODEL_DIR` (как в CoreSmokeTest).
|
||||
|
||||
## Обязательная сквозная проверка (приложить вывод)
|
||||
|
||||
```bash
|
||||
cd /root/WORK/memo
|
||||
./gradlew :memo-watch:installDist -q
|
||||
rm -rf /tmp/mw && cp -r /root/WORK/memo-e2e /tmp/mw
|
||||
find /tmp/mw -name .memo -type d -prune -exec rm -rf {} + 2>/dev/null
|
||||
|
||||
# 1) старт: 3 базы, все наполнены сразу
|
||||
MEMO_MODEL_DIR=/root/WORK/memo/models/siglip2 \
|
||||
memo-watch/build/install/memo-watch/bin/memo-watch /tmp/mw > /tmp/mw.log 2>&1 &
|
||||
WPID=$!
|
||||
sleep 40
|
||||
echo "--- базы:"; find /tmp/mw -name index.db | sort
|
||||
echo "--- чанки сразу после старта:"
|
||||
python3 - <<'EOF'
|
||||
import sqlite3, glob
|
||||
for db in sorted(glob.glob("/tmp/mw/**/.memo/index.db", recursive=True)):
|
||||
c = sqlite3.connect("file:"+db+"?mode=ro", uri=True)
|
||||
print(db.replace("/tmp/mw",""), "файлов", c.execute("SELECT COUNT(*) FROM files").fetchone()[0],
|
||||
"чанков", c.execute("SELECT COUNT(*) FROM chunks").fetchone()[0])
|
||||
c.close()
|
||||
EOF
|
||||
|
||||
# 2) параллельно с работающим демоном ищем тем же ядром — не должно быть locked
|
||||
memo-cli/build/install/memo/bin/memo search /tmp/mw/life/books "кто ведёт рассказ в романе" --k 3 --json | head -c 400
|
||||
echo
|
||||
kill $WPID 2>/dev/null; wait $WPID 2>/dev/null
|
||||
cat /tmp/mw.log
|
||||
```
|
||||
|
||||
Ожидается: РОВНО три пути `index.db`; у каждой коллекции ненулевые файлы и чанки СРАЗУ после старта;
|
||||
поиск при работающем демоне отвечает без ошибки `database is locked`; в логе — строки
|
||||
`индексирую: ... -> обновлено N` и `наблюдаю: ...` (по три каждого вида).
|
||||
|
||||
После: ./gradlew test --rerun-tasks — все тесты проекта зелёные.
|
||||
Коммит: git add -A && git commit -m "watch: первичный проход при старте, коллекции по правилу ядра"
|
||||
|
||||
СТРОГИЕ ЗАПРЕТЫ:
|
||||
- Не выводить план текстом; сразу правь файлы.
|
||||
- Не менять memo-core, memo-cli, memo-mcp.
|
||||
- Не менять смысл существующих тестов WatcherTest.
|
||||
- Не добавлять зависимости.
|
||||
@@ -0,0 +1,162 @@
|
||||
Проект: /root/WORK/memo (Kotlin/JVM). Заказ: скачивание модели — командой и автоматически.
|
||||
|
||||
## Зачем
|
||||
|
||||
Сейчас модель (287 МБ) надо скачивать руками curl-ом по инструкции. Это единственный шаг,
|
||||
который ломает «взял и пользуешься». Нужно: (1) явная команда «просто скачай модель»,
|
||||
(2) автоматическое скачивание, когда модель понадобилась, а её нет.
|
||||
|
||||
## Что уже известно про сервер (проверено)
|
||||
|
||||
```bash
|
||||
curl -sSIL http://static.binom.pw/models/siglip2/text_model_int8.onnx
|
||||
# HTTP/1.1 200 OK, Content-Length: 283438275, Etag: "6a9d4b7c-10e4ecc3"
|
||||
curl -sSIL http://static.binom.pw/models/siglip2/tokenizer.model
|
||||
# HTTP/1.1 200 OK, Content-Length: 4241003
|
||||
curl -sS -r 100-199 -D - http://static.binom.pw/models/siglip2/tokenizer.model
|
||||
# HTTP/1.1 206 Partial Content, Content-Range: bytes 100-199/4241003, Accept-Ranges: bytes
|
||||
```
|
||||
|
||||
Сервер поддерживает **докачку (Range)** и отдаёт **Content-Length**. Файлов контрольных сумм
|
||||
на сервере НЕТ (`.sha256` → 404), листинг каталога закрыт (403). Значит, проверка целостности —
|
||||
по размеру из `Content-Length`, а не по хэшу.
|
||||
|
||||
## Что сделать
|
||||
|
||||
### 1. `memo-core`: `ModelStore.kt` (новый файл, пакет `memo.core`)
|
||||
|
||||
Объект/класс без внешних зависимостей — только JDK 21 (`java.net.http.HttpClient`).
|
||||
|
||||
```kotlin
|
||||
object ModelStore {
|
||||
const val DEFAULT_BASE_URL = "http://static.binom.pw/models/siglip2"
|
||||
val FILES = listOf("text_model_int8.onnx", "tokenizer.model")
|
||||
|
||||
data class Result(val downloaded: List<String>, val skipped: List<String>, val bytes: Long)
|
||||
|
||||
/** Гарантирует наличие всех файлов модели в dir. Возвращает имена скачанных/пропущенных. */
|
||||
fun ensure(
|
||||
dir: java.io.File,
|
||||
baseUrl: String = DEFAULT_BASE_URL,
|
||||
force: Boolean = false,
|
||||
log: (String) -> Unit = {},
|
||||
timeoutMillis: Long = 60_000,
|
||||
): Result
|
||||
|
||||
/** Пути к модели в том же порядке (modelPath, tokenizerPath). */
|
||||
fun paths(dir: java.io.File): Pair<String, String>
|
||||
}
|
||||
```
|
||||
|
||||
Требования к `ensure`:
|
||||
|
||||
1. **Пропуск без сети.** Если файл существует, непустой и не `force` → он в `skipped`, **ни одного
|
||||
сетевого запроса** по нему не делается. Это важно: обычный запуск поиска офлайн обязан работать.
|
||||
2. **Докачка.** Качать в `<имя>.part` рядом с целевым файлом. Если `.part` уже есть и непустой —
|
||||
продолжить с его размера, отправив `Range: bytes=<size>-`; ответ `206` → дописывать в конец;
|
||||
ответ `200` (сервер проигнорировал Range) → начать файл заново.
|
||||
3. **Проверка размера.** После завершения сравнить размер с `Content-Length` из того же ответа
|
||||
(или из `HEAD`). Не совпало → удалить `.part`, бросить `IllegalStateException` с обоими числами.
|
||||
4. **Атомарность.** Только после успешной проверки `.part` переименовывается в целевое имя
|
||||
(`File.renameTo`), чтобы оборванная закачка не оставила «валидный на вид» файл.
|
||||
5. **Прогресс в stderr**, не чаще раза в 2 секунды: `скачиваю <имя>: 45% (128 МБ / 283 МБ)`.
|
||||
В stdout — ничего.
|
||||
6. **Директорию создать** (`mkdirs`), если её нет.
|
||||
7. Ошибки сети/HTTP-кода (не 200/206) — исключение с понятным текстом и именем файла.
|
||||
|
||||
### 2. `memo-cli`: команда `memo model`
|
||||
|
||||
```
|
||||
memo model [--dir <путь>] [--url <база>] [--force]
|
||||
```
|
||||
|
||||
- без `--dir` — директория из `MEMO_MODEL_DIR` (или `/root/WORK/memo/models/siglip2`, как уже
|
||||
заведено в `modelPaths()`);
|
||||
- печатает по-русски, что скачано, что уже было, сколько байт;
|
||||
- exit 0 при успехе, 1 при ошибке.
|
||||
- Добавить эту команду в текст `usage` (он печатается в `HelpCmd`).
|
||||
|
||||
### 3. Автоматическое скачивание
|
||||
|
||||
Во всех точках, где модель нужна (`memo-cli` index/search/status, `memo-mcp` при старте):
|
||||
перед созданием `Embedder` вызвать `ModelStore.ensure(dir)` — **если файлов нет или они пустые**.
|
||||
Если файлы на месте — вызова сети не происходит (см. п.1), поведение не меняется.
|
||||
|
||||
Отключение: переменная `MEMO_MODEL_AUTO_DOWNLOAD=0` → скачивание не выполняется, а при отсутствии
|
||||
модели выдаётся внятная ошибка: `модель не найдена в <dir>; запустите: memo model`.
|
||||
|
||||
Сообщение о скачивании выводить в stderr с первой строкой вида
|
||||
`модель не найдена в <dir>, скачиваю с <url> (≈287 МБ, один раз)`.
|
||||
|
||||
### 4. Тесты: `memo-core/src/test/kotlin/memo/core/ModelStoreTest.kt` (новый файл)
|
||||
|
||||
Поднять **локальный HTTP-сервер на JDK** (`com.sun.net.httpserver.HttpServer`, без зависимостей),
|
||||
слушать на `127.0.0.1` со случайным портом. Никакого выхода в интернет.
|
||||
|
||||
1. `downloadsMissingFiles` — на диске пусто, «сервер» отдаёт 2 файла → оба скачаны, размеры совпали,
|
||||
содержимое совпало побайтно, `.part` не остался.
|
||||
2. `skipsExistingWithoutNetwork` — файлы уже есть; **сервер считать запросы (AtomicInteger)** →
|
||||
после `ensure` счётчик равен **0**, оба файла в `skipped`.
|
||||
3. `resumesPartialDownload` — в `.part` лежит первая половина файла; сервер на запрос с `Range`
|
||||
отвечает `206` с хвостом → итоговый файл полный, побайтно равен исходному.
|
||||
4. `failsOnSizeMismatch` — сервер объявляет `Content-Length` больше, чем реально отдаёт, и рвёт
|
||||
соединение → `ensure` бросает исключение, целевого файла нет, `.part` удалён.
|
||||
5. `forceRedownloads` — файл есть, `force = true` → скачан заново.
|
||||
|
||||
## Обязательная сквозная проверка (приложить вывод)
|
||||
|
||||
```bash
|
||||
cd /root/WORK/memo
|
||||
./gradlew installDist -q
|
||||
CLI=memo-cli/build/install/memo/bin/memo
|
||||
|
||||
# 1) пустая директория: команда model скачивает (база — локальный сервер, НЕ интернет, чтобы
|
||||
# проверка была воспроизводимой; для этого в проверке используем свой http-сервер на python)
|
||||
rm -rf /tmp/memo-models && mkdir -p /tmp/memo-models/src
|
||||
cp models/siglip2/* /tmp/memo-models/src/
|
||||
cd /tmp/memo-models/src && python3 -m http.server 18999 > /tmp/memo-http.log 2>&1 &
|
||||
HPID=$!
|
||||
sleep 2
|
||||
cd /root/WORK/memo
|
||||
MEMO_MODEL_DIR=/tmp/memo-models/dst $CLI model --dir /tmp/memo-models/dst --url http://127.0.0.1:18999
|
||||
echo "--- что появилось:"; ls -l /tmp/memo-models/dst; md5sum /tmp/memo-models/dst/* models/siglip2/*
|
||||
|
||||
# 2) повторный запуск: ничего не качает
|
||||
MEMO_MODEL_DIR=/tmp/memo-models/dst $CLI model --dir /tmp/memo-models/dst --url http://127.0.0.1:18999
|
||||
|
||||
# 3) автоскачивание: поиск на пустом месте сам тянет модель
|
||||
rm -rf /tmp/memo-models/dst2
|
||||
MEMO_MODEL_DIR=/tmp/memo-models/dst2 $CLI index /root/WORK/memo-e2e 2>&1 | head -4
|
||||
ls -l /tmp/memo-models/dst2
|
||||
|
||||
# 4) отключение автоскачивания
|
||||
rm -rf /tmp/memo-models/dst3
|
||||
MEMO_MODEL_AUTO_DOWNLOAD=0 MEMO_MODEL_DIR=/tmp/memo-models/dst3 $CLI index /root/WORK/memo-e2e; echo "exit=$?"
|
||||
|
||||
kill $HPID 2>/dev/null
|
||||
```
|
||||
|
||||
Ожидается: (1) оба файла скачаны, md5 совпадают с оригиналом, `.part` нет; (2) во второй раз
|
||||
«уже на месте», сеть не тронута; (3) при автоскачивании модель появилась и индексация прошла;
|
||||
(4) с `MEMO_MODEL_AUTO_DOWNLOAD=0` — ошибка с подсказкой `memo model`, ненулевой код.
|
||||
|
||||
**Важно:** шаг 3 и 4 проверяют автоскачивание с продакшн-URL по умолчанию (интернет доступен в этом
|
||||
окружении). Если интернета в момент проверки нет — приложить вывод и явно сказать об этом.
|
||||
|
||||
После: `./gradlew test --rerun-tasks` — все тесты зелёные (было 36, станет больше).
|
||||
Коммит осмысленным сообщением.
|
||||
|
||||
## Обновить документацию
|
||||
|
||||
- `MANUAL.md`: в §3 убрать ручной curl и написать, что модель скачивается сама при первом
|
||||
использовании, плюс команда `$CLI model` для скачивания заранее; упомянуть
|
||||
`MEMO_MODEL_AUTO_DOWNLOAD=0`.
|
||||
- `README.md`: в разделе «Быстрый старт» — строка про `memo model`.
|
||||
|
||||
## СТРОГИЕ ЗАПРЕТЫ
|
||||
|
||||
- Ни одной новой внешней зависимости (только JDK).
|
||||
- Не менять `memo-core`'s схему БД, `Chunker`, `Searcher`, `Indexer`, `Collections`.
|
||||
- Не менять смысл существующих тестов.
|
||||
- Не выводить план текстом; сразу правь файлы.
|
||||
- В тестах не ходить в интернет (только локальный HttpServer).
|
||||
@@ -0,0 +1,104 @@
|
||||
Проект: /root/WORK/memo (Kotlin/JVM). Заказ: закрыть ДВЕ дыры в покрытии `ModelStoreTest`,
|
||||
доказанные мутационной проверкой. Правки только в тестовом файле, боевой код НЕ трогать.
|
||||
|
||||
Прогон, который их нашёл:
|
||||
python3 e2e/mutation_check.py --gradle ./gradlew
|
||||
|
||||
Выжившие мутации:
|
||||
1. `ModelStore.kt`: `if (startAt > 0L) requestBuilder.header("Range", "bytes=$startAt-")`
|
||||
→ `if (false) ...` | ожидался провал `resumesPartialDownload` — НЕ упал.
|
||||
2. `ModelStore.kt`: `if (declaredBody >= 0 && written != declaredBody) {`
|
||||
→ `if (false) {` | ожидался провал `failsOnSizeMismatch` — НЕ упал.
|
||||
|
||||
## Почему они выжили (разобрано, не догадка)
|
||||
|
||||
**1. `resumesPartialDownload`.** Тестовый сервер (`serveBytes`) отдаёт `206` с хвостом только если
|
||||
в запросе есть заголовок `Range`; без него он отдаёт `200` и **полное содержимое**. Поэтому при
|
||||
мутации (Range не отправляется) срабатывает штатный запасной путь «начать заново», итоговый файл
|
||||
получается правильным — и тест, проверяющий только «файл в итоге верный», проходит.
|
||||
Тест не проверяет того, ради чего написан: что докачка **действительно** шла хвостом.
|
||||
|
||||
**2. `failsOnSizeMismatch`.** Сервер объявляет `Content-Length` больше, чем пишет, соединение
|
||||
обрывается — у клиента вылетает `IOException`, который `download` превращает в
|
||||
`IllegalStateException("не удалось скачать ...")`. Тест принимает `msg.contains("не удалось скачать")`,
|
||||
поэтому проверка размера не проверяется вообще: исключение приходит из другого места.
|
||||
|
||||
**Важное наблюдение по бою.** В `ModelStore.download` две проверки размера:
|
||||
`written != declaredBody` (первая) и `part.length() != expectedTotal` (вторая, из `Content-Range`).
|
||||
Через `java.net.http` первая на практике недостижима: оборванный ответ всегда даёт `IOException`,
|
||||
а не чистый EOF. Значит это защитный код, а не дыра в покрытии — в таблице мутаций его надо
|
||||
заменить на мутацию ВТОРОЙ проверки, которая достижима (см. ниже).
|
||||
|
||||
## Что сделать
|
||||
|
||||
### Правка теста `resumesPartialDownload`
|
||||
|
||||
Сделать так, чтобы «докачка» была доказана, а не предположена:
|
||||
|
||||
- сервер записывает в счётчики: сколько запросов пришло, у скольких был заголовок `Range`,
|
||||
и сколько всего байт тела он отдал;
|
||||
- **если заголовка `Range` нет для файла, у которого уже есть `.part`** — сервер отвечает кодом
|
||||
`400` и тела не отдаёт (докачки без `Range` не бывает; тест не должен иметь запасного пути);
|
||||
- после `ensure` тест обязан утверждать:
|
||||
- `rangeRequests >= 1` — докачка действительно была запрошена;
|
||||
- файл в итоге побайтно равен исходному (оставить);
|
||||
- `.part` переименован (оставить).
|
||||
|
||||
При мутации №1 (`Range` не отправляется) сервер ответит `400` → `ensure` бросит исключение → тест упадёт.
|
||||
|
||||
### Замена сценария `failsOnSizeMismatch` на два теста
|
||||
|
||||
**`rejectsWrongTotalFromContentRange`** (новый, закрывает мутацию №2) — «сервер соврал про общий
|
||||
размер, файл принимать нельзя»:
|
||||
|
||||
- в директории лежит `.part` = первые 4000 байт файла из 8000;
|
||||
- сервер на запрос с `Range: bytes=4000-` отвечает `206`:
|
||||
- `Content-Length: 4000`, тело — реальный хвост 4000 байт (то есть транспорт отдаёт ровно
|
||||
столько, сколько объявил — никакой `IOException`);
|
||||
- `Content-Range: bytes 4000-7999/999999` — **итог соврал**;
|
||||
- ожидание: `ModelStore.ensure` бросает `IllegalStateException`, сообщение содержит `размер`,
|
||||
целевого файла нет, `.part` удалён.
|
||||
|
||||
Проверить, что сценарий действительно бьёт в нужную проверку: при `if (false)` на
|
||||
`part.length() != expectedTotal` тест обязан провалиться (файл будет установлен, исключения не будет).
|
||||
|
||||
**`truncatedResponseDoesNotProduceFile`** (переименовать бывший `failsOnSizeMismatch`) — оставить
|
||||
как проверку поведения «оборванный ответ не оставляет файла», но **убрать из принимаемых сообщение
|
||||
«не удалось скачать»**, чтобы тест не «зеленел» за счёт сетевой ошибки. Ожидать явно любое
|
||||
`IllegalStateException` с непустым сообщением и отсутствие целевого файла и `.part`.
|
||||
Отдельно проверить и записать в отчёте: какую ветку кода реально ловит этот тест (по сообщению) —
|
||||
то есть является ли он проверкой размера или сетевого обрыва. В отчёте написать прямо.
|
||||
|
||||
### Таблица мутаций `e2e/mutations.tsv`
|
||||
|
||||
- **Убрать** строку, целящуюся в `if (declaredBody >= 0 && written != declaredBody) {`
|
||||
(по разбору выше — недостижимо через `java.net.http`; ложная цель).
|
||||
- **Добавить** мутацию во вторую проверку:
|
||||
`if (expectedTotal >= 0 && actualTotal != expectedTotal) {` → `if (false) {`,
|
||||
обязанный уронить `rejectsWrongTotalFromContentRange`.
|
||||
- Строку про `Range` оставить, но теперь она обязана валить `resumesPartialDownload`.
|
||||
|
||||
Правило: каждая строка таблицы — либо убитая мутация, либо честное объяснение в комментарии,
|
||||
почему цель недостижима (с доказательством прогоном).
|
||||
|
||||
## Обязательная проверка (приложить вывод)
|
||||
|
||||
```bash
|
||||
cd /root/WORK/memo
|
||||
python3 e2e/mutation_check.py --gradle ./gradlew 2>&1 | tail -25
|
||||
echo "--- ожидается: ВСЕ МУТАЦИИ УБИТЫ"
|
||||
./gradlew test --rerun-tasks -q 2>&1 | tail -3
|
||||
```
|
||||
|
||||
Плюс отдельно, для каждого из двух новых/изменённых тестов — доказательство, что он валит мутацию:
|
||||
применить мутацию руками, прогнать только `ModelStoreTest`, показать FAIL, откатить
|
||||
(`git checkout -- memo-core/src/main/kotlin/memo/core/ModelStore.kt`), убедиться в PASS.
|
||||
|
||||
## СТРОГИЕ ЗАПРЕТЫ
|
||||
|
||||
- **Не менять боевой код** `ModelStore.kt` (и вообще ничего в `src/main`). Заказ — только тесты
|
||||
и таблица мутаций.
|
||||
- Не менять другие тесты.
|
||||
- Не добавлять зависимости.
|
||||
- Не выводить план текстом; сразу правь файлы.
|
||||
- Не удалять `.gitignore`, не коммитить `models/` и `*.db`.
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/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")
|
||||
old = old.replace("\\n", "\n")
|
||||
new = new.replace("\\n", "\n")
|
||||
# no-op мутация ничего не проверяет — это ошибка таблицы, а не дыра в покрытии.
|
||||
if old == new:
|
||||
print(f"ОШИБКА ТАБЛИЦЫ: no-op мутация для {must_fail} (old == new)")
|
||||
return 2
|
||||
rows.append((f, old, new, must_fail))
|
||||
|
||||
print("=== базовая линия (без мутаций) ===")
|
||||
base = run_tests(a.gradle)
|
||||
print(f"тестов найдено: {len(base)}; упавших: {[k for k, v in base.items() if v == 'FAIL']}")
|
||||
|
||||
survivors = []
|
||||
misapplied = 0
|
||||
for f, old, new, must_fail in rows:
|
||||
backup = f + ".bak"
|
||||
shutil.copy2(f, backup)
|
||||
try:
|
||||
src = open(f, encoding="utf-8").read()
|
||||
if old not in src:
|
||||
# Мутация не применилась: фрагмента нет в файле. Это ошибка таблицы,
|
||||
# а не доказательство покрытия — такой прогон ничего не значит.
|
||||
print(f"ОШИБКА ТАБЛИЦЫ | мутация не применилась в {f}: {old[:70]!r}")
|
||||
misapplied += 1
|
||||
continue
|
||||
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
|
||||
if misapplied:
|
||||
print(f"\nПРОВАЛ: {misapplied} мутаций не применились — прогон недействителен")
|
||||
return 2
|
||||
print("\nВСЕ МУТАЦИИ УБИТЫ")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,29 @@
|
||||
# file<TAB>old<TAB>new<TAB>must_fail_test
|
||||
# Прогон: python3 e2e/mutation_check.py --gradle ./gradlew
|
||||
# Правило: мутация обязана уронить ровно тот тест, который её ловит. Выжившая мутация = дыра в покрытии.
|
||||
# no-op мутации (old == new) запрещены — харнесс их отклоняет.
|
||||
# --- memo-core: ksqlite / FTS5 / vec0 ---
|
||||
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
|
||||
# --- Чанкер ---
|
||||
memo-core/src/main/kotlin/memo/core/Chunker.kt Regex("^(#{1,3}) (.*)$") Regex("^(#+) (.*)$") fourHashesIsNotAHeading
|
||||
memo-core/src/main/kotlin/memo/core/Chunker.kt val trimmed = body.trim() val trimmed = body.uppercase() fourHashesIsNotAHeading
|
||||
memo-core/src/main/kotlin/memo/core/Chunker.kt private const val CHUNK_OVERLAP = 600 private const val CHUNK_OVERLAP = 0 longSectionIsSplitWithOverlap
|
||||
# --- Коллекции (дефект тройного обхода, найден на приёмке) ---
|
||||
memo-core/src/main/kotlin/memo/core/Collections.kt it.isFile && it.extension == "md" it.name.endsWith("md") findCollectionsReturnsOnlyDirsWithDirectMarkdown
|
||||
# --- Индексатор: пропуск неизменённого файла ---
|
||||
memo-core/src/main/kotlin/memo/core/Indexer.kt if (existing.mtime == mtime && existing.size == size) {\n return false if (existing.mtime == mtime && existing.size == size) {\n return true indexerSkipsUnchangedFile
|
||||
# --- Поиск: слияние режимов и ограничение k ---
|
||||
memo-core/src/main/kotlin/memo/core/Searcher.kt if (mode == SearchMode.LEX || mode == SearchMode.HYBRID) lexSearch(query) else emptyList() if (mode == SearchMode.LEX) lexSearch(query) else emptyList() hybridCombinesBoth
|
||||
memo-core/src/main/kotlin/memo/core/Searcher.kt .sortedByDescending { it.value }.take(k) .sortedByDescending { it.value }.take(k + 1) resultsRespectKAndTextLength
|
||||
# --- MCP: ошибка инструмента обязана помечаться isError ---
|
||||
memo-mcp/src/main/kotlin/memo/mcp/McpServer.kt ],"isError":true} ],"isError":false} searchWithoutPathIsError
|
||||
# --- Watcher: debounce сводит правки к одному вызову ---
|
||||
memo-watch/src/main/kotlin/memo/watch/Watcher.kt _indexCalls.incrementAndGet() _indexCalls.get() modifyTriggersSingleIndexCall
|
||||
# --- ModelStore: скачивание модели (ModelStoreTest) ---
|
||||
memo-core/src/main/kotlin/memo/core/ModelStore.kt if (!force && target.isFile && target.length() > 0L) { if (false) { skipsExistingWithoutNetwork
|
||||
memo-core/src/main/kotlin/memo/core/ModelStore.kt part.renameTo(target) part.renameTo(java.io.File(dir, "$name.WRONG")) downloadsMissingFiles
|
||||
memo-core/src/main/kotlin/memo/core/ModelStore.kt if (startAt > 0L) requestBuilder.header("Range", "bytes=$startAt-") if (false) requestBuilder.header("Range", "bytes=$startAt-") resumesPartialDownload
|
||||
memo-core/src/main/kotlin/memo/core/ModelStore.kt if (expectedTotal >= 0 && actualTotal != expectedTotal) { if (false) { rejectsWrongTotalFromContentRange
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 54.
|
@@ -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.
|
||||
как понять, почему сборка съедает место на диске work/jira 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
|
||||
что запрещено трогать на машине с двумя картами infra infra/servers.md sem
|
||||
где живёт хранилище контейнерных образов infra 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,14 @@
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
application
|
||||
}
|
||||
|
||||
application {
|
||||
mainClass.set("memo.cli.MainKt")
|
||||
applicationName = "memo"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":memo-core"))
|
||||
testImplementation(kotlin("test"))
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
package memo.cli
|
||||
|
||||
import memo.core.Db
|
||||
import memo.core.Embedder
|
||||
import memo.core.Hit
|
||||
import memo.core.Indexer
|
||||
import memo.core.ModelStore
|
||||
import memo.core.RefreshHook
|
||||
import memo.core.SearchMode
|
||||
import memo.core.Searcher
|
||||
import memo.core.findCollections
|
||||
import memo.core.resolveCollection
|
||||
import java.io.File
|
||||
|
||||
sealed interface Cmd
|
||||
data class IndexCmd(val path: String) : Cmd
|
||||
data class SearchCmd(
|
||||
val path: String,
|
||||
val query: String,
|
||||
val k: Int,
|
||||
val mode: SearchMode,
|
||||
val json: Boolean,
|
||||
) : Cmd
|
||||
data class StatusCmd(val path: String) : Cmd
|
||||
data class McpProbeCmd(val tool: String, val argsJson: String) : Cmd
|
||||
data class ModelCmd(val dir: String, val baseUrl: String, val force: Boolean) : Cmd
|
||||
data object HelpCmd : Cmd
|
||||
|
||||
fun parseArgs(args: Array<String>): Cmd {
|
||||
if (args.isEmpty()) return HelpCmd
|
||||
return when (val cmd = args[0]) {
|
||||
"index" -> {
|
||||
val path = args.getOrNull(1) ?: return HelpCmd
|
||||
IndexCmd(path)
|
||||
}
|
||||
"search" -> parseSearch(args.drop(1))
|
||||
"status" -> {
|
||||
val path = args.getOrNull(1) ?: return HelpCmd
|
||||
StatusCmd(path)
|
||||
}
|
||||
"model" -> parseModel(args.drop(1))
|
||||
"mcp-probe" -> parseMcpProbe(args.drop(1))
|
||||
"--help", "-h" -> HelpCmd
|
||||
else -> HelpCmd
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseModel(rest: List<String>): Cmd {
|
||||
var dir = System.getenv("MEMO_MODEL_DIR") ?: "/root/WORK/memo/models/siglip2"
|
||||
var url = ModelStore.DEFAULT_BASE_URL
|
||||
var force = false
|
||||
var i = 0
|
||||
while (i < rest.size) {
|
||||
when (rest[i]) {
|
||||
"--dir" -> {
|
||||
val v = rest.getOrNull(i + 1) ?: return HelpCmd
|
||||
dir = v
|
||||
i += 2
|
||||
}
|
||||
"--url" -> {
|
||||
val v = rest.getOrNull(i + 1) ?: return HelpCmd
|
||||
url = v
|
||||
i += 2
|
||||
}
|
||||
"--force" -> {
|
||||
force = true
|
||||
i += 1
|
||||
}
|
||||
else -> return HelpCmd
|
||||
}
|
||||
}
|
||||
return ModelCmd(dir, url, force)
|
||||
}
|
||||
|
||||
private fun parseSearch(rest: List<String>): Cmd {
|
||||
if (rest.size < 2) return HelpCmd
|
||||
val path = rest[0]
|
||||
val query = rest[1]
|
||||
var k = 8
|
||||
var mode = SearchMode.HYBRID
|
||||
var json = false
|
||||
var i = 2
|
||||
while (i < rest.size) {
|
||||
when (val a = rest[i]) {
|
||||
"--k" -> {
|
||||
val v = rest.getOrNull(i + 1) ?: return HelpCmd
|
||||
val n = v.toIntOrNull() ?: return HelpCmd
|
||||
k = n
|
||||
i += 2
|
||||
}
|
||||
"--mode" -> {
|
||||
val v = rest.getOrNull(i + 1) ?: return HelpCmd
|
||||
mode = when (v) {
|
||||
"hybrid" -> SearchMode.HYBRID
|
||||
"lex" -> SearchMode.LEX
|
||||
"vec" -> SearchMode.VEC
|
||||
else -> return HelpCmd
|
||||
}
|
||||
i += 2
|
||||
}
|
||||
"--json" -> {
|
||||
json = true
|
||||
i += 1
|
||||
}
|
||||
else -> return HelpCmd
|
||||
}
|
||||
}
|
||||
return SearchCmd(path, query, k, mode, json)
|
||||
}
|
||||
|
||||
private fun parseMcpProbe(rest: List<String>): Cmd {
|
||||
var tool: String? = null
|
||||
var argsJson = "{}"
|
||||
var i = 0
|
||||
while (i < rest.size) {
|
||||
when (rest[i]) {
|
||||
"--tool" -> {
|
||||
tool = rest.getOrNull(i + 1) ?: return HelpCmd
|
||||
i += 2
|
||||
}
|
||||
"--args" -> {
|
||||
argsJson = rest.getOrNull(i + 1) ?: return HelpCmd
|
||||
i += 2
|
||||
}
|
||||
else -> return HelpCmd
|
||||
}
|
||||
}
|
||||
val t = tool ?: return HelpCmd
|
||||
return McpProbeCmd(t, argsJson)
|
||||
}
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
try {
|
||||
when (val cmd = parseArgs(args)) {
|
||||
is IndexCmd -> runIndex(cmd)
|
||||
is SearchCmd -> runSearch(cmd)
|
||||
is StatusCmd -> runStatus(cmd)
|
||||
is ModelCmd -> runModel(cmd)
|
||||
is McpProbeCmd -> runMcpProbe(cmd)
|
||||
HelpCmd -> printHelp()
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
System.err.println(t.message ?: t.toString())
|
||||
kotlin.system.exitProcess(1)
|
||||
}
|
||||
}
|
||||
|
||||
private fun printHelp() {
|
||||
println(
|
||||
"""
|
||||
usage:
|
||||
memo index <path>
|
||||
memo search <path> <query> [--k N] [--mode hybrid|lex|vec] [--json]
|
||||
memo status <path>
|
||||
memo model [--dir <path>] [--url <base>] [--force]
|
||||
memo mcp-probe --tool <name> [--args <json>]
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
|
||||
private fun modelDir(): File =
|
||||
File(System.getenv("MEMO_MODEL_DIR") ?: "/root/WORK/memo/models/siglip2")
|
||||
|
||||
private fun modelPaths(): Pair<String, String> = ModelStore.paths(modelDir())
|
||||
|
||||
private fun ensureModel(dir: File) {
|
||||
if (ModelStore.isComplete(dir)) return
|
||||
if (System.getenv("MEMO_MODEL_AUTO_DOWNLOAD") == "0") {
|
||||
val msg = "модель не найдена в ${dir.absolutePath}; запустите: memo model"
|
||||
System.err.println(msg)
|
||||
throw IllegalStateException(msg)
|
||||
}
|
||||
System.err.println(
|
||||
"модель не найдена в ${dir.absolutePath}, скачиваю с ${ModelStore.DEFAULT_BASE_URL} (≈287 МБ, один раз)",
|
||||
)
|
||||
ModelStore.ensure(dir, log = { System.err.println(it) })
|
||||
}
|
||||
|
||||
private fun runModel(cmd: ModelCmd) {
|
||||
val dir = File(cmd.dir)
|
||||
val result = ModelStore.ensure(
|
||||
dir = dir,
|
||||
baseUrl = cmd.baseUrl,
|
||||
force = cmd.force,
|
||||
log = { System.err.println(it) },
|
||||
)
|
||||
if (result.downloaded.isEmpty()) {
|
||||
println("модель уже на месте в ${dir.absolutePath}: ${result.skipped.joinToString(", ")}")
|
||||
} else {
|
||||
println("скачано: ${result.downloaded.joinToString(", ")} (${result.bytes} байт)")
|
||||
if (result.skipped.isNotEmpty()) {
|
||||
println("уже было: ${result.skipped.joinToString(", ")}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun runIndex(cmd: IndexCmd) {
|
||||
val base = resolveCollection(File(cmd.path))
|
||||
val collections = findCollections(base)
|
||||
if (collections.isEmpty()) {
|
||||
System.err.println("коллекции не найдены в ${base.absolutePath}")
|
||||
return
|
||||
}
|
||||
val (modelPath, tokenizerPath) = modelPaths()
|
||||
ensureModel(modelDir())
|
||||
var totalUpdated = 0
|
||||
for (coll in collections) {
|
||||
val memoDir = File(coll, ".memo")
|
||||
memoDir.mkdirs()
|
||||
val dbPath = File(memoDir, "index.db").absolutePath
|
||||
val totalFiles = coll.walkTopDown()
|
||||
.maxDepth(8)
|
||||
.count { it.isFile && it.extension == "md" }
|
||||
val db = Db(dbPath)
|
||||
try {
|
||||
db.init()
|
||||
val embedder = Embedder(modelPath, tokenizerPath)
|
||||
try {
|
||||
val updated = Indexer(db, embedder).indexTree(coll)
|
||||
println("индексировано: $updated обновлено, $totalFiles файлов всего")
|
||||
totalUpdated += updated
|
||||
} finally {
|
||||
embedder.close()
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
println("итого: $totalUpdated обновлено в ${collections.size} коллекциях")
|
||||
}
|
||||
|
||||
private fun runSearch(cmd: SearchCmd) {
|
||||
val targets = resolveSearchTargets(File(cmd.path))
|
||||
if (targets.isEmpty()) {
|
||||
if (cmd.json) println("[]")
|
||||
else println("коллекции не найдены")
|
||||
return
|
||||
}
|
||||
val (modelPath, tokenizerPath) = modelPaths()
|
||||
ensureModel(modelDir())
|
||||
val allHits = ArrayList<Hit>()
|
||||
for (coll in targets) {
|
||||
val dbPath = File(coll, ".memo/index.db")
|
||||
if (!dbPath.exists()) continue
|
||||
runCatching {
|
||||
val db = Db(dbPath.absolutePath)
|
||||
try {
|
||||
val embedder = Embedder(modelPath, tokenizerPath)
|
||||
try {
|
||||
val refresh = RefreshHook { root ->
|
||||
val innerDbPath = File(root, ".memo/index.db")
|
||||
if (innerDbPath.exists()) {
|
||||
val innerDb = Db(innerDbPath.absolutePath)
|
||||
try {
|
||||
Indexer(innerDb, embedder).indexTree(root)
|
||||
} finally {
|
||||
innerDb.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
val hits = Searcher(db, embedder, refresh)
|
||||
.search(coll, cmd.query, cmd.k, cmd.mode)
|
||||
allHits.addAll(hits)
|
||||
} finally {
|
||||
embedder.close()
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
val merged = allHits.sortedByDescending { it.score }.take(cmd.k)
|
||||
if (cmd.json) {
|
||||
printJsonHits(merged)
|
||||
} else {
|
||||
for (h in merged) {
|
||||
val text = if (h.text.length > 300) h.text.substring(0, 300) else h.text
|
||||
println(String.format("%.3f %s:%d %s", h.score, h.path, h.line, h.heading))
|
||||
for (line in text.lines()) {
|
||||
println(" $line")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveSearchTargets(path: File): List<File> {
|
||||
if (path.isFile && path.extension == "md") {
|
||||
return listOfNotNull(path.parentFile)
|
||||
}
|
||||
if (File(path, ".memo/index.db").exists()) {
|
||||
return listOf(path)
|
||||
}
|
||||
val subs = path.listFiles()
|
||||
?.filter { it.isDirectory && File(it, ".memo/index.db").exists() }
|
||||
?: emptyList()
|
||||
return subs
|
||||
}
|
||||
|
||||
private fun runStatus(cmd: StatusCmd) {
|
||||
val base = resolveCollection(File(cmd.path))
|
||||
val collections = if (File(base, ".memo/index.db").exists()) {
|
||||
listOf(base)
|
||||
} else {
|
||||
findCollections(base)
|
||||
}
|
||||
if (collections.isEmpty()) {
|
||||
println("коллекции не найдены")
|
||||
return
|
||||
}
|
||||
for (coll in collections) {
|
||||
val dbPath = File(coll, ".memo/index.db")
|
||||
if (!dbPath.exists()) {
|
||||
println("${coll.name}: нет индекса")
|
||||
continue
|
||||
}
|
||||
val db = Db(dbPath.absolutePath)
|
||||
try {
|
||||
val fileCount = countInt(db, "SELECT COUNT(*) FROM files")
|
||||
val chunkCount = countInt(db, "SELECT COUNT(*) FROM chunks")
|
||||
val indexedAt = maxDouble(db, "SELECT indexed_at FROM files WHERE indexed_at IS NOT NULL")
|
||||
val date = if (indexedAt != null) {
|
||||
java.time.Instant.ofEpochSecond(indexedAt.toLong()).toString()
|
||||
} else {
|
||||
"—"
|
||||
}
|
||||
println("$coll: файлов $fileCount, чанков $chunkCount, индекс $date")
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun countInt(db: Db, sql: String): Int {
|
||||
val stmt = db.conn.prepare(sql)
|
||||
return try {
|
||||
val rs = stmt.executeQuery()
|
||||
try {
|
||||
if (rs.next()) rs.getInt(0) ?: 0 else 0
|
||||
} finally {
|
||||
rs.close()
|
||||
}
|
||||
} finally {
|
||||
stmt.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun maxDouble(db: Db, sql: String): Double? {
|
||||
val stmt = db.conn.prepare(sql)
|
||||
return try {
|
||||
val rs = stmt.executeQuery()
|
||||
try {
|
||||
var best: Double? = null
|
||||
while (rs.next()) {
|
||||
val v = rs.getDouble(0) ?: continue
|
||||
if (best == null || v > best) best = v
|
||||
}
|
||||
best
|
||||
} finally {
|
||||
rs.close()
|
||||
}
|
||||
} finally {
|
||||
stmt.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun runMcpProbe(cmd: McpProbeCmd) {
|
||||
when (cmd.tool) {
|
||||
"memo_reindex" -> printMcpError("not implemented yet")
|
||||
"memo_search" -> {
|
||||
val args = parseJsonArgs(cmd.argsJson)
|
||||
val path = (args["path"] as? String) ?: ""
|
||||
val query = (args["query"] as? String) ?: ""
|
||||
val k = (args["k"] as? Number)?.toInt() ?: 8
|
||||
val mode = when (args["mode"] as? String) {
|
||||
"lex" -> SearchMode.LEX
|
||||
"vec" -> SearchMode.VEC
|
||||
else -> SearchMode.HYBRID
|
||||
}
|
||||
val jsonOutput = (args["json"] as? Boolean) ?: true
|
||||
val hits = performSearch(path, query, k, mode)
|
||||
val text = if (jsonOutput) formatHitsJson(hits) else formatHitsHuman(hits)
|
||||
printMcpOk(text)
|
||||
}
|
||||
"memo_status" -> {
|
||||
val args = parseJsonArgs(cmd.argsJson)
|
||||
val path = (args["path"] as? String) ?: ""
|
||||
val text = formatStatusInner(path)
|
||||
printMcpOk(text)
|
||||
}
|
||||
else -> printMcpError("unknown tool: ${cmd.tool}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun performSearch(path: String, query: String, k: Int, mode: SearchMode): List<Hit> {
|
||||
val targets = resolveSearchTargets(File(path))
|
||||
if (targets.isEmpty()) return emptyList()
|
||||
val (modelPath, tokenizerPath) = modelPaths()
|
||||
ensureModel(modelDir())
|
||||
val allHits = ArrayList<Hit>()
|
||||
for (coll in targets) {
|
||||
val dbPath = File(coll, ".memo/index.db")
|
||||
if (!dbPath.exists()) continue
|
||||
runCatching {
|
||||
val db = Db(dbPath.absolutePath)
|
||||
try {
|
||||
val embedder = Embedder(modelPath, tokenizerPath)
|
||||
try {
|
||||
val refresh = RefreshHook { root ->
|
||||
val innerDbPath = File(root, ".memo/index.db")
|
||||
if (innerDbPath.exists()) {
|
||||
val innerDb = Db(innerDbPath.absolutePath)
|
||||
try {
|
||||
Indexer(innerDb, embedder).indexTree(root)
|
||||
} finally {
|
||||
innerDb.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
val hits = Searcher(db, embedder, refresh)
|
||||
.search(coll, query, k, mode)
|
||||
allHits.addAll(hits)
|
||||
} finally {
|
||||
embedder.close()
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
return allHits.sortedByDescending { it.score }.take(k)
|
||||
}
|
||||
|
||||
private fun formatStatusInner(path: String): String {
|
||||
val base = resolveCollection(File(path))
|
||||
val collections = if (File(base, ".memo/index.db").exists()) {
|
||||
listOf(base)
|
||||
} else {
|
||||
findCollections(base)
|
||||
}
|
||||
if (collections.isEmpty()) return "коллекции не найдены"
|
||||
val sb = StringBuilder()
|
||||
for (coll in collections) {
|
||||
val dbPath = File(coll, ".memo/index.db")
|
||||
if (!dbPath.exists()) {
|
||||
sb.append("${coll.name}: нет индекса\n")
|
||||
continue
|
||||
}
|
||||
val db = Db(dbPath.absolutePath)
|
||||
try {
|
||||
val fileCount = countInt(db, "SELECT COUNT(*) FROM files")
|
||||
val chunkCount = countInt(db, "SELECT COUNT(*) FROM chunks")
|
||||
val indexedAt = maxDouble(db, "SELECT indexed_at FROM files WHERE indexed_at IS NOT NULL")
|
||||
val date = if (indexedAt != null) {
|
||||
java.time.Instant.ofEpochSecond(indexedAt.toLong()).toString()
|
||||
} else {
|
||||
"—"
|
||||
}
|
||||
sb.append("$coll: файлов $fileCount, чанков $chunkCount, индекс $date\n")
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
return sb.toString().trimEnd('\n')
|
||||
}
|
||||
|
||||
private fun printJsonHits(hits: List<Hit>) {
|
||||
println(formatHitsJson(hits))
|
||||
}
|
||||
|
||||
private fun formatHitsJson(hits: List<Hit>): String {
|
||||
val sb = StringBuilder()
|
||||
sb.append('[')
|
||||
for ((i, h) in hits.withIndex()) {
|
||||
if (i > 0) sb.append(',')
|
||||
sb.append('{')
|
||||
sb.append("\"path\":").append(jsonStr(h.path)).append(',')
|
||||
sb.append("\"line\":").append(h.line).append(',')
|
||||
sb.append("\"heading\":").append(jsonStr(h.heading)).append(',')
|
||||
sb.append("\"score\":").append("%.6f".format(h.score)).append(',')
|
||||
sb.append("\"text\":").append(jsonStr(h.text))
|
||||
sb.append('}')
|
||||
}
|
||||
sb.append(']')
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun formatHitsHuman(hits: List<Hit>): String {
|
||||
val sb = StringBuilder()
|
||||
for (h in hits) {
|
||||
val text = if (h.text.length > 300) h.text.substring(0, 300) else h.text
|
||||
sb.append(String.format("%.3f %s:%d %s", h.score, h.path, h.line, h.heading))
|
||||
sb.append('\n')
|
||||
for (line in text.lines()) {
|
||||
sb.append(" ").append(line).append('\n')
|
||||
}
|
||||
}
|
||||
return sb.toString().trimEnd('\n')
|
||||
}
|
||||
|
||||
private fun jsonStr(s: String): String {
|
||||
val sb = StringBuilder(s.length + 2)
|
||||
sb.append('"')
|
||||
for (c in s) {
|
||||
when (c) {
|
||||
'"' -> sb.append("\\\"")
|
||||
'\\' -> sb.append("\\\\")
|
||||
'\n' -> sb.append("\\n")
|
||||
'\r' -> sb.append("\\r")
|
||||
'\t' -> sb.append("\\t")
|
||||
'\b' -> sb.append("\\b")
|
||||
'\u000C' -> sb.append("\\f")
|
||||
else -> if (c.code < 0x20) {
|
||||
sb.append("\\u%04x".format(c.code))
|
||||
} else {
|
||||
sb.append(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.append('"')
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun printMcpOk(text: String) {
|
||||
println("""{"content":[{"type":"text","text":${jsonStr(text)}}],"isError":false}""")
|
||||
}
|
||||
|
||||
private fun printMcpError(text: String) {
|
||||
println("""{"content":[{"type":"text","text":${jsonStr(text)}}],"isError":true}""")
|
||||
}
|
||||
|
||||
private fun parseJsonArgs(json: String): Map<String, Any?> {
|
||||
val map = HashMap<String, Any?>()
|
||||
val trimmed = json.trim()
|
||||
if (trimmed.isEmpty() || trimmed == "{}") return map
|
||||
val inner = trimmed.trim().removePrefix("{").removeSuffix("}")
|
||||
if (inner.isBlank()) return map
|
||||
var i = 0
|
||||
while (i < inner.length) {
|
||||
while (i < inner.length && inner[i].isWhitespace()) i++
|
||||
if (i >= inner.length) break
|
||||
if (inner[i] != '"') return map
|
||||
val keyEnd = readJsonString(inner, i)
|
||||
val key = unescapeJson(inner.substring(i + 1, keyEnd))
|
||||
i = keyEnd + 1
|
||||
while (i < inner.length && inner[i].isWhitespace()) i++
|
||||
if (i >= inner.length || inner[i] != ':') return map
|
||||
i++
|
||||
while (i < inner.length && inner[i].isWhitespace()) i++
|
||||
if (i >= inner.length) return map
|
||||
val (value, next) = readJsonValue(inner, i)
|
||||
map[key] = value
|
||||
i = next
|
||||
while (i < inner.length && inner[i].isWhitespace()) i++
|
||||
if (i < inner.length && inner[i] == ',') i++
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
private fun readJsonString(s: String, start: Int): Int {
|
||||
var i = start + 1
|
||||
while (i < s.length) {
|
||||
if (s[i] == '\\') {
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if (s[i] == '"') return i
|
||||
i++
|
||||
}
|
||||
return s.length
|
||||
}
|
||||
|
||||
private fun readJsonValue(s: String, start: Int): Pair<Any?, Int> {
|
||||
val c = s[start]
|
||||
return when {
|
||||
c == '"' -> {
|
||||
val end = readJsonString(s, start)
|
||||
unescapeJson(s.substring(start + 1, end)) to (end + 1)
|
||||
}
|
||||
c == 't' && s.regionMatches(start, "true", 0, 4) -> true to (start + 4)
|
||||
c == 'f' && s.regionMatches(start, "false", 0, 5) -> false to (start + 5)
|
||||
c == 'n' && s.regionMatches(start, "null", 0, 4) -> null to (start + 4)
|
||||
c == '-' || c.isDigit() -> {
|
||||
var i = start
|
||||
while (i < s.length && (s[i].isDigit() || s[i] == '-' || s[i] == '+' || s[i] == '.' || s[i] == 'e' || s[i] == 'E')) i++
|
||||
val raw = s.substring(start, i)
|
||||
val num = raw.toDoubleOrNull() ?: raw
|
||||
num to i
|
||||
}
|
||||
else -> null to (start + 1)
|
||||
}
|
||||
}
|
||||
|
||||
private fun unescapeJson(s: String): String {
|
||||
val sb = StringBuilder(s.length)
|
||||
var i = 0
|
||||
while (i < s.length) {
|
||||
val c = s[i]
|
||||
if (c == '\\' && i + 1 < s.length) {
|
||||
when (s[i + 1]) {
|
||||
'"' -> sb.append('"')
|
||||
'\\' -> sb.append('\\')
|
||||
'/' -> sb.append('/')
|
||||
'n' -> sb.append('\n')
|
||||
'r' -> sb.append('\r')
|
||||
't' -> sb.append('\t')
|
||||
'b' -> sb.append('\b')
|
||||
'f' -> sb.append('\u000C')
|
||||
'u' -> {
|
||||
if (i + 5 < s.length) {
|
||||
val hex = s.substring(i + 2, i + 6)
|
||||
sb.append(hex.toInt(16).toChar())
|
||||
i += 4
|
||||
}
|
||||
}
|
||||
}
|
||||
i += 2
|
||||
} else {
|
||||
sb.append(c)
|
||||
i++
|
||||
}
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package memo.cli
|
||||
|
||||
import memo.core.SearchMode
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class CliArgsTest {
|
||||
|
||||
@Test
|
||||
fun indexCommandParsed() {
|
||||
val cmd = parseArgs(arrayOf("index", "/tmp/x"))
|
||||
assertTrue(cmd is IndexCmd, "ожидался IndexCmd, получено $cmd")
|
||||
assertEquals("/tmp/x", cmd.path)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchDefaultsAreK8HybridNoJson() {
|
||||
val cmd = parseArgs(arrayOf("search", "/tmp/x", "вопрос"))
|
||||
assertTrue(cmd is SearchCmd, "ожидался SearchCmd, получено $cmd")
|
||||
assertEquals(8, cmd.k)
|
||||
assertEquals(SearchMode.HYBRID, cmd.mode)
|
||||
assertEquals(false, cmd.json)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchFlagsParsed() {
|
||||
val cmd = parseArgs(
|
||||
arrayOf("search", "/tmp/x", "вопрос", "--k", "3", "--mode", "lex", "--json")
|
||||
)
|
||||
assertTrue(cmd is SearchCmd, "ожидался SearchCmd, получено $cmd")
|
||||
assertEquals(3, cmd.k)
|
||||
assertEquals(SearchMode.LEX, cmd.mode)
|
||||
assertEquals(true, cmd.json)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun badKValueFallsBackToHelp() {
|
||||
val cmd = parseArgs(arrayOf("search", "/tmp/x", "вопрос", "--k", "abc"))
|
||||
assertTrue(cmd is HelpCmd, "ожидался HelpCmd, получено $cmd")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownCommandFallsBackToHelp() {
|
||||
val cmd = parseArgs(arrayOf("сломать"))
|
||||
assertTrue(cmd is HelpCmd, "ожидался HelpCmd, получено $cmd")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api("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,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,32 @@
|
||||
package memo.core
|
||||
|
||||
import java.io.File
|
||||
|
||||
fun isCollection(dir: File): Boolean {
|
||||
if (!dir.isDirectory) return false
|
||||
return dir.listFiles()?.any { it.isFile && it.extension == "md" } == true
|
||||
}
|
||||
|
||||
fun resolveCollection(path: File): File {
|
||||
if (path.isFile) return path.parentFile ?: path
|
||||
if (isCollection(path)) return path
|
||||
val descendants = findCollections(path)
|
||||
if (descendants.size == 1) return descendants[0]
|
||||
return path
|
||||
}
|
||||
|
||||
fun findCollections(root: File): List<File> {
|
||||
if (!root.isDirectory) return emptyList()
|
||||
val result = LinkedHashSet<File>()
|
||||
fun walk(d: File) {
|
||||
if (isCollection(d)) result.add(d)
|
||||
val children = d.listFiles() ?: return
|
||||
for (c in children) {
|
||||
if (c.isDirectory && !c.name.startsWith(".")) {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
return result.sortedBy { it.absolutePath }
|
||||
}
|
||||
@@ -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,196 @@
|
||||
package memo.core
|
||||
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
|
||||
class Indexer(private val db: Db, private val embedder: Embedder) {
|
||||
|
||||
fun indexFile(path: File): Boolean {
|
||||
val absPath = path.absolutePath
|
||||
val mtime = path.lastModified() / 1000.0
|
||||
val size = path.length()
|
||||
|
||||
val existing = readFileRecord(absPath)
|
||||
if (existing != null) {
|
||||
if (existing.mtime == mtime && existing.size == size) {
|
||||
return false
|
||||
}
|
||||
val content = path.readText()
|
||||
val newHash = sha256Hex(content)
|
||||
if (existing.hash == newHash) {
|
||||
updateFileTouched(absPath, mtime, size)
|
||||
return false
|
||||
}
|
||||
reindex(absPath, content, mtime, size, newHash)
|
||||
return true
|
||||
}
|
||||
|
||||
val content = path.readText()
|
||||
val hash = sha256Hex(content)
|
||||
reindex(absPath, content, mtime, size, hash)
|
||||
return true
|
||||
}
|
||||
|
||||
fun indexTree(root: File): Int {
|
||||
var count = 0
|
||||
walk(root) { f ->
|
||||
if (f.isFile && f.extension == "md") {
|
||||
if (indexFile(f)) count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
private fun walk(dir: File, action: (File) -> Unit) {
|
||||
if (!dir.isDirectory) return
|
||||
val children = dir.listFiles() ?: return
|
||||
for (child in children) {
|
||||
if (child.isDirectory) {
|
||||
if (child.name.startsWith(".")) continue
|
||||
walk(child, action)
|
||||
} else {
|
||||
action(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class FileRecord(val mtime: Double, val size: Long, val hash: String)
|
||||
|
||||
private fun readFileRecord(absPath: String): FileRecord? {
|
||||
val stmt = db.conn.prepare("SELECT mtime, size, hash FROM files WHERE path = ?")
|
||||
return try {
|
||||
stmt.bindText(1, absPath)
|
||||
val rs = stmt.executeQuery()
|
||||
try {
|
||||
if (rs.next()) {
|
||||
FileRecord(rs.getDouble(0)!!, rs.getLong(1)!!, rs.getText(2)!!)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} finally {
|
||||
rs.close()
|
||||
}
|
||||
} finally {
|
||||
stmt.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateFileTouched(absPath: String, mtime: Double, size: Long) {
|
||||
val stmt = db.conn.prepare(
|
||||
"UPDATE files SET mtime = ?, size = ?, indexed_at = ? WHERE path = ?"
|
||||
)
|
||||
try {
|
||||
stmt.bindDouble(1, mtime)
|
||||
stmt.bindLong(2, size)
|
||||
stmt.bindDouble(3, System.currentTimeMillis() / 1000.0)
|
||||
stmt.bindText(4, absPath)
|
||||
stmt.executeUpdate()
|
||||
} finally {
|
||||
stmt.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun reindex(
|
||||
absPath: String,
|
||||
content: String,
|
||||
mtime: Double,
|
||||
size: Long,
|
||||
hash: String,
|
||||
) {
|
||||
val conn = db.conn
|
||||
conn.beginTransaction()
|
||||
try {
|
||||
val delFts = conn.prepare(
|
||||
"DELETE FROM chunks_fts WHERE rowid IN (SELECT id FROM chunks WHERE path = ?)"
|
||||
)
|
||||
delFts.bindText(1, absPath)
|
||||
delFts.executeUpdate()
|
||||
delFts.close()
|
||||
|
||||
val delVec = conn.prepare(
|
||||
"DELETE FROM chunks_vec WHERE rowid IN (SELECT id FROM chunks WHERE path = ?)"
|
||||
)
|
||||
delVec.bindText(1, absPath)
|
||||
delVec.executeUpdate()
|
||||
delVec.close()
|
||||
|
||||
val delChunks = conn.prepare("DELETE FROM chunks WHERE path = ?")
|
||||
delChunks.bindText(1, absPath)
|
||||
delChunks.executeUpdate()
|
||||
delChunks.close()
|
||||
|
||||
val chunks = chunkMarkdown(content)
|
||||
val insChunk = conn.prepare(
|
||||
"INSERT INTO chunks(path, heading, line, ord, text, hash) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
val insFts = conn.prepare(
|
||||
"INSERT INTO chunks_fts(rowid, text, heading) VALUES (?, ?, ?)"
|
||||
)
|
||||
val insVec = conn.prepare(
|
||||
"INSERT INTO chunks_vec(rowid, embedding) VALUES (?, ?)"
|
||||
)
|
||||
try {
|
||||
for (chunk in chunks) {
|
||||
val embedding = embedder.embed(chunk.text)
|
||||
val chunkHash = sha256Hex(chunk.text)
|
||||
insChunk.reset()
|
||||
insChunk.bindText(1, absPath)
|
||||
insChunk.bindText(2, chunk.heading)
|
||||
insChunk.bindLong(3, chunk.line.toLong())
|
||||
insChunk.bindLong(4, chunk.ord.toLong())
|
||||
insChunk.bindText(5, chunk.text)
|
||||
insChunk.bindText(6, chunkHash)
|
||||
insChunk.executeUpdate()
|
||||
val id = conn.lastInsertRowId
|
||||
insFts.reset()
|
||||
insFts.bindLong(1, id)
|
||||
insFts.bindText(2, chunk.text)
|
||||
insFts.bindText(3, chunk.heading)
|
||||
insFts.executeUpdate()
|
||||
insVec.reset()
|
||||
insVec.bindLong(1, id)
|
||||
insVec.bindVector(2, embedding)
|
||||
insVec.executeUpdate()
|
||||
}
|
||||
} finally {
|
||||
insChunk.close()
|
||||
insFts.close()
|
||||
insVec.close()
|
||||
}
|
||||
|
||||
val upsert = conn.prepare(
|
||||
"INSERT INTO files(path, mtime, size, hash, indexed_at) VALUES (?, ?, ?, ?, ?) " +
|
||||
"ON CONFLICT(path) DO UPDATE SET " +
|
||||
"mtime = excluded.mtime, size = excluded.size, " +
|
||||
"hash = excluded.hash, indexed_at = excluded.indexed_at"
|
||||
)
|
||||
try {
|
||||
upsert.bindText(1, absPath)
|
||||
upsert.bindDouble(2, mtime)
|
||||
upsert.bindLong(3, size)
|
||||
upsert.bindText(4, hash)
|
||||
upsert.bindDouble(5, System.currentTimeMillis() / 1000.0)
|
||||
upsert.executeUpdate()
|
||||
} finally {
|
||||
upsert.close()
|
||||
}
|
||||
|
||||
conn.commit()
|
||||
} catch (t: Throwable) {
|
||||
conn.rollback()
|
||||
throw t
|
||||
}
|
||||
}
|
||||
|
||||
private fun sha256Hex(text: String): String {
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
val bytes = md.digest(text.toByteArray(Charsets.UTF_8))
|
||||
val sb = StringBuilder(bytes.size * 2)
|
||||
for (b in bytes) {
|
||||
val v = b.toInt() and 0xFF
|
||||
sb.append((v ushr 4).toString(16))
|
||||
sb.append((v and 0xF).toString(16))
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package memo.core
|
||||
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.net.URI
|
||||
import java.net.http.HttpClient
|
||||
import java.net.http.HttpRequest
|
||||
import java.net.http.HttpResponse
|
||||
import java.time.Duration
|
||||
|
||||
object ModelStore {
|
||||
const val DEFAULT_BASE_URL = "http://static.binom.pw/models/siglip2"
|
||||
val FILES = listOf("text_model_int8.onnx", "tokenizer.model")
|
||||
|
||||
data class Result(val downloaded: List<String>, val skipped: List<String>, val bytes: Long)
|
||||
|
||||
/** Пути к модели в том же порядке (modelPath, tokenizerPath). */
|
||||
fun paths(dir: File): Pair<String, String> =
|
||||
File(dir, FILES[0]).absolutePath to File(dir, FILES[1]).absolutePath
|
||||
|
||||
/** Все файлы модели присутствуют и непустые. */
|
||||
fun isComplete(dir: File): Boolean = FILES.all { name ->
|
||||
val f = File(dir, name)
|
||||
f.isFile && f.length() > 0L
|
||||
}
|
||||
|
||||
/** Гарантирует наличие всех файлов модели в dir. Возвращает имена скачанных/пропущенных. */
|
||||
fun ensure(
|
||||
dir: File,
|
||||
baseUrl: String = DEFAULT_BASE_URL,
|
||||
force: Boolean = false,
|
||||
log: (String) -> Unit = {},
|
||||
timeoutMillis: Long = 60_000,
|
||||
): Result {
|
||||
dir.mkdirs()
|
||||
val base = baseUrl.trimEnd('/')
|
||||
val client = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofMillis(timeoutMillis))
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.build()
|
||||
val downloaded = ArrayList<String>()
|
||||
val skipped = ArrayList<String>()
|
||||
var bytes = 0L
|
||||
for (name in FILES) {
|
||||
val target = File(dir, name)
|
||||
if (!force && target.isFile && target.length() > 0L) {
|
||||
skipped.add(name)
|
||||
continue
|
||||
}
|
||||
bytes += download(client, dir, name, base, log)
|
||||
downloaded.add(name)
|
||||
}
|
||||
return Result(downloaded, skipped, bytes)
|
||||
}
|
||||
|
||||
private fun download(
|
||||
client: HttpClient,
|
||||
dir: File,
|
||||
name: String,
|
||||
baseUrl: String,
|
||||
log: (String) -> Unit,
|
||||
): Long {
|
||||
val target = File(dir, name)
|
||||
val part = File(dir, "$name.part")
|
||||
var startAt = if (part.isFile) part.length() else 0L
|
||||
if (part.exists() && startAt == 0L) part.delete()
|
||||
|
||||
val url = "$baseUrl/$name"
|
||||
val requestBuilder = HttpRequest.newBuilder(URI.create(url)).GET()
|
||||
if (startAt > 0L) requestBuilder.header("Range", "bytes=$startAt-")
|
||||
val response = try {
|
||||
client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofInputStream())
|
||||
} catch (e: IOException) {
|
||||
part.delete()
|
||||
throw IllegalStateException("не удалось скачать $name с $url: ${e.message}", e)
|
||||
} catch (e: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
part.delete()
|
||||
throw IllegalStateException("скачивание $name прервано", e)
|
||||
}
|
||||
|
||||
val status = response.statusCode()
|
||||
if (status != 200 && status != 206) {
|
||||
response.body().close()
|
||||
part.delete()
|
||||
throw IllegalStateException("не удалось скачать $name: HTTP $status ($url)")
|
||||
}
|
||||
|
||||
val resuming = status == 206 && startAt > 0L
|
||||
if (!resuming) startAt = 0L
|
||||
val declaredBody = response.headers().firstValueAsLong("Content-Length").orElse(-1L)
|
||||
val rangeTotal = response.headers().firstValue("Content-Range").orElse(null)
|
||||
?.substringAfterLast('/')?.trim()?.toLongOrNull()
|
||||
val expectedTotal = when {
|
||||
resuming -> rangeTotal ?: if (declaredBody >= 0) startAt + declaredBody else -1L
|
||||
declaredBody >= 0 -> declaredBody
|
||||
else -> -1L
|
||||
}
|
||||
|
||||
var written = 0L
|
||||
try {
|
||||
response.body().use { input ->
|
||||
FileOutputStream(part, resuming).use { out ->
|
||||
val buf = ByteArray(1 shl 16)
|
||||
var lastLog = System.currentTimeMillis()
|
||||
while (true) {
|
||||
val n = input.read(buf)
|
||||
if (n < 0) break
|
||||
out.write(buf, 0, n)
|
||||
written += n
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastLog >= 2_000L) {
|
||||
lastLog = now
|
||||
log(progress(name, startAt + written, expectedTotal))
|
||||
}
|
||||
}
|
||||
out.flush()
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
part.delete()
|
||||
throw IllegalStateException("не удалось скачать $name с $url: ${e.message}", e)
|
||||
}
|
||||
|
||||
if (declaredBody >= 0 && written != declaredBody) {
|
||||
val actual = startAt + written
|
||||
val expected = startAt + declaredBody
|
||||
part.delete()
|
||||
throw IllegalStateException(
|
||||
"размер $name не совпал: ожидалось $expected байт, получено $actual байт",
|
||||
)
|
||||
}
|
||||
val actualTotal = part.length()
|
||||
if (expectedTotal >= 0 && actualTotal != expectedTotal) {
|
||||
part.delete()
|
||||
throw IllegalStateException(
|
||||
"размер $name не совпал: ожидалось $expectedTotal байт, получено $actualTotal байт",
|
||||
)
|
||||
}
|
||||
if (!part.renameTo(target)) {
|
||||
part.delete()
|
||||
throw IllegalStateException("не удалось переименовать $name.part в $name")
|
||||
}
|
||||
log("скачано $name: ${megabytes(actualTotal)} МБ")
|
||||
return actualTotal
|
||||
}
|
||||
|
||||
private fun progress(name: String, done: Long, total: Long): String =
|
||||
if (total > 0) {
|
||||
val pct = (done * 100 / total).coerceIn(0L, 100L)
|
||||
"скачиваю $name: $pct% (${megabytes(done)} МБ / ${megabytes(total)} МБ)"
|
||||
} else {
|
||||
"скачиваю $name: ${megabytes(done)} МБ"
|
||||
}
|
||||
|
||||
private fun megabytes(bytes: Long): Long = Math.round(bytes / 1_000_000.0)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package memo.core
|
||||
|
||||
import java.io.File
|
||||
|
||||
enum class SearchMode { HYBRID, LEX, VEC }
|
||||
|
||||
data class Hit(
|
||||
val path: String,
|
||||
val line: Int,
|
||||
val heading: String,
|
||||
val score: Double,
|
||||
val text: String,
|
||||
)
|
||||
|
||||
fun interface RefreshHook { fun refresh(root: File) }
|
||||
|
||||
class Searcher(
|
||||
private val db: Db,
|
||||
private val embedder: Embedder,
|
||||
private val refresh: RefreshHook? = null,
|
||||
) {
|
||||
fun search(
|
||||
root: File,
|
||||
query: String,
|
||||
k: Int = 8,
|
||||
mode: SearchMode = SearchMode.HYBRID,
|
||||
): List<Hit> {
|
||||
if (refresh != null) {
|
||||
refresh.refresh(root)
|
||||
}
|
||||
|
||||
val lexRows: List<Pair<Long, Double>> =
|
||||
if (mode == SearchMode.LEX || mode == SearchMode.HYBRID) lexSearch(query) else emptyList()
|
||||
val vecRows: List<Pair<Long, Double>> =
|
||||
if (mode == SearchMode.VEC || mode == SearchMode.HYBRID) vecSearch(query) else emptyList()
|
||||
|
||||
val scores = HashMap<Long, Double>()
|
||||
for ((idx, row) in lexRows.withIndex()) {
|
||||
val rank = idx + 1
|
||||
scores.merge(row.first, 1.0 / (60.0 + rank), Double::plus)
|
||||
}
|
||||
for ((idx, row) in vecRows.withIndex()) {
|
||||
val rank = idx + 1
|
||||
scores.merge(row.first, 1.0 / (60.0 + rank), Double::plus)
|
||||
}
|
||||
|
||||
if (scores.isEmpty()) return emptyList()
|
||||
|
||||
val topEntries = scores.entries.sortedByDescending { it.value }.take(k)
|
||||
val orderedRowids = topEntries.map { it.key }
|
||||
val scoreByRowid = orderedRowids.associateWith { scores[it]!! }
|
||||
|
||||
val placeholders = orderedRowids.joinToString(",") { "?" }
|
||||
val select = db.conn.prepare(
|
||||
"SELECT id, path, line, heading, text FROM chunks WHERE id IN ($placeholders)"
|
||||
)
|
||||
val rowData = HashMap<Long, RowData>()
|
||||
try {
|
||||
for ((idx, id) in orderedRowids.withIndex()) {
|
||||
select.bindLong(idx + 1, id)
|
||||
}
|
||||
val rs = select.executeQuery()
|
||||
try {
|
||||
while (rs.next()) {
|
||||
val id = rs.getLong(0)!!
|
||||
val path = rs.getText(1) ?: ""
|
||||
val line = rs.getInt(2)!!
|
||||
val heading = rs.getText(3) ?: ""
|
||||
val textRaw = rs.getText(4) ?: ""
|
||||
val text = if (textRaw.length > 1200) textRaw.substring(0, 1200) else textRaw
|
||||
rowData[id] = RowData(path, line, heading, text)
|
||||
}
|
||||
} finally {
|
||||
rs.close()
|
||||
}
|
||||
} finally {
|
||||
select.close()
|
||||
}
|
||||
|
||||
return orderedRowids.mapNotNull { id ->
|
||||
val rd = rowData[id] ?: return@mapNotNull null
|
||||
Hit(
|
||||
path = rd.path,
|
||||
line = rd.line,
|
||||
heading = rd.heading,
|
||||
score = scoreByRowid[id] ?: 0.0,
|
||||
text = rd.text,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun lexSearch(query: String): List<Pair<Long, Double>> {
|
||||
val tokens = tokensOf(query)
|
||||
if (tokens.isEmpty()) return emptyList()
|
||||
val matchExpr = tokens.joinToString(" OR ") { "\"$it\"" }
|
||||
val stmt = db.conn.prepare(
|
||||
"SELECT rowid, bm25(chunks_fts) AS s FROM chunks_fts " +
|
||||
"WHERE chunks_fts MATCH ? ORDER BY s LIMIT 32"
|
||||
)
|
||||
return try {
|
||||
stmt.bindText(1, matchExpr)
|
||||
val rs = stmt.executeQuery()
|
||||
try {
|
||||
val out = ArrayList<Pair<Long, Double>>()
|
||||
while (rs.next()) {
|
||||
out.add(rs.getLong(0)!! to rs.getDouble(1)!!)
|
||||
}
|
||||
out
|
||||
} finally {
|
||||
rs.close()
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
emptyList()
|
||||
} finally {
|
||||
stmt.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun vecSearch(query: String): List<Pair<Long, Double>> {
|
||||
val stmt = db.conn.prepare(
|
||||
"SELECT rowid, distance FROM chunks_vec " +
|
||||
"WHERE embedding MATCH ? ORDER BY distance LIMIT 32"
|
||||
)
|
||||
return try {
|
||||
stmt.bindVector(1, embedder.embed(query))
|
||||
val rs = stmt.executeQuery()
|
||||
try {
|
||||
val out = ArrayList<Pair<Long, Double>>()
|
||||
while (rs.next()) {
|
||||
out.add(rs.getLong(0)!! to rs.getDouble(1)!!)
|
||||
}
|
||||
out
|
||||
} finally {
|
||||
rs.close()
|
||||
}
|
||||
} finally {
|
||||
stmt.close()
|
||||
}
|
||||
}
|
||||
|
||||
private data class RowData(val path: String, val line: Int, val heading: String, val text: String)
|
||||
}
|
||||
|
||||
private fun tokensOf(query: String): List<String> {
|
||||
val tokens = ArrayList<String>()
|
||||
val sb = StringBuilder()
|
||||
for (ch in query) {
|
||||
if (Character.isLetterOrDigit(ch)) {
|
||||
sb.append(ch)
|
||||
} else {
|
||||
if (sb.length >= 2) tokens.add(sb.toString())
|
||||
sb.setLength(0)
|
||||
}
|
||||
}
|
||||
if (sb.length >= 2) tokens.add(sb.toString())
|
||||
return if (tokens.size <= 8) tokens else tokens.subList(0, 8)
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package memo.core
|
||||
|
||||
import java.io.File
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class CollectionsTest {
|
||||
|
||||
private fun newRoot(): File {
|
||||
val root = File.createTempFile("memo-collections-", "")
|
||||
assertTrue(root.delete(), "temp cleanup")
|
||||
assertTrue(root.mkdirs(), "temp mkdir")
|
||||
root.deleteOnExit()
|
||||
return root
|
||||
}
|
||||
|
||||
private fun touch(parent: File, vararg rel: String): File {
|
||||
var cur = parent
|
||||
for (seg in rel.dropLast(1)) {
|
||||
cur = File(cur, seg)
|
||||
cur.mkdirs()
|
||||
}
|
||||
val f = File(cur, rel.last())
|
||||
f.writeText("# ${rel.last()}\n")
|
||||
f.deleteOnExit()
|
||||
return f
|
||||
}
|
||||
|
||||
@Test
|
||||
fun findCollectionsReturnsOnlyDirsWithDirectMarkdown() {
|
||||
val root = newRoot()
|
||||
touch(root, "a.md")
|
||||
touch(root, "sub", "b.md")
|
||||
touch(root, "sub", "deep", "c.md")
|
||||
File(root, "empty").mkdirs()
|
||||
touch(root, "nested", "only", "deep", "d.md")
|
||||
File(root, "fakemd").mkdirs()
|
||||
File(root, "fakemd/submd").mkdirs()
|
||||
File(root, "readme.txt").writeText("не markdown")
|
||||
|
||||
val got = findCollections(root).map { it.absolutePath }.toSet()
|
||||
val want = setOf(
|
||||
root.absolutePath,
|
||||
File(root, "sub").absolutePath,
|
||||
File(root, "sub/deep").absolutePath,
|
||||
File(root, "nested/only/deep").absolutePath,
|
||||
)
|
||||
assertEquals(want, got)
|
||||
assertTrue(File(root, "empty").absolutePath !in got, "empty must not be a collection")
|
||||
assertTrue(File(root, "nested").absolutePath !in got, "nested must not be a collection")
|
||||
assertTrue(File(root, "nested/only").absolutePath !in got, "nested/only must not be a collection")
|
||||
assertTrue(File(root, "fakemd").absolutePath !in got, "fakemd must not be a collection")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parentDirWithoutDirectMarkdownIsNotCollection() {
|
||||
val root = newRoot()
|
||||
touch(root, "top", "inner", "x.md")
|
||||
|
||||
val got = findCollections(root).map { it.absolutePath }.toSet()
|
||||
assertEquals(setOf(File(root, "top/inner").absolutePath), got)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveCollectionRaisesToNearestWithMarkdown() {
|
||||
val root = newRoot()
|
||||
val note = touch(root, "top", "inner", "x.md")
|
||||
|
||||
val fileResult = resolveCollection(note)
|
||||
assertEquals(File(root, "top/inner").absolutePath, fileResult.absolutePath)
|
||||
|
||||
val dirResult = resolveCollection(File(root, "top"))
|
||||
assertEquals(File(root, "top/inner").absolutePath, dirResult.absolutePath)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hiddenDirsAreSkipped() {
|
||||
val root = newRoot()
|
||||
touch(root, ".hidden", "a.md")
|
||||
touch(root, "vis", "b.md")
|
||||
File(root, ".memo").mkdirs()
|
||||
File(File(root, ".memo"), "index.db").writeText("")
|
||||
|
||||
val got = findCollections(root).map { it.absolutePath }.toSet()
|
||||
assertEquals(setOf(File(root, "vis").absolutePath), got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package memo.core
|
||||
|
||||
import java.io.File
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun indexerSkipsUnchangedFile() {
|
||||
val tempDir = File.createTempFile("memo-indexer-", "")
|
||||
assertTrue(tempDir.delete(), "temp dir cleanup")
|
||||
assertTrue(tempDir.mkdir(), "temp dir create")
|
||||
tempDir.deleteOnExit()
|
||||
|
||||
val note = File(tempDir, "note.md")
|
||||
note.writeText("# Заголовок\nтело заметки\n## Второй\nещё текст")
|
||||
note.deleteOnExit()
|
||||
|
||||
val dbFile = File(tempDir, "index.db")
|
||||
dbFile.deleteOnExit()
|
||||
|
||||
val modelDir = System.getenv("MEMO_MODEL_DIR") ?: "/root/WORK/memo/models/siglip2"
|
||||
val modelPath = "$modelDir/text_model_int8.onnx"
|
||||
val tokenizerPath = "$modelDir/tokenizer.model"
|
||||
|
||||
Db(dbFile.absolutePath).use { db ->
|
||||
db.init()
|
||||
Embedder(modelPath, tokenizerPath).use { embedder ->
|
||||
val indexer = Indexer(db, embedder)
|
||||
|
||||
assertTrue(indexer.indexFile(note), "первый indexFile обязан переиндексировать")
|
||||
assertFalse(
|
||||
indexer.indexFile(note),
|
||||
"второй indexFile без изменений обязан вернуть false",
|
||||
)
|
||||
|
||||
note.appendText("\n## Третий\nновый текст\n")
|
||||
assertTrue(
|
||||
indexer.indexFile(note),
|
||||
"indexFile после изменения содержимого обязан вернуть true",
|
||||
)
|
||||
|
||||
val stmt = db.conn.prepare("SELECT count(*) FROM chunks")
|
||||
try {
|
||||
val rs = stmt.executeQuery()
|
||||
try {
|
||||
assertTrue(rs.next(), "SELECT count(*) должен вернуть строку")
|
||||
val count = rs.getLong(0)!!
|
||||
assertTrue(count > 0, "ожидались чанки, получено $count")
|
||||
} finally {
|
||||
rs.close()
|
||||
}
|
||||
} finally {
|
||||
stmt.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package memo.core
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange
|
||||
import com.sun.net.httpserver.HttpHandler
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import java.net.InetSocketAddress
|
||||
import java.nio.file.Files
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ModelStoreTest {
|
||||
|
||||
@Test
|
||||
fun downloadsMissingFiles() {
|
||||
val dir = createTempDir()
|
||||
val onnx = deterministicBytes(123_456, seed = 1)
|
||||
val tok = deterministicBytes(45_678, seed = 2)
|
||||
val server = startServer { ex ->
|
||||
when (ex.requestURI.path) {
|
||||
"/text_model_int8.onnx" -> serveBytes(ex, onnx, count = null)
|
||||
"/tokenizer.model" -> serveBytes(ex, tok, count = null)
|
||||
else -> ex.sendResponseHeaders(404, -1).also { ex.close() }
|
||||
}
|
||||
}
|
||||
try {
|
||||
val result = ModelStore.ensure(dir, baseUrl = server.base())
|
||||
assertEquals(listOf("text_model_int8.onnx", "tokenizer.model"), result.downloaded)
|
||||
assertEquals(emptyList(), result.skipped)
|
||||
assertEquals((onnx.size + tok.size).toLong(), result.bytes)
|
||||
|
||||
val onnxFile = java.io.File(dir, "text_model_int8.onnx")
|
||||
val tokFile = java.io.File(dir, "tokenizer.model")
|
||||
assertTrue(onnxFile.isFile, "onnx должен быть скачан")
|
||||
assertTrue(tokFile.isFile, "tokenizer должен быть скачан")
|
||||
assertEquals(onnx.size.toLong(), onnxFile.length())
|
||||
assertEquals(tok.size.toLong(), tokFile.length())
|
||||
assertContentEquals(onnx, onnxFile.readBytes())
|
||||
assertContentEquals(tok, tokFile.readBytes())
|
||||
assertFalse(java.io.File(dir, "text_model_int8.onnx.part").exists())
|
||||
assertFalse(java.io.File(dir, "tokenizer.model.part").exists())
|
||||
} finally {
|
||||
server.stop(0)
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun skipsExistingWithoutNetwork() {
|
||||
val dir = createTempDir()
|
||||
val onnx = deterministicBytes(10_000, seed = 3)
|
||||
val tok = deterministicBytes(8_000, seed = 4)
|
||||
java.io.File(dir, "text_model_int8.onnx").writeBytes(onnx)
|
||||
java.io.File(dir, "tokenizer.model").writeBytes(tok)
|
||||
val count = AtomicInteger(0)
|
||||
val server = startServer { ex ->
|
||||
count.incrementAndGet()
|
||||
ex.sendResponseHeaders(500, -1)
|
||||
ex.close()
|
||||
}
|
||||
try {
|
||||
val result = ModelStore.ensure(dir, baseUrl = server.base())
|
||||
assertEquals(0, count.get(), "ensure не должен ходить в сеть, если всё уже на месте")
|
||||
assertEquals(emptyList(), result.downloaded)
|
||||
assertEquals(listOf("text_model_int8.onnx", "tokenizer.model"), result.skipped)
|
||||
assertEquals(0L, result.bytes)
|
||||
assertContentEquals(onnx, java.io.File(dir, "text_model_int8.onnx").readBytes())
|
||||
assertContentEquals(tok, java.io.File(dir, "tokenizer.model").readBytes())
|
||||
} finally {
|
||||
server.stop(0)
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resumesPartialDownload() {
|
||||
val dir = createTempDir()
|
||||
val full = deterministicBytes(20_000, seed = 5)
|
||||
java.io.File(dir, "text_model_int8.onnx").writeBytes(deterministicBytes(5_000, seed = 55))
|
||||
val part = java.io.File(dir, "tokenizer.model.part")
|
||||
val half = full.size / 2
|
||||
part.writeBytes(full.copyOfRange(0, half))
|
||||
|
||||
val totalRequests = AtomicInteger(0)
|
||||
val rangeRequests = AtomicInteger(0)
|
||||
val bytesServed = AtomicLong(0L)
|
||||
val server = startServer { ex ->
|
||||
when (ex.requestURI.path) {
|
||||
"/text_model_int8.onnx" -> ex.sendResponseHeaders(500, -1).also { ex.close() }
|
||||
"/tokenizer.model" -> {
|
||||
totalRequests.incrementAndGet()
|
||||
val range = ex.requestHeaders.getFirst("Range")
|
||||
if (range != null && range.startsWith("bytes=")) {
|
||||
rangeRequests.incrementAndGet()
|
||||
val spec = range.removePrefix("bytes=")
|
||||
val start = spec.substringBefore('-').trim().toLong()
|
||||
if (start in 0..full.size.toLong()) {
|
||||
val tail = full.copyOfRange(start.toInt(), full.size)
|
||||
ex.responseHeaders.set(
|
||||
"Content-Range",
|
||||
"bytes $start-${full.size - 1}/${full.size}",
|
||||
)
|
||||
ex.sendResponseHeaders(206, tail.size.toLong())
|
||||
bytesServed.addAndGet(tail.size.toLong())
|
||||
ex.responseBody.use { it.write(tail) }
|
||||
return@startServer
|
||||
}
|
||||
}
|
||||
ex.sendResponseHeaders(400, -1)
|
||||
ex.close()
|
||||
}
|
||||
else -> ex.sendResponseHeaders(404, -1).also { ex.close() }
|
||||
}
|
||||
}
|
||||
try {
|
||||
val result = ModelStore.ensure(dir, baseUrl = server.base())
|
||||
assertEquals(listOf("tokenizer.model"), result.downloaded)
|
||||
assertTrue(
|
||||
rangeRequests.get() >= 1,
|
||||
"докачка должна была пойти хвостом: Range-запросов=${rangeRequests.get()}, всего=${totalRequests.get()}",
|
||||
)
|
||||
assertTrue(
|
||||
bytesServed.get() < full.size.toLong(),
|
||||
"докачка хвостом обязана отдать меньше полного файла: " +
|
||||
"отдано=${bytesServed.get()}, файл=${full.size}",
|
||||
)
|
||||
val tokFile = java.io.File(dir, "tokenizer.model")
|
||||
assertTrue(tokFile.isFile, "tokenizer должен быть собран из .part + хвоста")
|
||||
assertEquals(full.size.toLong(), tokFile.length())
|
||||
assertContentEquals(full, tokFile.readBytes())
|
||||
assertFalse(java.io.File(dir, "tokenizer.model.part").exists(), ".part обязан быть переименован")
|
||||
} finally {
|
||||
server.stop(0)
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsWrongTotalFromContentRange() {
|
||||
val dir = createTempDir()
|
||||
val full = deterministicBytes(8_000, seed = 11)
|
||||
val part = java.io.File(dir, "text_model_int8.onnx.part")
|
||||
part.writeBytes(full.copyOfRange(0, 4_000))
|
||||
java.io.File(dir, "tokenizer.model").writeBytes(deterministicBytes(1_000, seed = 12))
|
||||
|
||||
val server = startServer { ex ->
|
||||
when (ex.requestURI.path) {
|
||||
"/text_model_int8.onnx" -> {
|
||||
val range = ex.requestHeaders.getFirst("Range")
|
||||
if (range == null || !range.startsWith("bytes=")) {
|
||||
ex.sendResponseHeaders(400, -1)
|
||||
ex.close()
|
||||
return@startServer
|
||||
}
|
||||
val start = range.removePrefix("bytes=").substringBefore('-').trim().toLong()
|
||||
val tail = full.copyOfRange(start.toInt(), full.size)
|
||||
ex.responseHeaders.set(
|
||||
"Content-Range",
|
||||
"bytes $start-${full.size - 1}/999999",
|
||||
)
|
||||
ex.sendResponseHeaders(206, tail.size.toLong())
|
||||
ex.responseBody.use { it.write(tail) }
|
||||
}
|
||||
"/tokenizer.model" -> ex.sendResponseHeaders(500, -1).also { ex.close() }
|
||||
else -> ex.sendResponseHeaders(404, -1).also { ex.close() }
|
||||
}
|
||||
}
|
||||
try {
|
||||
val ex = assertFailsWith<IllegalStateException>(
|
||||
"ensure обязан бросить исключение при лжи про общий размер в Content-Range",
|
||||
) {
|
||||
ModelStore.ensure(dir, baseUrl = server.base())
|
||||
}
|
||||
val msg = ex.message ?: ""
|
||||
assertTrue(
|
||||
msg.contains("размер"),
|
||||
"сообщение должно указывать на проблему с размером: $msg",
|
||||
)
|
||||
assertFalse(
|
||||
java.io.File(dir, "text_model_int8.onnx").exists(),
|
||||
"целевого файла быть не должно",
|
||||
)
|
||||
assertFalse(
|
||||
java.io.File(dir, "text_model_int8.onnx.part").exists(),
|
||||
".part обязан быть удалён",
|
||||
)
|
||||
} finally {
|
||||
server.stop(0)
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun truncatedResponseDoesNotProduceFile() {
|
||||
val dir = createTempDir()
|
||||
val real = deterministicBytes(8_000, seed = 6)
|
||||
val declaredSize = (real.size + 5_000).toLong()
|
||||
java.io.File(dir, "tokenizer.model").writeBytes(deterministicBytes(1_000, seed = 13))
|
||||
val server = startServer { ex ->
|
||||
when (ex.requestURI.path) {
|
||||
"/text_model_int8.onnx" -> {
|
||||
ex.sendResponseHeaders(200, declaredSize)
|
||||
ex.responseBody.use { it.write(real) }
|
||||
ex.close()
|
||||
}
|
||||
"/tokenizer.model" -> ex.sendResponseHeaders(500, -1).also { ex.close() }
|
||||
else -> ex.sendResponseHeaders(404, -1).also { ex.close() }
|
||||
}
|
||||
}
|
||||
try {
|
||||
val ex = assertFailsWith<IllegalStateException>(
|
||||
"ensure обязан бросить исключение при оборванном ответе",
|
||||
) {
|
||||
ModelStore.ensure(dir, baseUrl = server.base())
|
||||
}
|
||||
assertTrue(
|
||||
(ex.message ?: "").isNotEmpty(),
|
||||
"исключение должно иметь осмысленное сообщение: '${ex.message}'",
|
||||
)
|
||||
assertFalse(
|
||||
java.io.File(dir, "text_model_int8.onnx").exists(),
|
||||
"целевого файла быть не должно",
|
||||
)
|
||||
assertFalse(
|
||||
java.io.File(dir, "text_model_int8.onnx.part").exists(),
|
||||
".part обязан быть удалён",
|
||||
)
|
||||
} finally {
|
||||
server.stop(0)
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun forceRedownloads() {
|
||||
val dir = createTempDir()
|
||||
val garbageOnnx = deterministicBytes(9_999, seed = 7)
|
||||
val garbageTok = deterministicBytes(3_333, seed = 8)
|
||||
java.io.File(dir, "text_model_int8.onnx").writeBytes(garbageOnnx)
|
||||
java.io.File(dir, "tokenizer.model").writeBytes(garbageTok)
|
||||
val onnx = deterministicBytes(11_111, seed = 9)
|
||||
val tok = deterministicBytes(4_444, seed = 10)
|
||||
val server = startServer { ex ->
|
||||
when (ex.requestURI.path) {
|
||||
"/text_model_int8.onnx" -> serveBytes(ex, onnx, count = null)
|
||||
"/tokenizer.model" -> serveBytes(ex, tok, count = null)
|
||||
else -> ex.sendResponseHeaders(404, -1).also { ex.close() }
|
||||
}
|
||||
}
|
||||
try {
|
||||
val result = ModelStore.ensure(dir, baseUrl = server.base(), force = true)
|
||||
assertEquals(listOf("text_model_int8.onnx", "tokenizer.model"), result.downloaded)
|
||||
assertContentEquals(onnx, java.io.File(dir, "text_model_int8.onnx").readBytes())
|
||||
assertContentEquals(tok, java.io.File(dir, "tokenizer.model").readBytes())
|
||||
} finally {
|
||||
server.stop(0)
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createTempDir(): java.io.File {
|
||||
val d = Files.createTempDirectory("memo-modelstore-").toFile()
|
||||
d.deleteOnExit()
|
||||
return d
|
||||
}
|
||||
|
||||
private fun deterministicBytes(size: Int, seed: Int): ByteArray {
|
||||
val out = ByteArray(size)
|
||||
var v = seed * 2_654_435_761 + 1
|
||||
for (i in out.indices) {
|
||||
v = v * 1_664_525 + 1_013_904_223
|
||||
out[i] = (v ushr 16 and 0xFF).toByte()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun startServer(handler: (HttpExchange) -> Unit): HttpServerWrap {
|
||||
val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
|
||||
server.createContext("/", HttpHandler { ex -> handler(ex) })
|
||||
server.executor = null
|
||||
server.start()
|
||||
return HttpServerWrap(server, server.address.port)
|
||||
}
|
||||
|
||||
private class HttpServerWrap(private val server: HttpServer, val port: Int) {
|
||||
fun base(): String = "http://127.0.0.1:$port"
|
||||
fun stop(delay: Int) = server.stop(delay)
|
||||
}
|
||||
|
||||
private fun serveBytes(ex: HttpExchange, data: ByteArray, count: AtomicInteger?) {
|
||||
count?.incrementAndGet()
|
||||
val range = ex.requestHeaders.getFirst("Range")
|
||||
if (range != null && range.startsWith("bytes=")) {
|
||||
val spec = range.removePrefix("bytes=")
|
||||
val start = spec.substringBefore('-').trim().toLong()
|
||||
if (start in 0..data.size.toLong()) {
|
||||
val tail = data.copyOfRange(start.toInt(), data.size)
|
||||
ex.responseHeaders.set("Content-Range", "bytes $start-${data.size - 1}/${data.size}")
|
||||
ex.sendResponseHeaders(206, tail.size.toLong())
|
||||
ex.responseBody.use { it.write(tail) }
|
||||
return
|
||||
}
|
||||
}
|
||||
ex.sendResponseHeaders(200, data.size.toLong())
|
||||
ex.responseBody.use { it.write(data) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package memo.core
|
||||
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SearcherTest {
|
||||
|
||||
private class Fixture : AutoCloseable {
|
||||
val tempDir: File = Files.createTempDirectory("memo-searcher-").toFile()
|
||||
val db = Db(File(tempDir, "index.db").absolutePath)
|
||||
val embedder: Embedder
|
||||
|
||||
init {
|
||||
db.init()
|
||||
val modelDir = System.getenv("MEMO_MODEL_DIR") ?: "/root/WORK/memo/models/siglip2"
|
||||
val modelPath = "$modelDir/text_model_int8.onnx"
|
||||
val tokenizerPath = "$modelDir/tokenizer.model"
|
||||
embedder = Embedder(modelPath, tokenizerPath)
|
||||
}
|
||||
|
||||
fun addChunk(path: String, heading: String, line: Int, text: String) {
|
||||
val conn = db.conn
|
||||
val insChunk = conn.prepare(
|
||||
"INSERT INTO chunks(path, heading, line, ord, text, hash) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
try {
|
||||
insChunk.bindText(1, path)
|
||||
insChunk.bindText(2, heading)
|
||||
insChunk.bindLong(3, line.toLong())
|
||||
insChunk.bindLong(4, 0L)
|
||||
insChunk.bindText(5, text)
|
||||
insChunk.bindText(6, "$path#$line")
|
||||
insChunk.executeUpdate()
|
||||
} finally {
|
||||
insChunk.close()
|
||||
}
|
||||
val id = conn.lastInsertRowId
|
||||
val insFts = conn.prepare(
|
||||
"INSERT INTO chunks_fts(rowid, text, heading) VALUES (?, ?, ?)"
|
||||
)
|
||||
try {
|
||||
insFts.bindLong(1, id)
|
||||
insFts.bindText(2, text)
|
||||
insFts.bindText(3, heading)
|
||||
insFts.executeUpdate()
|
||||
} finally {
|
||||
insFts.close()
|
||||
}
|
||||
val insVec = conn.prepare(
|
||||
"INSERT INTO chunks_vec(rowid, embedding) VALUES (?, ?)"
|
||||
)
|
||||
try {
|
||||
insVec.bindLong(1, id)
|
||||
insVec.bindVector(2, embedder.embed(text))
|
||||
insVec.executeUpdate()
|
||||
} finally {
|
||||
insVec.close()
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
embedder.close()
|
||||
db.close()
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lexModeFindsExactValue() {
|
||||
Fixture().use { f ->
|
||||
f.addChunk("/a.md", "A", 1, "Прокси корпоративных доменов на 76.132")
|
||||
f.addChunk("/b.md", "B", 1, "Список контактов службы поддержки")
|
||||
f.addChunk("/c.md", "C", 1, "Описание архитектуры сетевого шлюза")
|
||||
val s = Searcher(f.db, f.embedder)
|
||||
val hits = s.search(f.tempDir, "76.132", k = 8, mode = SearchMode.LEX)
|
||||
assertTrue(hits.isNotEmpty(), "ожидались хиты, получено 0")
|
||||
assertTrue(hits[0].text.contains("76.132"), "первый хит должен содержать 76.132")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun vecModeFindsSemanticMatch() {
|
||||
Fixture().use { f ->
|
||||
f.addChunk("/server.md", "Server", 1, "Настройка сервера приложений и конфигурация Tomcat")
|
||||
f.addChunk("/book.md", "Book", 1, "Аннотация книги по истории Древнего Рима")
|
||||
f.addChunk("/ci.md", "CI", 1, "Пайплайн выпуска приложения: сборка, тесты, деплой в Kubernetes")
|
||||
val s = Searcher(f.db, f.embedder)
|
||||
val hits = s.search(f.tempDir, "настройку выпуска приложения", k = 1, mode = SearchMode.VEC)
|
||||
assertTrue(hits.isNotEmpty(), "ожидались хиты, получено 0")
|
||||
assertEquals("/ci.md", hits[0].path, "первый хит должен быть чанк про CI")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lexModeIgnoresVectorOnlyMatch() {
|
||||
Fixture().use { f ->
|
||||
f.addChunk("/p.md", "P", 1, "опрос")
|
||||
f.addChunk("/s.md", "S", 1, "случай")
|
||||
f.addChunk("/z.md", "Z", 1, "знание")
|
||||
val s = Searcher(f.db, f.embedder)
|
||||
val lex = s.search(f.tempDir, "автомобиль", k = 8, mode = SearchMode.LEX)
|
||||
val vec = s.search(f.tempDir, "автомобиль", k = 8, mode = SearchMode.VEC)
|
||||
assertTrue(lex.isEmpty(), "LEX должен быть пустым, получили ${lex.size} хитов")
|
||||
assertTrue(vec.isNotEmpty(), "VEC должен быть непустым, получили 0")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hybridCombinesBoth() {
|
||||
Fixture().use { f ->
|
||||
f.addChunk("/exact.md", "Exact", 1, "Прокси корпоративных доменов на 76.132")
|
||||
f.addChunk("/sem.md", "Sem", 1, "Конвейер поставки продуктов: компиляция, испытания, выкладка в кластер")
|
||||
f.addChunk("/other.md", "Other", 1, "Заметки о книге по истории")
|
||||
val s = Searcher(f.db, f.embedder)
|
||||
val hits = s.search(f.tempDir, "выпуск приложения 76.132", k = 5, mode = SearchMode.HYBRID)
|
||||
val paths = hits.map { it.path }.toSet()
|
||||
assertTrue(paths.contains("/exact.md"), "чанк exact отсутствует в: $paths")
|
||||
assertTrue(paths.contains("/sem.md"), "чанк sem отсутствует в: $paths")
|
||||
assertEquals("/exact.md", hits[0].path, "exact.md должен быть первым в HYBRID за счёт лекс-буста, получено: ${hits.map { it.path }}")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resultsRespectKAndTextLength() {
|
||||
Fixture().use { f ->
|
||||
f.addChunk("/a.md", "A", 1, "Первый фрагмент про сервер")
|
||||
f.addChunk("/b.md", "B", 1, "Второй фрагмент про книгу")
|
||||
f.addChunk("/c.md", "C", 1, "Третий фрагмент про CI")
|
||||
f.addChunk("/d.md", "D", 1, "Четвёртый фрагмент про настройку")
|
||||
val s = Searcher(f.db, f.embedder)
|
||||
val hits = s.search(f.tempDir, "фрагмент", k = 2, mode = SearchMode.HYBRID)
|
||||
assertEquals(2, hits.size, "должно быть ровно 2 хита при k=2")
|
||||
for (h in hits) {
|
||||
assertTrue(h.text.isNotEmpty(), "text не должен быть пустым: $h")
|
||||
assertTrue(h.text.length <= 1200, "длина text превышает 1200: ${h.text.length}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
application
|
||||
kotlin("plugin.serialization") version "2.4.10"
|
||||
}
|
||||
|
||||
application {
|
||||
mainClass.set("memo.mcp.McpServerKt")
|
||||
applicationName = "memo-mcp"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":memo-core"))
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||
testImplementation(kotlin("test"))
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
package memo.mcp
|
||||
|
||||
import memo.core.Db
|
||||
import memo.core.Embedder
|
||||
import memo.core.Indexer
|
||||
import memo.core.ModelStore
|
||||
import memo.core.RefreshHook
|
||||
import memo.core.SearchMode
|
||||
import memo.core.Searcher
|
||||
import memo.core.findCollections
|
||||
import memo.core.isCollection
|
||||
import memo.core.resolveCollection
|
||||
import java.io.File
|
||||
|
||||
private const val PROTOCOL_VERSION = "2024-11-05"
|
||||
private const val SERVER_NAME = "memo"
|
||||
private const val SERVER_VERSION = "0.1.0"
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
val br = System.`in`.bufferedReader()
|
||||
while (true) {
|
||||
val line = br.readLine() ?: break
|
||||
if (line.isBlank()) continue
|
||||
try {
|
||||
val resp = handleRequest(line)
|
||||
if (resp != null) {
|
||||
println(resp)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
System.err.println("mcp: ${t.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleRequest(line: String): String? {
|
||||
val parsed = try {
|
||||
JsonParser(line).parse()
|
||||
} catch (t: Throwable) {
|
||||
return rpcError(null, -32700, "Parse error")
|
||||
}
|
||||
val obj = parsed as? Map<String, Any?> ?: return rpcError(null, -32600, "Invalid Request")
|
||||
if (!obj.containsKey("id")) return null
|
||||
val id = obj["id"]
|
||||
val method = obj["method"] as? String ?: return rpcError(id, -32600, "Invalid Request")
|
||||
val params = obj["params"]
|
||||
return when (method) {
|
||||
"initialize" -> result(id, initializeResult())
|
||||
"tools/list" -> result(id, toolsListResult())
|
||||
"tools/call" -> {
|
||||
try {
|
||||
val r = handleToolCall(params)
|
||||
result(id, r)
|
||||
} catch (t: Throwable) {
|
||||
result(id, toolErrorContent(t.message ?: "error"))
|
||||
}
|
||||
}
|
||||
else -> rpcError(id, -32601, "Method not found")
|
||||
}
|
||||
}
|
||||
|
||||
private fun initializeResult(): String =
|
||||
"""{"protocolVersion":"$PROTOCOL_VERSION","capabilities":{"tools":{}},"serverInfo":{"name":"$SERVER_NAME","version":"$SERVER_VERSION"}}"""
|
||||
|
||||
private fun toolsListResult(): String =
|
||||
"""{"tools":[""" +
|
||||
"""{"name":"memo_search","description":"Гибридный поиск по индексу","inputSchema":{"type":"object","properties":{"path":{"type":"string"},"query":{"type":"string"},"k":{"type":"integer","default":8},"mode":{"type":"string","enum":["hybrid","lex","vec"],"default":"hybrid"}},"required":["path","query"]}},""" +
|
||||
"""{"name":"memo_status","description":"Статус индекса коллекции","inputSchema":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}},""" +
|
||||
"""{"name":"memo_reindex","description":"Полная переиндексация","inputSchema":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}}""" +
|
||||
"""]}"""
|
||||
|
||||
private fun handleToolCall(params: Any?): String {
|
||||
val p = params as? Map<String, Any?> ?: throw IllegalArgumentException("params required")
|
||||
val name = p["name"] as? String ?: throw IllegalArgumentException("name required")
|
||||
val args = p["arguments"] as? Map<String, Any?> ?: emptyMap()
|
||||
return when (name) {
|
||||
"memo_search" -> {
|
||||
val path = args["path"] as? String
|
||||
if (path.isNullOrBlank()) {
|
||||
toolErrorContent("аргумент path обязателен")
|
||||
} else {
|
||||
val query = args["query"] as? String
|
||||
if (query.isNullOrBlank()) {
|
||||
toolErrorContent("аргумент query обязателен")
|
||||
} else {
|
||||
val k = (args["k"] as? Number)?.toInt() ?: 8
|
||||
val mode = when (args["mode"] as? String) {
|
||||
"lex" -> SearchMode.LEX
|
||||
"vec" -> SearchMode.VEC
|
||||
else -> SearchMode.HYBRID
|
||||
}
|
||||
toolOk(toolSearch(path, query, k, mode))
|
||||
}
|
||||
}
|
||||
}
|
||||
"memo_status" -> {
|
||||
val path = args["path"] as? String
|
||||
if (path.isNullOrBlank()) {
|
||||
toolErrorContent("аргумент path обязателен")
|
||||
} else {
|
||||
toolOk(toolStatus(path))
|
||||
}
|
||||
}
|
||||
"memo_reindex" -> {
|
||||
val path = args["path"] as? String
|
||||
if (path.isNullOrBlank()) {
|
||||
toolErrorContent("аргумент path обязателен")
|
||||
} else {
|
||||
toolOk(toolReindex(path))
|
||||
}
|
||||
}
|
||||
else -> toolErrorContent("unknown tool: $name")
|
||||
}
|
||||
}
|
||||
|
||||
fun toolSearch(path: String, query: String, k: Int, mode: SearchMode): String {
|
||||
val root = resolveCollection(File(path))
|
||||
val targets = selectTargets(root)
|
||||
if (targets.isEmpty()) return "коллекции не найдены"
|
||||
ensureModel()
|
||||
val (modelPath, tokenizerPath) = modelPaths()
|
||||
val allHits = ArrayList<memo.core.Hit>()
|
||||
val errors = ArrayList<String>()
|
||||
val embedder = Embedder(modelPath, tokenizerPath)
|
||||
try {
|
||||
for (coll in targets) {
|
||||
val memoDir = File(coll, ".memo")
|
||||
val dbPath = File(memoDir, "index.db")
|
||||
memoDir.mkdirs()
|
||||
val existedBefore = dbPath.exists()
|
||||
val db = Db(dbPath.absolutePath)
|
||||
try {
|
||||
db.init()
|
||||
if (!existedBefore) {
|
||||
Indexer(db, embedder).indexTree(coll)
|
||||
}
|
||||
val refresh = RefreshHook { r ->
|
||||
val innerDbPath = File(r, ".memo/index.db")
|
||||
if (innerDbPath.exists()) {
|
||||
val innerDb = Db(innerDbPath.absolutePath)
|
||||
try {
|
||||
innerDb.init()
|
||||
Indexer(innerDb, embedder).indexTree(r)
|
||||
} finally {
|
||||
innerDb.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
val hits = Searcher(db, embedder, refresh).search(coll, query, k, mode)
|
||||
allHits.addAll(hits)
|
||||
} catch (t: Throwable) {
|
||||
errors.add("ошибка в ${coll.name}: ${t.message ?: t.javaClass.simpleName}")
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
embedder.close()
|
||||
}
|
||||
val merged = allHits.sortedByDescending { it.score }.take(k)
|
||||
val sb = StringBuilder()
|
||||
for (h in merged) {
|
||||
sb.append("${h.path}:${h.line} ${h.heading}\n${h.text}\n")
|
||||
}
|
||||
for (e in errors) {
|
||||
if (sb.isNotEmpty()) sb.append('\n')
|
||||
sb.append(e)
|
||||
}
|
||||
val out = sb.toString().trimEnd('\n')
|
||||
return if (out.isEmpty()) "ничего не найдено" else out
|
||||
}
|
||||
|
||||
fun toolStatus(path: String): String {
|
||||
val base = resolveCollection(File(path))
|
||||
val collections = selectTargets(base)
|
||||
if (collections.isEmpty()) return "коллекции не найдены"
|
||||
val sb = StringBuilder()
|
||||
for (coll in collections) {
|
||||
val dbPath = File(coll, ".memo/index.db")
|
||||
if (!dbPath.exists()) {
|
||||
sb.append("${coll.name}: нет индекса\n")
|
||||
continue
|
||||
}
|
||||
val db = Db(dbPath.absolutePath)
|
||||
try {
|
||||
val fileCount = countInt(db, "SELECT COUNT(*) FROM files")
|
||||
val chunkCount = countInt(db, "SELECT COUNT(*) FROM chunks")
|
||||
val indexedAt = maxDouble(db, "SELECT indexed_at FROM files WHERE indexed_at IS NOT NULL")
|
||||
val date = if (indexedAt != null) {
|
||||
java.time.Instant.ofEpochSecond(indexedAt.toLong()).toString()
|
||||
} else {
|
||||
"—"
|
||||
}
|
||||
sb.append("$coll: файлов $fileCount, чанков $chunkCount, индекс $date\n")
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
return sb.toString().trimEnd('\n')
|
||||
}
|
||||
|
||||
fun toolReindex(path: String): String {
|
||||
val root = resolveCollection(File(path))
|
||||
val collections = selectTargets(root)
|
||||
if (collections.isEmpty()) return "коллекции не найдены"
|
||||
ensureModel()
|
||||
val (modelPath, tokenizerPath) = modelPaths()
|
||||
var totalUpdated = 0
|
||||
val embedder = Embedder(modelPath, tokenizerPath)
|
||||
try {
|
||||
for (coll in collections) {
|
||||
val memoDir = File(coll, ".memo")
|
||||
if (memoDir.exists()) {
|
||||
memoDir.deleteRecursively()
|
||||
}
|
||||
memoDir.mkdirs()
|
||||
val dbPath = File(memoDir, "index.db")
|
||||
val db = Db(dbPath.absolutePath)
|
||||
try {
|
||||
db.init()
|
||||
totalUpdated += Indexer(db, embedder).indexTree(coll)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
embedder.close()
|
||||
}
|
||||
return "переиндексировано файлов: $totalUpdated"
|
||||
}
|
||||
|
||||
private fun selectTargets(root: File): List<File> {
|
||||
if (!root.exists()) return emptyList()
|
||||
if (isCollection(root)) return listOf(root)
|
||||
return findCollections(root)
|
||||
}
|
||||
|
||||
private fun modelDir(): File =
|
||||
File(System.getenv("MEMO_MODEL_DIR") ?: "/root/WORK/memo/models/siglip2")
|
||||
|
||||
private fun modelPaths(): Pair<String, String> = ModelStore.paths(modelDir())
|
||||
|
||||
private fun ensureModel() {
|
||||
val dir = modelDir()
|
||||
if (ModelStore.isComplete(dir)) return
|
||||
if (System.getenv("MEMO_MODEL_AUTO_DOWNLOAD") == "0") {
|
||||
val msg = "модель не найдена в ${dir.absolutePath}; запустите: memo model"
|
||||
System.err.println(msg)
|
||||
throw IllegalStateException(msg)
|
||||
}
|
||||
System.err.println(
|
||||
"модель не найдена в ${dir.absolutePath}, скачиваю с ${ModelStore.DEFAULT_BASE_URL} (≈287 МБ, один раз)",
|
||||
)
|
||||
ModelStore.ensure(dir, log = { System.err.println(it) })
|
||||
}
|
||||
|
||||
private fun countInt(db: Db, sql: String): Int {
|
||||
val stmt = db.conn.prepare(sql)
|
||||
return try {
|
||||
val rs = stmt.executeQuery()
|
||||
try {
|
||||
if (rs.next()) rs.getInt(0) ?: 0 else 0
|
||||
} finally {
|
||||
rs.close()
|
||||
}
|
||||
} finally {
|
||||
stmt.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun maxDouble(db: Db, sql: String): Double? {
|
||||
val stmt = db.conn.prepare(sql)
|
||||
return try {
|
||||
val rs = stmt.executeQuery()
|
||||
try {
|
||||
var best: Double? = null
|
||||
while (rs.next()) {
|
||||
val v = rs.getDouble(0) ?: continue
|
||||
if (best == null || v > best) best = v
|
||||
}
|
||||
best
|
||||
} finally {
|
||||
rs.close()
|
||||
}
|
||||
} finally {
|
||||
stmt.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun toolOk(text: String): String =
|
||||
"""{"content":[{"type":"text","text":${jsonStr(text)}}],"isError":false}"""
|
||||
|
||||
private fun toolErrorContent(text: String): String =
|
||||
"""{"content":[{"type":"text","text":${jsonStr(text)}}],"isError":true}"""
|
||||
|
||||
private fun result(id: Any?, body: String): String =
|
||||
"""{"jsonrpc":"2.0","id":${idLiteral(id)},"result":$body}"""
|
||||
|
||||
private fun rpcError(id: Any?, code: Int, message: String): String =
|
||||
"""{"jsonrpc":"2.0","id":${idLiteral(id)},"error":{"code":$code,"message":${jsonStr(message)}}}"""
|
||||
|
||||
private fun idLiteral(id: Any?): String = when (id) {
|
||||
null -> "null"
|
||||
is Number -> if (id.toDouble().rem(1.0) == 0.0) id.toLong().toString() else id.toString()
|
||||
is Boolean -> id.toString()
|
||||
else -> jsonStr(id.toString())
|
||||
}
|
||||
|
||||
private fun jsonStr(s: String): String {
|
||||
val sb = StringBuilder(s.length + 2)
|
||||
sb.append('"')
|
||||
for (c in s) {
|
||||
when (c) {
|
||||
'"' -> sb.append("\\\"")
|
||||
'\\' -> sb.append("\\\\")
|
||||
'\n' -> sb.append("\\n")
|
||||
'\r' -> sb.append("\\r")
|
||||
'\t' -> sb.append("\\t")
|
||||
'\b' -> sb.append("\\b")
|
||||
'\u000C' -> sb.append("\\f")
|
||||
else -> if (c.code < 0x20) {
|
||||
sb.append("\\u%04x".format(c.code))
|
||||
} else {
|
||||
sb.append(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.append('"')
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private class JsonParser(private val s: String) {
|
||||
private var pos = 0
|
||||
|
||||
fun parse(): Any? {
|
||||
skipWs()
|
||||
val v = parseValue()
|
||||
skipWs()
|
||||
if (pos != s.length) throw IllegalArgumentException("trailing data at $pos")
|
||||
return v
|
||||
}
|
||||
|
||||
private fun parseValue(): Any? {
|
||||
skipWs()
|
||||
if (pos >= s.length) throw IllegalArgumentException("unexpected end")
|
||||
return when (val c = s[pos]) {
|
||||
'{' -> parseObject()
|
||||
'[' -> parseArray()
|
||||
'"' -> parseString()
|
||||
't' -> parseLiteral("true", true)
|
||||
'f' -> parseLiteral("false", false)
|
||||
'n' -> parseLiteral("null", null)
|
||||
'-', in '0'..'9' -> parseNumber()
|
||||
else -> throw IllegalArgumentException("unexpected char '$c' at $pos")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseObject(): Map<String, Any?> {
|
||||
expect('{')
|
||||
val map = LinkedHashMap<String, Any?>()
|
||||
skipWs()
|
||||
if (peek() == '}') { pos++; return map }
|
||||
while (true) {
|
||||
skipWs()
|
||||
val key = parseString()
|
||||
skipWs()
|
||||
expect(':')
|
||||
map[key] = parseValue()
|
||||
skipWs()
|
||||
when (peek()) {
|
||||
',' -> { pos++; continue }
|
||||
'}' -> { pos++; return map }
|
||||
else -> throw IllegalArgumentException("expected ',' or '}' at $pos")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseArray(): List<Any?> {
|
||||
expect('[')
|
||||
val list = ArrayList<Any?>()
|
||||
skipWs()
|
||||
if (peek() == ']') { pos++; return list }
|
||||
while (true) {
|
||||
list.add(parseValue())
|
||||
skipWs()
|
||||
when (peek()) {
|
||||
',' -> { pos++; continue }
|
||||
']' -> { pos++; return list }
|
||||
else -> throw IllegalArgumentException("expected ',' or ']' at $pos")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseString(): String {
|
||||
expect('"')
|
||||
val sb = StringBuilder()
|
||||
while (pos < s.length) {
|
||||
val c = s[pos]
|
||||
if (c == '"') {
|
||||
pos++
|
||||
return sb.toString()
|
||||
}
|
||||
if (c == '\\') {
|
||||
if (pos + 1 >= s.length) throw IllegalArgumentException("bad escape at end")
|
||||
when (s[pos + 1]) {
|
||||
'"' -> sb.append('"')
|
||||
'\\' -> sb.append('\\')
|
||||
'/' -> sb.append('/')
|
||||
'n' -> sb.append('\n')
|
||||
'r' -> sb.append('\r')
|
||||
't' -> sb.append('\t')
|
||||
'b' -> sb.append('\b')
|
||||
'f' -> sb.append('\u000C')
|
||||
'u' -> {
|
||||
if (pos + 6 > s.length) throw IllegalArgumentException("bad unicode escape")
|
||||
val hex = s.substring(pos + 2, pos + 6)
|
||||
sb.append(hex.toInt(16).toChar())
|
||||
pos += 4
|
||||
}
|
||||
else -> throw IllegalArgumentException("bad escape '\\${s[pos + 1]}'")
|
||||
}
|
||||
pos += 2
|
||||
} else {
|
||||
sb.append(c)
|
||||
pos++
|
||||
}
|
||||
}
|
||||
throw IllegalArgumentException("unterminated string")
|
||||
}
|
||||
|
||||
private fun parseNumber(): Any {
|
||||
val start = pos
|
||||
if (s[pos] == '-') pos++
|
||||
var hasDigit = false
|
||||
while (pos < s.length && s[pos].isDigit()) { hasDigit = true; pos++ }
|
||||
var isFloat = false
|
||||
if (pos < s.length && s[pos] == '.') {
|
||||
isFloat = true; pos++
|
||||
while (pos < s.length && s[pos].isDigit()) { hasDigit = true; pos++ }
|
||||
}
|
||||
if (pos < s.length && (s[pos] == 'e' || s[pos] == 'E')) {
|
||||
isFloat = true; pos++
|
||||
if (pos < s.length && (s[pos] == '+' || s[pos] == '-')) pos++
|
||||
while (pos < s.length && s[pos].isDigit()) { hasDigit = true; pos++ }
|
||||
}
|
||||
if (!hasDigit) throw IllegalArgumentException("bad number at $start")
|
||||
val raw = s.substring(start, pos)
|
||||
return if (isFloat) raw.toDouble() else raw.toLong()
|
||||
}
|
||||
|
||||
private fun parseLiteral(lit: String, value: Any?): Any? {
|
||||
if (s.regionMatches(pos, lit, 0, lit.length)) {
|
||||
pos += lit.length
|
||||
return value
|
||||
}
|
||||
throw IllegalArgumentException("expected literal '$lit' at $pos")
|
||||
}
|
||||
|
||||
private fun peek(): Char = if (pos < s.length) s[pos] else '\u0000'
|
||||
|
||||
private fun expect(c: Char) {
|
||||
if (peek() != c) throw IllegalArgumentException("expected '$c' at $pos")
|
||||
pos++
|
||||
}
|
||||
|
||||
private fun skipWs() {
|
||||
while (pos < s.length && s[pos].isWhitespace()) pos++
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package memo.mcp
|
||||
|
||||
import memo.core.SearchMode
|
||||
import java.io.File
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.fail
|
||||
|
||||
class McpColdStartTest {
|
||||
|
||||
private fun modelPaths(): Pair<String, String> {
|
||||
val modelDir = System.getenv("MEMO_MODEL_DIR") ?: "/root/WORK/memo/models/siglip2"
|
||||
return "$modelDir/text_model_int8.onnx" to "$modelDir/tokenizer.model"
|
||||
}
|
||||
|
||||
private fun requireModel() {
|
||||
val (modelPath, tokenizerPath) = modelPaths()
|
||||
if (!File(modelPath).exists() || !File(tokenizerPath).exists()) {
|
||||
fail("модель не найдена: ${File(modelPath).parent}")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchIndexesCollectionOnColdStart() {
|
||||
requireModel()
|
||||
|
||||
val base = File.createTempFile("memo-coldstart-", "")
|
||||
assertTrue(base.delete(), "temp cleanup")
|
||||
assertTrue(base.mkdir(), "temp create")
|
||||
base.deleteOnExit()
|
||||
|
||||
File(base, "proxy.md").writeText(
|
||||
"# Прокси\nвнутренние домены ходят через шлюз 76.1\n"
|
||||
)
|
||||
File(base, "other.md").writeText(
|
||||
"# Прочее\nсовсем другая заметка про книгу\n"
|
||||
)
|
||||
File(base, "proxy.md").deleteOnExit()
|
||||
File(base, "other.md").deleteOnExit()
|
||||
|
||||
val memoDir = File(base, ".memo")
|
||||
assertFalse(memoDir.exists(), "до поиска .memo не должен существовать")
|
||||
|
||||
val resp = toolSearch(base.absolutePath, "внутренние домены шлюз", 5, SearchMode.HYBRID)
|
||||
assertFalse(
|
||||
resp.contains("коллекции не найдены"),
|
||||
"холодный старт обязан сам индексировать, ответ: $resp"
|
||||
)
|
||||
assertTrue(
|
||||
resp.contains("76.1"),
|
||||
"ожидалось упоминание 76.1 в ответе, получено: $resp"
|
||||
)
|
||||
|
||||
val dbFile = File(base, ".memo/index.db")
|
||||
assertTrue(dbFile.exists(), "после поиска .memo/index.db обязан существовать")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchOnEmptyDirReportsNoCollections() {
|
||||
val base = File.createTempFile("memo-empty-", "")
|
||||
assertTrue(base.delete(), "temp cleanup")
|
||||
assertTrue(base.mkdir(), "temp create")
|
||||
base.deleteOnExit()
|
||||
|
||||
val resp = toolSearch(base.absolutePath, "что угодно", 5, SearchMode.HYBRID)
|
||||
assertEquals(
|
||||
"коллекции не найдены",
|
||||
resp,
|
||||
"пустой каталог без .md обязан вернуть 'коллекции не найдены'"
|
||||
)
|
||||
assertFalse(
|
||||
File(base, ".memo").exists(),
|
||||
"в пустом каталоге .memo не должен создаваться"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package memo.mcp
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class McpProtocolTest {
|
||||
|
||||
@Test
|
||||
fun initializeHandshake() {
|
||||
val resp = handleRequest(
|
||||
"""{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"""
|
||||
)
|
||||
assertNotNull(resp, "initialize обязан вернуть ответ")
|
||||
assertTrue(resp.contains("2024-11-05"), "ожидался protocolVersion 2024-11-05, получено: $resp")
|
||||
assertTrue(resp.contains("memo"), "ожидалось имя сервера memo, получено: $resp")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toolsListHasThreeTools() {
|
||||
val resp = handleRequest(
|
||||
"""{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"""
|
||||
)
|
||||
assertNotNull(resp, "tools/list обязан вернуть ответ")
|
||||
assertTrue(resp.contains("memo_search"), "ожидался memo_search, получено: $resp")
|
||||
assertTrue(resp.contains("memo_status"), "ожидался memo_status, получено: $resp")
|
||||
assertTrue(resp.contains("memo_reindex"), "ожидался memo_reindex, получено: $resp")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownMethodReturns32601() {
|
||||
val resp = handleRequest(
|
||||
"""{"jsonrpc":"2.0","id":3,"method":"foo/bar"}"""
|
||||
)
|
||||
assertNotNull(resp, "неизвестный метод обязан вернуть JSON-RPC error")
|
||||
assertTrue(resp.contains("-32601"), "ожидался код -32601, получено: $resp")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchWithoutPathIsError() {
|
||||
val resp = handleRequest(
|
||||
"""{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"memo_search","arguments":{}}}"""
|
||||
)
|
||||
assertNotNull(resp, "tools/call обязан вернуть ответ")
|
||||
assertTrue(resp.contains("\"isError\":true"), "ожидался isError:true, получено: $resp")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notificationProducesNoResponse() {
|
||||
val resp = handleRequest(
|
||||
"""{"jsonrpc":"2.0","method":"notifications/initialized"}"""
|
||||
)
|
||||
assertNull(resp, "уведомление не должно порождать ответ, получено: $resp")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
application
|
||||
}
|
||||
|
||||
application {
|
||||
mainClass.set("memo.watch.WatchMainKt")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":memo-core"))
|
||||
testImplementation(kotlin("test"))
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
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
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
if (args.isEmpty()) {
|
||||
System.err.println("usage: watch <path>")
|
||||
exitProcess(2)
|
||||
}
|
||||
val raw = File(args[0])
|
||||
val base = resolveCollection(raw)
|
||||
val collections = findCollections(base)
|
||||
if (collections.isEmpty()) {
|
||||
System.err.println("коллекции не найдены в ${base.absolutePath}")
|
||||
exitProcess(1)
|
||||
}
|
||||
|
||||
val modelDir = System.getenv("MEMO_MODEL_DIR") ?: "/root/WORK/memo/models/siglip2"
|
||||
val modelPath = "$modelDir/text_model_int8.onnx"
|
||||
val tokenizerPath = "$modelDir/tokenizer.model"
|
||||
|
||||
val ctxList = collections.map { 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)
|
||||
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 -> safeIndexTree(indexersByCollection.getValue(coll).indexer, coll) },
|
||||
)
|
||||
|
||||
Runtime.getRuntime().addShutdownHook(
|
||||
Thread({
|
||||
try { watcher.close() } catch (_: Throwable) {}
|
||||
for (ctx in ctxList) {
|
||||
try { ctx.embedder.close() } catch (_: Throwable) {}
|
||||
try { ctx.db.close() } catch (_: Throwable) {}
|
||||
}
|
||||
}, "memo-watch-shutdown")
|
||||
)
|
||||
|
||||
watcher.start()
|
||||
for (ctx in ctxList) {
|
||||
println("наблюдаю: ${ctx.root.absolutePath}")
|
||||
}
|
||||
|
||||
CountDownLatch(1).await()
|
||||
}
|
||||
|
||||
private data class CollCtx(
|
||||
val root: File,
|
||||
val db: Db,
|
||||
val embedder: Embedder,
|
||||
val indexer: Indexer,
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package memo.watch
|
||||
|
||||
import java.io.File
|
||||
import java.nio.file.ClosedWatchServiceException
|
||||
import java.nio.file.FileSystems
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.StandardWatchEventKinds
|
||||
import java.nio.file.WatchKey
|
||||
import java.nio.file.WatchService
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class Watcher(
|
||||
private val collections: List<File>,
|
||||
private val index: (File) -> Unit,
|
||||
private val debounceMillis: Long = 500,
|
||||
private val reconcileMillis: Long = 10 * 60 * 1000,
|
||||
) : AutoCloseable {
|
||||
|
||||
private val watchService: WatchService = FileSystems.getDefault().newWatchService()
|
||||
private val keyToCollection = ConcurrentHashMap<WatchKey, File>()
|
||||
private val running = AtomicBoolean(false)
|
||||
private val closed = AtomicBoolean(false)
|
||||
private val _indexCalls = AtomicInteger(0)
|
||||
|
||||
val indexCalls: Int get() = _indexCalls.get()
|
||||
|
||||
private val thread: Thread = Thread({ runLoop() }, "memo-watcher").apply { isDaemon = true }
|
||||
|
||||
fun start() {
|
||||
if (!running.compareAndSet(false, true)) return
|
||||
for (coll in collections) {
|
||||
try {
|
||||
registerRecursive(coll, coll)
|
||||
} catch (t: Throwable) {
|
||||
System.err.println("watcher: register ${coll.absolutePath} failed: ${t.message}")
|
||||
}
|
||||
}
|
||||
thread.start()
|
||||
}
|
||||
|
||||
private fun registerRecursive(dir: File, collection: File) {
|
||||
if (!dir.isDirectory) return
|
||||
if (dir.name.startsWith(".")) return
|
||||
val key = dir.toPath().register(
|
||||
watchService,
|
||||
StandardWatchEventKinds.ENTRY_CREATE,
|
||||
StandardWatchEventKinds.ENTRY_MODIFY,
|
||||
StandardWatchEventKinds.ENTRY_DELETE,
|
||||
)
|
||||
keyToCollection[key] = collection
|
||||
val children = dir.listFiles() ?: return
|
||||
for (child in children) {
|
||||
if (child.isDirectory && !child.name.startsWith(".")) {
|
||||
registerRecursive(child, collection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (!closed.compareAndSet(false, true)) return
|
||||
running.set(false)
|
||||
thread.interrupt()
|
||||
try { watchService.close() } catch (_: Throwable) {}
|
||||
try { thread.join(2000) } catch (_: InterruptedException) {}
|
||||
}
|
||||
|
||||
private fun runLoop() {
|
||||
var nextReconcile = System.currentTimeMillis() + reconcileMillis
|
||||
while (running.get()) {
|
||||
try {
|
||||
val firstKey = watchService.poll(debounceMillis, TimeUnit.MILLISECONDS)
|
||||
if (firstKey != null) {
|
||||
val affected = HashSet<File>()
|
||||
processKey(firstKey, affected)
|
||||
val endDeadline = System.currentTimeMillis() + debounceMillis
|
||||
while (running.get()) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now >= endDeadline) break
|
||||
val remaining = endDeadline - now
|
||||
val nextKey = watchService.poll(remaining, TimeUnit.MILLISECONDS) ?: break
|
||||
processKey(nextKey, affected)
|
||||
}
|
||||
for (coll in affected) {
|
||||
callIndex(coll)
|
||||
}
|
||||
}
|
||||
if (System.currentTimeMillis() >= nextReconcile) {
|
||||
nextReconcile = System.currentTimeMillis() + reconcileMillis
|
||||
for (coll in collections) {
|
||||
callIndex(coll)
|
||||
}
|
||||
}
|
||||
} catch (_: ClosedWatchServiceException) {
|
||||
break
|
||||
} catch (_: InterruptedException) {
|
||||
if (!running.get()) break
|
||||
} catch (t: Throwable) {
|
||||
System.err.println("watcher loop: ${t.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun processKey(key: WatchKey, affected: MutableSet<File>) {
|
||||
val collection = keyToCollection[key] ?: return
|
||||
for (event in key.pollEvents()) {
|
||||
val kind = event.kind()
|
||||
if (kind === StandardWatchEventKinds.OVERFLOW) continue
|
||||
val ctx = event.context() as? Path ?: continue
|
||||
val watchable = key.watchable() as? Path ?: continue
|
||||
val ev = watchable.resolve(ctx).toFile()
|
||||
when (kind) {
|
||||
StandardWatchEventKinds.ENTRY_CREATE -> {
|
||||
affected.add(collection)
|
||||
if (ev.isDirectory && !ev.name.startsWith(".")) {
|
||||
try {
|
||||
registerRecursive(ev, collection)
|
||||
} catch (t: Throwable) {
|
||||
System.err.println("watcher: registerRecursive ${ev.absolutePath} failed: ${t.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
StandardWatchEventKinds.ENTRY_MODIFY,
|
||||
StandardWatchEventKinds.ENTRY_DELETE -> affected.add(collection)
|
||||
}
|
||||
}
|
||||
val valid = key.reset()
|
||||
if (!valid) keyToCollection.remove(key)
|
||||
}
|
||||
|
||||
private fun callIndex(coll: File) {
|
||||
_indexCalls.incrementAndGet()
|
||||
try {
|
||||
index(coll)
|
||||
} catch (t: Throwable) {
|
||||
System.err.println("watcher: index ${coll.absolutePath} failed: ${t.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package memo.watch
|
||||
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class WatcherTest {
|
||||
|
||||
@Test
|
||||
fun modifyTriggersSingleIndexCall() {
|
||||
val tempDir = Files.createTempDirectory("memo-watch-").toFile()
|
||||
tempDir.deleteOnExit()
|
||||
val note = File(tempDir, "note.md")
|
||||
note.writeText("# initial")
|
||||
note.deleteOnExit()
|
||||
val counter = AtomicInteger(0)
|
||||
val watcher = Watcher(
|
||||
collections = listOf(tempDir),
|
||||
index = { counter.incrementAndGet() },
|
||||
debounceMillis = 200,
|
||||
reconcileMillis = TimeUnit.HOURS.toMillis(1),
|
||||
)
|
||||
try {
|
||||
watcher.start()
|
||||
Thread.sleep(300)
|
||||
note.appendText("\nappended")
|
||||
assertTrue(
|
||||
waitFor(5_000, 100) { counter.get() >= 1 },
|
||||
"index должен сработать в течение 5с, получено ${counter.get()}",
|
||||
)
|
||||
Thread.sleep(1500)
|
||||
assertEquals(1, counter.get(), "ожидался ровно 1 вызов index, получено ${counter.get()}")
|
||||
assertTrue(watcher.indexCalls >= 1, "встроенный счётчик indexCalls не растёт, получено ${watcher.indexCalls}")
|
||||
} finally {
|
||||
watcher.close()
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unchangedFileDoesNotTriggerIndex() {
|
||||
val tempDir = Files.createTempDirectory("memo-watch-").toFile()
|
||||
tempDir.deleteOnExit()
|
||||
val note = File(tempDir, "note.md")
|
||||
note.writeText("# initial")
|
||||
note.deleteOnExit()
|
||||
val counter = AtomicInteger(0)
|
||||
val watcher = Watcher(
|
||||
collections = listOf(tempDir),
|
||||
index = { counter.incrementAndGet() },
|
||||
debounceMillis = 200,
|
||||
reconcileMillis = TimeUnit.HOURS.toMillis(1),
|
||||
)
|
||||
try {
|
||||
watcher.start()
|
||||
Thread.sleep(300)
|
||||
Thread.sleep(1500)
|
||||
assertEquals(0, counter.get(), "не должно быть вызовов index, получено ${counter.get()}")
|
||||
} finally {
|
||||
watcher.close()
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun closeIsIdempotentAndStopsThread() {
|
||||
val tempDir = Files.createTempDirectory("memo-watch-").toFile()
|
||||
tempDir.deleteOnExit()
|
||||
val watcher = Watcher(
|
||||
collections = listOf(tempDir),
|
||||
index = {},
|
||||
)
|
||||
watcher.start()
|
||||
watcher.close()
|
||||
watcher.close()
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
|
||||
private fun waitFor(timeoutMs: Long, stepMs: Long, condition: () -> Boolean): Boolean {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (condition()) return true
|
||||
Thread.sleep(stepMs)
|
||||
}
|
||||
return condition()
|
||||
}
|
||||
}
|
||||
Executable
+164
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env bash
|
||||
# Единый приёмочный прогон memo. Запускает ВСЁ, что должен пройти проект, и печатает
|
||||
# один вердикт. Возвращает 0, если все контуры зелёные.
|
||||
#
|
||||
# bash scripts/accept.sh # полный прогон
|
||||
# bash scripts/accept.sh --fast # без мутационной проверки (она самая долгая)
|
||||
#
|
||||
# Контуры:
|
||||
# 1. Юнит-тесты всех модулей (gradle test --rerun-tasks).
|
||||
# 2. Мутационная проверка тестов (e2e/mutation_check.py) — тесты обязаны падать на сломанном коде.
|
||||
# 3. Сквозной индекс эталонного корпуса: идемпотентность и ровно 3 коллекции.
|
||||
# 4. Сквозной recall@5 на эталонном корпусе (e2e/run_e2e.py).
|
||||
# 5. Демон слежения: первичный проход, 3 базы, отсутствие «locked» при параллельном чтении.
|
||||
# 6. MCP-протокол живым клиентом (scripts/mcp_probe.py), включая холодный старт.
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
CORPUS="${MEMO_CORPUS:-/root/WORK/memo-e2e}"
|
||||
export MEMO_MODEL_DIR="${MEMO_MODEL_DIR:-$ROOT/models/siglip2}"
|
||||
FAST=0
|
||||
[ "${1:-}" = "--fast" ] && FAST=1
|
||||
|
||||
FAILED=()
|
||||
|
||||
section() { printf '\n=== %s ===\n' "$1"; }
|
||||
|
||||
section "0. сборка"
|
||||
if ./gradlew installDist -q >/tmp/memo-build.log 2>&1; then
|
||||
echo "OK сборка и установка дистрибутивов"
|
||||
else
|
||||
echo "ПРОВАЛ сборки:"; tail -30 /tmp/memo-build.log; FAILED+=("сборка"); fi
|
||||
|
||||
CLI="$ROOT/memo-cli/build/install/memo/bin/memo"
|
||||
MCP="$ROOT/memo-mcp/build/install/memo-mcp/bin/memo-mcp"
|
||||
|
||||
section "1. юнит-тесты"
|
||||
if ./gradlew test --rerun-tasks >/tmp/memo-test.log 2>&1; then
|
||||
python3 - <<'EOF'
|
||||
import glob, xml.etree.ElementTree as ET
|
||||
tot = fail = 0
|
||||
for f in glob.glob('**/build/test-results/test/*.xml', recursive=True):
|
||||
r = ET.parse(f).getroot()
|
||||
tot += int(r.get('tests') or 0); fail += int(r.get('failures') or 0) + int(r.get('errors') or 0)
|
||||
print(f"OK тестов {tot}, провалов {fail}")
|
||||
EOF
|
||||
else
|
||||
echo "ПРОВАЛ тестов:"; grep -E "FAILED|error:" /tmp/memo-test.log | head -20; FAILED+=("тесты"); fi
|
||||
|
||||
if [ "$FAST" = "0" ]; then
|
||||
section "2. мутационная проверка"
|
||||
python3 e2e/mutation_check.py --gradle ./gradlew > /tmp/memo-mut.log 2>&1
|
||||
MUT_RC=$?
|
||||
if [ "$MUT_RC" = "0" ]; then
|
||||
echo "OK все мутации убиты"
|
||||
elif [ "$MUT_RC" = "2" ]; then
|
||||
echo "ПРОВАЛ: таблица мутаций недействительна (no-op или неприменимая мутация)"
|
||||
grep -E "ОШИБКА ТАБЛИЦЫ|ПРОВАЛ" /tmp/memo-mut.log | head -10; FAILED+=("таблица мутаций")
|
||||
else
|
||||
echo "ПРОВАЛ: есть выжившие мутации"; tail -20 /tmp/memo-mut.log; FAILED+=("мутации"); fi
|
||||
fi
|
||||
|
||||
section "3. сквозной индекс эталонного корпуса"
|
||||
# .memo лежит не только на верхнем уровне (life/books, work/jira), поэтому ищем по дереву,
|
||||
# иначе часть баз переживает очистку и прогон перестаёт быть чистым.
|
||||
find "$CORPUS" -name .memo -type d -prune -exec rm -rf {} + 2>/dev/null
|
||||
FIRST=$("$CLI" index "$CORPUS" 2>&1 | tail -1)
|
||||
SECOND=$("$CLI" index "$CORPUS" 2>&1 | tail -1)
|
||||
DBS=$(find "$CORPUS" -name index.db | wc -l)
|
||||
echo " первый прогон : $FIRST"
|
||||
echo " второй прогон : $SECOND"
|
||||
echo " баз индекса : $DBS (ожидается 3)"
|
||||
if echo "$SECOND" | grep -q "итого: 0 обновлено" && [ "$DBS" = "3" ]; then
|
||||
echo "OK индексация идемпотентна, коллекций 3"
|
||||
else
|
||||
echo "ПРОВАЛ индексации (ожидалось «итого: 0 обновлено» и 3 базы)"; FAILED+=("индексация"); fi
|
||||
|
||||
section "4. recall@5"
|
||||
if python3 e2e/run_e2e.py --cli "$CLI" --root "$CORPUS" --k 5 > /tmp/memo-e2e.log 2>&1 \
|
||||
&& grep -q "ИТОГ: ПРОЙДЕНО" /tmp/memo-e2e.log; then
|
||||
grep -E "^(sem|lex):" /tmp/memo-e2e.log; echo "OK recall"
|
||||
else
|
||||
echo "ПРОВАЛ recall:"; tail -25 /tmp/memo-e2e.log; FAILED+=("recall"); fi
|
||||
|
||||
section "5. демон слежения (memo-watch)"
|
||||
# Демон проверяется отдельным контуром: три его дефекта (6 баз вместо 3, отсутствие первичного
|
||||
# прохода, «locked» при параллельном чтении) прошли мимо юнит-тестов и зелёной приёмки.
|
||||
WATCH="$ROOT/memo-watch/build/install/memo-watch/bin/memo-watch"
|
||||
WROOT=/tmp/memo-accept-watch
|
||||
rm -rf "$WROOT"; cp -r "$CORPUS" "$WROOT"
|
||||
find "$WROOT" -name .memo -type d -prune -exec rm -rf {} + 2>/dev/null
|
||||
"$WATCH" "$WROOT" > /tmp/memo-watch.log 2>&1 &
|
||||
WPID=$!
|
||||
WOK=1
|
||||
# ждём не «на глаз», а по факту: пока в каждой коллекции не появятся чанки (максимум 120 с)
|
||||
for _ in $(seq 1 120); do
|
||||
READY=$(python3 - "$WROOT" <<'EOF'
|
||||
import sqlite3, glob, sys
|
||||
root = sys.argv[1]
|
||||
dbs = sorted(glob.glob(root + "/**/.memo/index.db", recursive=True))
|
||||
if len(dbs) != 3:
|
||||
print(0); raise SystemExit
|
||||
n = 0
|
||||
for db in dbs:
|
||||
try:
|
||||
c = sqlite3.connect("file:" + db + "?mode=ro", uri=True)
|
||||
if c.execute("SELECT COUNT(*) FROM chunks").fetchone()[0] > 0: n += 1
|
||||
c.close()
|
||||
except Exception:
|
||||
pass
|
||||
print(n)
|
||||
EOF
|
||||
)
|
||||
[ "$READY" = "3" ] && break
|
||||
sleep 1
|
||||
done
|
||||
WDBS=$(find "$WROOT" -name index.db | wc -l)
|
||||
echo " баз индекса : $WDBS (ожидается 3)"
|
||||
if [ "$WDBS" != "3" ]; then
|
||||
echo "ПРОВАЛ: демон завёл $WDBS баз вместо 3"; WOK=0
|
||||
fi
|
||||
if [ "$READY" = "3" ]; then
|
||||
echo "OK первичный проход: все 3 коллекции наполнены сразу после старта"
|
||||
else
|
||||
echo "ПРОВАЛ: первичного прохода нет — наполнено коллекций: $READY из 3"; WOK=0
|
||||
fi
|
||||
# параллельно с работающим демоном — тот же файл читаем поиском
|
||||
WSEARCH=$("$CLI" search "$WROOT/life/books" "кто ведёт рассказ в романе" --k 1 2>&1)
|
||||
if echo "$WSEARCH" | grep -qi "locked"; then
|
||||
echo "ПРОВАЛ: database is locked при чтении во время работы демона"; WOK=0
|
||||
elif [ -n "$WSEARCH" ]; then
|
||||
echo "OK поиск при работающем демоне отвечает без locked"
|
||||
else
|
||||
echo "ПРОВАЛ: поиск при работающем демоне ничего не вернул"; WOK=0
|
||||
fi
|
||||
kill "$WPID" 2>/dev/null; wait "$WPID" 2>/dev/null
|
||||
[ "$WOK" = "1" ] || FAILED+=("демон слежения")
|
||||
|
||||
section "6. MCP: протокол + холодный старт"
|
||||
COLD=/tmp/memo-accept-cold
|
||||
rm -rf "$COLD"; cp -r "$CORPUS" "$COLD"
|
||||
find "$COLD" -name .memo -type d -prune -exec rm -rf {} + 2>/dev/null
|
||||
if python3 scripts/mcp_probe.py --cmd "$MCP" \
|
||||
--call memo_search \
|
||||
--args "{\"path\":\"$COLD/infra\",\"query\":\"основа для разрешения имён в сети\",\"k\":3}" \
|
||||
> /tmp/memo-mcp.log 2>&1; then
|
||||
if grep -q "коллекции не найдены" /tmp/memo-mcp.log; then
|
||||
echo "ПРОВАЛ: холодный старт не индексирует"; FAILED+=("MCP холодный старт")
|
||||
else
|
||||
echo "OK MCP: протокол отвечает, холодный старт индексирует"
|
||||
grep -E "^OK" /tmp/memo-mcp.log | sed 's/^/ /'
|
||||
fi
|
||||
else
|
||||
echo "ПРОВАЛ MCP:"; tail -20 /tmp/memo-mcp.log; FAILED+=("MCP протокол"); fi
|
||||
|
||||
section "ВЕРДИКТ"
|
||||
if [ ${#FAILED[@]} -eq 0 ]; then
|
||||
echo "ПРОЙДЕНО — все контуры зелёные"
|
||||
exit 0
|
||||
else
|
||||
printf 'ПРОВАЛ — контуры: %s\n' "$(IFS=', '; echo "${FAILED[*]}")"
|
||||
exit 1
|
||||
fi
|
||||
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