Files
recipe/HISTORY/2026-08-28-obdai-receipt.md
T

44 KiB
Raw Blame History

История: 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:
/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?

Ответ

Да. Рекомендуемая схема:

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

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.

Результат модели:

{"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 он не записывается.

Ответ модели:

{"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 вернул:

{"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 отсутствует.

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:

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 с настройкой:

{"temperature":0,"maxOutputTokens":300,"thinkingConfig":{"thinkingLevel":"minimal"}}

Ответ: HTTP 200. Gemini вернул текст:

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