635 lines
44 KiB
Markdown
635 lines
44 KiB
Markdown
# История: obdai.ru/receipt
|
||
|
||
## 2026-08-29: Android MVP camera pipeline
|
||
|
||
- Установлены пользовательские Android SDK 35, Build Tools 35.0.0 и Gradle 8.11.1.
|
||
- Добавлен Android-проект `android-app` с CameraX `ImageAnalysis`, ML Kit Text Recognition, RAM-only crop и multipart-клиентом `/receipt`.
|
||
- Исправлена конвертация `YUV_420_888` с учетом `rowStride`, `pixelStride` и поворота кадра.
|
||
- Старые кадры освобождаются при замене; запрещенные storage API в `app/src` не обнаружены.
|
||
- Добавлено масштабирование координат crop и JVM unit-тест `CropHelperTest` с Robolectric.
|
||
- Проверка `:app:testDebugUnitTest :app:assembleDebug` завершилась `BUILD SUCCESSFUL`.
|
||
- Версия Android-приложения повышена до `0.1.4`.
|
||
|
||
## 2026-08-28
|
||
|
||
### Вопрос
|
||
Как запущен `obdai.ru` на ВМ?
|
||
|
||
### Ответ и проверка
|
||
ВМ: `5.172.178.213`.
|
||
|
||
Проверены systemd, Docker, процессы и nginx-конфигурация.
|
||
|
||
- `obdai.ru` описан в `/etc/nginx/sites-enabled/elmer`.
|
||
- Основной HTTPS-маршрут nginx проксируется на `127.0.0.1:8000`.
|
||
- Backend запускается через `elmer.service`.
|
||
- Команда backend:
|
||
|
||
```text
|
||
/opt/elmer/venv/bin/gunicorn -w 4 -b 127.0.0.1:8000 --timeout 180 web.app:app
|
||
```
|
||
|
||
- Дополнительный маршрут nginx проксируется на `127.0.0.1:8888`, где работает контейнер `llmui`.
|
||
- `elmer.service` и новый сервис должны оставаться независимыми.
|
||
|
||
### Вопрос
|
||
Можно ли запустить собственный Python-сервис по адресу `obdai.ru/receipt`?
|
||
|
||
### Ответ
|
||
Да. Рекомендуемая схема:
|
||
|
||
```text
|
||
obdai.ru/receipt -> nginx -> receipt.service -> FastAPI на 127.0.0.1:8768
|
||
```
|
||
|
||
Новый сервис должен иметь отдельный каталог, virtualenv, systemd-юнит, порт, логи, конфигурацию и процесс деплоя. Он не должен импортировать код `elmer`, использовать его virtualenv или зависеть от его перезапуска.
|
||
|
||
### Вопрос
|
||
Нужно ли учитывать независимость `elmer`?
|
||
|
||
### Ответ
|
||
Да. `elmer` не изменяется и не перезапускается при работе с receipt-service. Для него создаётся отдельный systemd-юнит, например `receipt.service`, с автозапуском и `Restart=on-failure`.
|
||
|
||
### Архитектурное решение
|
||
Сервис принимает изображение рецепта или фрагменты с лекарствами и дозировками, распознаёт их через OCR/vision-модель, проверяет названия по локальному справочнику лекарственных средств и возвращает структурированный JSON. ФИО и дата рождения не должны попадать в запрос, ответ, логи или постоянное хранилище.
|
||
|
||
Рекомендуемые компоненты:
|
||
|
||
- FastAPI и Pydantic;
|
||
- Gunicorn с Uvicorn worker;
|
||
- отдельный Python virtualenv;
|
||
- SQLite read-only для справочника на MVP;
|
||
- rapidfuzz для сопоставления;
|
||
- OCR/vision-модель;
|
||
- `gpt-oss-120b` только после проверки поддержки изображений;
|
||
- nginx reverse proxy;
|
||
- systemd.
|
||
|
||
### Контрольные ограничения
|
||
|
||
- Сервис stateless на MVP.
|
||
- Исходные изображения обрабатываются только в памяти.
|
||
- Размер файла и MIME проверяются по magic bytes.
|
||
- Сомнительные совпадения возвращаются с `uncertain=true` и кандидатами.
|
||
- LLM не принимает окончательное медицинское решение и не изменяет дозировку.
|
||
|
||
### Выполненные действия
|
||
|
||
1. Изучен `/home/naeel/MED/recipe/PLAN/chat_messages.md`.
|
||
2. Изучена инструкция по ВМ `~/nubes/HowTo/vm-access-and-services.md`.
|
||
3. Выполнена диагностическая проверка ВМ по SSH.
|
||
4. Найден nginx-конфиг `elmer` и backend `elmer.service`.
|
||
5. Подтверждено, что отдельный receipt-service можно разместить на `127.0.0.1:8768`.
|
||
6. Создан этот журнал.
|
||
7. Создан план в `PLAN/receipt-service-plan.md`.
|
||
|
||
## Дополнительное требование ведения истории
|
||
|
||
Пользователь явно потребовал документировать в `HISTORY` всё происходящее, включая:
|
||
|
||
- вопросы и ответы;
|
||
- выполненные действия и команды;
|
||
- гипотезы и рассуждения, относящиеся к задаче;
|
||
- ошибки команд и ошибочные предположения;
|
||
- результаты проверок;
|
||
- изменения архитектурных решений и требований.
|
||
|
||
### Проверка DeepSeek
|
||
|
||
- Сначала ключи DeepSeek ошибочно проверялись через `api.aillm.ru`; это был неправильный endpoint.
|
||
- После исправления endpoint на `https://api.deepseek.com` оба ключа дали доступ к API.
|
||
- В списке моделей обнаружена `deepseek-v4-flash-vision-exp`.
|
||
- Реальное изображение `PLAN/lekar.png` было отправлено в vision-модель.
|
||
- Ответ API: HTTP 200.
|
||
- Модель вернула два сомнительных результата: `Clonoxamin` и `Sio`; оба с `uncertain=true`.
|
||
- Вывод: vision-вход модель принимает, но качество распознавания нельзя считать достаточным без словаря и пользовательского подтверждения.
|
||
|
||
### Ошибки и ограничения
|
||
|
||
- Первый тест `gpt-oss-120b` принял multimodal-запрос, но в reasoning указал, что изображения не видит.
|
||
- Один тест завершился кодом `5`, потому что служебная строка `HTTP_STATUS` смешалась с JSON перед обработкой `jq`.
|
||
- В диагностической команде проверки DeepSeek была ошибка форматирования вывода suffix ключа; она не повлияла на HTTP-запрос.
|
||
- Переданные API-ключи опубликованы в чате; их следует отозвать и заменить.
|
||
|
||
### Текущий статус
|
||
|
||
Реализация кода не начиналась. Зафиксированы архитектура, инфраструктурная схема и результаты проверки доступных моделей. Следующий этап после отдельного подтверждения требований: подготовка MVP receipt-service и его независимого запуска рядом с `elmer`.
|
||
|
||
## Обезличивание выполняется в Android-приложении
|
||
|
||
По решению пользователя полный снимок рецепта обрабатывается на Android-
|
||
устройстве. Камера может получить полный рецепт, но до отправки на ВМ
|
||
приложение должно вырезать только зону препаратов.
|
||
|
||
На ВМ передаётся только обезличенный crop, содержащий название препарата,
|
||
дозировку, количество, схему, частоту и длительность приёма; дополнительно
|
||
допустимо имя врача. ФИО пациента, дата рождения и другие данные пациента не
|
||
передаются на ВМ, не отправляются в Gemini, не сохраняются и не возвращаются.
|
||
|
||
Полный снимок не должен покидать Android-устройство. Текущий серверный API
|
||
принимает переданное изображение и не может доказать, что клиент действительно
|
||
вырезал персональные данные, поэтому это обязательная ответственность
|
||
будущего Android crop pipeline. После обработки Android должен удалить свои
|
||
локальные временные копии; серверная обработка уже выполняется в памяти и не
|
||
создаёт постоянных файлов изображений.
|
||
|
||
Пользователь подтвердил реализацию подхода, в котором словарь лекарств участвует непосредственно в принятии решения, а не используется только для косметического исправления OCR.
|
||
|
||
### Pipeline
|
||
|
||
```text
|
||
Android crop зон лекарств
|
||
-> preprocessing (original, grayscale, contrast, sharpen, threshold, upscale)
|
||
-> DeepSeek Vision генерирует несколько гипотез
|
||
-> candidate resolver
|
||
-> локальный словарь лекарств
|
||
-> отдельный разбор дозировки и формы выпуска
|
||
-> decision engine: CONFIDENT / NEED_CONFIRMATION / UNKNOWN
|
||
-> JSON и overlay в Android
|
||
```
|
||
|
||
### Решения
|
||
|
||
- Не отправлять модели весь рецепт, если клиент может передать отдельные строки или зоны.
|
||
- Для каждой лекарственной зоны допускается несколько вариантов preprocessing и повторное распознавание.
|
||
- Vision-модель должна возвращать `observed_text` и альтернативы, а не безусловно выбирать препарат.
|
||
- Resolver использует exact match, aliases, fuzzy/character similarity, лекарственную форму и совместимость дозировки.
|
||
- Название и дозировка разбираются отдельно.
|
||
- Недопустимая дозировка является сигналом ошибки и переводит результат в `NEED_CONFIRMATION`, но не исправляется автоматически.
|
||
- В API добавить `POST /receipt/api/v1/confirm` для выбора пользователем и накопления correction samples.
|
||
- На MVP использовать SQLite в `/opt/receipt/dictionary/drugs.sqlite`.
|
||
- Подтверждённые пользователем исправления в будущем хранить отдельно как обезличенный dataset.
|
||
- `elmer.service` не изменяется и не становится зависимостью receipt-service.
|
||
|
||
### Гипотезы для проверки
|
||
|
||
- Передача ограниченного списка кандидатов vision-модели повысит точность по сравнению с единственным свободным ответом.
|
||
- Отдельные crops лекарственных строк дадут лучшее качество и меньше PII, чем полный рецепт.
|
||
- Совместимость названия с дозировкой и формой выпуска снизит опасные false positive.
|
||
- Два или более preprocessing-варианта могут дать больший выигрыш, чем немедленное дообучение большой модели.
|
||
|
||
### Следующий технический этап
|
||
|
||
Разработать MVP receipt backend: FastAPI, SQLite-схема справочника, DeepSeek Vision client, candidate resolver, scoring, Pydantic API, unit-тесты и независимую конфигурацию systemd/nginx. До выбора порогов проверить pipeline на 50–200 обезличенных рецептах.
|
||
|
||
## Мнения внешних ассистентов
|
||
|
||
### ChatGPT
|
||
|
||
Зафиксирован подход:
|
||
|
||
- vision-модель генерирует несколько гипотез текста;
|
||
- локальный словарь участвует в принятии решения, а не только исправляет OCR;
|
||
- дозировка и форма выпуска проверяются отдельно;
|
||
- статусы результата: `CONFIDENT`, `NEED_CONFIRMATION`, `UNKNOWN`;
|
||
- отдельный endpoint подтверждения позволяет собирать correction samples;
|
||
- сервис размещается в `/opt/receipt/`, слушает `127.0.0.1:8768` и работает через отдельный `receipt.service`;
|
||
- `elmer.service` не изменяется и не становится зависимостью;
|
||
- на MVP рекомендуется SQLite.
|
||
|
||
### Gemini
|
||
|
||
Мнение Gemini в целом совпало с этим направлением:
|
||
|
||
- архитектура: Android crop -> nginx -> FastAPI -> DeepSeek Vision -> Lexicon Matcher -> Dosage/Form Validator -> Confidence Aggregator -> JSON -> Android overlay;
|
||
- DeepSeek Vision должен возвращать изображение, `observed_text`, top-N гипотез и сырой текст дозировки;
|
||
- словарь должен включать МНН, торговые названия, латинские и русские варианты, aliases, формы выпуска, дозировки и типичные ошибки;
|
||
- resolver должен использовать exact match, aliases, Damerau-Levenshtein, phonetic matching, Trie/SymSpell и совместимость дозировки/формы;
|
||
- итоговый score рекомендуется строить из vision score, dictionary similarity, dose compatibility и form compatibility, а коэффициенты проверять экспериментально;
|
||
- название, strength и режим приёма следует распознавать раздельно;
|
||
- недопустимая дозировка должна снижать уверенность и выдавать предупреждение, но не исправляться автоматически;
|
||
- вместо полного рецепта лучше отправлять отдельные crops лекарственных строк;
|
||
- для каждой зоны полезно создавать несколько вариантов preprocessing: grayscale, contrast, sharpen, threshold, upscale;
|
||
- рекомендуется добавить `POST /receipt/api/v1/confirm` для подтверждения пользователем и накопления обезличенных correction samples;
|
||
- SQLite подходит для локального read-heavy справочника на первом этапе;
|
||
- PostgreSQL не следует подключать к `elmer`, если он понадобится позже, нужна отдельная БД и отдельный пользователь;
|
||
- структура `/opt/receipt/`, отдельный virtualenv, systemd-юнит и nginx location сохраняют полную независимость от `elmer`.
|
||
|
||
### Сводный вывод
|
||
|
||
Оба мнения поддерживают один основной pipeline: vision-модель используется для извлечения нескольких гипотез, локальный справочник ограничивает пространство кандидатов, отдельный валидатор проверяет дозировку и форму, а сомнительные результаты передаются пользователю на подтверждение.
|
||
|
||
Гипотезы, которые нельзя считать доказанными до эксперимента:
|
||
|
||
- передача top-N кандидатов модели повысит точность;
|
||
- несколько вариантов preprocessing дадут существенный выигрыш;
|
||
- проверка дозировки и формы уменьшит опасные false positive;
|
||
- SQLite останется достаточной при ожидаемой нагрузке.
|
||
|
||
### Ожидание команды
|
||
|
||
По состоянию на эту запись документирование выполнено. Код receipt-service, конфигурация ВМ и nginx не изменялись. Следующий шаг должен начинаться с MVP и проверки на обезличенных данных.
|
||
|
||
## Начало тестирования DeepSeek Flash
|
||
|
||
Пользователь дал команду приступить к тестированию через `deepseek-v4-flash-vision-exp`.
|
||
|
||
Цель эксперимента: добиться распознавания фрагмента `PLAN/lekar.png` до разработки backend и справочника.
|
||
|
||
Правила чистого эксперимента:
|
||
|
||
- каждый запрос отправляется как независимый запрос без истории;
|
||
- предыдущие ответы и гипотезы модели в новый запрос не передаются;
|
||
- ключи и секреты не выводятся в журнал;
|
||
- сначала проверяется базовый запрос, затем варианты preprocessing;
|
||
- меняется один фактор за раз;
|
||
- сравнивается содержательный текст распознавания, а не только HTTP 200;
|
||
- результат и ошибки фиксируются после каждого тестового этапа.
|
||
|
||
## Gemini: полный recipe1.png, первая попытка
|
||
|
||
Полное изображение `PLAN/recipe1.png` отправлено в `gemini-3.6-flash`.
|
||
Запрос завершился HTTP 200, но модель остановилась с `finishReason=MAX_TOKENS`.
|
||
Промпт требовал минимальный JSON и исключение ФИО и даты рождения.
|
||
|
||
Неполный вывод начинался с: `Date: 12 марта 20`.
|
||
|
||
Использование токенов: prompt 1131, image 1092, text 39, candidate output 19,
|
||
thoughts 477, total 1627. Стоимость по тарифу Gemini 3.6 Flash ($0.75 за 1M
|
||
входных и $3.75 за 1M выходных токенов), если считать prompt как вход и
|
||
candidate output как выход: $0.00092, то есть около 9 копеек при курсе 95
|
||
рублей за доллар.
|
||
|
||
Вывод: лимит 500 токенов оказался недостаточным; следующий независимый тест
|
||
проводится с существенно большим лимитом и ещё более короткой JSON-схемой.
|
||
|
||
## Gemini: повторный полный recipe1.png с лимитом 4000
|
||
|
||
Повторный независимый запрос отправлен с тем же полным изображением и
|
||
`maxOutputTokens=4000`. Локальная проверка подтвердила корректный JSON-запрос,
|
||
наличие двух частей (`text` и `inline_data`) и размер Base64 изображения
|
||
5 015 836 символов.
|
||
|
||
Gemini вернул HTTP 400 с HTML-страницей Google: `malformed or illegal request`.
|
||
Распознавание не выполнялось, поэтому стоимость результата распознавания не
|
||
начислена. Причина требует отдельной проверки; следующий шаг — повторить тест
|
||
с допустимым форматом/лимитом, сохранив изображение и минимальный вывод.
|
||
|
||
## Gemini: полный recipe1.png, лимит 8000
|
||
|
||
Повторный запрос выполнен с ключом, указанным пользователем, без сохранения
|
||
ключа в проекте. Полное `PLAN/recipe1.png` отправлено в `gemini-3.6-flash` с
|
||
минимальным англоязычным промптом и `maxOutputTokens=8000`.
|
||
|
||
Ответ завершён успешно: HTTP 200, `finishReason=STOP`.
|
||
|
||
Результат модели:
|
||
|
||
```json
|
||
{"date":"12.03.2026","medicines":[{"name":"Fluvoxamine","dosage":"100 mg"}]}
|
||
```
|
||
|
||
Usage metadata: prompt 1132 токена, включая image 1092 и text 40; candidate
|
||
output 32; thoughts 1432; total 2596.
|
||
|
||
При тарифе $0.75 за 1M входных токенов и $3.75 за 1M выходных стоимость без
|
||
учёта thoughts составляет $0.000969, или 0.092 рубля при курсе 95 рублей за
|
||
доллар. Если thoughts тарифицируются как выходные токены, верхняя оценка
|
||
составляет $0.006339, или 0.602 рубля. Фактический ответ распознал дату и
|
||
только один препарат; проверка полноты по изображению ещё нужна.
|
||
|
||
## Создание минимального Gemini proxy
|
||
|
||
По команде пользователя создан изолированный локальный модуль
|
||
`gemini_proxy/`, не изменяющий существующий `site` и `requirements.txt`.
|
||
|
||
Состав:
|
||
|
||
- `gemini_proxy/app.py` — FastAPI API с `GET /health` и `POST /gemini`;
|
||
- `gemini_proxy/requirements.txt` — отдельные зависимости;
|
||
- `gemini_proxy/.env.example` — только имена настроек без реального ключа;
|
||
- `gemini_proxy/test_app.py` — базовые тесты.
|
||
|
||
Ключ Gemini не захардкожен и не записывается в проект. Приложение читает его
|
||
из `GEMINI_API_KEY`; модель — из `GEMINI_MODEL`. Изображение принимается только
|
||
как JPEG/PNG/WEBP, ограничено 10 MB, не сохраняется на диск и отправляется в
|
||
Gemini как Base64. Ответ возвращает текст модели и usage metadata.
|
||
|
||
Ошибка во время реализации: сначала изображение ошибочно кодировалось через
|
||
hex вместо Base64. Узкий тест выявил проблему; код исправлен на
|
||
`base64.b64encode(...).decode("ascii")`.
|
||
|
||
Проверка после исправления: `pytest -q gemini_proxy/test_app.py` — 2 passed;
|
||
`python3 -m py_compile gemini_proxy/app.py` завершился успешно. Обнаружено
|
||
предупреждение зависимости FastAPI/Starlette о deprecated-интеграции с
|
||
текущим httpx TestClient; оно не влияет на результат тестов.
|
||
|
||
Модуль пока не развёрнут на немецком сервере и не подключён к nginx/systemd.
|
||
|
||
## Развёртывание Gemini proxy на немецкой ВМ
|
||
|
||
По команде пользователя модуль развёрнут на ВМ `95.179.252.111`.
|
||
|
||
Развёртывание выполнено изолированно:
|
||
|
||
- каталог приложения: `/opt/gemini-proxy`;
|
||
- системный пользователь и группа: `gemini-proxy`;
|
||
- отдельный virtualenv: `/opt/gemini-proxy/venv`;
|
||
- systemd-юнит: `gemini-proxy.service`;
|
||
- bind: `127.0.0.1:8768`;
|
||
- конфигурация: `/etc/gemini-proxy/gemini-proxy.env`;
|
||
- ключ в проект и журнал не записывался;
|
||
- в env-файле пока задана только модель, поэтому `GEMINI_API_KEY` ещё нужно
|
||
добавить перед использованием endpoint распознавания.
|
||
|
||
Подробное описание оставлено в `/opt/gemini-proxy/README.md`.
|
||
|
||
При первом запуске обнаружено, что на ВМ отсутствовал пакет
|
||
`python3.10-venv`; пакет установлен, virtualenv создан, зависимости установлены.
|
||
После этого юнит был установлен повторно.
|
||
|
||
Первая проверка health попала в момент запуска и получила `connection refused`.
|
||
Проверка по systemd/journal подтвердила нормальный запуск Uvicorn на
|
||
`127.0.0.1:8768`; повторный health вернул `{"status":"ok"}`.
|
||
|
||
Проверки изоляции: `gemini-proxy.service` активен; `GEMINI_API_KEY` отсутствует
|
||
в серверном env-файле намеренно; существующий `elmer` не перезапускался и не
|
||
использовался. Nginx-маршрут пока не добавлялся, поэтому proxy снаружи ВМ не
|
||
доступен.
|
||
|
||
## Активация ключа и проверка proxy
|
||
|
||
По дополнительной команде пользователя API-ключ Gemini установлен только на
|
||
немецкой ВМ в `/etc/gemini-proxy/gemini-proxy.env`. В репозиторий и HISTORY
|
||
ключ не записывался. Перезапущен только `gemini-proxy.service`.
|
||
|
||
Контрольный запрос с `PLAN/lekar.png` через `POST /gemini` на localhost ВМ:
|
||
HTTP 200.
|
||
|
||
## Защита recipe API
|
||
|
||
По команде пользователя добавлена Bearer-защита endpoint `POST /recipe`.
|
||
Приложение сравнивает заголовок `Authorization` со значением
|
||
`RECIPE_API_TOKEN` из EnvironmentFile; при отсутствии или неверном токене
|
||
возвращается HTTP 401. Health endpoint остаётся доступен без авторизации.
|
||
|
||
Добавлен `recipe_service/.gitignore`, исключающий `.env`, `*.env`,
|
||
`__pycache__` и `*.pyc`. В `.env.example` добавлено только имя настройки без
|
||
секретного значения. Реальный случайный токен будет храниться только на ВМ в
|
||
`/etc/recipe/recipe.env`; в git и HISTORY он не записывается.
|
||
|
||
Ответ модели:
|
||
|
||
```json
|
||
{"date":null,"medicines":[{"name":"Fluvoxamini","dosage":"100 mg"}]}
|
||
```
|
||
|
||
Usage: prompt 1139, image 1102, text 37, candidate output 23, thoughts 650,
|
||
total 1812. Прокси работает; внешний nginx-маршрут пока не добавлялся.
|
||
|
||
## Проверка полного текста каракулей через proxy
|
||
|
||
Промпт в `gemini_proxy/app.py` изменён: теперь модель должна транскрибировать
|
||
весь видимый медицинский текст фрагмента, включая несколько лекарств,
|
||
дозировки, частоту, способ и длительность приёма, без нормализации и выдумок.
|
||
Фиксированная схема списка лекарств удалена; ответ proxy по-прежнему содержит
|
||
одно поле `text` и usage.
|
||
|
||
После синхронизации `app.py` и перезапуска только `gemini-proxy` выполнен
|
||
контрольный запрос с полным `lekar.png`. Получен HTTP 200. Gemini вернул:
|
||
|
||
```json
|
||
{"text":"Fluvoxamini 100 mg\nDtd N 30 in tab\nS. по 1 т 1 р в день\nвнутри в течение\n30 дней."}
|
||
```
|
||
|
||
Модель сохранила название, дозировку, количество, схему приёма и длительность
|
||
30 дней. Usage: prompt 1170, image 1102, text 68, candidate output 48,
|
||
thoughts 1259, total 2477. Временный файл ответа не был доступен из-за
|
||
`PrivateTmp=true`; повторная проверка через stdout успешно получила чистый
|
||
результат. Ключ в HISTORY не записывался.
|
||
|
||
## Прозрачная передача prompt и настроек Gemini
|
||
|
||
Выяснено, что предыдущая версия proxy сама формировала prompt и принудительно
|
||
добавляла `thinkingConfig`. Это нарушало требование: российский сервер должен
|
||
передавать немецкому proxy изображение, prompt, `generationConfig` и при
|
||
необходимости ключ, а немецкий proxy только пересылает их в Gemini.
|
||
|
||
Proxy изменён: `POST /gemini` теперь принимает multipart-поля `image`,
|
||
обязательный `prompt`, JSON-строку `generation_config` и необязательный
|
||
`api_key_override`. Поле `generation_config` проверяется как JSON-объект и
|
||
передаётся без подмены. Фиксированный prompt и настройки thinking удалены.
|
||
|
||
Локальная проверка после изменения: 3 теста прошли, синтаксис `app.py`
|
||
проверен через `py_compile`.
|
||
|
||
Изменение развёрнуто на немецкую ВМ. End-to-end проверка с `lekar.png`,
|
||
внешним prompt, `generation_config={"temperature":0,"maxOutputTokens":2000}`
|
||
и ключом override завершилась HTTP 200. Ответ содержит `Fluvoxamini 100 mg`,
|
||
`DtdN 30 in tab`, схему `1 т 1 р. в день` и длительность `30 дней`.
|
||
|
||
Usage проверки: prompt 1117, image 1102, text 15, candidate output 80,
|
||
thoughts 1760, total 2957. Ключ в журнал не записывался.
|
||
|
||
## Минимальный recipe-сервис на ВМ 213
|
||
|
||
По команде пользователя создан отдельный Flask-сервис
|
||
`recipe_service/`. Его назначение на текущем этапе: принять картинку и prompt,
|
||
передать их в Gemini с ключом и JSON-настройками из серверного EnvironmentFile,
|
||
вернуть `text` и `usage`. Разбор рецепта и дополнительная логика пока не
|
||
реализуются.
|
||
|
||
Развёрнуты отдельные каталог `/opt/recipe`, пользователь `recipe`, virtualenv,
|
||
systemd-юнит `recipe.service` и внутренний bind `127.0.0.1:8770`. Порт 8769
|
||
был занят существующим процессом, поэтому выбран 8770; внешний URL от этого не
|
||
меняется.
|
||
|
||
Ключ и настройки записаны только на ВМ в `/etc/recipe/recipe.env` с правами
|
||
`0640`, без хардкода в коде и без записи в HISTORY. В конфигурации задана
|
||
модель `gemini-3.6-flash`, лимит 300 токенов и `thinkingLevel=minimal`.
|
||
|
||
Добавлен отдельный nginx location `https://obdai.ru/recipe/`, проксирующий на
|
||
`127.0.0.1:8770`. `nginx -t` успешен, внешний `GET /recipe/health` вернул
|
||
`{"status":"ok"}`.
|
||
|
||
Ошибки и исправления: первая команда деплоя использовала путь к ключу,
|
||
существующий только на локальной машине, и получила `No such file`; ключ затем
|
||
передан через stdin. Первая health-проверка обращалась к занятому старому
|
||
порту/в момент старта; после переноса на 8770 сервис работает. Первый внешний
|
||
health сразу после reload дал кратковременный 404, повторная проверка вернула
|
||
200.
|
||
|
||
## Проверка `PLAN/lekar1.png` с минимальным thinking
|
||
|
||
Предыдущий тест использовал ошибочный crop из `lekar.png`. По уточнению
|
||
пользователя отправлен именно файл `PLAN/lekar1.png` размером 1543x470.
|
||
Параметры: `thinkingConfig.thinkingLevel=minimal`, temperature 0,
|
||
maxOutputTokens 300.
|
||
|
||
Результат: HTTP 200, `thoughtsTokenCount` отсутствует.
|
||
|
||
```text
|
||
Risperidoni 2 mg
|
||
DTD N 60 in tab
|
||
S. по 1 та 2 р в день
|
||
внутри вечером
|
||
30 дн.
|
||
```
|
||
|
||
Usage: prompt 1097, image 1080, text 17, candidate output 37, total 1134.
|
||
|
||
## Проверка `PLAN/lekar3.png`
|
||
|
||
Файл `PLAN/lekar3.png` отправлен через proxy с `thinkingLevel=minimal`.
|
||
Результат HTTP 200:
|
||
|
||
```text
|
||
Periciazini 10mg
|
||
Proin 100 in tab.
|
||
S. po 1/2 t 4 r. в день
|
||
внутрі в течение
|
||
30 дней
|
||
```
|
||
|
||
Usage: prompt 1117, image 1100, text 17, candidate output 43, total 1160.
|
||
`thoughtsTokenCount` отсутствует.
|
||
|
||
## Gemini 3.6 Flash: thinkingLevel minimal
|
||
|
||
Выполнен контрольный запрос второго лекарственного фрагмента через proxy с
|
||
настройкой:
|
||
|
||
```json
|
||
{"temperature":0,"maxOutputTokens":300,"thinkingConfig":{"thinkingLevel":"minimal"}}
|
||
```
|
||
|
||
Ответ: HTTP 200. Gemini вернул текст:
|
||
|
||
```text
|
||
Didir 30 m tab
|
||
S. по 1т 1/р в день
|
||
внутрі ввечері
|
||
30 днів.
|
||
```
|
||
|
||
`thoughtsTokenCount` в ответе отсутствует, что подтверждает отсутствие
|
||
отдельно тарифицируемых reasoning-токенов в этом запросе. Usage: prompt 1119,
|
||
image 1102, text 17, candidate output 34, total 1153.
|
||
|
||
## Bearer-защита recipe API
|
||
|
||
Добавлена Bearer-защита `POST /recipe`: без заголовка или с неверным значением
|
||
`Authorization: Bearer ...` сервис возвращает HTTP 401. `/health` остаётся
|
||
доступен без авторизации.
|
||
|
||
Случайный токен установлен на ВМ 213 в `/etc/recipe/recipe.env` с правами
|
||
`0640` и владельцем `root:recipe`; значение токена не записывалось в git или
|
||
HISTORY. Добавлен `recipe_service/.gitignore` для `.env`, `*.env`,
|
||
`__pycache__` и `*.pyc`.
|
||
|
||
Синтаксис `recipe_service/app.py`, `git diff --check` и локальная проверка Flask
|
||
прошли. После перезапуска `recipe.service` запрос без токена получил HTTP 401.
|
||
Авторизованный запрос дошёл до Gemini, но получил HTTP 502: `API key not valid`.
|
||
Следовательно, Bearer-защита работает, а `GEMINI_API_KEY` в серверном env-файле
|
||
недействителен и требует замены.
|
||
|
||
## Вопрос Sonnet о доступности немецкого proxy
|
||
|
||
Sonnet запросил подтверждение доступности `95.179.252.111:8768` с ВМ 213.
|
||
Ответ: в имеющейся проверке такой прямой запрос не выполнялся, поэтому факт
|
||
доступности этого адреса не подтверждён. Подтверждено только, что ранее proxy
|
||
работал на немецкой ВМ на `127.0.0.1:8768`, то есть bind-адрес сам по себе не
|
||
доказывает доступность с ВМ 213. Для выбора транспорта требуется отдельная
|
||
проверка соединения с таймаутом; до неё нельзя утверждать, что прямой маршрут
|
||
или SSH-туннель уже работает.
|
||
|
||
Проверка выполнена с ВМ 213 командой `curl --max-time 5` к
|
||
`http://95.179.252.111:8768/health`: соединение отклонено, получен
|
||
`http_code=000` и ошибка `Failed to connect`. Прямой маршрут между ВМ не
|
||
доступен; для продолжения требуется SSH-туннель или изменение bind/firewall
|
||
немецкого proxy.
|
||
|
||
## Проверка плана Sonnet и транспорт
|
||
|
||
План Sonnet проверен по фактическим конфигурациям. На ВМ 213 в
|
||
`/etc/recipe/recipe.env` уже отсутствует `GEMINI_API_KEY`, присутствуют
|
||
`RECIPE_API_TOKEN` и `GEMINI_PROXY_URL`; `recipe.service` активен. В
|
||
задеплоенном `/opt/recipe/app.py` обнаружен дефект: ответ proxy разбирался как
|
||
Gemini-native (`candidates`/`usageMetadata`), хотя proxy возвращает поля
|
||
`text`/`usage`. В workspace исправлен разбор этих полей; локальные `py_compile`
|
||
и `git diff --check` прошли.
|
||
|
||
Дополнительная проверка SSH-туннеля показала, что на ВМ 213 нет ключа, дающего
|
||
вход на немецкую ВМ: российский ключ получил `Permission denied`, а `vultr.ppk`
|
||
не распознан как пригодный OpenSSH-ключ. Создание туннеля без нового
|
||
разрешённого ключа невозможно. Nginx на ВМ 213 уже содержит отдельные location
|
||
для `/recipe` и `/recipe/`; их изменение без новой подтверждённой проблемы не
|
||
выполнялось.
|
||
|
||
## Настройка SSH-туннеля и проверка фактического конфига
|
||
|
||
По команде пользователя ключ доступа к немецкой ВМ передан на ВМ 213 с
|
||
правами `0600` в `/home/naeel/.ssh/gemini_proxy_key`; успешный SSH-вход на
|
||
немецкую ВМ подтверждён без вывода ключа. На ВМ 213 создан и запущен
|
||
`gemini-tunnel.service`, перенаправляющий `127.0.0.1:8768` на немецкий
|
||
`127.0.0.1:8768`; health через туннель вернул HTTP 200.
|
||
|
||
Проверка показала, что фактический `/etc/nginx/sites-enabled/elmer` не
|
||
подключает локальный nginx-фрагмент, а `/etc/recipe/recipe.env` всё ещё
|
||
содержал внешний `GEMINI_PROXY_URL`, несмотря на работающий туннель. Поэтому
|
||
предыдущий end-to-end запрос дал 503, а `/recipe/` дал 404. Эти фактические
|
||
конфигурации требуют точечной синхронизации с workspace и повторной проверки.
|
||
|
||
SSH-ключ с локальной машины передан на ВМ 213 в
|
||
`/home/naeel/.ssh/gemini_proxy_key` с правами `0600`; вход на немецкую ВМ
|
||
подтверждён. Создан `gemini-tunnel.service`, через который локальный
|
||
`127.0.0.1:8768` на ВМ 213 направляется к немецкому proxy. Gemini key на ВМ 213
|
||
не используется.
|
||
|
||
Исправлен разбор ответа proxy в `recipe_service/app.py` (`text`/`usage`), а
|
||
также добавлен Flask route для `/recipe/`. Фактический nginx-конфиг на ВМ 213
|
||
синхронизирован с рабочими prefix locations; regex location с URI в
|
||
`proxy_pass` отклонён nginx и заменён допустимой конфигурацией.
|
||
|
||
Итоговые проверки: health HTTP 200; POST без токена HTTP 401; POST с неверным
|
||
токеном HTTP 401; авторизованный POST `/recipe` HTTP 200 с непустыми `text` и
|
||
`usage`; авторизованный POST `/recipe/` HTTP 200 с непустыми `text` и `usage`;
|
||
redirect отсутствует. `gemini-tunnel.service`, `recipe.service` и
|
||
`elmer.service` имеют статус active. `elmer.service` не перезапускался.
|
||
|
||
## Проверка хранения изображений
|
||
|
||
В задеплоенном `recipe_service/app.py` изображение читается через
|
||
`image.read()` в память и передаётся proxy через `requests.post(files=...)`.
|
||
Операций записи изображения на диск в коде нет; постоянное хранилище для
|
||
изображений не используется. `recipe.service` active.
|
||
|
||
В `/tmp` ВМ 213 обнаружены файлы от предыдущих ручных диагностических
|
||
запросов, включая `lekar1.png` и JSON-ответы. Они не создаются рабочим
|
||
pipeline автоматически и требуют отдельного разрешения на удаление. Это
|
||
отдельный остаток тестовых команд, а не постоянное хранилище приложения.
|
||
|
||
## SQLite-статистика запросов
|
||
|
||
По команде пользователя добавлена SQLite-база статистики на ВМ 213:
|
||
`/var/lib/recipe/metrics.sqlite3`. Хранятся request ID, время, полный IP,
|
||
User-Agent, method/path, MIME и размер изображения, длина prompt, HTTP-статус,
|
||
длительность, размер ответа, Gemini usage и безопасное описание ошибки.
|
||
Изображение, prompt, распознанный текст, Bearer-токен и Gemini key в базу не
|
||
записываются.
|
||
|
||
Первый запуск после добавления статистики выявил подтверждённую ошибку
|
||
`sqlite3.OperationalError: attempt to write a readonly database`: файл базы
|
||
был `root:root` с правами `644`, а unit использовал `ProtectSystem=strict`.
|
||
Файл переведён во владение `recipe:recipe` с правами `0660`, в unit добавлены
|
||
`StateDirectory=recipe` и `StateDirectoryMode=0770`.
|
||
|
||
## Основной endpoint `/receipt`
|
||
|
||
По уточнению пользователя основным публичным endpoint сделан
|
||
`https://obdai.ru/receipt`. Добавлены Flask-маршруты `/receipt` и `/receipt/`,
|
||
nginx-маршруты без redirect и health `/receipt/health`. Старые `/recipe` и
|
||
`/recipe/` сохранены для совместимости.
|
||
|
||
Проверен автоматический сценарий через ВМ 213: временный тестовый image был
|
||
передан на ВМ, отправлен с ВМ через `POST /receipt`, после ответа временные
|
||
файлы удалены. Health вернул HTTP 200, OCR вернул HTTP 200 с непустыми `text`
|
||
и `usage`. Рабочий код принимает изображение в память; изображения не
|
||
сохраняются в постоянное хранилище.
|
||
|
||
После исправления `recipe.service` active. Таблица `requests` создана
|
||
автоматически. Проверка записала 401 и успешный OCR: последняя строка содержит
|
||
IP `127.0.0.1`, MIME `image/png`, размер 531101 байт, длину prompt 88 и
|
||
непустой usage JSON. Тестовый image-файл удалён после запроса; приложение
|
||
читает изображение в память и не пишет его на диск.
|