16 Commits
29 changed files with 1392 additions and 43 deletions
+290 -1
View File
@@ -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`.
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 = 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")
}
+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")
+1 -2
View File
@@ -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")
+15
View File
@@ -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
+3 -3
View File
@@ -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
+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`. Минимальный 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
View File
@@ -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)
)
+79
View File
@@ -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;
+45 -2
View File
@@ -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;
+4
View File
@@ -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
+223
View File
@@ -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
View File
@@ -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