# История: 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-файл удалён после запроса; приложение читает изображение в память и не пишет его на диск.