# История: 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.