Compare commits
9
Commits
17def7789b
...
3bcff192e6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bcff192e6 | ||
|
|
24a64baf93 | ||
|
|
d3e6ec3a9b | ||
|
|
5cfbab3eba | ||
|
|
d7c2ca7c45 | ||
|
|
773b088bfa | ||
|
|
eae62e76f8 | ||
|
|
9f0974aaa9 | ||
|
|
169662566d |
@@ -105,7 +105,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.
|
||||
|
||||
@@ -330,6 +346,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 +495,130 @@ 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-файл удалён после запроса; приложение
|
||||
читает изображение в память и не пишет его на диск.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
@@ -0,0 +1,3 @@
|
||||
.gradle/
|
||||
build/
|
||||
local.properties
|
||||
@@ -0,0 +1,60 @@
|
||||
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 = 2
|
||||
versionName = "0.1.1"
|
||||
|
||||
buildConfigField("String", "RECEIPT_API_TOKEN", "\"\"")
|
||||
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.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.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
|
||||
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
# App-specific R8 rules.
|
||||
@@ -0,0 +1,19 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<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,112 @@
|
||||
package ru.obdai.receipt
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.graphics.Bitmap
|
||||
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.Button
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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 java.util.concurrent.Executors
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private var latestBitmap by mutableStateOf<Bitmap?>(null)
|
||||
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 { CameraScreen(latestBitmap, ::onPreviewReady) }
|
||||
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
|
||||
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 -> latestBitmap = bitmap })
|
||||
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() }
|
||||
cameraExecutor.shutdown()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CameraScreen(bitmap: Bitmap?, onPreviewReady: (PreviewView) -> 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 = { /* Crop and recognize are wired by ReceiptViewModel. */ }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Распознать")
|
||||
}
|
||||
}
|
||||
if (bitmap != null) ResultOverlay(bitmap, "")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResultOverlay(bitmap: Bitmap, text: String) {
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
drawImage(bitmap.asImageBitmap(), dstSize = IntSize(size.width.toInt(), size.height.toInt()))
|
||||
}
|
||||
Text(text, color = Color.Red, modifier = Modifier.padding(24.dp))
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package ru.obdai.receipt.camera
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.ImageFormat
|
||||
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 y = planes[0].buffer
|
||||
val u = planes[1].buffer
|
||||
val v = planes[2].buffer
|
||||
val ySize = y.remaining()
|
||||
val uSize = u.remaining()
|
||||
val vSize = v.remaining()
|
||||
val nv21 = ByteArray(ySize + uSize + vSize)
|
||||
y.get(nv21, 0, ySize)
|
||||
v.get(nv21, ySize, vSize)
|
||||
u.get(nv21, ySize + vSize, uSize)
|
||||
val jpeg = ByteArrayOutputStream()
|
||||
YuvImage(nv21, ImageFormat.NV21, width, height, null)
|
||||
.compressToJpeg(Rect(0, 0, width, height), 92, jpeg)
|
||||
return BitmapFactory.decodeByteArray(jpeg.toByteArray(), 0, jpeg.size())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package ru.obdai.receipt.crop
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Rect
|
||||
|
||||
object CropHelper {
|
||||
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,42 @@
|
||||
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
|
||||
|
||||
@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()
|
||||
) {
|
||||
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,51 @@
|
||||
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.network.ApiClient
|
||||
|
||||
sealed interface UiState {
|
||||
data object Idle : UiState
|
||||
data object Analyzing : UiState
|
||||
data class Result(val bitmap: Bitmap, val text: String) : UiState
|
||||
data class Error(val message: String) : UiState
|
||||
}
|
||||
|
||||
class ReceiptViewModel(private val apiClient: ApiClient) : 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
(_state.value as? UiState.Result)?.bitmap?.let { bitmap ->
|
||||
if (!bitmap.isRecycled) bitmap.recycle()
|
||||
}
|
||||
_state.value = UiState.Idle
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
private fun Bitmap.toJpegBytes(): ByteArray {
|
||||
val output = java.io.ByteArrayOutputStream()
|
||||
compress(Bitmap.CompressFormat.JPEG, 92, output)
|
||||
return output.toByteArray()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<style name="Theme.ReceiptCamera" parent="android:style/Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
plugins {
|
||||
id("com.android.application") version "8.7.3" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
@@ -0,0 +1,18 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "ReceiptCamera"
|
||||
include(":app")
|
||||
@@ -1,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
|
||||
@@ -0,0 +1,4 @@
|
||||
.env
|
||||
*.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -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
@@ -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
|
||||
@@ -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=(",", ":"))
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user