Compare commits

..
25 changed files with 1139 additions and 38 deletions
+280 -1
View File
@@ -1,5 +1,24 @@
# История: 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-28
### Вопрос
@@ -105,7 +124,23 @@ obdai.ru/receipt -> nginx -> receipt.service -> FastAPI на 127.0.0.1:8768
Реализация кода не начиналась. Зафиксированы архитектура, инфраструктурная схема и результаты проверки доступных моделей. Следующий этап после отдельного подтверждения требований: подготовка MVP receipt-service и его независимого запуска рядом с `elmer`.
## Утверждённое архитектурное решение
## Обезличивание выполняется в Android-приложении
По решению пользователя полный снимок рецепта обрабатывается на Android-
устройстве. Камера может получить полный рецепт, но до отправки на ВМ
приложение должно вырезать только зону препаратов.
На ВМ передаётся только обезличенный crop, содержащий название препарата,
дозировку, количество, схему, частоту и длительность приёма; дополнительно
допустимо имя врача. ФИО пациента, дата рождения и другие данные пациента не
передаются на ВМ, не отправляются в Gemini, не сохраняются и не возвращаются.
Полный снимок не должен покидать Android-устройство. Текущий серверный API
принимает переданное изображение и не может доказать, что клиент действительно
вырезал персональные данные, поэтому это обязательная ответственность
будущего Android crop pipeline. После обработки Android должен удалить свои
локальные временные копии; серверная обработка уже выполняется в памяти и не
создаёт постоянных файлов изображений.
Пользователь подтвердил реализацию подхода, в котором словарь лекарств участвует непосредственно в принятии решения, а не используется только для косметического исправления OCR.
@@ -224,6 +259,65 @@ thoughts 477, total 1627. Стоимость по тарифу Gemini 3.6 Flash
candidate output как выход: $0.00092, то есть около 9 копеек при курсе 95
рублей за доллар.
## 2026-08-31: Критическая валидация отчета Opus
Пользователь запросил документировать анализ Opus, выполнить критическую
сверку по коду и сформулировать вопросы до внесения изменений.
### Что подтверждено по коду
- `recipe_service/app.py`: есть дублирование вызовов `record(...)` по веткам
ответа; единый финализатор уменьшит риск расхождений полей метрик.
- `recipe_service/app.py`: `duration_ms` считается только в ветке `401`,
в остальных ветках передается `0`.
- `recipe_service/app.py`: используется `__import__('time').monotonic()`
вместо обычного `import time`.
- `recipe_service/app.py`: сравнение Bearer-токена выполнено через `==`,
без constant-time сравнения.
- `recipe_service/metrics.py`: `record()` вызывает `initialize()` на каждый
запрос, что приводит к повторной инициализации схемы БД.
- `gemini_proxy/app.py`: endpoint принимает `api_key_override` из формы.
- `android-app/app/build.gradle.kts`: `RECEIPT_API_TOKEN` попадает в
`BuildConfig`, значит секрет присутствует в клиентском APK.
- `android-app/app/src/main/java/ru/obdai/receipt/camera/CameraManager.kt`:
на каждом кадре выполняется цепочка YUV -> JPEG -> Bitmap.
- `android-app/app/src/main/java/ru/obdai/receipt/MainActivity.kt`:
текст результата выводится и в `Column`, и в `ResultOverlay`.
- `android-app/app/src/main/java/ru/obdai/receipt/crop/MedicationZoneDetector.kt`:
дата в формате `dd.mm.yyyy` сама по себе триггерит `PatientDataDetected`.
### Что уточнено по замечаниям Opus
- Замечание про `client_ip=127.0.0.1` корректно как риск достоверности
метрик за nginx. Для надежного учета реального IP требуется явная политика
доверия прокси и корректная обработка заголовков.
- Замечание про детализацию `502` корректно как вопрос баланса между
диагностикой и минимизацией утечки деталей провайдера.
- Замечание про отсутствие тестов для `recipe_service` подтверждено: в
текущем дереве есть `gemini_proxy/test_app.py`, но нет тестового файла
для `recipe_service`.
### Вопросы к Opus (блокеры перед правками)
1. По `gemini_proxy/app.py`: удаляем `api_key_override` полностью или
оставляем только в debug-режиме под отдельным флагом окружения?
2. По `recipe_service/app.py` ветке `502`: какой контракт ошибки нужен
клиенту - единое сообщение, коды причин, или ограниченный whitelist
провайдерских причин без сырого текста?
3. По учету IP за nginx: доверяем строго одному прокси (`x_for=1`) или
нужен более строгий trust chain по сети/подсети?
4. По Android токену: подтверждаем переход на краткоживущие серверные
токены (token exchange), или остается текущая схема MVP с явной
фиксацией риска?
5. По privacy-gate: дата в medication-зоне должна блокировать отправку,
или только снижать confidence и требовать подтверждение пользователя?
### Итог
Отчет Opus в значительной части подтвержден фактами по текущему коду.
Критичные изменения backend можно готовить после ответов на вопросы выше,
чтобы не сломать контракт клиента и не ослабить security-policy.
Вывод: лимит 500 токенов оказался недостаточным; следующий независимый тест
проводится с существенно большим лимитом и ещё более короткой JSON-схемой.
@@ -330,6 +424,18 @@ hex вместо Base64. Узкий тест выявил проблему; ко
Контрольный запрос с `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
@@ -467,3 +573,176 @@ S. по 1т 1/р в день
`thoughtsTokenCount` в ответе отсутствует, что подтверждает отсутствие
отдельно тарифицируемых reasoning-токенов в этом запросе. Usage: prompt 1119,
image 1102, text 17, candidate output 34, total 1153.
## Bearer-защита recipe API
Добавлена Bearer-защита `POST /recipe`: без заголовка или с неверным значением
`Authorization: Bearer ...` сервис возвращает HTTP 401. `/health` остаётся
доступен без авторизации.
Случайный токен установлен на ВМ 213 в `/etc/recipe/recipe.env` с правами
`0640` и владельцем `root:recipe`; значение токена не записывалось в git или
HISTORY. Добавлен `recipe_service/.gitignore` для `.env`, `*.env`,
`__pycache__` и `*.pyc`.
Синтаксис `recipe_service/app.py`, `git diff --check` и локальная проверка Flask
прошли. После перезапуска `recipe.service` запрос без токена получил HTTP 401.
Авторизованный запрос дошёл до Gemini, но получил HTTP 502: `API key not valid`.
Следовательно, Bearer-защита работает, а `GEMINI_API_KEY` в серверном env-файле
недействителен и требует замены.
## Вопрос Sonnet о доступности немецкого proxy
Sonnet запросил подтверждение доступности `95.179.252.111:8768` с ВМ 213.
Ответ: в имеющейся проверке такой прямой запрос не выполнялся, поэтому факт
доступности этого адреса не подтверждён. Подтверждено только, что ранее proxy
работал на немецкой ВМ на `127.0.0.1:8768`, то есть bind-адрес сам по себе не
доказывает доступность с ВМ 213. Для выбора транспорта требуется отдельная
проверка соединения с таймаутом; до неё нельзя утверждать, что прямой маршрут
или SSH-туннель уже работает.
Проверка выполнена с ВМ 213 командой `curl --max-time 5` к
`http://95.179.252.111:8768/health`: соединение отклонено, получен
`http_code=000` и ошибка `Failed to connect`. Прямой маршрут между ВМ не
доступен; для продолжения требуется SSH-туннель или изменение bind/firewall
немецкого proxy.
## Проверка плана Sonnet и транспорт
План Sonnet проверен по фактическим конфигурациям. На ВМ 213 в
`/etc/recipe/recipe.env` уже отсутствует `GEMINI_API_KEY`, присутствуют
`RECIPE_API_TOKEN` и `GEMINI_PROXY_URL`; `recipe.service` активен. В
задеплоенном `/opt/recipe/app.py` обнаружен дефект: ответ proxy разбирался как
Gemini-native (`candidates`/`usageMetadata`), хотя proxy возвращает поля
`text`/`usage`. В workspace исправлен разбор этих полей; локальные `py_compile`
и `git diff --check` прошли.
Дополнительная проверка SSH-туннеля показала, что на ВМ 213 нет ключа, дающего
вход на немецкую ВМ: российский ключ получил `Permission denied`, а `vultr.ppk`
не распознан как пригодный OpenSSH-ключ. Создание туннеля без нового
разрешённого ключа невозможно. Nginx на ВМ 213 уже содержит отдельные location
для `/recipe` и `/recipe/`; их изменение без новой подтверждённой проблемы не
выполнялось.
## Настройка SSH-туннеля и проверка фактического конфига
По команде пользователя ключ доступа к немецкой ВМ передан на ВМ 213 с
правами `0600` в `/home/naeel/.ssh/gemini_proxy_key`; успешный SSH-вход на
немецкую ВМ подтверждён без вывода ключа. На ВМ 213 создан и запущен
`gemini-tunnel.service`, перенаправляющий `127.0.0.1:8768` на немецкий
`127.0.0.1:8768`; health через туннель вернул HTTP 200.
Проверка показала, что фактический `/etc/nginx/sites-enabled/elmer` не
подключает локальный nginx-фрагмент, а `/etc/recipe/recipe.env` всё ещё
содержал внешний `GEMINI_PROXY_URL`, несмотря на работающий туннель. Поэтому
предыдущий end-to-end запрос дал 503, а `/recipe/` дал 404. Эти фактические
конфигурации требуют точечной синхронизации с workspace и повторной проверки.
SSH-ключ с локальной машины передан на ВМ 213 в
`/home/naeel/.ssh/gemini_proxy_key` с правами `0600`; вход на немецкую ВМ
подтверждён. Создан `gemini-tunnel.service`, через который локальный
`127.0.0.1:8768` на ВМ 213 направляется к немецкому proxy. Gemini key на ВМ 213
не используется.
Исправлен разбор ответа proxy в `recipe_service/app.py` (`text`/`usage`), а
также добавлен Flask route для `/recipe/`. Фактический nginx-конфиг на ВМ 213
синхронизирован с рабочими prefix locations; regex location с URI в
`proxy_pass` отклонён nginx и заменён допустимой конфигурацией.
Итоговые проверки: health HTTP 200; POST без токена HTTP 401; POST с неверным
токеном HTTP 401; авторизованный POST `/recipe` HTTP 200 с непустыми `text` и
`usage`; авторизованный POST `/recipe/` HTTP 200 с непустыми `text` и `usage`;
redirect отсутствует. `gemini-tunnel.service`, `recipe.service` и
`elmer.service` имеют статус active. `elmer.service` не перезапускался.
## Проверка хранения изображений
В задеплоенном `recipe_service/app.py` изображение читается через
`image.read()` в память и передаётся proxy через `requests.post(files=...)`.
Операций записи изображения на диск в коде нет; постоянное хранилище для
изображений не используется. `recipe.service` active.
В `/tmp` ВМ 213 обнаружены файлы от предыдущих ручных диагностических
запросов, включая `lekar1.png` и JSON-ответы. Они не создаются рабочим
pipeline автоматически и требуют отдельного разрешения на удаление. Это
отдельный остаток тестовых команд, а не постоянное хранилище приложения.
## SQLite-статистика запросов
По команде пользователя добавлена SQLite-база статистики на ВМ 213:
`/var/lib/recipe/metrics.sqlite3`. Хранятся request ID, время, полный IP,
User-Agent, method/path, MIME и размер изображения, длина prompt, HTTP-статус,
длительность, размер ответа, Gemini usage и безопасное описание ошибки.
Изображение, prompt, распознанный текст, Bearer-токен и Gemini key в базу не
записываются.
Первый запуск после добавления статистики выявил подтверждённую ошибку
`sqlite3.OperationalError: attempt to write a readonly database`: файл базы
был `root:root` с правами `644`, а unit использовал `ProtectSystem=strict`.
Файл переведён во владение `recipe:recipe` с правами `0660`, в unit добавлены
`StateDirectory=recipe` и `StateDirectoryMode=0770`.
## Основной endpoint `/receipt`
По уточнению пользователя основным публичным endpoint сделан
`https://obdai.ru/receipt`. Добавлены Flask-маршруты `/receipt` и `/receipt/`,
nginx-маршруты без redirect и health `/receipt/health`. Старые `/recipe` и
`/recipe/` сохранены для совместимости.
Проверен автоматический сценарий через ВМ 213: временный тестовый image был
передан на ВМ, отправлен с ВМ через `POST /receipt`, после ответа временные
файлы удалены. Health вернул HTTP 200, OCR вернул HTTP 200 с непустыми `text`
и `usage`. Рабочий код принимает изображение в память; изображения не
сохраняются в постоянное хранилище.
После исправления `recipe.service` active. Таблица `requests` создана
автоматически. Проверка записала 401 и успешный OCR: последняя строка содержит
IP `127.0.0.1`, MIME `image/png`, размер 531101 байт, длину prompt 88 и
непустой usage JSON. Тестовый image-файл удалён после запроса; приложение
читает изображение в память и не пишет его на диск.
## 2026-08-31: критическая валидация отчета Opus и обновление плана
Проведена проверка утверждений Opus по фактическому коду и конфигурации.
Подтверждено по backend:
- В `recipe_service/app.py` есть многократное дублирование блоков `record(...)` по веткам обработки.
- В `recipe_service/app.py` длительность `duration_ms` в большинстве веток фиксируется как `0`; фактический расчет есть только для `401`.
- В `recipe_service/app.py` используется `__import__('time').monotonic()` вместо нормального `import time`.
- В `recipe_service/app.py` сравнение bearer-токена выполнено через `==`, без constant-time сравнения.
- В `recipe_service/app.py` в `502` наружу пробрасывается provider detail, что может раскрывать внутренние детали upstream-ошибок.
- В `recipe_service/metrics.py` функция `record()` вызывает `initialize()` на каждый запрос.
- `recipe_service/nginx-recipe.conf` передает `X-Real-IP` и `X-Forwarded-For`, но в Flask используется `request.remote_addr`; без `ProxyFix` это адрес прокси.
Подтверждено по инфраструктуре:
- `recipe_service/recipe.service` слушает только `127.0.0.1:8770`.
- `gemini_proxy/gemini-proxy.service` слушает только `127.0.0.1:8768`.
Подтверждено по Android:
- `android-app/app/build.gradle.kts` задает `BuildConfig.RECEIPT_API_TOKEN`, токен попадает в клиентское приложение.
- `android-app/app/src/main/java/ru/obdai/receipt/network/ApiClient.kt` отправляет `Authorization: Bearer ...` из `BuildConfig`.
- `android-app/app/src/main/java/ru/obdai/receipt/camera/CameraManager.kt` выполняет YUV->JPEG->Bitmap на кадр в analyzer.
- `android-app/app/src/main/java/ru/obdai/receipt/MainActivity.kt` выводит результат и в `Column`, и в `ResultOverlay`.
- `android-app/app/src/main/java/ru/obdai/receipt/crop/MedicationZoneDetector.kt` блокирует при любой дате по regex, что дает ложные privacy-срабатывания.
- `android-app/app/src/main/AndroidManifest.xml` содержит `usesCleartextTraffic="false"`.
Подтверждено по тестам и зависимостям:
- Тесты присутствуют только в `gemini_proxy/test_app.py`.
- Для `recipe_service` тестов нет.
- Корневой `requirements.txt` и `recipe_service/requirements.txt` расходятся по version bounds.
Дополнительные риски, подтвержденные параллельным аудитом секретов:
- В локальных репозиториях обнаружены реальные токены/JWT и tracked secret-like файлы; часть секретов не отслеживается Git, но присутствует в рабочих каталогах.
- В `tf_provider` обнаружен tracked `secrets/llm.key`; в истории есть многочисленные совпадения по маркерам `sk-`.
- В `nifi_fork` история содержит совпадения по маркерам `sk-` и `AKIA`; часть совпадений относится к документации/тестам и требует ручной triage по значениям.
Статус:
- Отчет Opus по backend и Android в критических пунктах в основном подтвержден.
- Для части исторических совпадений по секретам нужна ручная классификация: реальный ключ, тестовый маркер или документационный пример.
- Код в этой итерации не менялся; выполнено документирование и валидация.
+35
View File
@@ -182,3 +182,38 @@ crop лекарственной строки
- начать с SQLite и перейти на отдельный PostgreSQL только при появлении требований к параллельной записи, админке или нескольким экземплярам сервиса.
Перед фиксацией порогов и коэффициентов scoring провести эксперимент на 50–200 обезличенных рецептах.
## 12. Критический план по результатам валидации Opus (2026-08-31)
### Подтвержденные проблемы и приоритет
1. `recipe_service/metrics.py`: убрать `initialize()` из `record()`; инициализация должна быть только на старте сервиса.
2. `recipe_service/app.py`: устранить дублирование `record(...)` через единый финализатор/обертку завершения запроса.
3. `recipe_service/app.py`: считать `duration_ms` во всех ветках, а не только при `401`.
4. `recipe_service/app.py`: заменить сравнение токена на constant-time (`hmac.compare_digest`).
5. `recipe_service/app.py`: минимизировать детализацию `502` для клиента, подробности оставлять в логах.
6. `recipe_service/app.py`: добавить учет заголовков прокси (`ProxyFix`) для корректного client IP.
7. Тесты: добавить `recipe_service/test_app.py` с кейсами `401/400/415/413/200`.
8. `requirements.txt`: выровнять root и `recipe_service/requirements.txt` по version bounds.
### Android-блок (после подтверждения модели безопасности)
1. Убрать долговременный статический секрет из APK или перейти на схему краткоживущих токенов.
2. Оптимизировать pipeline камеры: снизить частоту тяжелой конвертации или заменить способ получения bitmap.
3. Убрать двойной вывод результата (`Column` и `ResultOverlay`) и оставить один источник отображения.
4. Пересмотреть `containsPatientData` в `MedicationZoneDetector`: дата сама по себе не должна блокировать распознавание.
### Вопросы к Opus (требуют уточнения до правок)
1. `api_key_override` в `gemini_proxy/app.py`: удалять полностью или оставить только для локального debug-режима за флагом окружения?
2. По `502`: какой клиентский формат ошибки нужен — единое сообщение или кодизированные причины без деталей провайдера?
3. Для `ProxyFix`: доверяем ровно одному прокси (`x_for=1`) или нужен более строгий trust chain?
4. Для Android: подтверждаем перенос от статического API-токена к короткоживущему серверному token exchange?
5. По privacy-гейту: дата рецепта в medication-зоне допустима или должна только понижать confidence без блокировки?
### План выполнения после подтверждения
1. Backend фаза A: `metrics.py` + `app.py` (инициализация, duration, record-finalizer, compare_digest, ProxyFix).
2. Backend фаза B: тесты `recipe_service/test_app.py` + проверка pytest.
3. Backend фаза C: harmonize requirements.
4. Android фаза D: безопасность токена + оптимизация камеры + privacy-gate.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

