Compare commits
3 Commits
2c7bca0d82
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 607137fdf7 | |||
| 69a0ebe0fa | |||
| 3054705176 |
@@ -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` рядом — пересобираемый кэш.** Агент (или человек) пишет и
|
**Markdown на диске — истина, `.db` рядом — пересобираемый кэш.** Агент (или человек) пишет и
|
||||||
читает обычные `.md`-файлы своими штатными средствами: `memo` не требует ни специального API записи,
|
читает обычные `.md`-файлы своими штатными средствами: `memo` не требует ни специального API записи,
|
||||||
ни изменения привычек. Он следит за файловой системой, держит рядом с каждой папкой-темой
|
ни изменения привычек. Он следит за файловой системой, держит рядом с каждой папкой-темой
|
||||||
@@ -46,6 +50,7 @@ memo-mcp # MCP-сервер (stdio): memo_search / memo_status / memo_reinde
|
|||||||
./gradlew installDist # собрать все дистрибутивы
|
./gradlew installDist # собрать все дистрибутивы
|
||||||
|
|
||||||
CLI=memo-cli/build/install/memo/bin/memo
|
CLI=memo-cli/build/install/memo/bin/memo
|
||||||
|
$CLI model # скачать модель (необязательно — скачается сама при первом запуске)
|
||||||
$CLI index ~/notes # проиндексировать дерево (коллекции найдутся сами)
|
$CLI index ~/notes # проиндексировать дерево (коллекции найдутся сами)
|
||||||
$CLI search ~/notes "чем чинят карточку в jellyfin"
|
$CLI search ~/notes "чем чинят карточку в jellyfin"
|
||||||
$CLI search ~/notes "76.132" --mode lex --json
|
$CLI search ~/notes "76.132" --mode lex --json
|
||||||
@@ -90,6 +95,7 @@ bash scripts/accept.sh --fast # то же без мутационной про
|
|||||||
|
|
||||||
| Документ | Что внутри |
|
| Документ | Что внутри |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| [`MANUAL.md`](MANUAL.md) | **инструкция для человека**: как пользоваться, команды, подключение к агенту |
|
||||||
| [`docs/SPEC.md`](docs/SPEC.md) | полная спека: модель данных, индекс, маршрутизация, watcher, интерфейс, приёмка |
|
| [`docs/SPEC.md`](docs/SPEC.md) | полная спека: модель данных, индекс, маршрутизация, watcher, интерфейс, приёмка |
|
||||||
| [`TASK.md`](TASK.md) | ТЗ для исполнителя: стек, схема БД, контракты, чего не делать |
|
| [`TASK.md`](TASK.md) | ТЗ для исполнителя: стек, схема БД, контракты, чего не делать |
|
||||||
| [`TESTING.md`](TESTING.md) | тест-план: приёмочные проверки, команды, признаки провала |
|
| [`TESTING.md`](TESTING.md) | тест-план: приёмочные проверки, команды, признаки провала |
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -22,3 +22,8 @@ memo-core/src/main/kotlin/memo/core/Searcher.kt .sortedByDescending { it.value }
|
|||||||
memo-mcp/src/main/kotlin/memo/mcp/McpServer.kt ],"isError":true} ],"isError":false} searchWithoutPathIsError
|
memo-mcp/src/main/kotlin/memo/mcp/McpServer.kt ],"isError":true} ],"isError":false} searchWithoutPathIsError
|
||||||
# --- Watcher: debounce сводит правки к одному вызову ---
|
# --- Watcher: debounce сводит правки к одному вызову ---
|
||||||
memo-watch/src/main/kotlin/memo/watch/Watcher.kt _indexCalls.incrementAndGet() _indexCalls.get() modifyTriggersSingleIndexCall
|
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.
|
@@ -4,6 +4,7 @@ import memo.core.Db
|
|||||||
import memo.core.Embedder
|
import memo.core.Embedder
|
||||||
import memo.core.Hit
|
import memo.core.Hit
|
||||||
import memo.core.Indexer
|
import memo.core.Indexer
|
||||||
|
import memo.core.ModelStore
|
||||||
import memo.core.RefreshHook
|
import memo.core.RefreshHook
|
||||||
import memo.core.SearchMode
|
import memo.core.SearchMode
|
||||||
import memo.core.Searcher
|
import memo.core.Searcher
|
||||||
@@ -22,6 +23,7 @@ data class SearchCmd(
|
|||||||
) : Cmd
|
) : Cmd
|
||||||
data class StatusCmd(val path: String) : Cmd
|
data class StatusCmd(val path: String) : Cmd
|
||||||
data class McpProbeCmd(val tool: String, val argsJson: 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
|
data object HelpCmd : Cmd
|
||||||
|
|
||||||
fun parseArgs(args: Array<String>): Cmd {
|
fun parseArgs(args: Array<String>): Cmd {
|
||||||
@@ -36,12 +38,40 @@ fun parseArgs(args: Array<String>): Cmd {
|
|||||||
val path = args.getOrNull(1) ?: return HelpCmd
|
val path = args.getOrNull(1) ?: return HelpCmd
|
||||||
StatusCmd(path)
|
StatusCmd(path)
|
||||||
}
|
}
|
||||||
|
"model" -> parseModel(args.drop(1))
|
||||||
"mcp-probe" -> parseMcpProbe(args.drop(1))
|
"mcp-probe" -> parseMcpProbe(args.drop(1))
|
||||||
"--help", "-h" -> HelpCmd
|
"--help", "-h" -> HelpCmd
|
||||||
else -> 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 {
|
private fun parseSearch(rest: List<String>): Cmd {
|
||||||
if (rest.size < 2) return HelpCmd
|
if (rest.size < 2) return HelpCmd
|
||||||
val path = rest[0]
|
val path = rest[0]
|
||||||
@@ -100,13 +130,19 @@ private fun parseMcpProbe(rest: List<String>): Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun main(args: Array<String>) {
|
fun main(args: Array<String>) {
|
||||||
|
try {
|
||||||
when (val cmd = parseArgs(args)) {
|
when (val cmd = parseArgs(args)) {
|
||||||
is IndexCmd -> runIndex(cmd)
|
is IndexCmd -> runIndex(cmd)
|
||||||
is SearchCmd -> runSearch(cmd)
|
is SearchCmd -> runSearch(cmd)
|
||||||
is StatusCmd -> runStatus(cmd)
|
is StatusCmd -> runStatus(cmd)
|
||||||
|
is ModelCmd -> runModel(cmd)
|
||||||
is McpProbeCmd -> runMcpProbe(cmd)
|
is McpProbeCmd -> runMcpProbe(cmd)
|
||||||
HelpCmd -> printHelp()
|
HelpCmd -> printHelp()
|
||||||
}
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
System.err.println(t.message ?: t.toString())
|
||||||
|
kotlin.system.exitProcess(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun printHelp() {
|
private fun printHelp() {
|
||||||
@@ -116,14 +152,46 @@ private fun printHelp() {
|
|||||||
memo index <path>
|
memo index <path>
|
||||||
memo search <path> <query> [--k N] [--mode hybrid|lex|vec] [--json]
|
memo search <path> <query> [--k N] [--mode hybrid|lex|vec] [--json]
|
||||||
memo status <path>
|
memo status <path>
|
||||||
|
memo model [--dir <path>] [--url <base>] [--force]
|
||||||
memo mcp-probe --tool <name> [--args <json>]
|
memo mcp-probe --tool <name> [--args <json>]
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun modelPaths(): Pair<String, String> {
|
private fun modelDir(): File =
|
||||||
val modelDir = System.getenv("MEMO_MODEL_DIR") ?: "/root/WORK/memo/models/siglip2"
|
File(System.getenv("MEMO_MODEL_DIR") ?: "/root/WORK/memo/models/siglip2")
|
||||||
return "$modelDir/text_model_int8.onnx" to "$modelDir/tokenizer.model"
|
|
||||||
|
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) {
|
private fun runIndex(cmd: IndexCmd) {
|
||||||
@@ -134,6 +202,7 @@ private fun runIndex(cmd: IndexCmd) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
val (modelPath, tokenizerPath) = modelPaths()
|
val (modelPath, tokenizerPath) = modelPaths()
|
||||||
|
ensureModel(modelDir())
|
||||||
var totalUpdated = 0
|
var totalUpdated = 0
|
||||||
for (coll in collections) {
|
for (coll in collections) {
|
||||||
val memoDir = File(coll, ".memo")
|
val memoDir = File(coll, ".memo")
|
||||||
@@ -168,6 +237,7 @@ private fun runSearch(cmd: SearchCmd) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
val (modelPath, tokenizerPath) = modelPaths()
|
val (modelPath, tokenizerPath) = modelPaths()
|
||||||
|
ensureModel(modelDir())
|
||||||
val allHits = ArrayList<Hit>()
|
val allHits = ArrayList<Hit>()
|
||||||
for (coll in targets) {
|
for (coll in targets) {
|
||||||
val dbPath = File(coll, ".memo/index.db")
|
val dbPath = File(coll, ".memo/index.db")
|
||||||
@@ -325,6 +395,7 @@ private fun performSearch(path: String, query: String, k: Int, mode: SearchMode)
|
|||||||
val targets = resolveSearchTargets(File(path))
|
val targets = resolveSearchTargets(File(path))
|
||||||
if (targets.isEmpty()) return emptyList()
|
if (targets.isEmpty()) return emptyList()
|
||||||
val (modelPath, tokenizerPath) = modelPaths()
|
val (modelPath, tokenizerPath) = modelPaths()
|
||||||
|
ensureModel(modelDir())
|
||||||
val allHits = ArrayList<Hit>()
|
val allHits = ArrayList<Hit>()
|
||||||
for (coll in targets) {
|
for (coll in targets) {
|
||||||
val dbPath = File(coll, ".memo/index.db")
|
val dbPath = File(coll, ".memo/index.db")
|
||||||
|
|||||||
@@ -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,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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package memo.mcp
|
|||||||
import memo.core.Db
|
import memo.core.Db
|
||||||
import memo.core.Embedder
|
import memo.core.Embedder
|
||||||
import memo.core.Indexer
|
import memo.core.Indexer
|
||||||
|
import memo.core.ModelStore
|
||||||
import memo.core.RefreshHook
|
import memo.core.RefreshHook
|
||||||
import memo.core.SearchMode
|
import memo.core.SearchMode
|
||||||
import memo.core.Searcher
|
import memo.core.Searcher
|
||||||
@@ -115,6 +116,7 @@ fun toolSearch(path: String, query: String, k: Int, mode: SearchMode): String {
|
|||||||
val root = resolveCollection(File(path))
|
val root = resolveCollection(File(path))
|
||||||
val targets = selectTargets(root)
|
val targets = selectTargets(root)
|
||||||
if (targets.isEmpty()) return "коллекции не найдены"
|
if (targets.isEmpty()) return "коллекции не найдены"
|
||||||
|
ensureModel()
|
||||||
val (modelPath, tokenizerPath) = modelPaths()
|
val (modelPath, tokenizerPath) = modelPaths()
|
||||||
val allHits = ArrayList<memo.core.Hit>()
|
val allHits = ArrayList<memo.core.Hit>()
|
||||||
val errors = ArrayList<String>()
|
val errors = ArrayList<String>()
|
||||||
@@ -200,6 +202,7 @@ fun toolReindex(path: String): String {
|
|||||||
val root = resolveCollection(File(path))
|
val root = resolveCollection(File(path))
|
||||||
val collections = selectTargets(root)
|
val collections = selectTargets(root)
|
||||||
if (collections.isEmpty()) return "коллекции не найдены"
|
if (collections.isEmpty()) return "коллекции не найдены"
|
||||||
|
ensureModel()
|
||||||
val (modelPath, tokenizerPath) = modelPaths()
|
val (modelPath, tokenizerPath) = modelPaths()
|
||||||
var totalUpdated = 0
|
var totalUpdated = 0
|
||||||
val embedder = Embedder(modelPath, tokenizerPath)
|
val embedder = Embedder(modelPath, tokenizerPath)
|
||||||
@@ -231,9 +234,23 @@ private fun selectTargets(root: File): List<File> {
|
|||||||
return findCollections(root)
|
return findCollections(root)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun modelPaths(): Pair<String, String> {
|
private fun modelDir(): File =
|
||||||
val modelDir = System.getenv("MEMO_MODEL_DIR") ?: "/root/WORK/memo/models/siglip2"
|
File(System.getenv("MEMO_MODEL_DIR") ?: "/root/WORK/memo/models/siglip2")
|
||||||
return "$modelDir/text_model_int8.onnx" to "$modelDir/tokenizer.model"
|
|
||||||
|
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 {
|
private fun countInt(db: Db, sql: String): Int {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package memo.watch
|
|||||||
import memo.core.Db
|
import memo.core.Db
|
||||||
import memo.core.Embedder
|
import memo.core.Embedder
|
||||||
import memo.core.Indexer
|
import memo.core.Indexer
|
||||||
|
import memo.core.findCollections
|
||||||
|
import memo.core.resolveCollection
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.concurrent.CountDownLatch
|
import java.util.concurrent.CountDownLatch
|
||||||
import kotlin.system.exitProcess
|
import kotlin.system.exitProcess
|
||||||
@@ -13,8 +15,8 @@ fun main(args: Array<String>) {
|
|||||||
exitProcess(2)
|
exitProcess(2)
|
||||||
}
|
}
|
||||||
val raw = File(args[0])
|
val raw = File(args[0])
|
||||||
val base = if (raw.name == ".memo") raw.parentFile ?: raw else raw
|
val base = resolveCollection(raw)
|
||||||
val collections = discoverCollections(base)
|
val collections = findCollections(base)
|
||||||
if (collections.isEmpty()) {
|
if (collections.isEmpty()) {
|
||||||
System.err.println("коллекции не найдены в ${base.absolutePath}")
|
System.err.println("коллекции не найдены в ${base.absolutePath}")
|
||||||
exitProcess(1)
|
exitProcess(1)
|
||||||
@@ -34,10 +36,14 @@ fun main(args: Array<String>) {
|
|||||||
CollCtx(coll, db, embedder, indexer)
|
CollCtx(coll, db, embedder, indexer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (ctx in ctxList) {
|
||||||
|
safeIndexTree(ctx.indexer, ctx.root)
|
||||||
|
}
|
||||||
|
|
||||||
val indexersByCollection = ctxList.associateBy { it.root }
|
val indexersByCollection = ctxList.associateBy { it.root }
|
||||||
val watcher = Watcher(
|
val watcher = Watcher(
|
||||||
collections = ctxList.map { it.root },
|
collections = ctxList.map { it.root },
|
||||||
index = { coll -> indexersByCollection.getValue(coll).indexer.indexTree(coll) },
|
index = { coll -> safeIndexTree(indexersByCollection.getValue(coll).indexer, coll) },
|
||||||
)
|
)
|
||||||
|
|
||||||
Runtime.getRuntime().addShutdownHook(
|
Runtime.getRuntime().addShutdownHook(
|
||||||
@@ -65,26 +71,25 @@ private data class CollCtx(
|
|||||||
val indexer: Indexer,
|
val indexer: Indexer,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun discoverCollections(base: File): List<File> {
|
private fun safeIndexTree(indexer: Indexer, root: File): Int {
|
||||||
if (!base.isDirectory) return emptyList()
|
return try {
|
||||||
val candidates = LinkedHashSet<File>()
|
val n = indexer.indexTree(root)
|
||||||
candidates.add(base)
|
println("индексирую: ${root.absolutePath} -> обновлено $n")
|
||||||
val q = ArrayDeque<Pair<File, Int>>()
|
n
|
||||||
q.addLast(base to 0)
|
} catch (t: Throwable) {
|
||||||
while (q.isNotEmpty()) {
|
System.err.println(
|
||||||
val (d, depth) = q.removeFirst()
|
"watcher: indexTree ${root.absolutePath}: ${t.message}, повтор через 1с"
|
||||||
if (depth >= 2) continue
|
)
|
||||||
val children = d.listFiles() ?: continue
|
Thread.sleep(1000)
|
||||||
for (c in children) {
|
try {
|
||||||
if (c.isDirectory && !c.name.startsWith(".")) {
|
val n = indexer.indexTree(root)
|
||||||
candidates.add(c)
|
println("индексирую: ${root.absolutePath} -> обновлено $n")
|
||||||
q.addLast(c to depth + 1)
|
n
|
||||||
|
} catch (t2: Throwable) {
|
||||||
|
System.err.println(
|
||||||
|
"watcher: повтор indexTree ${root.absolutePath} провалился: ${t2.message}"
|
||||||
|
)
|
||||||
|
0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return candidates.filter { d ->
|
|
||||||
d.walkTopDown()
|
|
||||||
.maxDepth(8)
|
|
||||||
.any { it.isFile && it.extension == "md" }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package memo.watch
|
||||||
|
|
||||||
|
import memo.core.Db
|
||||||
|
import memo.core.Embedder
|
||||||
|
import memo.core.Indexer
|
||||||
|
import memo.core.findCollections
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.test.fail
|
||||||
|
|
||||||
|
class WatchMainLogicTest {
|
||||||
|
|
||||||
|
private fun newRoot(prefix: String): File {
|
||||||
|
val root = File.createTempFile(prefix, "")
|
||||||
|
assertTrue(root.delete(), "temp cleanup")
|
||||||
|
assertTrue(root.mkdirs(), "temp mkdir")
|
||||||
|
root.deleteOnExit()
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun touchMd(parent: File, name: String, body: String = "# $name\n"): File {
|
||||||
|
val f = File(parent, name)
|
||||||
|
f.writeText(body)
|
||||||
|
f.deleteOnExit()
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun collectionsFoundByCoreRule() {
|
||||||
|
val root = newRoot("memo-watchlogic-coll-")
|
||||||
|
touchMd(root, "a.md")
|
||||||
|
val sub = File(root, "sub"); sub.mkdirs()
|
||||||
|
touchMd(sub, "b.md")
|
||||||
|
val nested = File(root, "nested"); nested.mkdirs()
|
||||||
|
val only = File(nested, "only"); only.mkdirs()
|
||||||
|
val deep = File(only, "deep"); deep.mkdirs()
|
||||||
|
touchMd(deep, "d.md")
|
||||||
|
File(root, "empty").mkdirs()
|
||||||
|
|
||||||
|
val got = findCollections(root).map { it.absolutePath }.toSet()
|
||||||
|
val want = setOf(
|
||||||
|
root.absolutePath,
|
||||||
|
File(root, "sub").absolutePath,
|
||||||
|
File(root, "nested/only/deep").absolutePath,
|
||||||
|
)
|
||||||
|
assertEquals(3, got.size, "ожидалось ровно 3 коллекции, получено ${got.size}")
|
||||||
|
assertEquals(want, got, "набор коллекций не совпадает с правилом ядра")
|
||||||
|
assertTrue(
|
||||||
|
File(root, "nested").absolutePath !in got,
|
||||||
|
"nested без .md напрямую не должен быть коллекцией",
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
File(root, "nested/only").absolutePath !in got,
|
||||||
|
"nested/only без .md напрямую не должен быть коллекцией",
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
File(root, "empty").absolutePath !in got,
|
||||||
|
"пустой каталог не должен быть коллекцией",
|
||||||
|
)
|
||||||
|
|
||||||
|
root.deleteRecursively()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun startupIndexPassFillsEveryCollection() {
|
||||||
|
val root = newRoot("memo-watchlogic-startup-")
|
||||||
|
val c1 = File(root, "c1"); c1.mkdirs()
|
||||||
|
touchMd(c1, "x.md", "# x\nпривет мир\n## Второй\nещё немного текста\n")
|
||||||
|
val c2 = File(root, "c2"); c2.mkdirs()
|
||||||
|
touchMd(c2, "y.md", "# y\nдругой текст\n## Третий\nещё строка\n")
|
||||||
|
|
||||||
|
val modelDir = System.getenv("MEMO_MODEL_DIR") ?: "/root/WORK/memo/models/siglip2"
|
||||||
|
val modelPath = "$modelDir/text_model_int8.onnx"
|
||||||
|
val tokenizerPath = "$modelDir/tokenizer.model"
|
||||||
|
if (!File(modelPath).exists() || !File(tokenizerPath).exists()) {
|
||||||
|
fail("модель не найдена: $modelDir")
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Ctx(val db: Db, val embedder: Embedder, val indexer: Indexer)
|
||||||
|
|
||||||
|
val ctxByColl: Map<File, Ctx> = listOf(c1, c2).associateWith { coll ->
|
||||||
|
val memoDir = File(coll, ".memo"); memoDir.mkdirs()
|
||||||
|
val db = Db(File(memoDir, "index.db").absolutePath)
|
||||||
|
db.init()
|
||||||
|
val embedder = Embedder(modelPath, tokenizerPath)
|
||||||
|
val indexer = Indexer(db, embedder)
|
||||||
|
Ctx(db, embedder, indexer)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
for ((coll, ctx) in ctxByColl) {
|
||||||
|
val updated = ctx.indexer.indexTree(coll)
|
||||||
|
assertTrue(
|
||||||
|
updated > 0,
|
||||||
|
"indexTree обязан переиндексировать хотя бы один файл в ${coll.absolutePath}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for ((coll, ctx) in ctxByColl) {
|
||||||
|
val filesStmt = ctx.db.conn.prepare("SELECT COUNT(*) FROM files")
|
||||||
|
val chunksStmt = ctx.db.conn.prepare("SELECT COUNT(*) FROM chunks")
|
||||||
|
try {
|
||||||
|
val rsF = filesStmt.executeQuery()
|
||||||
|
val filesCount = try {
|
||||||
|
assertTrue(rsF.next(), "SELECT COUNT(*) FROM files должен вернуть строку")
|
||||||
|
rsF.getLong(0)!!
|
||||||
|
} finally { rsF.close() }
|
||||||
|
val rsC = chunksStmt.executeQuery()
|
||||||
|
val chunksCount = try {
|
||||||
|
assertTrue(rsC.next(), "SELECT COUNT(*) FROM chunks должен вернуть строку")
|
||||||
|
rsC.getLong(0)!!
|
||||||
|
} finally { rsC.close() }
|
||||||
|
assertEquals(
|
||||||
|
1L, filesCount,
|
||||||
|
"ожидался ровно 1 файл в ${coll.absolutePath}, получено $filesCount",
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
chunksCount > 0,
|
||||||
|
"ожидались чанки в ${coll.absolutePath}, получено $chunksCount",
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
filesStmt.close()
|
||||||
|
chunksStmt.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
for ((_, ctx) in ctxByColl) {
|
||||||
|
try { ctx.embedder.close() } catch (_: Throwable) {}
|
||||||
|
try { ctx.db.close() } catch (_: Throwable) {}
|
||||||
|
}
|
||||||
|
root.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
-3
@@ -8,8 +8,10 @@
|
|||||||
# Контуры:
|
# Контуры:
|
||||||
# 1. Юнит-тесты всех модулей (gradle test --rerun-tasks).
|
# 1. Юнит-тесты всех модулей (gradle test --rerun-tasks).
|
||||||
# 2. Мутационная проверка тестов (e2e/mutation_check.py) — тесты обязаны падать на сломанном коде.
|
# 2. Мутационная проверка тестов (e2e/mutation_check.py) — тесты обязаны падать на сломанном коде.
|
||||||
# 3. Сквозной recall@5 на эталонном корпусе (e2e/run_e2e.py).
|
# 3. Сквозной индекс эталонного корпуса: идемпотентность и ровно 3 коллекции.
|
||||||
# 4. MCP-протокол живым клиентом (scripts/mcp_probe.py), включая холодный старт.
|
# 4. Сквозной recall@5 на эталонном корпусе (e2e/run_e2e.py).
|
||||||
|
# 5. Демон слежения: первичный проход, 3 базы, отсутствие «locked» при параллельном чтении.
|
||||||
|
# 6. MCP-протокол живым клиентом (scripts/mcp_probe.py), включая холодный старт.
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
@@ -81,7 +83,61 @@ if python3 e2e/run_e2e.py --cli "$CLI" --root "$CORPUS" --k 5 > /tmp/memo-e2e.lo
|
|||||||
else
|
else
|
||||||
echo "ПРОВАЛ recall:"; tail -25 /tmp/memo-e2e.log; FAILED+=("recall"); fi
|
echo "ПРОВАЛ recall:"; tail -25 /tmp/memo-e2e.log; FAILED+=("recall"); fi
|
||||||
|
|
||||||
section "5. MCP: протокол + холодный старт"
|
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
|
COLD=/tmp/memo-accept-cold
|
||||||
rm -rf "$COLD"; cp -r "$CORPUS" "$COLD"
|
rm -rf "$COLD"; cp -r "$CORPUS" "$COLD"
|
||||||
find "$COLD" -name .memo -type d -prune -exec rm -rf {} + 2>/dev/null
|
find "$COLD" -name .memo -type d -prune -exec rm -rf {} + 2>/dev/null
|
||||||
|
|||||||
Reference in New Issue
Block a user