51 KiB
История: 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с CameraXImageAnalysis, 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-29: Code review fixes
- Добавлено разрешение
INTERNET; для OkHttp заданы connect/read/call timeouts. - Аналитические Bitmap больше не рисуются поверх live preview на каждом кадре.
- YUV-конвертация учитывает
rowStride,pixelStrideи rotation; старые live-кадры освобождаются. - Добавлен локальный privacy gate: признаки ФИО, пациента или даты блокируют отправку.
- ML Kit различает найденную зону, отсутствие текста и
PrivacyBlocked. - Unit-тесты и
assembleDebugдля версии0.1.5завершились успешно.
2026-08-29: Emulator check
- Создан AVD
receipt-api35на Android 35 Google APIs x86_64. - Запуск в текущем WSL невозможен: Android Emulator требует KVM с VMX/SVM, доступного аппаратного ускорения нет.
:app:testDebugUnitTest :app:assembleDebugбез эмулятора завершилисьBUILD SUCCESSFUL.- Версия Android-приложения повышена до
0.1.6.
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 не принимает окончательное медицинское решение и не изменяет дозировку.
Выполненные действия
- Изучен
/home/naeel/MED/recipe/PLAN/chat_messages.md. - Изучена инструкция по ВМ
~/nubes/HowTo/vm-access-and-services.md. - Выполнена диагностическая проверка ВМ по SSH.
- Найден nginx-конфиг
elmerи backendelmer.service. - Подтверждено, что отдельный receipt-service можно разместить на
127.0.0.1:8768. - Создан этот журнал.
- Создан план в
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-файл удалён после запроса; приложение
читает изображение в память и не пишет его на диск.
2026-08-31: Реализация backend-фиксов по плану v2
По команде пользователя выполнены изменения backend-компонентов и тестов.
Изменения в recipe_service
recipe_service/metrics.py:- из
record()удалён вызовinitialize(); - добавлена
count_since(client_ip, started_at_from)для rate limiting.
- из
recipe_service/app.py:- добавлен
ProxyFix(..., x_for=1, x_proto=1, x_host=1); - сравнение токена переведено на
hmac.compare_digest; - введён единый финализатор
finalize(...)вместо дублированияrecord(...); duration_msсчитается во всех ветках черезtime.monotonic();- добавлен rate limit
20запросов/минута на IP (429 too many requests); - зафиксирован контракт
502:{"error":"upstream recognition failed","code":"upstream_error"}; - детали апстрима пишутся только в лог сервера с
request_id.
- добавлен
- добавлен
recipe_service/test_app.py(покрытие:health,401,400,415,413,200,502-контракт,429).
Изменения в gemini_proxy
gemini_proxy/app.py: удалёнapi_key_override; ключ только изGEMINI_API_KEY.gemini_proxy/test_app.py: добавлен тест, чтоapi_key_overrideв форме не даёт доступ безGEMINI_API_KEY.
Изменения зависимостей
- выровнен root
requirements.txtпо version bounds сrecipe_service/requirements.txt:Flask>=3.0,<4gunicorn>=21.2,<24requests>=2.31,<3
Проверки
py_compileизменённых Python-файлов: успешно.recipe_service:pytest -q->8 passed.gemini_proxy:pytest -q->4 passed.
Отдельно зафиксировано
Первый запуск тестов recipe_service дал PermissionError на /var/lib/recipe
при import-time initialize(). Исправлено в тесте ранней установкой
RECIPE_METRICS_DB в временный путь до импорта app.
2026-08-31: Безопасная оптимизация без смены поведения
По дополнительной команде пользователя выполнен пакет low-risk улучшений, направленный на производительность и устойчивость, без изменения основного контракта API.
Изменения
recipe_service/metrics.py:- добавлен индекс
idx_requests_client_ip_started_at ON requests(client_ip, started_at)для ускорения выборки rate limiting.
- добавлен индекс
recipe_service/app.py:- ответ
429унифицирован и дополнен стабильным полемcode="rate_limited"при сохраненииerror="too many requests".
- ответ
recipe_service/test_app.py:- обновлена проверка
429с новым полемcode; - добавлен тест граничного случая лимитера (
19запросов ->200); - добавлен тест чтения последней записи в SQLite-метриках с проверкой
status_code,duration_msиerrorпосле ветки502.
- обновлена проверка
Проверки
py_compileизменённых Python-файлов: успешно.recipe_service:pytest -q->10 passed.gemini_proxy:pytest -q->4 passed.
Вывод
Оптимизации применены без регрессий. Поведение успешного запроса, а также
статусы 400/401/413/415/502 сохранены; 429 дополнен машинным кодом
ошибки для стабильной клиентской обработки.
2026-08-31: Nginx-level rate limiting (основной лимитер)
По команде пользователя добавлен основной лимит запросов на уровне nginx,
при сохранении app-level fallback в recipe_service/app.py.
Изменения конфигурации
- Добавлен новый файл
recipe_service/nginx-rate-limit-http.conf:limit_req_zone $binary_remote_addr zone=recipe_api_per_ip:10m rate=20r/m;limit_req_status 429;- файл предназначен для single-include внутри
http { ... }.
- Обновлён
recipe_service/nginx-recipe.conf:- для
location = /recipe,location /recipe/,location = /receipt,location /receipt/добавленlimit_req zone=recipe_api_per_ip burst=5 nodelay;.
- для
Результат
- Лимит теперь применяется единообразно для всех воркеров gunicorn на входе nginx, а не только внутри отдельного процесса приложения.
- Python fallback-лимитер сохранён как защитный второй контур.
Проверки
py_compileизменённых Python-файлов: успешно.recipe_service:pytest -q->10 passed.