Files
recipe/HISTORY/2026-08-28-obdai-receipt.md
T

442 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# История: 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.