+3
View File
@@ -0,0 +1,3 @@
.gradle/
build/
local.properties
+63
View File
@@ -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 = 6
versionName = "0.1.5"
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")
}
+1
View File
@@ -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)
}
}
+5
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
+18
View File
@@ -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")
+3 -3
View File
@@ -1,3 +1,3 @@
GEMINI_API_KEY=replace-with-secret
GEMINI_MODEL=gemini-3.6-flash
GEMINI_GENERATION_CONFIG={"temperature":0,"maxOutputTokens":300,"thinkingConfig":{"thinkingLevel":"minimal"}}
GEMINI_PROXY_URL=http://127.0.0.1:8768/gemini
GEMINI_GENERATION_CONFIG={"temperature":0,"maxOutputTokens":300,"thinkingConfig":{"thinkingLevel":"minimal"}}
RECIPE_API_TOKEN=replace-with-random-token
+4
View File
@@ -0,0 +1,4 @@
.env
*.env
__pycache__/
*.pyc
+13 -6
View File
@@ -1,13 +1,20 @@
# obdai.ru/recipe
# obdai.ru/receipt
Минимальный Flask-сервис на ВМ `5.172.178.213`.
Вход: `POST /recipe` в формате `multipart/form-data` с полями `image` и
`prompt`. Изображение: JPEG/PNG/WEBP, не более 10 MB.
Вход: `POST /receipt` в формате `multipart/form-data` с полями `image` и
`prompt`. Требуется заголовок `Authorization: Bearer <RECIPE_API_TOKEN>`.
Изображение: JPEG/PNG/WEBP, не более 10 MB.
Ключ Gemini и JSON-строка `GEMINI_GENERATION_CONFIG` находятся только в
серверном EnvironmentFile. Сервис передаёт изображение, prompt и настройки в
Gemini и возвращает `text` и `usage`; разбор рецепта выполняется позднее.
`POST /receipt/` и старые пути `/recipe` и `/recipe/` также поддерживаются.
URL немецкого Gemini proxy и JSON-строка `GEMINI_GENERATION_CONFIG` находятся
только в серверном EnvironmentFile. Сервис передаёт изображение, prompt и
настройки в proxy и возвращает `text` и `usage`; разбор рецепта выполняется
позднее. API-ключ Gemini остаётся только на немецкой ВМ и в recipe service не
передаётся.
Токен `RECIPE_API_TOKEN` также хранится только в EnvironmentFile и не
передаётся в Gemini.
Сервис изолирован от `elmer`: отдельные каталог, virtualenv, пользователь,
порт, systemd-юнит и настройки nginx. Изображения на диск не сохраняются.
+96 -26
View File
@@ -1,70 +1,140 @@
import base64
import json
import os
import requests
from flask import Flask, jsonify, request
try:
from recipe_service.metrics import initialize, record, request_context, usage_json
except ModuleNotFoundError:
from metrics import initialize, record, request_context, usage_json
app = Flask(__name__)
MAX_IMAGE_BYTES = 10 * 1024 * 1024
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"
initialize()
def settings() -> tuple[str, dict]:
key = os.environ.get("GEMINI_API_KEY")
if not key:
raise RuntimeError("GEMINI_API_KEY is not configured")
def settings() -> dict:
try:
config = json.loads(os.environ.get("GEMINI_GENERATION_CONFIG", "{}"))
except json.JSONDecodeError as exc:
raise RuntimeError("GEMINI_GENERATION_CONFIG is invalid") from exc
if not isinstance(config, dict):
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 authorization == f"Bearer {expected}")
@app.get("/health")
@app.get("/receipt/health")
def health():
return jsonify(status="ok")
@app.post("/receipt")
@app.post("/receipt/")
@app.post("/recipe")
@app.post("/recipe/")
def recipe():
request_id, started_at, started_monotonic = request_context()
image_mime = None
image_bytes = None
prompt_chars = None
status_code = 500
response_bytes = None
usage = {}
error = None
if not authorized():
status_code = 401
error = "unauthorized"
response = jsonify(error=error)
record(request_id=request_id, started_at=started_at, client_ip=request.remote_addr,
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=int((__import__('time').monotonic() - started_monotonic) * 1000),
response_bytes=len(response.get_data()), usage_json=usage_json(usage), error=error)
return response, status_code
image = request.files.get("image")
prompt = request.form.get("prompt")
if image is None or not prompt:
return jsonify(error="image and prompt are required"), 400
status_code = 400
error = "image and prompt are required"
response = jsonify(error=error)
record(request_id=request_id, started_at=started_at, client_ip=request.remote_addr,
user_agent=request.user_agent.string, method=request.method, path=request.path,
status_code=status_code, duration_ms=0, response_bytes=len(response.get_data()),
usage_json=usage_json(usage), error=error)
return response, status_code
if image.mimetype not in ALLOWED_TYPES:
return jsonify(error="unsupported image type"), 415
status_code = 415
error = "unsupported image type"
response = jsonify(error=error)
record(request_id=request_id, started_at=started_at, client_ip=request.remote_addr,
user_agent=request.user_agent.string, method=request.method, path=request.path,
image_mime=image.mimetype, prompt_chars=len(prompt), status_code=status_code,
duration_ms=0, response_bytes=len(response.get_data()), usage_json=usage_json(usage), error=error)
return response, status_code
image_data = image.read(MAX_IMAGE_BYTES + 1)
if len(image_data) > MAX_IMAGE_BYTES:
return jsonify(error="image is too large"), 413
status_code = 413
error = "image is too large"
response = jsonify(error=error)
record(request_id=request_id, started_at=started_at, client_ip=request.remote_addr,
user_agent=request.user_agent.string, method=request.method, path=request.path,
image_mime=image.mimetype, image_bytes=len(image_data), prompt_chars=len(prompt),
status_code=status_code, duration_ms=0, response_bytes=len(response.get_data()),
usage_json=usage_json(usage), error=error)
return response, status_code
image_mime = image.mimetype
image_bytes = len(image_data)
prompt_chars = len(prompt)
try:
key, config = settings()
config = settings()
response = requests.post(
f"{GEMINI_URL}/{os.environ.get('GEMINI_MODEL', 'gemini-3.6-flash')}:generateContent",
params={"key": key},
json={
"contents": [{"parts": [{"text": prompt}, {"inline_data": {
"mime_type": image.mimetype,
"data": base64.b64encode(image_data).decode("ascii"),
}}]}],
"generationConfig": config,
},
os.environ.get("GEMINI_PROXY_URL", PROXY_URL),
files={"image": (image.filename or "image", image_data, image.mimetype)},
data={"prompt": prompt, "generation_config": json.dumps(config)},
timeout=180,
)
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"
status_code = 503
response = jsonify(error=error)
record(request_id=request_id, started_at=started_at, client_ip=request.remote_addr,
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=0, response_bytes=len(response.get_data()),
usage_json=usage_json(usage), error=error)
return response, status_code
if response.status_code != 200:
try:
detail = response.json().get("error", {}).get("message", "Gemini request failed")
except ValueError:
detail = "Gemini request failed"
return jsonify(error=detail), 502
error = detail
status_code = 502
response = jsonify(error=error)
record(request_id=request_id, started_at=started_at, client_ip=request.remote_addr,
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=0, response_bytes=len(response.get_data()),
usage_json=usage_json(usage), error=error)
return response, status_code
data = response.json()
return jsonify(
text=data.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text"),
usage=data.get("usageMetadata", {}),
)
usage = data.get("usage", {})
result = jsonify(text=data.get("text"), usage=usage)
record(request_id=request_id, started_at=started_at, client_ip=request.remote_addr,
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=200, duration_ms=0, response_bytes=len(result.get_data()),
usage_json=usage_json(usage), error=None)
return result
+62
View File
@@ -0,0 +1,62 @@
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)")
def record(**values) -> None:
initialize()
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 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=(",", ":"))
+41 -2
View File
@@ -1,5 +1,44 @@
location /recipe/ {
proxy_pass http://127.0.0.1:8770/;
location = /recipe {
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/ {
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 {
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/ {
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;
+4
View File
@@ -2,6 +2,8 @@
Description=Minimal recipe Gemini proxy
After=network-online.target
Wants=network-online.target
Requires=gemini-tunnel.service
After=gemini-tunnel.service
[Service]
User=recipe
@@ -15,6 +17,8 @@ NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
StateDirectory=recipe
StateDirectoryMode=0770
[Install]
WantedBy=multi-user.target