Compare commits
16
Commits
17def7789b
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8a65185ef | ||
|
|
caeaa8c6fa | ||
|
|
5a6abe3db7 | ||
|
|
3d846d0be5 | ||
|
|
c88224104e | ||
|
|
ac46bf4fcb | ||
|
|
3909650177 | ||
|
|
3bcff192e6 | ||
|
|
24a64baf93 | ||
|
|
d3e6ec3a9b | ||
|
|
5cfbab3eba | ||
|
|
d7c2ca7c45 | ||
|
|
773b088bfa | ||
|
|
eae62e76f8 | ||
|
|
9f0974aaa9 | ||
|
|
169662566d |
@@ -1,5 +1,31 @@
|
|||||||
# История: obdai.ru/receipt
|
# История: 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-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
|
## 2026-08-28
|
||||||
|
|
||||||
### Вопрос
|
### Вопрос
|
||||||
@@ -105,7 +131,23 @@ obdai.ru/receipt -> nginx -> receipt.service -> FastAPI на 127.0.0.1:8768
|
|||||||
|
|
||||||
Реализация кода не начиналась. Зафиксированы архитектура, инфраструктурная схема и результаты проверки доступных моделей. Следующий этап после отдельного подтверждения требований: подготовка MVP receipt-service и его независимого запуска рядом с `elmer`.
|
Реализация кода не начиналась. Зафиксированы архитектура, инфраструктурная схема и результаты проверки доступных моделей. Следующий этап после отдельного подтверждения требований: подготовка MVP receipt-service и его независимого запуска рядом с `elmer`.
|
||||||
|
|
||||||
## Утверждённое архитектурное решение
|
## Обезличивание выполняется в Android-приложении
|
||||||
|
|
||||||
|
По решению пользователя полный снимок рецепта обрабатывается на Android-
|
||||||
|
устройстве. Камера может получить полный рецепт, но до отправки на ВМ
|
||||||
|
приложение должно вырезать только зону препаратов.
|
||||||
|
|
||||||
|
На ВМ передаётся только обезличенный crop, содержащий название препарата,
|
||||||
|
дозировку, количество, схему, частоту и длительность приёма; дополнительно
|
||||||
|
допустимо имя врача. ФИО пациента, дата рождения и другие данные пациента не
|
||||||
|
передаются на ВМ, не отправляются в Gemini, не сохраняются и не возвращаются.
|
||||||
|
|
||||||
|
Полный снимок не должен покидать Android-устройство. Текущий серверный API
|
||||||
|
принимает переданное изображение и не может доказать, что клиент действительно
|
||||||
|
вырезал персональные данные, поэтому это обязательная ответственность
|
||||||
|
будущего Android crop pipeline. После обработки Android должен удалить свои
|
||||||
|
локальные временные копии; серверная обработка уже выполняется в памяти и не
|
||||||
|
создаёт постоянных файлов изображений.
|
||||||
|
|
||||||
Пользователь подтвердил реализацию подхода, в котором словарь лекарств участвует непосредственно в принятии решения, а не используется только для косметического исправления OCR.
|
Пользователь подтвердил реализацию подхода, в котором словарь лекарств участвует непосредственно в принятии решения, а не используется только для косметического исправления OCR.
|
||||||
|
|
||||||
@@ -330,6 +372,18 @@ hex вместо Base64. Узкий тест выявил проблему; ко
|
|||||||
Контрольный запрос с `PLAN/lekar.png` через `POST /gemini` на localhost ВМ:
|
Контрольный запрос с `PLAN/lekar.png` через `POST /gemini` на localhost ВМ:
|
||||||
HTTP 200.
|
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
|
```json
|
||||||
@@ -467,3 +521,238 @@ S. по 1т 1/р в день
|
|||||||
`thoughtsTokenCount` в ответе отсутствует, что подтверждает отсутствие
|
`thoughtsTokenCount` в ответе отсутствует, что подтверждает отсутствие
|
||||||
отдельно тарифицируемых reasoning-токенов в этом запросе. Usage: prompt 1119,
|
отдельно тарифицируемых reasoning-токенов в этом запросе. Usage: prompt 1119,
|
||||||
image 1102, text 17, candidate output 34, total 1153.
|
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,<4`
|
||||||
|
- `gunicorn>=21.2,<24`
|
||||||
|
- `requests>=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`.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
local.properties
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "ru.obdai.receipt"
|
||||||
|
compileSdk = 35
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "ru.obdai.receipt"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 35
|
||||||
|
versionCode = 7
|
||||||
|
versionName = "0.1.6"
|
||||||
|
|
||||||
|
val apiToken = providers.environmentVariable("RECEIPT_API_TOKEN").orNull ?: ""
|
||||||
|
buildConfigField("String", "RECEIPT_API_TOKEN", "\"${apiToken.replace("\\", "\\\\").replace("\"", "\\\"")}\"")
|
||||||
|
buildConfigField("String", "RECEIPT_API_URL", "\"https://obdai.ru/receipt\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
kotlinOptions { jvmTarget = "17" }
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
|
||||||
|
implementation(composeBom)
|
||||||
|
androidTestImplementation(composeBom)
|
||||||
|
|
||||||
|
implementation("androidx.core:core-ktx:1.15.0")
|
||||||
|
implementation("androidx.activity:activity-compose:1.10.0")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
||||||
|
implementation("androidx.compose.ui:ui")
|
||||||
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
|
implementation("androidx.compose.material3:material3")
|
||||||
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
|
|
||||||
|
implementation("androidx.camera:camera-camera2:1.4.1")
|
||||||
|
implementation("androidx.camera:camera-lifecycle:1.4.1")
|
||||||
|
implementation("androidx.camera:camera-view:1.4.1")
|
||||||
|
implementation("com.google.mlkit:text-recognition:16.0.1")
|
||||||
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||||
|
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
testImplementation("org.robolectric:robolectric:4.14.1")
|
||||||
|
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
|
||||||
|
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
|
||||||
|
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
||||||
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
|
||||||
|
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
# App-specific R8 rules.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:label="Receipt Camera"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.ReceiptCamera"
|
||||||
|
android:usesCleartextTraffic="false">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package ru.obdai.receipt
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Rect
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.camera.core.CameraSelector
|
||||||
|
import androidx.camera.core.ImageAnalysis
|
||||||
|
import androidx.camera.core.Preview
|
||||||
|
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||||
|
import androidx.camera.view.PreviewView
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import ru.obdai.receipt.camera.CameraManager
|
||||||
|
import ru.obdai.receipt.crop.CropHelper
|
||||||
|
import ru.obdai.receipt.network.ApiClient
|
||||||
|
import ru.obdai.receipt.viewmodel.ReceiptViewModel
|
||||||
|
import ru.obdai.receipt.viewmodel.UiState
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
private var latestBitmap by mutableStateOf<Bitmap?>(null)
|
||||||
|
private var capturedBitmap by mutableStateOf<Bitmap?>(null)
|
||||||
|
private var detectedBounds by mutableStateOf<Rect?>(null)
|
||||||
|
private var previewSize by mutableStateOf(android.util.Size(1, 1))
|
||||||
|
private val cameraExecutor = Executors.newSingleThreadExecutor()
|
||||||
|
private val cameraManager = CameraManager()
|
||||||
|
private val permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||||
|
if (granted) previewView?.let(::startCamera)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContent {
|
||||||
|
val receiptViewModel: ReceiptViewModel = viewModel(
|
||||||
|
factory = object : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
return ReceiptViewModel(
|
||||||
|
ApiClient(BuildConfig.RECEIPT_API_URL, BuildConfig.RECEIPT_API_TOKEN)
|
||||||
|
) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
CameraScreen(
|
||||||
|
bitmap = latestBitmap,
|
||||||
|
capturedBitmap = capturedBitmap,
|
||||||
|
bounds = detectedBounds,
|
||||||
|
previewSize = previewSize,
|
||||||
|
state = receiptViewModel.state.collectAsState().value,
|
||||||
|
onPreviewReady = ::onPreviewReady,
|
||||||
|
onCapture = { capturedBitmap = latestBitmap },
|
||||||
|
onRecognize = { bitmap ->
|
||||||
|
receiptViewModel.detectZone(bitmap) { bounds ->
|
||||||
|
detectedBounds = bounds
|
||||||
|
if (bounds != null) {
|
||||||
|
receiptViewModel.recognize(
|
||||||
|
CropHelper.crop(bitmap, bounds),
|
||||||
|
"Recognize only medicine names, dosage, quantity and schedule. Return concise text."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onPreviewReady(view: PreviewView) {
|
||||||
|
if (previewView === view) return
|
||||||
|
previewView = view
|
||||||
|
view.post { previewSize = android.util.Size(view.width.coerceAtLeast(1), view.height.coerceAtLeast(1)) }
|
||||||
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
startCamera(view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startCamera(view: PreviewView) {
|
||||||
|
val providerFuture = ProcessCameraProvider.getInstance(this)
|
||||||
|
providerFuture.addListener({
|
||||||
|
val provider = providerFuture.get()
|
||||||
|
val preview = Preview.Builder().build()
|
||||||
|
val analysis = ImageAnalysis.Builder()
|
||||||
|
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||||
|
.build()
|
||||||
|
analysis.setAnalyzer(cameraExecutor, cameraManager.analyzer { bitmap ->
|
||||||
|
runOnUiThread {
|
||||||
|
val previous = latestBitmap
|
||||||
|
latestBitmap = bitmap
|
||||||
|
if (previous != null && previous !== capturedBitmap && !previous.isRecycled) previous.recycle()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
provider.unbindAll()
|
||||||
|
preview.setSurfaceProvider(view.surfaceProvider)
|
||||||
|
provider.bindToLifecycle(this, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
|
||||||
|
}, ContextCompat.getMainExecutor(this))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var previewView: PreviewView? = null
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
latestBitmap?.let { if (!it.isRecycled) it.recycle() }
|
||||||
|
if (capturedBitmap !== latestBitmap) capturedBitmap?.let { if (!it.isRecycled) it.recycle() }
|
||||||
|
cameraExecutor.shutdown()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CameraScreen(
|
||||||
|
bitmap: Bitmap?,
|
||||||
|
capturedBitmap: Bitmap?,
|
||||||
|
bounds: Rect?,
|
||||||
|
state: UiState,
|
||||||
|
previewSize: android.util.Size,
|
||||||
|
onPreviewReady: (PreviewView) -> Unit,
|
||||||
|
onCapture: () -> Unit,
|
||||||
|
onRecognize: (Bitmap) -> Unit
|
||||||
|
) {
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
AndroidView(
|
||||||
|
factory = { context -> PreviewView(context).also(onPreviewReady) },
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth().padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Button(onClick = onCapture, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Зафиксировать кадр")
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = { capturedBitmap?.let(onRecognize) },
|
||||||
|
enabled = capturedBitmap != null && state !is UiState.Analyzing,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
if (state is UiState.Analyzing) CircularProgressIndicator()
|
||||||
|
else Text(if (bounds == null) "Найти и распознать препараты" else "Распознать crop")
|
||||||
|
}
|
||||||
|
if (state is UiState.Error) Text(state.message, color = Color.Red)
|
||||||
|
if (state is UiState.PrivacyBlocked) {
|
||||||
|
Text("Обнаружены данные пациента. Отправка заблокирована.", color = Color.Red)
|
||||||
|
}
|
||||||
|
if (state is UiState.Result) Text(state.text, color = Color.Red, modifier = Modifier.padding(24.dp))
|
||||||
|
}
|
||||||
|
if (capturedBitmap != null && bounds != null) {
|
||||||
|
CropOutline(
|
||||||
|
CropHelper.scaleToView(
|
||||||
|
bounds,
|
||||||
|
capturedBitmap.width,
|
||||||
|
capturedBitmap.height,
|
||||||
|
previewSize.width,
|
||||||
|
previewSize.height
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (state is UiState.Result) ResultOverlay(state.bitmap, state.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CropOutline(bounds: Rect) {
|
||||||
|
Canvas(Modifier.fillMaxSize()) {
|
||||||
|
drawRect(
|
||||||
|
color = Color.Green,
|
||||||
|
topLeft = androidx.compose.ui.geometry.Offset(bounds.left.toFloat(), bounds.top.toFloat()),
|
||||||
|
size = androidx.compose.ui.geometry.Size(bounds.width().toFloat(), bounds.height().toFloat()),
|
||||||
|
style = androidx.compose.ui.graphics.drawscope.Stroke(width = 4f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ResultOverlay(bitmap: Bitmap, text: String) {
|
||||||
|
Canvas(Modifier.fillMaxSize()) {
|
||||||
|
drawImage(bitmap.asImageBitmap(), dstSize = IntSize(size.width.toInt(), size.height.toInt()))
|
||||||
|
}
|
||||||
|
if (text.isNotBlank()) {
|
||||||
|
Text(text, color = Color.Red, modifier = Modifier.padding(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package ru.obdai.receipt.camera
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.ImageFormat
|
||||||
|
import android.graphics.Matrix
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.graphics.YuvImage
|
||||||
|
import androidx.camera.core.ImageAnalysis
|
||||||
|
import androidx.camera.core.ImageProxy
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
|
||||||
|
class CameraManager {
|
||||||
|
fun analyzer(onFrame: (Bitmap) -> Unit): ImageAnalysis.Analyzer = ImageAnalysis.Analyzer { image ->
|
||||||
|
try {
|
||||||
|
image.toBitmap()?.let(onFrame)
|
||||||
|
} finally {
|
||||||
|
image.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ImageProxy.toBitmap(): Bitmap? {
|
||||||
|
if (format != ImageFormat.YUV_420_888 || planes.size < 3) return null
|
||||||
|
val nv21 = ByteArray(width * height * 3 / 2)
|
||||||
|
copyPlane(planes[0], width, height, nv21, 0, 1)
|
||||||
|
copyPlane(planes[2], width / 2, height / 2, nv21, width * height, 2)
|
||||||
|
copyPlane(planes[1], width / 2, height / 2, nv21, width * height + 1, 2)
|
||||||
|
val jpeg = ByteArrayOutputStream()
|
||||||
|
YuvImage(nv21, ImageFormat.NV21, width, height, null)
|
||||||
|
.compressToJpeg(Rect(0, 0, width, height), 92, jpeg)
|
||||||
|
val decoded = BitmapFactory.decodeByteArray(jpeg.toByteArray(), 0, jpeg.size()) ?: return null
|
||||||
|
if (imageInfo.rotationDegrees == 0) return decoded
|
||||||
|
return Bitmap.createBitmap(
|
||||||
|
decoded,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
decoded.width,
|
||||||
|
decoded.height,
|
||||||
|
Matrix().apply { postRotate(imageInfo.rotationDegrees.toFloat()) },
|
||||||
|
true
|
||||||
|
).also { if (it !== decoded) decoded.recycle() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyPlane(
|
||||||
|
plane: ImageProxy.PlaneProxy,
|
||||||
|
planeWidth: Int,
|
||||||
|
planeHeight: Int,
|
||||||
|
output: ByteArray,
|
||||||
|
outputOffset: Int,
|
||||||
|
outputPixelStride: Int
|
||||||
|
) {
|
||||||
|
val buffer = plane.buffer.duplicate()
|
||||||
|
val rowStride = plane.rowStride
|
||||||
|
val pixelStride = plane.pixelStride
|
||||||
|
for (row in 0 until planeHeight) {
|
||||||
|
for (column in 0 until planeWidth) {
|
||||||
|
val sourceIndex = row * rowStride + column * pixelStride
|
||||||
|
output[outputOffset + row * planeWidth * outputPixelStride + column * outputPixelStride] = buffer.get(sourceIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package ru.obdai.receipt.crop
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Rect
|
||||||
|
|
||||||
|
object CropHelper {
|
||||||
|
fun scaleToView(bounds: Rect, bitmapWidth: Int, bitmapHeight: Int, viewWidth: Int, viewHeight: Int): Rect {
|
||||||
|
require(bitmapWidth > 0 && bitmapHeight > 0 && viewWidth > 0 && viewHeight > 0)
|
||||||
|
return Rect(
|
||||||
|
bounds.left * viewWidth / bitmapWidth,
|
||||||
|
bounds.top * viewHeight / bitmapHeight,
|
||||||
|
bounds.right * viewWidth / bitmapWidth,
|
||||||
|
bounds.bottom * viewHeight / bitmapHeight
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun crop(source: Bitmap, bounds: Rect): Bitmap {
|
||||||
|
require(!source.isRecycled) { "Source bitmap is recycled" }
|
||||||
|
val left = bounds.left.coerceIn(0, source.width)
|
||||||
|
val top = bounds.top.coerceIn(0, source.height)
|
||||||
|
val right = bounds.right.coerceIn(left, source.width)
|
||||||
|
val bottom = bounds.bottom.coerceIn(top, source.height)
|
||||||
|
require(right > left && bottom > top) { "Crop bounds are empty" }
|
||||||
|
return Bitmap.createBitmap(source, left, top, right - left, bottom - top)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package ru.obdai.receipt.crop
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Rect
|
||||||
|
import com.google.mlkit.vision.common.InputImage
|
||||||
|
import com.google.mlkit.vision.text.TextRecognition
|
||||||
|
import com.google.mlkit.vision.text.latin.TextRecognizerOptions
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import java.util.regex.Pattern
|
||||||
|
|
||||||
|
sealed interface ZoneDetection {
|
||||||
|
data class Found(val bounds: Rect) : ZoneDetection
|
||||||
|
data object NoText : ZoneDetection
|
||||||
|
data object PatientDataDetected : ZoneDetection
|
||||||
|
}
|
||||||
|
|
||||||
|
class MedicationZoneDetector {
|
||||||
|
private val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)
|
||||||
|
|
||||||
|
suspend fun detect(bitmap: Bitmap): ZoneDetection = suspendCancellableCoroutine { continuation ->
|
||||||
|
recognizer.process(InputImage.fromBitmap(bitmap, 0))
|
||||||
|
.addOnSuccessListener { result ->
|
||||||
|
val text = result.text
|
||||||
|
if (containsPatientData(text)) {
|
||||||
|
continuation.resume(ZoneDetection.PatientDataDetected)
|
||||||
|
return@addOnSuccessListener
|
||||||
|
}
|
||||||
|
val blocks = result.textBlocks
|
||||||
|
.map { it.boundingBox }
|
||||||
|
.filterNotNull()
|
||||||
|
.filter { it.top > bitmap.height / 5 }
|
||||||
|
val bounds = blocks.reduceOrNull { first, next ->
|
||||||
|
Rect(first).apply { union(next) }
|
||||||
|
}
|
||||||
|
continuation.resume(bounds?.let(ZoneDetection::Found) ?: ZoneDetection.NoText)
|
||||||
|
}
|
||||||
|
.addOnFailureListener { continuation.resume(ZoneDetection.NoText) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
recognizer.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun containsPatientData(text: String): Boolean {
|
||||||
|
val normalized = text.lowercase()
|
||||||
|
val date = Pattern.compile("""\b\d{1,2}[./-]\d{1,2}[./-]\d{2,4}\b""").matcher(normalized).find()
|
||||||
|
val labels = listOf("ф.и.о", "фамилия", "имя", "отчество", "дата рождения", "пациент")
|
||||||
|
return date || labels.any(normalized::contains)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package ru.obdai.receipt.network
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ReceiptResponse(val text: String? = null, val usage: Usage? = null)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Usage(val promptTokens: Int? = null, val candidatesTokens: Int? = null, val totalTokens: Int? = null)
|
||||||
|
|
||||||
|
class ApiClient(
|
||||||
|
private val endpoint: String,
|
||||||
|
private val token: String,
|
||||||
|
private val client: OkHttpClient = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(120, TimeUnit.SECONDS)
|
||||||
|
.callTimeout(150, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
) {
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
fun recognize(imageBytes: ByteArray, prompt: String): ReceiptResponse {
|
||||||
|
require(token.isNotBlank()) { "RECEIPT_API_TOKEN is not configured" }
|
||||||
|
val imageBody = imageBytes.toRequestBody("image/jpeg".toMediaType())
|
||||||
|
val body = MultipartBody.Builder()
|
||||||
|
.setType(MultipartBody.FORM)
|
||||||
|
.addFormDataPart("image", "medications.jpg", imageBody)
|
||||||
|
.addFormDataPart("prompt", prompt)
|
||||||
|
.build()
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(endpoint)
|
||||||
|
.header("Authorization", "Bearer $token")
|
||||||
|
.post(body)
|
||||||
|
.build()
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
check(response.isSuccessful) { "Receipt API returned HTTP ${response.code}" }
|
||||||
|
return json.decodeFromString(response.body?.string().orEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package ru.obdai.receipt.viewmodel
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import ru.obdai.receipt.crop.MedicationZoneDetector
|
||||||
|
import ru.obdai.receipt.crop.ZoneDetection
|
||||||
|
import ru.obdai.receipt.network.ApiClient
|
||||||
|
|
||||||
|
sealed interface UiState {
|
||||||
|
data object Idle : UiState
|
||||||
|
data object Analyzing : UiState
|
||||||
|
data object PrivacyBlocked : UiState
|
||||||
|
data class Result(val bitmap: Bitmap, val text: String) : UiState
|
||||||
|
data class Error(val message: String) : UiState
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReceiptViewModel(
|
||||||
|
private val apiClient: ApiClient,
|
||||||
|
private val zoneDetector: MedicationZoneDetector = MedicationZoneDetector()
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _state = MutableStateFlow<UiState>(UiState.Idle)
|
||||||
|
val state: StateFlow<UiState> = _state
|
||||||
|
|
||||||
|
fun recognize(crop: Bitmap, prompt: String) {
|
||||||
|
require(!crop.isRecycled) { "Crop bitmap is recycled" }
|
||||||
|
_state.value = UiState.Analyzing
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
val bytes = crop.toJpegBytes()
|
||||||
|
apiClient.recognize(bytes, prompt)
|
||||||
|
}.onSuccess { response ->
|
||||||
|
_state.value = UiState.Result(crop, response.text.orEmpty())
|
||||||
|
}.onFailure { error ->
|
||||||
|
_state.value = UiState.Error(error.message ?: "Recognition failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun detectZone(bitmap: Bitmap, onDetected: (android.graphics.Rect?) -> Unit) {
|
||||||
|
viewModelScope.launch(Dispatchers.Default) {
|
||||||
|
when (val detection = zoneDetector.detect(bitmap)) {
|
||||||
|
is ZoneDetection.Found -> onDetected(detection.bounds)
|
||||||
|
ZoneDetection.PatientDataDetected -> {
|
||||||
|
_state.value = UiState.PrivacyBlocked
|
||||||
|
onDetected(null)
|
||||||
|
}
|
||||||
|
ZoneDetection.NoText -> onDetected(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
(_state.value as? UiState.Result)?.bitmap?.let { bitmap ->
|
||||||
|
if (!bitmap.isRecycled) bitmap.recycle()
|
||||||
|
}
|
||||||
|
_state.value = UiState.Idle
|
||||||
|
zoneDetector.close()
|
||||||
|
super.onCleared()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Bitmap.toJpegBytes(): ByteArray {
|
||||||
|
return java.io.ByteArrayOutputStream().use { output ->
|
||||||
|
compress(Bitmap.CompressFormat.JPEG, 92, output)
|
||||||
|
output.toByteArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<style name="Theme.ReceiptCamera" parent="android:style/Theme.Material.Light.NoActionBar" />
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package ru.obdai.receipt.crop
|
||||||
|
|
||||||
|
import android.graphics.Rect
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
class CropHelperTest {
|
||||||
|
@Test
|
||||||
|
fun scalesBitmapBoundsToViewBounds() {
|
||||||
|
val result = CropHelper.scaleToView(Rect(100, 200, 500, 600), 1000, 1000, 500, 1000)
|
||||||
|
assertEquals(Rect(50, 200, 250, 600), result)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.7.3" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "ReceiptCamera"
|
||||||
|
include(":app")
|
||||||
+1
-2
@@ -25,7 +25,6 @@ async def recognize(
|
|||||||
image: Annotated[UploadFile, File(...)],
|
image: Annotated[UploadFile, File(...)],
|
||||||
prompt: Annotated[str, Form(...)],
|
prompt: Annotated[str, Form(...)],
|
||||||
generation_config: Annotated[str, Form()] = "{}",
|
generation_config: Annotated[str, Form()] = "{}",
|
||||||
api_key_override: Annotated[str | None, Form()] = None,
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
if image.content_type not in ALLOWED_TYPES:
|
if image.content_type not in ALLOWED_TYPES:
|
||||||
raise HTTPException(status_code=415, detail="Unsupported image type")
|
raise HTTPException(status_code=415, detail="Unsupported image type")
|
||||||
@@ -34,7 +33,7 @@ async def recognize(
|
|||||||
if len(image_data) > MAX_IMAGE_BYTES:
|
if len(image_data) > MAX_IMAGE_BYTES:
|
||||||
raise HTTPException(status_code=413, detail="Image is too large")
|
raise HTTPException(status_code=413, detail="Image is too large")
|
||||||
|
|
||||||
api_key = api_key_override or os.getenv("GEMINI_API_KEY")
|
api_key = os.getenv("GEMINI_API_KEY")
|
||||||
if not api_key:
|
if not api_key:
|
||||||
raise HTTPException(status_code=503, detail="Gemini is not configured")
|
raise HTTPException(status_code=503, detail="Gemini is not configured")
|
||||||
|
|
||||||
|
|||||||
@@ -28,4 +28,19 @@ def test_missing_key_returns_service_unavailable(monkeypatch) -> None:
|
|||||||
files={"image": ("input.png", b"not-an-image", "image/png")},
|
files={"image": ("input.png", b"not-an-image", "image/png")},
|
||||||
data={"prompt": "test", "generation_config": "{}"},
|
data={"prompt": "test", "generation_config": "{}"},
|
||||||
)
|
)
|
||||||
|
assert response.status_code == 503
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_when_only_override_provided(monkeypatch) -> None:
|
||||||
|
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.post(
|
||||||
|
"/gemini",
|
||||||
|
files={"image": ("input.png", b"not-an-image", "image/png")},
|
||||||
|
data={
|
||||||
|
"prompt": "test",
|
||||||
|
"generation_config": "{}",
|
||||||
|
"api_key_override": "manual-key",
|
||||||
|
},
|
||||||
|
)
|
||||||
assert response.status_code == 503
|
assert response.status_code == 503
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
GEMINI_API_KEY=replace-with-secret
|
GEMINI_PROXY_URL=http://127.0.0.1:8768/gemini
|
||||||
GEMINI_MODEL=gemini-3.6-flash
|
GEMINI_GENERATION_CONFIG={"temperature":0,"maxOutputTokens":300,"thinkingConfig":{"thinkingLevel":"minimal"}}
|
||||||
GEMINI_GENERATION_CONFIG={"temperature":0,"maxOutputTokens":300,"thinkingConfig":{"thinkingLevel":"minimal"}}
|
RECIPE_API_TOKEN=replace-with-random-token
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
.env
|
||||||
|
*.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
@@ -1,13 +1,20 @@
|
|||||||
# obdai.ru/recipe
|
# obdai.ru/receipt
|
||||||
|
|
||||||
Минимальный Flask-сервис на ВМ `5.172.178.213`.
|
Минимальный Flask-сервис на ВМ `5.172.178.213`.
|
||||||
|
|
||||||
Вход: `POST /recipe` в формате `multipart/form-data` с полями `image` и
|
Вход: `POST /receipt` в формате `multipart/form-data` с полями `image` и
|
||||||
`prompt`. Изображение: JPEG/PNG/WEBP, не более 10 MB.
|
`prompt`. Требуется заголовок `Authorization: Bearer <RECIPE_API_TOKEN>`.
|
||||||
|
Изображение: JPEG/PNG/WEBP, не более 10 MB.
|
||||||
|
|
||||||
Ключ Gemini и JSON-строка `GEMINI_GENERATION_CONFIG` находятся только в
|
`POST /receipt/` и старые пути `/recipe` и `/recipe/` также поддерживаются.
|
||||||
серверном EnvironmentFile. Сервис передаёт изображение, prompt и настройки в
|
|
||||||
Gemini и возвращает `text` и `usage`; разбор рецепта выполняется позднее.
|
URL немецкого Gemini proxy и JSON-строка `GEMINI_GENERATION_CONFIG` находятся
|
||||||
|
только в серверном EnvironmentFile. Сервис передаёт изображение, prompt и
|
||||||
|
настройки в proxy и возвращает `text` и `usage`; разбор рецепта выполняется
|
||||||
|
позднее. API-ключ Gemini остаётся только на немецкой ВМ и в recipe service не
|
||||||
|
передаётся.
|
||||||
|
Токен `RECIPE_API_TOKEN` также хранится только в EnvironmentFile и не
|
||||||
|
передаётся в Gemini.
|
||||||
|
|
||||||
Сервис изолирован от `elmer`: отдельные каталог, virtualenv, пользователь,
|
Сервис изолирован от `elmer`: отдельные каталог, virtualenv, пользователь,
|
||||||
порт, systemd-юнит и настройки nginx. Изображения на диск не сохраняются.
|
порт, systemd-юнит и настройки nginx. Изображения на диск не сохраняются.
|
||||||
+103
-26
@@ -1,70 +1,147 @@
|
|||||||
import base64
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import hmac
|
||||||
|
import time
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from flask import Flask, jsonify, request
|
from flask import Flask, jsonify, request
|
||||||
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
|
|
||||||
|
try:
|
||||||
|
from recipe_service.metrics import count_since, initialize, record, request_context, usage_json
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
from metrics import count_since, initialize, record, request_context, usage_json
|
||||||
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
|
||||||
MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
||||||
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
|
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
|
||||||
GEMINI_URL = "https://generativelanguage.googleapis.com/v1beta/models"
|
PROXY_URL = "http://127.0.0.1:8768/gemini"
|
||||||
|
RATE_LIMIT_REQUESTS_PER_MINUTE = 20
|
||||||
|
|
||||||
|
initialize()
|
||||||
|
|
||||||
|
|
||||||
def settings() -> tuple[str, dict]:
|
def settings() -> dict:
|
||||||
key = os.environ.get("GEMINI_API_KEY")
|
|
||||||
if not key:
|
|
||||||
raise RuntimeError("GEMINI_API_KEY is not configured")
|
|
||||||
try:
|
try:
|
||||||
config = json.loads(os.environ.get("GEMINI_GENERATION_CONFIG", "{}"))
|
config = json.loads(os.environ.get("GEMINI_GENERATION_CONFIG", "{}"))
|
||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError as exc:
|
||||||
raise RuntimeError("GEMINI_GENERATION_CONFIG is invalid") from exc
|
raise RuntimeError("GEMINI_GENERATION_CONFIG is invalid") from exc
|
||||||
if not isinstance(config, dict):
|
if not isinstance(config, dict):
|
||||||
raise RuntimeError("GEMINI_GENERATION_CONFIG must be an object")
|
raise RuntimeError("GEMINI_GENERATION_CONFIG must be an object")
|
||||||
return key, config
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def authorized() -> bool:
|
||||||
|
expected = os.environ.get("RECIPE_API_TOKEN")
|
||||||
|
authorization = request.headers.get("Authorization", "")
|
||||||
|
return bool(expected and hmac.compare_digest(authorization, f"Bearer {expected}"))
|
||||||
|
|
||||||
|
|
||||||
|
def minute_start_utc(epoch_seconds: float) -> str:
|
||||||
|
return time.strftime("%Y-%m-%dT%H:%M:00Z", time.gmtime(epoch_seconds))
|
||||||
|
|
||||||
|
|
||||||
|
def is_rate_limited(client_ip: str, now_epoch: float) -> bool:
|
||||||
|
if not client_ip:
|
||||||
|
return False
|
||||||
|
window_start = minute_start_utc(now_epoch)
|
||||||
|
return count_since(client_ip=client_ip, started_at_from=window_start) >= RATE_LIMIT_REQUESTS_PER_MINUTE
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
@app.get("/receipt/health")
|
||||||
def health():
|
def health():
|
||||||
return jsonify(status="ok")
|
return jsonify(status="ok")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/receipt")
|
||||||
|
@app.post("/receipt/")
|
||||||
@app.post("/recipe")
|
@app.post("/recipe")
|
||||||
|
@app.post("/recipe/")
|
||||||
def recipe():
|
def recipe():
|
||||||
|
request_id, started_at, started_monotonic = request_context()
|
||||||
|
image_mime = None
|
||||||
|
image_bytes = None
|
||||||
|
prompt_chars = None
|
||||||
|
usage = {}
|
||||||
|
client_ip = request.remote_addr
|
||||||
|
|
||||||
|
def finalize(response, status_code: int, error: str | None):
|
||||||
|
duration_ms = int((time.monotonic() - started_monotonic) * 1000)
|
||||||
|
response_bytes = len(response.get_data())
|
||||||
|
record(
|
||||||
|
request_id=request_id,
|
||||||
|
started_at=started_at,
|
||||||
|
client_ip=client_ip,
|
||||||
|
user_agent=request.user_agent.string,
|
||||||
|
method=request.method,
|
||||||
|
path=request.path,
|
||||||
|
image_mime=image_mime,
|
||||||
|
image_bytes=image_bytes,
|
||||||
|
prompt_chars=prompt_chars,
|
||||||
|
status_code=status_code,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
response_bytes=response_bytes,
|
||||||
|
usage_json=usage_json(usage),
|
||||||
|
error=error,
|
||||||
|
)
|
||||||
|
return response, status_code
|
||||||
|
|
||||||
|
if not authorized():
|
||||||
|
return finalize(jsonify(error="unauthorized"), 401, "unauthorized")
|
||||||
|
|
||||||
|
if is_rate_limited(client_ip=client_ip or "", now_epoch=time.time()):
|
||||||
|
return finalize(
|
||||||
|
jsonify(error="too many requests", code="rate_limited"),
|
||||||
|
429,
|
||||||
|
"rate_limited",
|
||||||
|
)
|
||||||
|
|
||||||
image = request.files.get("image")
|
image = request.files.get("image")
|
||||||
prompt = request.form.get("prompt")
|
prompt = request.form.get("prompt")
|
||||||
if image is None or not prompt:
|
if image is None or not prompt:
|
||||||
return jsonify(error="image and prompt are required"), 400
|
return finalize(jsonify(error="image and prompt are required"), 400, "image and prompt are required")
|
||||||
|
|
||||||
|
prompt_chars = len(prompt)
|
||||||
|
|
||||||
if image.mimetype not in ALLOWED_TYPES:
|
if image.mimetype not in ALLOWED_TYPES:
|
||||||
return jsonify(error="unsupported image type"), 415
|
image_mime = image.mimetype
|
||||||
|
return finalize(jsonify(error="unsupported image type"), 415, "unsupported image type")
|
||||||
|
|
||||||
image_data = image.read(MAX_IMAGE_BYTES + 1)
|
image_data = image.read(MAX_IMAGE_BYTES + 1)
|
||||||
|
image_mime = image.mimetype
|
||||||
|
image_bytes = len(image_data)
|
||||||
|
|
||||||
if len(image_data) > MAX_IMAGE_BYTES:
|
if len(image_data) > MAX_IMAGE_BYTES:
|
||||||
return jsonify(error="image is too large"), 413
|
return finalize(jsonify(error="image is too large"), 413, "image is too large")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
key, config = settings()
|
config = settings()
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{GEMINI_URL}/{os.environ.get('GEMINI_MODEL', 'gemini-3.6-flash')}:generateContent",
|
os.environ.get("GEMINI_PROXY_URL", PROXY_URL),
|
||||||
params={"key": key},
|
files={"image": (image.filename or "image", image_data, image.mimetype)},
|
||||||
json={
|
data={"prompt": prompt, "generation_config": json.dumps(config)},
|
||||||
"contents": [{"parts": [{"text": prompt}, {"inline_data": {
|
|
||||||
"mime_type": image.mimetype,
|
|
||||||
"data": base64.b64encode(image_data).decode("ascii"),
|
|
||||||
}}]}],
|
|
||||||
"generationConfig": config,
|
|
||||||
},
|
|
||||||
timeout=180,
|
timeout=180,
|
||||||
)
|
)
|
||||||
except (requests.RequestException, RuntimeError) as exc:
|
except (requests.RequestException, RuntimeError) as exc:
|
||||||
return jsonify(error=str(exc) if isinstance(exc, RuntimeError) else "Gemini unavailable"), 503
|
error = str(exc) if isinstance(exc, RuntimeError) else "Gemini unavailable"
|
||||||
|
return finalize(jsonify(error=error), 503, error)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
try:
|
try:
|
||||||
detail = response.json().get("error", {}).get("message", "Gemini request failed")
|
detail = response.json().get("error", {}).get("message", "Gemini request failed")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
detail = "Gemini request failed"
|
detail = "Gemini request failed"
|
||||||
return jsonify(error=detail), 502
|
app.logger.warning("upstream_failure request_id=%s detail=%s", request_id, detail)
|
||||||
|
return finalize(
|
||||||
|
jsonify(error="upstream recognition failed", code="upstream_error"),
|
||||||
|
502,
|
||||||
|
"upstream_error",
|
||||||
|
)
|
||||||
|
|
||||||
data = response.json()
|
data = response.json()
|
||||||
return jsonify(
|
usage = data.get("usage", {})
|
||||||
text=data.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text"),
|
result = jsonify(text=data.get("text"), usage=usage)
|
||||||
usage=data.get("usageMetadata", {}),
|
return finalize(result, 200, None)
|
||||||
)
|
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_DB_PATH = "/var/lib/recipe/metrics.sqlite3"
|
||||||
|
|
||||||
|
|
||||||
|
def db_path() -> str:
|
||||||
|
return os.environ.get("RECIPE_METRICS_DB", DEFAULT_DB_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def initialize() -> None:
|
||||||
|
path = db_path()
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
with sqlite3.connect(path) as connection:
|
||||||
|
connection.execute("PRAGMA journal_mode=WAL")
|
||||||
|
connection.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS requests (
|
||||||
|
request_id TEXT PRIMARY KEY,
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
client_ip TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
method TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
image_mime TEXT,
|
||||||
|
image_bytes INTEGER,
|
||||||
|
prompt_chars INTEGER,
|
||||||
|
status_code INTEGER NOT NULL,
|
||||||
|
duration_ms INTEGER NOT NULL,
|
||||||
|
response_bytes INTEGER,
|
||||||
|
usage_json TEXT,
|
||||||
|
error TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
connection.execute("CREATE INDEX IF NOT EXISTS idx_requests_started_at ON requests(started_at)")
|
||||||
|
connection.execute("CREATE INDEX IF NOT EXISTS idx_requests_status_code ON requests(status_code)")
|
||||||
|
connection.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_requests_client_ip_started_at ON requests(client_ip, started_at)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def record(**values) -> None:
|
||||||
|
columns = [
|
||||||
|
"request_id", "started_at", "client_ip", "user_agent", "method",
|
||||||
|
"path", "image_mime", "image_bytes", "prompt_chars", "status_code",
|
||||||
|
"duration_ms", "response_bytes", "usage_json", "error",
|
||||||
|
]
|
||||||
|
payload = [values.get(column) for column in columns]
|
||||||
|
with sqlite3.connect(db_path()) as connection:
|
||||||
|
connection.execute(
|
||||||
|
f"INSERT INTO requests ({','.join(columns)}) VALUES ({','.join('?' for _ in columns)})",
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def count_since(client_ip: str, started_at_from: str) -> int:
|
||||||
|
with sqlite3.connect(db_path()) as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM requests
|
||||||
|
WHERE client_ip = ?
|
||||||
|
AND started_at >= ?
|
||||||
|
AND path IN ('/receipt', '/receipt/', '/recipe', '/recipe/')
|
||||||
|
""",
|
||||||
|
(client_ip, started_at_from),
|
||||||
|
).fetchone()
|
||||||
|
return int(row[0] if row else 0)
|
||||||
|
|
||||||
|
|
||||||
|
def request_context() -> tuple[str, str, float]:
|
||||||
|
return str(uuid.uuid4()), time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
def usage_json(usage: dict) -> str:
|
||||||
|
return json.dumps(usage, ensure_ascii=True, separators=(",", ":"))
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Include this file once inside nginx `http { ... }` block.
|
||||||
|
# Example: include /etc/nginx/conf.d/recipe-rate-limit-http.conf;
|
||||||
|
|
||||||
|
# Per-client limit for recipe/receipt API requests.
|
||||||
|
limit_req_zone $binary_remote_addr zone=recipe_api_per_ip:10m rate=20r/m;
|
||||||
|
|
||||||
|
# Return 429 for throttled requests.
|
||||||
|
limit_req_status 429;
|
||||||
@@ -1,5 +1,48 @@
|
|||||||
location /recipe/ {
|
location = /recipe {
|
||||||
proxy_pass http://127.0.0.1:8770/;
|
limit_req zone=recipe_api_per_ip burst=5 nodelay;
|
||||||
|
proxy_pass http://127.0.0.1:8770/recipe;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
client_max_body_size 10m;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
proxy_send_timeout 200s;
|
||||||
|
proxy_read_timeout 200s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /recipe/ {
|
||||||
|
limit_req zone=recipe_api_per_ip burst=5 nodelay;
|
||||||
|
proxy_pass http://127.0.0.1:8770/recipe/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
client_max_body_size 10m;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
proxy_send_timeout 200s;
|
||||||
|
proxy_read_timeout 200s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /receipt {
|
||||||
|
limit_req zone=recipe_api_per_ip burst=5 nodelay;
|
||||||
|
proxy_pass http://127.0.0.1:8770/receipt;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
client_max_body_size 10m;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
proxy_send_timeout 200s;
|
||||||
|
proxy_read_timeout 200s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /receipt/ {
|
||||||
|
limit_req zone=recipe_api_per_ip burst=5 nodelay;
|
||||||
|
proxy_pass http://127.0.0.1:8770/receipt/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
Description=Minimal recipe Gemini proxy
|
Description=Minimal recipe Gemini proxy
|
||||||
After=network-online.target
|
After=network-online.target
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
Requires=gemini-tunnel.service
|
||||||
|
After=gemini-tunnel.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
User=recipe
|
User=recipe
|
||||||
@@ -15,6 +17,8 @@ NoNewPrivileges=true
|
|||||||
PrivateTmp=true
|
PrivateTmp=true
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
ProtectHome=true
|
ProtectHome=true
|
||||||
|
StateDirectory=recipe
|
||||||
|
StateDirectoryMode=0770
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
os.environ.setdefault(
|
||||||
|
"RECIPE_METRICS_DB",
|
||||||
|
os.path.join(tempfile.gettempdir(), "recipe-service-tests-metrics.sqlite3"),
|
||||||
|
)
|
||||||
|
|
||||||
|
from app import app
|
||||||
|
|
||||||
|
|
||||||
|
class MockResponse:
|
||||||
|
def __init__(self, status_code: int, payload: dict | None = None):
|
||||||
|
self.status_code = status_code
|
||||||
|
self._payload = payload or {}
|
||||||
|
|
||||||
|
def json(self) -> dict:
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
|
||||||
|
def auth_header() -> dict[str, str]:
|
||||||
|
return {"Authorization": "Bearer test-token"}
|
||||||
|
|
||||||
|
|
||||||
|
def make_image(content: bytes = b"img") -> tuple[io.BytesIO, str, str]:
|
||||||
|
return io.BytesIO(content), "sample.png", "image/png"
|
||||||
|
|
||||||
|
|
||||||
|
def test_health() -> None:
|
||||||
|
client = app.test_client()
|
||||||
|
response = client.get("/health")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.get_json() == {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_requires_authorization(monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("RECIPE_API_TOKEN", "test-token")
|
||||||
|
client = app.test_client()
|
||||||
|
response = client.post("/receipt")
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert response.get_json() == {"error": "unauthorized"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_image_or_prompt(monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("RECIPE_API_TOKEN", "test-token")
|
||||||
|
client = app.test_client()
|
||||||
|
response = client.post("/receipt", headers=auth_header())
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.get_json() == {"error": "image and prompt are required"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsupported_type(monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("RECIPE_API_TOKEN", "test-token")
|
||||||
|
client = app.test_client()
|
||||||
|
response = client.post(
|
||||||
|
"/receipt",
|
||||||
|
headers=auth_header(),
|
||||||
|
data={
|
||||||
|
"prompt": "p",
|
||||||
|
"image": (io.BytesIO(b"x"), "bad.txt", "text/plain"),
|
||||||
|
},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
assert response.status_code == 415
|
||||||
|
assert response.get_json() == {"error": "unsupported image type"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_too_large(monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("RECIPE_API_TOKEN", "test-token")
|
||||||
|
client = app.test_client()
|
||||||
|
payload = b"a" * (10 * 1024 * 1024 + 1)
|
||||||
|
response = client.post(
|
||||||
|
"/receipt",
|
||||||
|
headers=auth_header(),
|
||||||
|
data={
|
||||||
|
"prompt": "p",
|
||||||
|
"image": (io.BytesIO(payload), "big.png", "image/png"),
|
||||||
|
},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
assert response.status_code == 413
|
||||||
|
assert response.get_json() == {"error": "image is too large"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_success(monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("RECIPE_API_TOKEN", "test-token")
|
||||||
|
|
||||||
|
def fake_post(*args, **kwargs):
|
||||||
|
return MockResponse(200, {"text": "ok", "usage": {"totalTokens": 10}})
|
||||||
|
|
||||||
|
monkeypatch.setattr(requests, "post", fake_post)
|
||||||
|
client = app.test_client()
|
||||||
|
response = client.post(
|
||||||
|
"/receipt",
|
||||||
|
headers=auth_header(),
|
||||||
|
data={
|
||||||
|
"prompt": "p",
|
||||||
|
"image": make_image(),
|
||||||
|
},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.get_json() == {"text": "ok", "usage": {"totalTokens": 10}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_upstream_502_contract(monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("RECIPE_API_TOKEN", "test-token")
|
||||||
|
|
||||||
|
def fake_post(*args, **kwargs):
|
||||||
|
return MockResponse(500, {"error": {"message": "provider detail"}})
|
||||||
|
|
||||||
|
monkeypatch.setattr(requests, "post", fake_post)
|
||||||
|
client = app.test_client()
|
||||||
|
response = client.post(
|
||||||
|
"/receipt",
|
||||||
|
headers=auth_header(),
|
||||||
|
data={
|
||||||
|
"prompt": "p",
|
||||||
|
"image": make_image(),
|
||||||
|
},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
assert response.status_code == 502
|
||||||
|
assert response.get_json() == {
|
||||||
|
"error": "upstream recognition failed",
|
||||||
|
"code": "upstream_error",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_rate_limit_returns_429(monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("RECIPE_API_TOKEN", "test-token")
|
||||||
|
|
||||||
|
def fake_count_since(client_ip: str, started_at_from: str) -> int:
|
||||||
|
return 20
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.count_since", fake_count_since)
|
||||||
|
client = app.test_client()
|
||||||
|
response = client.post(
|
||||||
|
"/receipt",
|
||||||
|
headers=auth_header(),
|
||||||
|
data={
|
||||||
|
"prompt": "p",
|
||||||
|
"image": make_image(),
|
||||||
|
},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
environ_base={"REMOTE_ADDR": "198.51.100.10"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 429
|
||||||
|
assert response.get_json() == {"error": "too many requests", "code": "rate_limited"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_rate_limit_allows_below_threshold(monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("RECIPE_API_TOKEN", "test-token")
|
||||||
|
|
||||||
|
def fake_count_since(client_ip: str, started_at_from: str) -> int:
|
||||||
|
return 19
|
||||||
|
|
||||||
|
def fake_post(*args, **kwargs):
|
||||||
|
return MockResponse(200, {"text": "ok", "usage": {}})
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.count_since", fake_count_since)
|
||||||
|
monkeypatch.setattr(requests, "post", fake_post)
|
||||||
|
|
||||||
|
client = app.test_client()
|
||||||
|
response = client.post(
|
||||||
|
"/receipt",
|
||||||
|
headers=auth_header(),
|
||||||
|
data={
|
||||||
|
"prompt": "p",
|
||||||
|
"image": make_image(),
|
||||||
|
},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
environ_base={"REMOTE_ADDR": "198.51.100.11"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_metrics_record_duration_and_status(monkeypatch) -> None:
|
||||||
|
db_file = os.path.join(tempfile.gettempdir(), "recipe-service-tests-metrics-duration.sqlite3")
|
||||||
|
if os.path.exists(db_file):
|
||||||
|
os.remove(db_file)
|
||||||
|
|
||||||
|
monkeypatch.setenv("RECIPE_METRICS_DB", db_file)
|
||||||
|
monkeypatch.setenv("RECIPE_API_TOKEN", "test-token")
|
||||||
|
|
||||||
|
from metrics import initialize
|
||||||
|
|
||||||
|
initialize()
|
||||||
|
|
||||||
|
def fake_post(*args, **kwargs):
|
||||||
|
return MockResponse(500, {"error": {"message": "provider detail"}})
|
||||||
|
|
||||||
|
monkeypatch.setattr(requests, "post", fake_post)
|
||||||
|
client = app.test_client()
|
||||||
|
response = client.post(
|
||||||
|
"/receipt",
|
||||||
|
headers=auth_header(),
|
||||||
|
data={
|
||||||
|
"prompt": "p",
|
||||||
|
"image": make_image(),
|
||||||
|
},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 502
|
||||||
|
connection = sqlite3.connect(db_file)
|
||||||
|
try:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT status_code, duration_ms, error FROM requests ORDER BY rowid DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
assert row is not None
|
||||||
|
status_code, duration_ms, error = row
|
||||||
|
assert status_code == 502
|
||||||
|
assert duration_ms >= 0
|
||||||
|
assert error == "upstream_error"
|
||||||
+3
-3
@@ -1,3 +1,3 @@
|
|||||||
Flask>=3.0
|
Flask>=3.0,<4
|
||||||
gunicorn>=21.2
|
gunicorn>=21.2,<24
|
||||||
requests>=2.31
|
requests>=2.31,<3
|
||||||
Reference in New Issue
Block a user