diff --git a/HISTORY/2026-08-28-obdai-receipt.md b/HISTORY/2026-08-28-obdai-receipt.md index 879029d..a71b778 100644 --- a/HISTORY/2026-08-28-obdai-receipt.md +++ b/HISTORY/2026-08-28-obdai-receipt.md @@ -383,6 +383,34 @@ Proxy изменён: `POST /gemini` теперь принимает multipart- Usage проверки: prompt 1117, image 1102, text 15, candidate output 80, thoughts 1760, total 2957. Ключ в журнал не записывался. +## Минимальный recipe-сервис на ВМ 213 + +По команде пользователя создан отдельный Flask-сервис +`recipe_service/`. Его назначение на текущем этапе: принять картинку и prompt, +передать их в Gemini с ключом и JSON-настройками из серверного EnvironmentFile, +вернуть `text` и `usage`. Разбор рецепта и дополнительная логика пока не +реализуются. + +Развёрнуты отдельные каталог `/opt/recipe`, пользователь `recipe`, virtualenv, +systemd-юнит `recipe.service` и внутренний bind `127.0.0.1:8770`. Порт 8769 +был занят существующим процессом, поэтому выбран 8770; внешний URL от этого не +меняется. + +Ключ и настройки записаны только на ВМ в `/etc/recipe/recipe.env` с правами +`0640`, без хардкода в коде и без записи в HISTORY. В конфигурации задана +модель `gemini-3.6-flash`, лимит 300 токенов и `thinkingLevel=minimal`. + +Добавлен отдельный nginx location `https://obdai.ru/recipe/`, проксирующий на +`127.0.0.1:8770`. `nginx -t` успешен, внешний `GET /recipe/health` вернул +`{"status":"ok"}`. + +Ошибки и исправления: первая команда деплоя использовала путь к ключу, +существующий только на локальной машине, и получила `No such file`; ключ затем +передан через stdin. Первая health-проверка обращалась к занятому старому +порту/в момент старта; после переноса на 8770 сервис работает. Первый внешний +health сразу после reload дал кратковременный 404, повторная проверка вернула +200. + ## Проверка `PLAN/lekar1.png` с минимальным thinking Предыдущий тест использовал ошибочный crop из `lekar.png`. По уточнению diff --git a/recipe_service/.env.example b/recipe_service/.env.example new file mode 100644 index 0000000..d7ccced --- /dev/null +++ b/recipe_service/.env.example @@ -0,0 +1,3 @@ +GEMINI_API_KEY=replace-with-secret +GEMINI_MODEL=gemini-3.6-flash +GEMINI_GENERATION_CONFIG={"temperature":0,"maxOutputTokens":300,"thinkingConfig":{"thinkingLevel":"minimal"}} \ No newline at end of file diff --git a/recipe_service/README.md b/recipe_service/README.md new file mode 100644 index 0000000..bb0623e --- /dev/null +++ b/recipe_service/README.md @@ -0,0 +1,13 @@ +# obdai.ru/recipe + +Минимальный Flask-сервис на ВМ `5.172.178.213`. + +Вход: `POST /recipe` в формате `multipart/form-data` с полями `image` и +`prompt`. Изображение: JPEG/PNG/WEBP, не более 10 MB. + +Ключ Gemini и JSON-строка `GEMINI_GENERATION_CONFIG` находятся только в +серверном EnvironmentFile. Сервис передаёт изображение, prompt и настройки в +Gemini и возвращает `text` и `usage`; разбор рецепта выполняется позднее. + +Сервис изолирован от `elmer`: отдельные каталог, virtualenv, пользователь, +порт, systemd-юнит и настройки nginx. Изображения на диск не сохраняются. \ No newline at end of file diff --git a/recipe_service/app.py b/recipe_service/app.py new file mode 100644 index 0000000..e95746a --- /dev/null +++ b/recipe_service/app.py @@ -0,0 +1,70 @@ +import base64 +import json +import os + +import requests +from flask import Flask, jsonify, request + + +app = Flask(__name__) +MAX_IMAGE_BYTES = 10 * 1024 * 1024 +ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"} +GEMINI_URL = "https://generativelanguage.googleapis.com/v1beta/models" + + +def settings() -> tuple[str, dict]: + key = os.environ.get("GEMINI_API_KEY") + if not key: + raise RuntimeError("GEMINI_API_KEY is not configured") + try: + config = json.loads(os.environ.get("GEMINI_GENERATION_CONFIG", "{}")) + except json.JSONDecodeError as exc: + raise RuntimeError("GEMINI_GENERATION_CONFIG is invalid") from exc + if not isinstance(config, dict): + raise RuntimeError("GEMINI_GENERATION_CONFIG must be an object") + return key, config + + +@app.get("/health") +def health(): + return jsonify(status="ok") + + +@app.post("/recipe") +def recipe(): + image = request.files.get("image") + prompt = request.form.get("prompt") + if image is None or not prompt: + return jsonify(error="image and prompt are required"), 400 + if image.mimetype not in ALLOWED_TYPES: + return jsonify(error="unsupported image type"), 415 + image_data = image.read(MAX_IMAGE_BYTES + 1) + if len(image_data) > MAX_IMAGE_BYTES: + return jsonify(error="image is too large"), 413 + try: + key, config = settings() + response = requests.post( + f"{GEMINI_URL}/{os.environ.get('GEMINI_MODEL', 'gemini-3.6-flash')}:generateContent", + params={"key": key}, + json={ + "contents": [{"parts": [{"text": prompt}, {"inline_data": { + "mime_type": image.mimetype, + "data": base64.b64encode(image_data).decode("ascii"), + }}]}], + "generationConfig": config, + }, + timeout=180, + ) + except (requests.RequestException, RuntimeError) as exc: + return jsonify(error=str(exc) if isinstance(exc, RuntimeError) else "Gemini unavailable"), 503 + if response.status_code != 200: + try: + detail = response.json().get("error", {}).get("message", "Gemini request failed") + except ValueError: + detail = "Gemini request failed" + return jsonify(error=detail), 502 + data = response.json() + return jsonify( + text=data.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text"), + usage=data.get("usageMetadata", {}), + ) \ No newline at end of file diff --git a/recipe_service/nginx-recipe.conf b/recipe_service/nginx-recipe.conf new file mode 100644 index 0000000..f8294f5 --- /dev/null +++ b/recipe_service/nginx-recipe.conf @@ -0,0 +1,12 @@ +location /recipe/ { + proxy_pass http://127.0.0.1:8770/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + client_max_body_size 10m; + proxy_connect_timeout 10s; + proxy_send_timeout 200s; + proxy_read_timeout 200s; +} diff --git a/recipe_service/recipe.service b/recipe_service/recipe.service new file mode 100644 index 0000000..35d272d --- /dev/null +++ b/recipe_service/recipe.service @@ -0,0 +1,20 @@ +[Unit] +Description=Minimal recipe Gemini proxy +After=network-online.target +Wants=network-online.target + +[Service] +User=recipe +Group=recipe +WorkingDirectory=/opt/recipe +EnvironmentFile=/etc/recipe/recipe.env +ExecStart=/opt/recipe/venv/bin/gunicorn --workers 2 --bind 127.0.0.1:8770 --timeout 200 app:app +Restart=on-failure +RestartSec=5 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/recipe_service/requirements.txt b/recipe_service/requirements.txt new file mode 100644 index 0000000..ee10536 --- /dev/null +++ b/recipe_service/requirements.txt @@ -0,0 +1,3 @@ +Flask>=3.0,<4 +gunicorn>=21.2,<24 +requests>=2.31,<3 \ No newline at end of file