Add isolated Gemini image proxy
This commit is contained in:
@@ -0,0 +1,441 @@
|
|||||||
|
# История: obdai.ru/receipt
|
||||||
|
|
||||||
|
## 2026-08-28
|
||||||
|
|
||||||
|
### Вопрос
|
||||||
|
Как запущен `obdai.ru` на ВМ?
|
||||||
|
|
||||||
|
### Ответ и проверка
|
||||||
|
ВМ: `5.172.178.213`.
|
||||||
|
|
||||||
|
Проверены systemd, Docker, процессы и nginx-конфигурация.
|
||||||
|
|
||||||
|
- `obdai.ru` описан в `/etc/nginx/sites-enabled/elmer`.
|
||||||
|
- Основной HTTPS-маршрут nginx проксируется на `127.0.0.1:8000`.
|
||||||
|
- Backend запускается через `elmer.service`.
|
||||||
|
- Команда backend:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/opt/elmer/venv/bin/gunicorn -w 4 -b 127.0.0.1:8000 --timeout 180 web.app:app
|
||||||
|
```
|
||||||
|
|
||||||
|
- Дополнительный маршрут nginx проксируется на `127.0.0.1:8888`, где работает контейнер `llmui`.
|
||||||
|
- `elmer.service` и новый сервис должны оставаться независимыми.
|
||||||
|
|
||||||
|
### Вопрос
|
||||||
|
Можно ли запустить собственный Python-сервис по адресу `obdai.ru/receipt`?
|
||||||
|
|
||||||
|
### Ответ
|
||||||
|
Да. Рекомендуемая схема:
|
||||||
|
|
||||||
|
```text
|
||||||
|
obdai.ru/receipt -> nginx -> receipt.service -> FastAPI на 127.0.0.1:8768
|
||||||
|
```
|
||||||
|
|
||||||
|
Новый сервис должен иметь отдельный каталог, virtualenv, systemd-юнит, порт, логи, конфигурацию и процесс деплоя. Он не должен импортировать код `elmer`, использовать его virtualenv или зависеть от его перезапуска.
|
||||||
|
|
||||||
|
### Вопрос
|
||||||
|
Нужно ли учитывать независимость `elmer`?
|
||||||
|
|
||||||
|
### Ответ
|
||||||
|
Да. `elmer` не изменяется и не перезапускается при работе с receipt-service. Для него создаётся отдельный systemd-юнит, например `receipt.service`, с автозапуском и `Restart=on-failure`.
|
||||||
|
|
||||||
|
### Архитектурное решение
|
||||||
|
Сервис принимает изображение рецепта или фрагменты с лекарствами и дозировками, распознаёт их через OCR/vision-модель, проверяет названия по локальному справочнику лекарственных средств и возвращает структурированный JSON. ФИО и дата рождения не должны попадать в запрос, ответ, логи или постоянное хранилище.
|
||||||
|
|
||||||
|
Рекомендуемые компоненты:
|
||||||
|
|
||||||
|
- FastAPI и Pydantic;
|
||||||
|
- Gunicorn с Uvicorn worker;
|
||||||
|
- отдельный Python virtualenv;
|
||||||
|
- SQLite read-only для справочника на MVP;
|
||||||
|
- rapidfuzz для сопоставления;
|
||||||
|
- OCR/vision-модель;
|
||||||
|
- `gpt-oss-120b` только после проверки поддержки изображений;
|
||||||
|
- nginx reverse proxy;
|
||||||
|
- systemd.
|
||||||
|
|
||||||
|
### Контрольные ограничения
|
||||||
|
|
||||||
|
- Сервис stateless на MVP.
|
||||||
|
- Исходные изображения обрабатываются только в памяти.
|
||||||
|
- Размер файла и MIME проверяются по magic bytes.
|
||||||
|
- Сомнительные совпадения возвращаются с `uncertain=true` и кандидатами.
|
||||||
|
- LLM не принимает окончательное медицинское решение и не изменяет дозировку.
|
||||||
|
|
||||||
|
### Выполненные действия
|
||||||
|
|
||||||
|
1. Изучен `/home/naeel/MED/recipe/PLAN/chat_messages.md`.
|
||||||
|
2. Изучена инструкция по ВМ `~/nubes/HowTo/vm-access-and-services.md`.
|
||||||
|
3. Выполнена диагностическая проверка ВМ по SSH.
|
||||||
|
4. Найден nginx-конфиг `elmer` и backend `elmer.service`.
|
||||||
|
5. Подтверждено, что отдельный receipt-service можно разместить на `127.0.0.1:8768`.
|
||||||
|
6. Создан этот журнал.
|
||||||
|
7. Создан план в `PLAN/receipt-service-plan.md`.
|
||||||
|
|
||||||
|
## Дополнительное требование ведения истории
|
||||||
|
|
||||||
|
Пользователь явно потребовал документировать в `HISTORY` всё происходящее, включая:
|
||||||
|
|
||||||
|
- вопросы и ответы;
|
||||||
|
- выполненные действия и команды;
|
||||||
|
- гипотезы и рассуждения, относящиеся к задаче;
|
||||||
|
- ошибки команд и ошибочные предположения;
|
||||||
|
- результаты проверок;
|
||||||
|
- изменения архитектурных решений и требований.
|
||||||
|
|
||||||
|
### Проверка DeepSeek
|
||||||
|
|
||||||
|
- Сначала ключи DeepSeek ошибочно проверялись через `api.aillm.ru`; это был неправильный endpoint.
|
||||||
|
- После исправления endpoint на `https://api.deepseek.com` оба ключа дали доступ к API.
|
||||||
|
- В списке моделей обнаружена `deepseek-v4-flash-vision-exp`.
|
||||||
|
- Реальное изображение `PLAN/lekar.png` было отправлено в vision-модель.
|
||||||
|
- Ответ API: HTTP 200.
|
||||||
|
- Модель вернула два сомнительных результата: `Clonoxamin` и `Sio`; оба с `uncertain=true`.
|
||||||
|
- Вывод: vision-вход модель принимает, но качество распознавания нельзя считать достаточным без словаря и пользовательского подтверждения.
|
||||||
|
|
||||||
|
### Ошибки и ограничения
|
||||||
|
|
||||||
|
- Первый тест `gpt-oss-120b` принял multimodal-запрос, но в reasoning указал, что изображения не видит.
|
||||||
|
- Один тест завершился кодом `5`, потому что служебная строка `HTTP_STATUS` смешалась с JSON перед обработкой `jq`.
|
||||||
|
- В диагностической команде проверки DeepSeek была ошибка форматирования вывода suffix ключа; она не повлияла на HTTP-запрос.
|
||||||
|
- Переданные API-ключи опубликованы в чате; их следует отозвать и заменить.
|
||||||
|
|
||||||
|
### Текущий статус
|
||||||
|
|
||||||
|
Реализация кода не начиналась. Зафиксированы архитектура, инфраструктурная схема и результаты проверки доступных моделей. Следующий этап после отдельного подтверждения требований: подготовка MVP receipt-service и его независимого запуска рядом с `elmer`.
|
||||||
|
|
||||||
|
## Утверждённое архитектурное решение
|
||||||
|
|
||||||
|
Пользователь подтвердил реализацию подхода, в котором словарь лекарств участвует непосредственно в принятии решения, а не используется только для косметического исправления OCR.
|
||||||
|
|
||||||
|
### Pipeline
|
||||||
|
|
||||||
|
```text
|
||||||
|
Android crop зон лекарств
|
||||||
|
-> preprocessing (original, grayscale, contrast, sharpen, threshold, upscale)
|
||||||
|
-> DeepSeek Vision генерирует несколько гипотез
|
||||||
|
-> candidate resolver
|
||||||
|
-> локальный словарь лекарств
|
||||||
|
-> отдельный разбор дозировки и формы выпуска
|
||||||
|
-> decision engine: CONFIDENT / NEED_CONFIRMATION / UNKNOWN
|
||||||
|
-> JSON и overlay в Android
|
||||||
|
```
|
||||||
|
|
||||||
|
### Решения
|
||||||
|
|
||||||
|
- Не отправлять модели весь рецепт, если клиент может передать отдельные строки или зоны.
|
||||||
|
- Для каждой лекарственной зоны допускается несколько вариантов preprocessing и повторное распознавание.
|
||||||
|
- Vision-модель должна возвращать `observed_text` и альтернативы, а не безусловно выбирать препарат.
|
||||||
|
- Resolver использует exact match, aliases, fuzzy/character similarity, лекарственную форму и совместимость дозировки.
|
||||||
|
- Название и дозировка разбираются отдельно.
|
||||||
|
- Недопустимая дозировка является сигналом ошибки и переводит результат в `NEED_CONFIRMATION`, но не исправляется автоматически.
|
||||||
|
- В API добавить `POST /receipt/api/v1/confirm` для выбора пользователем и накопления correction samples.
|
||||||
|
- На MVP использовать SQLite в `/opt/receipt/dictionary/drugs.sqlite`.
|
||||||
|
- Подтверждённые пользователем исправления в будущем хранить отдельно как обезличенный dataset.
|
||||||
|
- `elmer.service` не изменяется и не становится зависимостью receipt-service.
|
||||||
|
|
||||||
|
### Гипотезы для проверки
|
||||||
|
|
||||||
|
- Передача ограниченного списка кандидатов vision-модели повысит точность по сравнению с единственным свободным ответом.
|
||||||
|
- Отдельные crops лекарственных строк дадут лучшее качество и меньше PII, чем полный рецепт.
|
||||||
|
- Совместимость названия с дозировкой и формой выпуска снизит опасные false positive.
|
||||||
|
- Два или более preprocessing-варианта могут дать больший выигрыш, чем немедленное дообучение большой модели.
|
||||||
|
|
||||||
|
### Следующий технический этап
|
||||||
|
|
||||||
|
Разработать MVP receipt backend: FastAPI, SQLite-схема справочника, DeepSeek Vision client, candidate resolver, scoring, Pydantic API, unit-тесты и независимую конфигурацию systemd/nginx. До выбора порогов проверить pipeline на 50–200 обезличенных рецептах.
|
||||||
|
|
||||||
|
## Мнения внешних ассистентов
|
||||||
|
|
||||||
|
### ChatGPT
|
||||||
|
|
||||||
|
Зафиксирован подход:
|
||||||
|
|
||||||
|
- vision-модель генерирует несколько гипотез текста;
|
||||||
|
- локальный словарь участвует в принятии решения, а не только исправляет OCR;
|
||||||
|
- дозировка и форма выпуска проверяются отдельно;
|
||||||
|
- статусы результата: `CONFIDENT`, `NEED_CONFIRMATION`, `UNKNOWN`;
|
||||||
|
- отдельный endpoint подтверждения позволяет собирать correction samples;
|
||||||
|
- сервис размещается в `/opt/receipt/`, слушает `127.0.0.1:8768` и работает через отдельный `receipt.service`;
|
||||||
|
- `elmer.service` не изменяется и не становится зависимостью;
|
||||||
|
- на MVP рекомендуется SQLite.
|
||||||
|
|
||||||
|
### Gemini
|
||||||
|
|
||||||
|
Мнение Gemini в целом совпало с этим направлением:
|
||||||
|
|
||||||
|
- архитектура: Android crop -> nginx -> FastAPI -> DeepSeek Vision -> Lexicon Matcher -> Dosage/Form Validator -> Confidence Aggregator -> JSON -> Android overlay;
|
||||||
|
- DeepSeek Vision должен возвращать изображение, `observed_text`, top-N гипотез и сырой текст дозировки;
|
||||||
|
- словарь должен включать МНН, торговые названия, латинские и русские варианты, aliases, формы выпуска, дозировки и типичные ошибки;
|
||||||
|
- resolver должен использовать exact match, aliases, Damerau-Levenshtein, phonetic matching, Trie/SymSpell и совместимость дозировки/формы;
|
||||||
|
- итоговый score рекомендуется строить из vision score, dictionary similarity, dose compatibility и form compatibility, а коэффициенты проверять экспериментально;
|
||||||
|
- название, strength и режим приёма следует распознавать раздельно;
|
||||||
|
- недопустимая дозировка должна снижать уверенность и выдавать предупреждение, но не исправляться автоматически;
|
||||||
|
- вместо полного рецепта лучше отправлять отдельные crops лекарственных строк;
|
||||||
|
- для каждой зоны полезно создавать несколько вариантов preprocessing: grayscale, contrast, sharpen, threshold, upscale;
|
||||||
|
- рекомендуется добавить `POST /receipt/api/v1/confirm` для подтверждения пользователем и накопления обезличенных correction samples;
|
||||||
|
- SQLite подходит для локального read-heavy справочника на первом этапе;
|
||||||
|
- PostgreSQL не следует подключать к `elmer`, если он понадобится позже, нужна отдельная БД и отдельный пользователь;
|
||||||
|
- структура `/opt/receipt/`, отдельный virtualenv, systemd-юнит и nginx location сохраняют полную независимость от `elmer`.
|
||||||
|
|
||||||
|
### Сводный вывод
|
||||||
|
|
||||||
|
Оба мнения поддерживают один основной pipeline: vision-модель используется для извлечения нескольких гипотез, локальный справочник ограничивает пространство кандидатов, отдельный валидатор проверяет дозировку и форму, а сомнительные результаты передаются пользователю на подтверждение.
|
||||||
|
|
||||||
|
Гипотезы, которые нельзя считать доказанными до эксперимента:
|
||||||
|
|
||||||
|
- передача top-N кандидатов модели повысит точность;
|
||||||
|
- несколько вариантов preprocessing дадут существенный выигрыш;
|
||||||
|
- проверка дозировки и формы уменьшит опасные false positive;
|
||||||
|
- SQLite останется достаточной при ожидаемой нагрузке.
|
||||||
|
|
||||||
|
### Ожидание команды
|
||||||
|
|
||||||
|
По состоянию на эту запись документирование выполнено. Код receipt-service, конфигурация ВМ и nginx не изменялись. Следующий шаг должен начинаться с MVP и проверки на обезличенных данных.
|
||||||
|
|
||||||
|
## Начало тестирования DeepSeek Flash
|
||||||
|
|
||||||
|
Пользователь дал команду приступить к тестированию через `deepseek-v4-flash-vision-exp`.
|
||||||
|
|
||||||
|
Цель эксперимента: добиться распознавания фрагмента `PLAN/lekar.png` до разработки backend и справочника.
|
||||||
|
|
||||||
|
Правила чистого эксперимента:
|
||||||
|
|
||||||
|
- каждый запрос отправляется как независимый запрос без истории;
|
||||||
|
- предыдущие ответы и гипотезы модели в новый запрос не передаются;
|
||||||
|
- ключи и секреты не выводятся в журнал;
|
||||||
|
- сначала проверяется базовый запрос, затем варианты preprocessing;
|
||||||
|
- меняется один фактор за раз;
|
||||||
|
- сравнивается содержательный текст распознавания, а не только HTTP 200;
|
||||||
|
- результат и ошибки фиксируются после каждого тестового этапа.
|
||||||
|
|
||||||
|
## Gemini: полный recipe1.png, первая попытка
|
||||||
|
|
||||||
|
Полное изображение `PLAN/recipe1.png` отправлено в `gemini-3.6-flash`.
|
||||||
|
Запрос завершился HTTP 200, но модель остановилась с `finishReason=MAX_TOKENS`.
|
||||||
|
Промпт требовал минимальный JSON и исключение ФИО и даты рождения.
|
||||||
|
|
||||||
|
Неполный вывод начинался с: `Date: 12 марта 20`.
|
||||||
|
|
||||||
|
Использование токенов: prompt 1131, image 1092, text 39, candidate output 19,
|
||||||
|
thoughts 477, total 1627. Стоимость по тарифу Gemini 3.6 Flash ($0.75 за 1M
|
||||||
|
входных и $3.75 за 1M выходных токенов), если считать prompt как вход и
|
||||||
|
candidate output как выход: $0.00092, то есть около 9 копеек при курсе 95
|
||||||
|
рублей за доллар.
|
||||||
|
|
||||||
|
Вывод: лимит 500 токенов оказался недостаточным; следующий независимый тест
|
||||||
|
проводится с существенно большим лимитом и ещё более короткой JSON-схемой.
|
||||||
|
|
||||||
|
## Gemini: повторный полный recipe1.png с лимитом 4000
|
||||||
|
|
||||||
|
Повторный независимый запрос отправлен с тем же полным изображением и
|
||||||
|
`maxOutputTokens=4000`. Локальная проверка подтвердила корректный JSON-запрос,
|
||||||
|
наличие двух частей (`text` и `inline_data`) и размер Base64 изображения
|
||||||
|
5 015 836 символов.
|
||||||
|
|
||||||
|
Gemini вернул HTTP 400 с HTML-страницей Google: `malformed or illegal request`.
|
||||||
|
Распознавание не выполнялось, поэтому стоимость результата распознавания не
|
||||||
|
начислена. Причина требует отдельной проверки; следующий шаг — повторить тест
|
||||||
|
с допустимым форматом/лимитом, сохранив изображение и минимальный вывод.
|
||||||
|
|
||||||
|
## Gemini: полный recipe1.png, лимит 8000
|
||||||
|
|
||||||
|
Повторный запрос выполнен с ключом, указанным пользователем, без сохранения
|
||||||
|
ключа в проекте. Полное `PLAN/recipe1.png` отправлено в `gemini-3.6-flash` с
|
||||||
|
минимальным англоязычным промптом и `maxOutputTokens=8000`.
|
||||||
|
|
||||||
|
Ответ завершён успешно: HTTP 200, `finishReason=STOP`.
|
||||||
|
|
||||||
|
Результат модели:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"date":"12.03.2026","medicines":[{"name":"Fluvoxamine","dosage":"100 mg"}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
Usage metadata: prompt 1132 токена, включая image 1092 и text 40; candidate
|
||||||
|
output 32; thoughts 1432; total 2596.
|
||||||
|
|
||||||
|
При тарифе $0.75 за 1M входных токенов и $3.75 за 1M выходных стоимость без
|
||||||
|
учёта thoughts составляет $0.000969, или 0.092 рубля при курсе 95 рублей за
|
||||||
|
доллар. Если thoughts тарифицируются как выходные токены, верхняя оценка
|
||||||
|
составляет $0.006339, или 0.602 рубля. Фактический ответ распознал дату и
|
||||||
|
только один препарат; проверка полноты по изображению ещё нужна.
|
||||||
|
|
||||||
|
## Создание минимального Gemini proxy
|
||||||
|
|
||||||
|
По команде пользователя создан изолированный локальный модуль
|
||||||
|
`gemini_proxy/`, не изменяющий существующий `site` и `requirements.txt`.
|
||||||
|
|
||||||
|
Состав:
|
||||||
|
|
||||||
|
- `gemini_proxy/app.py` — FastAPI API с `GET /health` и `POST /gemini`;
|
||||||
|
- `gemini_proxy/requirements.txt` — отдельные зависимости;
|
||||||
|
- `gemini_proxy/.env.example` — только имена настроек без реального ключа;
|
||||||
|
- `gemini_proxy/test_app.py` — базовые тесты.
|
||||||
|
|
||||||
|
Ключ Gemini не захардкожен и не записывается в проект. Приложение читает его
|
||||||
|
из `GEMINI_API_KEY`; модель — из `GEMINI_MODEL`. Изображение принимается только
|
||||||
|
как JPEG/PNG/WEBP, ограничено 10 MB, не сохраняется на диск и отправляется в
|
||||||
|
Gemini как Base64. Ответ возвращает текст модели и usage metadata.
|
||||||
|
|
||||||
|
Ошибка во время реализации: сначала изображение ошибочно кодировалось через
|
||||||
|
hex вместо Base64. Узкий тест выявил проблему; код исправлен на
|
||||||
|
`base64.b64encode(...).decode("ascii")`.
|
||||||
|
|
||||||
|
Проверка после исправления: `pytest -q gemini_proxy/test_app.py` — 2 passed;
|
||||||
|
`python3 -m py_compile gemini_proxy/app.py` завершился успешно. Обнаружено
|
||||||
|
предупреждение зависимости FastAPI/Starlette о deprecated-интеграции с
|
||||||
|
текущим httpx TestClient; оно не влияет на результат тестов.
|
||||||
|
|
||||||
|
Модуль пока не развёрнут на немецком сервере и не подключён к nginx/systemd.
|
||||||
|
|
||||||
|
## Развёртывание Gemini proxy на немецкой ВМ
|
||||||
|
|
||||||
|
По команде пользователя модуль развёрнут на ВМ `95.179.252.111`.
|
||||||
|
|
||||||
|
Развёртывание выполнено изолированно:
|
||||||
|
|
||||||
|
- каталог приложения: `/opt/gemini-proxy`;
|
||||||
|
- системный пользователь и группа: `gemini-proxy`;
|
||||||
|
- отдельный virtualenv: `/opt/gemini-proxy/venv`;
|
||||||
|
- systemd-юнит: `gemini-proxy.service`;
|
||||||
|
- bind: `127.0.0.1:8768`;
|
||||||
|
- конфигурация: `/etc/gemini-proxy/gemini-proxy.env`;
|
||||||
|
- ключ в проект и журнал не записывался;
|
||||||
|
- в env-файле пока задана только модель, поэтому `GEMINI_API_KEY` ещё нужно
|
||||||
|
добавить перед использованием endpoint распознавания.
|
||||||
|
|
||||||
|
Подробное описание оставлено в `/opt/gemini-proxy/README.md`.
|
||||||
|
|
||||||
|
При первом запуске обнаружено, что на ВМ отсутствовал пакет
|
||||||
|
`python3.10-venv`; пакет установлен, virtualenv создан, зависимости установлены.
|
||||||
|
После этого юнит был установлен повторно.
|
||||||
|
|
||||||
|
Первая проверка health попала в момент запуска и получила `connection refused`.
|
||||||
|
Проверка по systemd/journal подтвердила нормальный запуск Uvicorn на
|
||||||
|
`127.0.0.1:8768`; повторный health вернул `{"status":"ok"}`.
|
||||||
|
|
||||||
|
Проверки изоляции: `gemini-proxy.service` активен; `GEMINI_API_KEY` отсутствует
|
||||||
|
в серверном env-файле намеренно; существующий `elmer` не перезапускался и не
|
||||||
|
использовался. Nginx-маршрут пока не добавлялся, поэтому proxy снаружи ВМ не
|
||||||
|
доступен.
|
||||||
|
|
||||||
|
## Активация ключа и проверка proxy
|
||||||
|
|
||||||
|
По дополнительной команде пользователя API-ключ Gemini установлен только на
|
||||||
|
немецкой ВМ в `/etc/gemini-proxy/gemini-proxy.env`. В репозиторий и HISTORY
|
||||||
|
ключ не записывался. Перезапущен только `gemini-proxy.service`.
|
||||||
|
|
||||||
|
Контрольный запрос с `PLAN/lekar.png` через `POST /gemini` на localhost ВМ:
|
||||||
|
HTTP 200.
|
||||||
|
|
||||||
|
Ответ модели:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"date":null,"medicines":[{"name":"Fluvoxamini","dosage":"100 mg"}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
Usage: prompt 1139, image 1102, text 37, candidate output 23, thoughts 650,
|
||||||
|
total 1812. Прокси работает; внешний nginx-маршрут пока не добавлялся.
|
||||||
|
|
||||||
|
## Проверка полного текста каракулей через proxy
|
||||||
|
|
||||||
|
Промпт в `gemini_proxy/app.py` изменён: теперь модель должна транскрибировать
|
||||||
|
весь видимый медицинский текст фрагмента, включая несколько лекарств,
|
||||||
|
дозировки, частоту, способ и длительность приёма, без нормализации и выдумок.
|
||||||
|
Фиксированная схема списка лекарств удалена; ответ proxy по-прежнему содержит
|
||||||
|
одно поле `text` и usage.
|
||||||
|
|
||||||
|
После синхронизации `app.py` и перезапуска только `gemini-proxy` выполнен
|
||||||
|
контрольный запрос с полным `lekar.png`. Получен HTTP 200. Gemini вернул:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"text":"Fluvoxamini 100 mg\nDtd N 30 in tab\nS. по 1 т 1 р в день\nвнутри в течение\n30 дней."}
|
||||||
|
```
|
||||||
|
|
||||||
|
Модель сохранила название, дозировку, количество, схему приёма и длительность
|
||||||
|
30 дней. Usage: prompt 1170, image 1102, text 68, candidate output 48,
|
||||||
|
thoughts 1259, total 2477. Временный файл ответа не был доступен из-за
|
||||||
|
`PrivateTmp=true`; повторная проверка через stdout успешно получила чистый
|
||||||
|
результат. Ключ в HISTORY не записывался.
|
||||||
|
|
||||||
|
## Прозрачная передача prompt и настроек Gemini
|
||||||
|
|
||||||
|
Выяснено, что предыдущая версия proxy сама формировала prompt и принудительно
|
||||||
|
добавляла `thinkingConfig`. Это нарушало требование: российский сервер должен
|
||||||
|
передавать немецкому proxy изображение, prompt, `generationConfig` и при
|
||||||
|
необходимости ключ, а немецкий proxy только пересылает их в Gemini.
|
||||||
|
|
||||||
|
Proxy изменён: `POST /gemini` теперь принимает multipart-поля `image`,
|
||||||
|
обязательный `prompt`, JSON-строку `generation_config` и необязательный
|
||||||
|
`api_key_override`. Поле `generation_config` проверяется как JSON-объект и
|
||||||
|
передаётся без подмены. Фиксированный prompt и настройки thinking удалены.
|
||||||
|
|
||||||
|
Локальная проверка после изменения: 3 теста прошли, синтаксис `app.py`
|
||||||
|
проверен через `py_compile`.
|
||||||
|
|
||||||
|
Изменение развёрнуто на немецкую ВМ. End-to-end проверка с `lekar.png`,
|
||||||
|
внешним prompt, `generation_config={"temperature":0,"maxOutputTokens":2000}`
|
||||||
|
и ключом override завершилась HTTP 200. Ответ содержит `Fluvoxamini 100 mg`,
|
||||||
|
`DtdN 30 in tab`, схему `1 т 1 р. в день` и длительность `30 дней`.
|
||||||
|
|
||||||
|
Usage проверки: prompt 1117, image 1102, text 15, candidate output 80,
|
||||||
|
thoughts 1760, total 2957. Ключ в журнал не записывался.
|
||||||
|
|
||||||
|
## Проверка `PLAN/lekar1.png` с минимальным thinking
|
||||||
|
|
||||||
|
Предыдущий тест использовал ошибочный crop из `lekar.png`. По уточнению
|
||||||
|
пользователя отправлен именно файл `PLAN/lekar1.png` размером 1543x470.
|
||||||
|
Параметры: `thinkingConfig.thinkingLevel=minimal`, temperature 0,
|
||||||
|
maxOutputTokens 300.
|
||||||
|
|
||||||
|
Результат: HTTP 200, `thoughtsTokenCount` отсутствует.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Risperidoni 2 mg
|
||||||
|
DTD N 60 in tab
|
||||||
|
S. по 1 та 2 р в день
|
||||||
|
внутри вечером
|
||||||
|
30 дн.
|
||||||
|
```
|
||||||
|
|
||||||
|
Usage: prompt 1097, image 1080, text 17, candidate output 37, total 1134.
|
||||||
|
|
||||||
|
## Проверка `PLAN/lekar3.png`
|
||||||
|
|
||||||
|
Файл `PLAN/lekar3.png` отправлен через proxy с `thinkingLevel=minimal`.
|
||||||
|
Результат HTTP 200:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Periciazini 10mg
|
||||||
|
Proin 100 in tab.
|
||||||
|
S. po 1/2 t 4 r. в день
|
||||||
|
внутрі в течение
|
||||||
|
30 дней
|
||||||
|
```
|
||||||
|
|
||||||
|
Usage: prompt 1117, image 1100, text 17, candidate output 43, total 1160.
|
||||||
|
`thoughtsTokenCount` отсутствует.
|
||||||
|
|
||||||
|
## Gemini 3.6 Flash: thinkingLevel minimal
|
||||||
|
|
||||||
|
Выполнен контрольный запрос второго лекарственного фрагмента через proxy с
|
||||||
|
настройкой:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"temperature":0,"maxOutputTokens":300,"thinkingConfig":{"thinkingLevel":"minimal"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ответ: HTTP 200. Gemini вернул текст:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Didir 30 m tab
|
||||||
|
S. по 1т 1/р в день
|
||||||
|
внутрі ввечері
|
||||||
|
30 днів.
|
||||||
|
```
|
||||||
|
|
||||||
|
`thoughtsTokenCount` в ответе отсутствует, что подтверждает отсутствие
|
||||||
|
отдельно тарифицируемых reasoning-токенов в этом запросе. Usage: prompt 1119,
|
||||||
|
image 1102, text 17, candidate output 34, total 1153.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 733 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 671 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 519 KiB |
@@ -0,0 +1,184 @@
|
|||||||
|
# План архитектуры obdai.ru/receipt
|
||||||
|
|
||||||
|
## 1. Цель
|
||||||
|
|
||||||
|
Создать независимый Python API-сервис для распознавания рукописных рецептов. API принимает изображение рецепта или фрагменты с названиями лекарств и дозировками, а возвращает структурированный текст для отображения в Android-приложении.
|
||||||
|
|
||||||
|
ФИО пациента, дата рождения, адрес и другие идентификаторы не должны извлекаться, сохраняться или возвращаться.
|
||||||
|
|
||||||
|
## 2. Размещение
|
||||||
|
|
||||||
|
```text
|
||||||
|
Android
|
||||||
|
-> HTTPS
|
||||||
|
nginx: obdai.ru/receipt/
|
||||||
|
-> 127.0.0.1:8768
|
||||||
|
receipt.service
|
||||||
|
-> FastAPI/Gunicorn
|
||||||
|
-> OCR/vision-модель
|
||||||
|
-> нормализация по справочнику лекарств
|
||||||
|
-> JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
`elmer` остаётся полностью независимым:
|
||||||
|
|
||||||
|
- код: `/opt/receipt/`;
|
||||||
|
- virtualenv: `/opt/receipt/venv/`;
|
||||||
|
- пользователь systemd: отдельный `receipt`;
|
||||||
|
- порт: `127.0.0.1:8768`;
|
||||||
|
- юнит: `/etc/systemd/system/receipt.service`;
|
||||||
|
- отдельные логи и секреты;
|
||||||
|
- отдельный deploy/restart;
|
||||||
|
- отсутствие общих импортов, virtualenv и файлов с `elmer`.
|
||||||
|
|
||||||
|
## 3. API
|
||||||
|
|
||||||
|
Основной endpoint: `POST /receipt/recognize`.
|
||||||
|
|
||||||
|
Формат: `multipart/form-data`.
|
||||||
|
|
||||||
|
Поля:
|
||||||
|
|
||||||
|
- `image` — JPEG, PNG или WEBP, до 10 MB;
|
||||||
|
- `request_id` — UUID клиента;
|
||||||
|
- `masked_zones` — координаты зон, которые нужно закрыть до OCR;
|
||||||
|
- при необходимости отдельный тип запроса для уже выделенных фрагментов.
|
||||||
|
|
||||||
|
Ответ должен содержать:
|
||||||
|
|
||||||
|
- `request_id`;
|
||||||
|
- дату рецепта, если она распознана;
|
||||||
|
- список лекарств;
|
||||||
|
- исходный распознанный фрагмент без PII;
|
||||||
|
- bounding box;
|
||||||
|
- дозировку и режим приёма как написано;
|
||||||
|
- `confidence`;
|
||||||
|
- `uncertain`;
|
||||||
|
- кандидатов из справочника при сомнительном совпадении.
|
||||||
|
|
||||||
|
Ошибки: `400`, `401`, `413`, `422`, `429`, `503`.
|
||||||
|
|
||||||
|
## 4. Pipeline
|
||||||
|
|
||||||
|
1. Проверить API-ключ, размер, magic bytes и разрешение изображения.
|
||||||
|
2. Применить маскирование зон до передачи изображения модели.
|
||||||
|
3. Выполнить масштабирование и deskew.
|
||||||
|
4. Получить OCR-текст и bounding boxes.
|
||||||
|
5. Выделить роли: лекарство, дозировка, режим, дата, прочее.
|
||||||
|
6. Удалить PII из результата.
|
||||||
|
7. Сопоставить название со справочником через нормализацию и `rapidfuzz`.
|
||||||
|
8. При близких или слабых совпадениях установить `uncertain=true`.
|
||||||
|
9. Валидировать ответ Pydantic.
|
||||||
|
10. Вернуть JSON без записи изображения и медицинского текста на диск.
|
||||||
|
|
||||||
|
## 5. Модель и OCR
|
||||||
|
|
||||||
|
Сначала проверить, поддерживает ли используемый endpoint `gpt-oss-120b` изображения. Размер 120B сам по себе не означает наличие vision-возможностей.
|
||||||
|
|
||||||
|
- Если модель vision-capable: изображение -> модель -> строгий JSON.
|
||||||
|
- Если text-only: Tesseract 5 или облачный OCR с русским языком и bounding boxes -> `gpt-oss-120b` для структурного разбора -> справочник.
|
||||||
|
- Финальное название лекарства нельзя принимать только от LLM: источник истины — справочник и fuzzy matching.
|
||||||
|
- Дозировка возвращается как распознанный текст, без медицинской интерпретации.
|
||||||
|
|
||||||
|
## 6. Справочник лекарств
|
||||||
|
|
||||||
|
Для MVP использовать локальную read-only SQLite БД.
|
||||||
|
|
||||||
|
Минимальные сущности:
|
||||||
|
|
||||||
|
- препарат;
|
||||||
|
- МНН;
|
||||||
|
- торговое название;
|
||||||
|
- форма выпуска;
|
||||||
|
- дозировка;
|
||||||
|
- производитель;
|
||||||
|
- синонимы и распространённые варианты написания;
|
||||||
|
- поисковый индекс;
|
||||||
|
- пары опасно похожих названий.
|
||||||
|
|
||||||
|
Возможный базовый источник — ГРЛС. Перед использованием необходимо проверить формат, условия использования, полноту данных и порядок обновления. Коммерческие справочники требуют отдельной лицензии.
|
||||||
|
|
||||||
|
Рекомендуемая логика:
|
||||||
|
|
||||||
|
- высокий score: совпадение;
|
||||||
|
- средний score: `uncertain=true` и список кандидатов;
|
||||||
|
- низкий score: совпадение не подтверждать;
|
||||||
|
- при близости двух похожих препаратов принудительно требовать подтверждение пользователя.
|
||||||
|
|
||||||
|
## 7. Персональные данные
|
||||||
|
|
||||||
|
- Android маскирует ФИО/ДР до отправки.
|
||||||
|
- Сервер повторно применяет `masked_zones` до OCR.
|
||||||
|
- В response schema отсутствуют поля пациента.
|
||||||
|
- Изображения и raw OCR не записываются на диск.
|
||||||
|
- В логах только `request_id`, статус, размер, latency и количество сомнительных позиций.
|
||||||
|
- TLS обеспечивается nginx.
|
||||||
|
- API требует Bearer-ключ.
|
||||||
|
- Должны быть TTL, rate limiting и аудит без медицинского содержимого.
|
||||||
|
|
||||||
|
## 8. Надёжность
|
||||||
|
|
||||||
|
- Жёсткие timeout для nginx, Gunicorn, HTTP-клиента модели и SQLite.
|
||||||
|
- На MVP синхронная обработка без Redis/Celery.
|
||||||
|
- При недоступности модели возвращать `503`.
|
||||||
|
- Ограничить размер запроса и частоту запросов.
|
||||||
|
- Health endpoint не должен требовать API-ключ.
|
||||||
|
- Metrics endpoint доступен только локально.
|
||||||
|
- Бэкапить только справочник и конфигурацию ключей, не изображения.
|
||||||
|
|
||||||
|
## 9. Этапы
|
||||||
|
|
||||||
|
### MVP
|
||||||
|
|
||||||
|
1. FastAPI, health и recognize.
|
||||||
|
2. Отдельные `/opt/receipt`, virtualenv, systemd и nginx location.
|
||||||
|
3. Загрузка справочника в SQLite.
|
||||||
|
4. OCR и базовый matcher.
|
||||||
|
5. Pydantic response без PII.
|
||||||
|
6. API-ключ, лимиты и journald.
|
||||||
|
|
||||||
|
### Следующий этап
|
||||||
|
|
||||||
|
- интеграция vision-модели;
|
||||||
|
- calibrated confidence;
|
||||||
|
- подтверждение кандидатов в Android;
|
||||||
|
- автообновление справочника;
|
||||||
|
- Prometheus metrics;
|
||||||
|
- circuit breaker;
|
||||||
|
- расширенная защита от похожих названий.
|
||||||
|
|
||||||
|
## 10. Основные риски
|
||||||
|
|
||||||
|
- `gpt-oss-120b` может оказаться text-only;
|
||||||
|
- рукописный русский текст может распознаваться с низкой точностью;
|
||||||
|
- fuzzy matching может выбрать похожий препарат;
|
||||||
|
- ГРЛС может быть неполным или неудобным для автоматической синхронизации;
|
||||||
|
- клиент может случайно отправить PII;
|
||||||
|
- медицинский сценарий требует явного отказа от диагностики и назначения лечения.
|
||||||
|
|
||||||
|
## 11. Утверждённый вариант распознавания
|
||||||
|
|
||||||
|
Сервис не строится как `OCR -> исправление строки`. Основной принцип: vision-модель возвращает несколько гипотез наблюдаемого текста, после чего локальный справочник участвует в выборе допустимого препарата.
|
||||||
|
|
||||||
|
```text
|
||||||
|
crop лекарственной строки
|
||||||
|
-> несколько вариантов preprocessing
|
||||||
|
-> DeepSeek Vision: observed_text + alternatives
|
||||||
|
-> exact/alias/fuzzy candidate resolver
|
||||||
|
-> проверка дозировки и лекарственной формы
|
||||||
|
-> scoring
|
||||||
|
-> CONFIDENT / NEED_CONFIRMATION / UNKNOWN
|
||||||
|
```
|
||||||
|
|
||||||
|
Ключевые правила:
|
||||||
|
|
||||||
|
- не просить модель угадывать единственный препарат без альтернатив;
|
||||||
|
- не отправлять весь словарь в prompt;
|
||||||
|
- сначала получать ограниченный набор кандидатов локальным поиском, затем передавать модели только top-N;
|
||||||
|
- название, strength и схему приёма распознавать отдельными полями;
|
||||||
|
- несовместимую дозировку использовать для снижения уверенности, а не для автоматического исправления;
|
||||||
|
- при близких названиях показывать варианты пользователю;
|
||||||
|
- добавить endpoint подтверждения результата, чтобы собирать обезличенные correction samples;
|
||||||
|
- начать с SQLite и перейти на отдельный PostgreSQL только при появлении требований к параллельной записи, админке или нескольким экземплярам сервиса.
|
||||||
|
|
||||||
|
Перед фиксацией порогов и коэффициентов scoring провести эксперимент на 50–200 обезличенных рецептах.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
GEMINI_API_KEY=replace-with-secret
|
||||||
|
GEMINI_MODEL=gemini-3.6-flash
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Gemini Proxy
|
||||||
|
|
||||||
|
Минимальный изолированный HTTP-прокси для вызова Gemini с изображением.
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
|
||||||
|
Прокси принимает один crop изображения от основного российского сервера,
|
||||||
|
передаёт его в Gemini Developer API и возвращает текст модели вместе с
|
||||||
|
метаданными использования токенов.
|
||||||
|
|
||||||
|
В прокси нет бизнес-логики распознавания рецептов, словаря лекарств,
|
||||||
|
нормализации, scoring или хранения результатов. Эти функции находятся на
|
||||||
|
основном сервере.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### `GET /health`
|
||||||
|
|
||||||
|
Возвращает:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"status":"ok"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /gemini`
|
||||||
|
|
||||||
|
Формат: `multipart/form-data`.
|
||||||
|
|
||||||
|
Поле `image` должно содержать JPEG, PNG или WEBP размером не более 10 MB.
|
||||||
|
|
||||||
|
Необязательное поле `api_key_override` позволяет передать ключ только для
|
||||||
|
текущего запроса. Если оно не задано, используется `GEMINI_API_KEY` из
|
||||||
|
серверной конфигурации. Передача ключа в запросе менее безопасна и допустима
|
||||||
|
только по защищённому каналу между доверенными серверами; ключ не записывается
|
||||||
|
прокси в логи или на диск.
|
||||||
|
|
||||||
|
Ответ:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"text": "{\"date\":null,\"medicines\":[]}",
|
||||||
|
"usage": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Секреты
|
||||||
|
|
||||||
|
API-ключ не хранится в коде и не коммитится. Сервис читает:
|
||||||
|
|
||||||
|
- `GEMINI_API_KEY` — обязательный ключ;
|
||||||
|
- `GEMINI_MODEL` — модель, по умолчанию `gemini-3.6-flash`.
|
||||||
|
|
||||||
|
На сервере секрет хранится отдельно в `/etc/gemini-proxy/gemini-proxy.env`
|
||||||
|
с правами `600`, владельцем `root:gemini-proxy` и не доступен через HTTP.
|
||||||
|
|
||||||
|
## Изоляция
|
||||||
|
|
||||||
|
- отдельный пользователь `gemini-proxy`;
|
||||||
|
- отдельный каталог `/opt/gemini-proxy`;
|
||||||
|
- отдельный virtualenv;
|
||||||
|
- отдельный systemd-юнит;
|
||||||
|
- bind только на `127.0.0.1:8768`;
|
||||||
|
- изображения обрабатываются в памяти и не сохраняются приложением;
|
||||||
|
- существующие сервисы и их virtualenv не используются и не перезапускаются.
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
```text
|
||||||
|
systemctl status gemini-proxy
|
||||||
|
systemctl restart gemini-proxy
|
||||||
|
curl http://127.0.0.1:8768/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Для внешнего доступа потребуется отдельный reverse-proxy маршрут nginx и
|
||||||
|
аутентификация между российским сервером и этим сервисом. До этого endpoint
|
||||||
|
доступен только локально на немецкой ВМ.
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Gemini image proxy", docs_url=None, redoc_url=None)
|
||||||
|
|
||||||
|
GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-3.6-flash")
|
||||||
|
GEMINI_API_URL = "https://generativelanguage.googleapis.com/v1beta/models"
|
||||||
|
MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
||||||
|
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health() -> dict[str, str]:
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/gemini")
|
||||||
|
async def recognize(
|
||||||
|
image: Annotated[UploadFile, File(...)],
|
||||||
|
prompt: Annotated[str, Form(...)],
|
||||||
|
generation_config: Annotated[str, Form()] = "{}",
|
||||||
|
api_key_override: Annotated[str | None, Form()] = None,
|
||||||
|
) -> dict:
|
||||||
|
if image.content_type not in ALLOWED_TYPES:
|
||||||
|
raise HTTPException(status_code=415, detail="Unsupported image type")
|
||||||
|
|
||||||
|
image_data = await image.read(MAX_IMAGE_BYTES + 1)
|
||||||
|
if len(image_data) > MAX_IMAGE_BYTES:
|
||||||
|
raise HTTPException(status_code=413, detail="Image is too large")
|
||||||
|
|
||||||
|
api_key = api_key_override or os.getenv("GEMINI_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
raise HTTPException(status_code=503, detail="Gemini is not configured")
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = json.loads(generation_config)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail="Invalid generation_config") from exc
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
raise HTTPException(status_code=422, detail="generation_config must be an object")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"contents": [{
|
||||||
|
"parts": [
|
||||||
|
{"text": prompt},
|
||||||
|
{
|
||||||
|
"inline_data": {
|
||||||
|
"mime_type": image.content_type,
|
||||||
|
"data": base64.b64encode(image_data).decode("ascii"),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}],
|
||||||
|
"generationConfig": config,
|
||||||
|
}
|
||||||
|
|
||||||
|
url = f"{GEMINI_API_URL}/{GEMINI_MODEL}:generateContent"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=180) as client:
|
||||||
|
response = await client.post(
|
||||||
|
url,
|
||||||
|
params={"key": api_key},
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise HTTPException(status_code=503, detail="Gemini unavailable") from exc
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
try:
|
||||||
|
provider_error = response.json().get("error", {}).get("message")
|
||||||
|
except ValueError:
|
||||||
|
provider_error = None
|
||||||
|
detail = provider_error or "Gemini request failed"
|
||||||
|
raise HTTPException(status_code=502, detail=detail)
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
try:
|
||||||
|
candidate = data["candidates"][0]
|
||||||
|
text = candidate["content"]["parts"][0]["text"]
|
||||||
|
except (KeyError, IndexError, TypeError) as exc:
|
||||||
|
raise HTTPException(status_code=502, detail="Invalid Gemini response") from exc
|
||||||
|
|
||||||
|
return {"text": text, "usage": data.get("usageMetadata", {})}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Minimal Gemini image proxy
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=gemini-proxy
|
||||||
|
Group=gemini-proxy
|
||||||
|
WorkingDirectory=/opt/gemini-proxy
|
||||||
|
EnvironmentFile=/etc/gemini-proxy/gemini-proxy.env
|
||||||
|
ExecStart=/opt/gemini-proxy/venv/bin/uvicorn app:app --host 127.0.0.1 --port 8768
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=/run
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi>=0.115,<1
|
||||||
|
httpx>=0.27,<1
|
||||||
|
python-multipart>=0.0.9,<1
|
||||||
|
uvicorn[standard]>=0.30,<1
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app import app
|
||||||
|
|
||||||
|
|
||||||
|
def test_health() -> None:
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/health")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_unsupported_type() -> None:
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.post(
|
||||||
|
"/gemini",
|
||||||
|
files={"image": ("input.txt", b"not-an-image", "text/plain")},
|
||||||
|
data={"prompt": "test", "generation_config": "{}"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 415
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_key_returns_service_unavailable(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": "{}"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 503
|
||||||
Reference in New Issue
Block a user