diff --git a/HISTORY/2026-08-28-obdai-receipt.md b/HISTORY/2026-08-28-obdai-receipt.md new file mode 100644 index 0000000..879029d --- /dev/null +++ b/HISTORY/2026-08-28-obdai-receipt.md @@ -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. diff --git a/PLAN/lekar.png b/PLAN/lekar.png new file mode 100644 index 0000000..d119ed2 Binary files /dev/null and b/PLAN/lekar.png differ diff --git a/PLAN/lekar1.png b/PLAN/lekar1.png new file mode 100644 index 0000000..ea2751f Binary files /dev/null and b/PLAN/lekar1.png differ diff --git a/PLAN/lekar3.png b/PLAN/lekar3.png new file mode 100644 index 0000000..08c9e2b Binary files /dev/null and b/PLAN/lekar3.png differ diff --git a/PLAN/receipt-service-plan.md b/PLAN/receipt-service-plan.md new file mode 100644 index 0000000..f5d2c57 --- /dev/null +++ b/PLAN/receipt-service-plan.md @@ -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 обезличенных рецептах. diff --git a/gemini_proxy/.env.example b/gemini_proxy/.env.example new file mode 100644 index 0000000..b968905 --- /dev/null +++ b/gemini_proxy/.env.example @@ -0,0 +1,2 @@ +GEMINI_API_KEY=replace-with-secret +GEMINI_MODEL=gemini-3.6-flash \ No newline at end of file diff --git a/gemini_proxy/README.md b/gemini_proxy/README.md new file mode 100644 index 0000000..f0f8704 --- /dev/null +++ b/gemini_proxy/README.md @@ -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 +доступен только локально на немецкой ВМ. \ No newline at end of file diff --git a/gemini_proxy/app.py b/gemini_proxy/app.py new file mode 100644 index 0000000..bed5ff5 --- /dev/null +++ b/gemini_proxy/app.py @@ -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", {})} \ No newline at end of file diff --git a/gemini_proxy/gemini-proxy.service b/gemini_proxy/gemini-proxy.service new file mode 100644 index 0000000..555a4f1 --- /dev/null +++ b/gemini_proxy/gemini-proxy.service @@ -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 \ No newline at end of file diff --git a/gemini_proxy/requirements.txt b/gemini_proxy/requirements.txt new file mode 100644 index 0000000..7ce09ad --- /dev/null +++ b/gemini_proxy/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.115,<1 +httpx>=0.27,<1 +python-multipart>=0.0.9,<1 +uvicorn[standard]>=0.30,<1 \ No newline at end of file diff --git a/gemini_proxy/test_app.py b/gemini_proxy/test_app.py new file mode 100644 index 0000000..76bd003 --- /dev/null +++ b/gemini_proxy/test_app.py @@ -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 \ No newline at end of file