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

635 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# История: 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-файл удалён после запроса; приложение
читает изображение в память и не пишет его на диск.