diff --git a/HISTORY/2026-08-28-obdai-receipt.md b/HISTORY/2026-08-28-obdai-receipt.md index 1ccd29e..b2675d9 100644 --- a/HISTORY/2026-08-28-obdai-receipt.md +++ b/HISTORY/2026-08-28-obdai-receipt.md @@ -560,3 +560,36 @@ SSH-ключ с локальной машины передан на ВМ 213 в `usage`; авторизованный POST `/recipe/` HTTP 200 с непустыми `text` и `usage`; redirect отсутствует. `gemini-tunnel.service`, `recipe.service` и `elmer.service` имеют статус active. `elmer.service` не перезапускался. + +## Проверка хранения изображений + +В задеплоенном `recipe_service/app.py` изображение читается через +`image.read()` в память и передаётся proxy через `requests.post(files=...)`. +Операций записи изображения на диск в коде нет; постоянное хранилище для +изображений не используется. `recipe.service` active. + +В `/tmp` ВМ 213 обнаружены файлы от предыдущих ручных диагностических +запросов, включая `lekar1.png` и JSON-ответы. Они не создаются рабочим +pipeline автоматически и требуют отдельного разрешения на удаление. Это +отдельный остаток тестовых команд, а не постоянное хранилище приложения. + +## SQLite-статистика запросов + +По команде пользователя добавлена SQLite-база статистики на ВМ 213: +`/var/lib/recipe/metrics.sqlite3`. Хранятся request ID, время, полный IP, +User-Agent, method/path, MIME и размер изображения, длина prompt, HTTP-статус, +длительность, размер ответа, Gemini usage и безопасное описание ошибки. +Изображение, prompt, распознанный текст, Bearer-токен и Gemini key в базу не +записываются. + +Первый запуск после добавления статистики выявил подтверждённую ошибку +`sqlite3.OperationalError: attempt to write a readonly database`: файл базы +был `root:root` с правами `644`, а unit использовал `ProtectSystem=strict`. +Файл переведён во владение `recipe:recipe` с правами `0660`, в unit добавлены +`StateDirectory=recipe` и `StateDirectoryMode=0770`. + +После исправления `recipe.service` active. Таблица `requests` создана +автоматически. Проверка записала 401 и успешный OCR: последняя строка содержит +IP `127.0.0.1`, MIME `image/png`, размер 531101 байт, длину prompt 88 и +непустой usage JSON. Тестовый image-файл удалён после запроса; приложение +читает изображение в память и не пишет его на диск. diff --git a/recipe_service/app.py b/recipe_service/app.py index 4b70c9e..924802b 100644 --- a/recipe_service/app.py +++ b/recipe_service/app.py @@ -4,12 +4,19 @@ import os import requests from flask import Flask, jsonify, request +try: + from recipe_service.metrics import initialize, record, request_context, usage_json +except ModuleNotFoundError: + from metrics import initialize, record, request_context, usage_json + app = Flask(__name__) MAX_IMAGE_BYTES = 10 * 1024 * 1024 ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"} PROXY_URL = "http://127.0.0.1:8768/gemini" +initialize() + def settings() -> dict: try: @@ -35,17 +42,58 @@ def health(): @app.post("/recipe") @app.post("/recipe/") def recipe(): + request_id, started_at, started_monotonic = request_context() + image_mime = None + image_bytes = None + prompt_chars = None + status_code = 500 + response_bytes = None + usage = {} + error = None if not authorized(): - return jsonify(error="unauthorized"), 401 + status_code = 401 + error = "unauthorized" + response = jsonify(error=error) + record(request_id=request_id, started_at=started_at, client_ip=request.remote_addr, + user_agent=request.user_agent.string, method=request.method, path=request.path, + image_mime=image_mime, image_bytes=image_bytes, prompt_chars=prompt_chars, + status_code=status_code, duration_ms=int((__import__('time').monotonic() - started_monotonic) * 1000), + response_bytes=len(response.get_data()), usage_json=usage_json(usage), error=error) + return response, status_code 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 + status_code = 400 + error = "image and prompt are required" + response = jsonify(error=error) + record(request_id=request_id, started_at=started_at, client_ip=request.remote_addr, + user_agent=request.user_agent.string, method=request.method, path=request.path, + status_code=status_code, duration_ms=0, response_bytes=len(response.get_data()), + usage_json=usage_json(usage), error=error) + return response, status_code if image.mimetype not in ALLOWED_TYPES: - return jsonify(error="unsupported image type"), 415 + status_code = 415 + error = "unsupported image type" + response = jsonify(error=error) + record(request_id=request_id, started_at=started_at, client_ip=request.remote_addr, + user_agent=request.user_agent.string, method=request.method, path=request.path, + image_mime=image.mimetype, prompt_chars=len(prompt), status_code=status_code, + duration_ms=0, response_bytes=len(response.get_data()), usage_json=usage_json(usage), error=error) + return response, status_code image_data = image.read(MAX_IMAGE_BYTES + 1) if len(image_data) > MAX_IMAGE_BYTES: - return jsonify(error="image is too large"), 413 + status_code = 413 + error = "image is too large" + response = jsonify(error=error) + record(request_id=request_id, started_at=started_at, client_ip=request.remote_addr, + user_agent=request.user_agent.string, method=request.method, path=request.path, + image_mime=image.mimetype, image_bytes=len(image_data), prompt_chars=len(prompt), + status_code=status_code, duration_ms=0, response_bytes=len(response.get_data()), + usage_json=usage_json(usage), error=error) + return response, status_code + image_mime = image.mimetype + image_bytes = len(image_data) + prompt_chars = len(prompt) try: config = settings() response = requests.post( @@ -55,15 +103,35 @@ def recipe(): timeout=180, ) except (requests.RequestException, RuntimeError) as exc: - return jsonify(error=str(exc) if isinstance(exc, RuntimeError) else "Gemini unavailable"), 503 + error = str(exc) if isinstance(exc, RuntimeError) else "Gemini unavailable" + status_code = 503 + response = jsonify(error=error) + record(request_id=request_id, started_at=started_at, client_ip=request.remote_addr, + user_agent=request.user_agent.string, method=request.method, path=request.path, + image_mime=image_mime, image_bytes=image_bytes, prompt_chars=prompt_chars, + status_code=status_code, duration_ms=0, response_bytes=len(response.get_data()), + usage_json=usage_json(usage), error=error) + return response, status_code 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 + error = detail + status_code = 502 + response = jsonify(error=error) + record(request_id=request_id, started_at=started_at, client_ip=request.remote_addr, + user_agent=request.user_agent.string, method=request.method, path=request.path, + image_mime=image_mime, image_bytes=image_bytes, prompt_chars=prompt_chars, + status_code=status_code, duration_ms=0, response_bytes=len(response.get_data()), + usage_json=usage_json(usage), error=error) + return response, status_code data = response.json() - return jsonify( - text=data.get("text"), - usage=data.get("usage", {}), - ) \ No newline at end of file + usage = data.get("usage", {}) + result = jsonify(text=data.get("text"), usage=usage) + record(request_id=request_id, started_at=started_at, client_ip=request.remote_addr, + user_agent=request.user_agent.string, method=request.method, path=request.path, + image_mime=image_mime, image_bytes=image_bytes, prompt_chars=prompt_chars, + status_code=200, duration_ms=0, response_bytes=len(result.get_data()), + usage_json=usage_json(usage), error=None) + return result \ No newline at end of file diff --git a/recipe_service/metrics.py b/recipe_service/metrics.py new file mode 100644 index 0000000..23eef41 --- /dev/null +++ b/recipe_service/metrics.py @@ -0,0 +1,62 @@ +import json +import os +import sqlite3 +import time +import uuid + + +DEFAULT_DB_PATH = "/var/lib/recipe/metrics.sqlite3" + + +def db_path() -> str: + return os.environ.get("RECIPE_METRICS_DB", DEFAULT_DB_PATH) + + +def initialize() -> None: + path = db_path() + os.makedirs(os.path.dirname(path), exist_ok=True) + with sqlite3.connect(path) as connection: + connection.execute("PRAGMA journal_mode=WAL") + connection.execute(""" + CREATE TABLE IF NOT EXISTS requests ( + request_id TEXT PRIMARY KEY, + started_at TEXT NOT NULL, + client_ip TEXT, + user_agent TEXT, + method TEXT NOT NULL, + path TEXT NOT NULL, + image_mime TEXT, + image_bytes INTEGER, + prompt_chars INTEGER, + status_code INTEGER NOT NULL, + duration_ms INTEGER NOT NULL, + response_bytes INTEGER, + usage_json TEXT, + error TEXT + ) + """) + connection.execute("CREATE INDEX IF NOT EXISTS idx_requests_started_at ON requests(started_at)") + connection.execute("CREATE INDEX IF NOT EXISTS idx_requests_status_code ON requests(status_code)") + + +def record(**values) -> None: + initialize() + columns = [ + "request_id", "started_at", "client_ip", "user_agent", "method", + "path", "image_mime", "image_bytes", "prompt_chars", "status_code", + "duration_ms", "response_bytes", "usage_json", "error", + ] + payload = [values.get(column) for column in columns] + with sqlite3.connect(db_path()) as connection: + connection.execute( + f"INSERT INTO requests ({','.join(columns)}) VALUES ({','.join('?' for _ in columns)})", + payload, + ) + + +def request_context() -> tuple[str, str, float]: + return str(uuid.uuid4()), time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), time.monotonic() + + +def usage_json(usage: dict) -> str: + return json.dumps(usage, ensure_ascii=True, separators=(",", ":")) \ No newline at end of file diff --git a/recipe_service/recipe.service b/recipe_service/recipe.service index c150623..2182319 100644 --- a/recipe_service/recipe.service +++ b/recipe_service/recipe.service @@ -17,6 +17,8 @@ NoNewPrivileges=true PrivateTmp=true ProtectSystem=strict ProtectHome=true +StateDirectory=recipe +StateDirectoryMode=0770 [Install] WantedBy=multi-user.target \ No newline at end of file