Files
sber-med-server/fpg_connect.py
T
apuc 5331730433 Поддержка хостов с путём (domain.com/v1/giga) в конфигурации
- split_host_path(): разделяет host/path на (scheme, hostname, base_path),
  поддерживает варианты без схемы и с ней
- REST_BASE собирается с учётом пути
- WS: hostname и base_path разделяются, WS_PATH_TEMPLATE включает base_path
- FPG_WS_PORT теперь настраивается через env (было захардкожено 8443)
- fpg_connect.py: варианты URI переписаны на WS_HOSTNAME/WS_BASE_PATH
2026-09-17 15:22:06 +03:00

472 lines
20 KiB
Python
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.
#!/usr/bin/env python3
"""
Скрипт для подключения к SDK ФПГ (GigaDoc) и тестирования всех этапов.
Этапы:
1. Авторизация (получение access token)
2. Создание сессии
3. Подключение к WebSocket
4. Отправка изображений
5. Получение результатов
6. Удаление сессии
Логирование ведётся в файл fpg_test.log
"""
import argparse
import asyncio
import glob
import json
import logging
import os
import sys
import time
from datetime import datetime
from pathlib import Path
import requests
# ─── Конфигурация ─────────────────────────────────────────────
# ВАЖНО: хост может содержать не только домен, но и путь
# (например "domain.com/v1/giga") — это учитывается при сборке URL.
OAUTH_URL = os.environ.get("FPG_OAUTH_URL", "https://oauth.sbermed.ai/token")
CLIENT_ID = os.environ.get("FPG_CLIENT_ID", "client_fpg_front_dnr")
CLIENT_SECRET = os.environ.get("FPG_CLIENT_SECRET", "")
def split_host_path(host: str) -> tuple:
"""Разделить 'host/path' на (scheme, hostname, base_path)."""
host = host.strip().rstrip("/")
scheme = ""
if "://" in host:
scheme, host = host.split("://", 1)
parts = host.split("/", 1)
hostname = parts[0]
base_path = "/" + parts[1] if len(parts) > 1 else ""
return scheme, hostname, base_path
REST_HOST = os.environ.get("FPG_REST_HOST", "gigadoc-dnr-sdk.sbermed.ai")
_rest_scheme, _rest_hostname, _rest_base_path = split_host_path(REST_HOST)
REST_BASE = f"{_rest_scheme or 'https'}://{_rest_hostname}{_rest_base_path}"
# WebSocket (рабочий хост подтверждён: gigadoc-dnr-sdk.sbermed.ai:8443)
WS_HOST = os.environ.get("FPG_WS_HOST", "gigadoc-dnr-sdk.sbermed.ai")
WS_PORT = int(os.environ.get("FPG_WS_PORT", "8443"))
_ws_scheme, WS_HOSTNAME, WS_BASE_PATH = split_host_path(WS_HOST)
WS_PATH_TEMPLATE = f"{WS_BASE_PATH}/api/v2/ws/predict/{{uid}}/image"
WS_ORIGIN = os.environ.get("FPG_WS_ORIGIN", "https://gigadoc-dnr-sdk.sbermed.ai")
DEVICE_ID = "web_prod"
FORM_FACTOR = "web_prod"
LOG_FILE = "fpg_test.log"
# ─── Логирование ──────────────────────────────────────────────
def setup_logging(log_file: str = LOG_FILE):
"""Настроить логирование в файл и консоль."""
logger = logging.getLogger("fpg")
logger.setLevel(logging.DEBUG)
# Формат
formatter = logging.Formatter(
"%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
# Файловый хендлер
fh = logging.FileHandler(log_file, encoding="utf-8")
fh.setLevel(logging.DEBUG)
fh.setFormatter(formatter)
logger.addHandler(fh)
# Консольный хендлер
ch = logging.StreamHandler(sys.stdout)
ch.setLevel(logging.INFO)
ch.setFormatter(formatter)
logger.addHandler(ch)
# Включаем логирование библиотек (requests, websockets, urllib3)
logging.getLogger("urllib3").setLevel(logging.DEBUG)
logging.getLogger("urllib3").addHandler(fh)
logging.getLogger("websockets").setLevel(logging.DEBUG)
logging.getLogger("websockets").addHandler(fh)
logging.getLogger("asyncio").setLevel(logging.DEBUG)
logging.getLogger("asyncio").addHandler(fh)
return logger
logger = logging.getLogger("fpg")
def log_headers(headers: dict, prefix: str = " "):
"""Залогировать HTTP-заголовки (маскируя секреты)."""
for k, v in headers.items():
if k.lower() in ("authorization", "client_secret", "cookie", "set-cookie"):
# Маскируем секретные заголовки
if isinstance(v, str) and len(v) > 20:
v = v[:20] + "..." + v[-5:]
else:
v = "***"
logger.debug(f"{prefix}{k}: {v}")
def log_response(resp, prefix: str = " "):
"""Залогировать HTTP-ответ подробно."""
logger.debug(f"{prefix}HTTP {resp.status_code} {resp.reason}")
logger.debug(f"{prefix}Headers:")
log_headers(dict(resp.headers), prefix + " ")
body = resp.text
if len(body) > 2000:
logger.debug(f"{prefix}Body ({len(body)} bytes): {body[:2000]}...")
else:
logger.debug(f"{prefix}Body: {body}")
# ─── Этап 1: Авторизация ──────────────────────────────────────
def get_token() -> str:
"""Получить access token через client_credentials."""
logger.info("=" * 60)
logger.info("ЭТАП 1: Авторизация")
logger.info("=" * 60)
logger.info(f"POST {OAUTH_URL}")
logger.debug(" Запрос:")
logger.debug(f" grant_type=client_credentials")
logger.debug(f" client_id={CLIENT_ID}")
logger.debug(f" client_secret={'*' * len(CLIENT_SECRET)}")
try:
resp = requests.post(
OAUTH_URL,
data={
"grant_type": "client_credentials",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
},
timeout=15,
)
logger.info(f"HTTP {resp.status_code}")
logger.debug(" Ответ:")
log_response(resp, " ")
resp.raise_for_status()
data = resp.json()
token = data["access_token"]
expires_in = data.get("expires_in", "?")
logger.info(f"✅ Токен получен (действует {expires_in} сек)")
logger.debug(f" token_type: {data.get('token_type')}")
logger.debug(f" scope: {data.get('scope')}")
logger.debug(f" refresh_expires_in: {data.get('refresh_expires_in')}")
logger.debug(f" not-before-policy: {data.get('not-before-policy')}")
logger.debug(f" access_token: {token[:80]}...")
logger.debug(f" access_token (полный, {len(token)} chars): {token}")
return token
except requests.exceptions.RequestException as e:
logger.error(f"❌ Ошибка авторизации: {e}")
if hasattr(e, "response") and e.response is not None:
logger.error(f" Статус: {e.response.status_code}")
logger.error(f" Ответ: {e.response.text}")
raise
# ─── Этап 2: Создание сессии ──────────────────────────────────
def create_session(token: str) -> str:
"""Создать сессию и вернуть uid."""
logger.info("=" * 60)
logger.info("ЭТАП 2: Создание сессии")
logger.info("=" * 60)
url = f"{REST_BASE}/api/v1/predict/new"
logger.info(f"POST {url}")
logger.debug(" Запрос:")
logger.debug(f" device_id={DEVICE_ID}")
logger.debug(f" form_factor={FORM_FACTOR}")
logger.debug(f" Authorization: Bearer {token[:30]}...")
try:
resp = requests.post(
url,
params={"device_id": DEVICE_ID, "form_factor": FORM_FACTOR},
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json",
},
timeout=15,
)
logger.info(f"HTTP {resp.status_code}")
logger.debug(" Ответ:")
log_response(resp, " ")
resp.raise_for_status()
uid = resp.json()["uid"]
logger.info(f"✅ Сессия создана: {uid}")
return uid
except requests.exceptions.RequestException as e:
logger.error(f"❌ Ошибка создания сессии: {e}")
if hasattr(e, "response") and e.response is not None:
logger.error(f" Статус: {e.response.status_code}")
logger.error(f" Ответ: {e.response.text}")
raise
# ─── Этап 3: WebSocket ────────────────────────────────────────
async def ws_scan(token: str, uid: str, ws_host: str, ws_port: int, image_dir: str):
"""Подключиться по WebSocket, отправить изображения, получить результат."""
import socket
import ssl
import websockets
logger.info("=" * 60)
logger.info("ЭТАП 3: WebSocket подключение")
logger.info("=" * 60)
# ── DNS резолв ──
logger.info(f"DNS резолв: {ws_host}")
try:
ips = socket.getaddrinfo(ws_host, ws_port)
for addr in ips:
logger.debug(f" {addr[0].name} -> {addr[4]}")
logger.info(f"✅ DNS резолв успешен: {len(ips)} записей")
except socket.gaierror as e:
logger.warning(f"⚠️ DNS резолв не удался: {e}")
logger.warning(f" Хост {ws_host} не найден. Пробуем другие варианты...")
# ── Собираем изображения ──
patterns = ["*.jpg", "*.jpeg", "*.png"]
images = []
for p in patterns:
images.extend(glob.glob(os.path.join(image_dir, p)))
images.sort()
if not images:
logger.warning(f"Нет изображений в {image_dir}, использую тестовое")
images = ["/tmp/test_face.jpg"]
if not os.path.exists(images[0]):
logger.error("Тестовое изображение не найдено")
return None
logger.info(f"Найдено изображений: {len(images)}")
for img in images:
size = os.path.getsize(img)
logger.debug(f" {img} ({size} bytes)")
# ── Пробуем несколько вариантов URI ──
# ws_host может быть с путём (domain.com/v1/giga) — разделяем.
_scheme, _h, _p = split_host_path(ws_host)
_ws_hostname = _h
_ws_path = _p or WS_BASE_PATH
uris = [
f"wss://{_ws_hostname}:{ws_port}{_ws_path}/api/v2/ws/predict/{uid}/image",
f"wss://{WS_HOSTNAME}:{ws_port}{WS_PATH_TEMPLATE.format(uid=uid)}",
f"wss://{WS_HOSTNAME}:443{WS_PATH_TEMPLATE.format(uid=uid)}",
]
last_error = None
for uri in uris:
logger.info(f"Попытка подключения: {uri}")
logger.info(" ── Заголовки WebSocket-запроса ──")
logger.info(f" GET {WS_PATH_TEMPLATE.format(uid=uid)} HTTP/1.1")
logger.info(f" Host: {ws_host}")
logger.info(f" Upgrade: websocket")
logger.info(f" Connection: Upgrade")
logger.info(f" Sec-WebSocket-Version: 13")
logger.info(f" Sec-WebSocket-Protocol: bearer.<TOKEN> (авторизация через subprotocol)")
logger.info(f" Origin: https://gigadoc.sbermed.ai")
logger.info(f" (полный токен: {token})")
logger.info(" ────────────────────────────────")
try:
async with websockets.connect(
uri,
open_timeout=10,
subprotocols=[f"bearer.{token}"],
origin=WS_ORIGIN,
extra_headers={
"Cache-Control": "no-cache",
"Pragma": "no-cache",
},
compression="deflate",
ping_interval=20,
ping_timeout=20,
max_size=None,
) as ws:
logger.info(f"✅ WebSocket подключен: {uri}")
logger.debug(f" Протокол: {ws.subprotocol}")
logger.debug(f" Расширения: {ws.extensions if hasattr(ws, 'extensions') else 'n/a'}")
# Отправляем изображения
logger.info("=" * 60)
logger.info("ЭТАП 4: Отправка изображений")
logger.info("=" * 60)
result = None
for i, img_path in enumerate(images):
with open(img_path, "rb") as f:
img_data = f.read()
logger.info(f" [{i+1}/{len(images)}] WS → отправка изображения: {img_path} ({len(img_data)} bytes, binary)")
t_start = time.time()
await ws.send(img_data)
t_send = time.time() - t_start
logger.debug(f" Отправлено за {t_send*1000:.1f} мс")
# Читаем ответ
try:
resp = await asyncio.wait_for(ws.recv(), timeout=5)
t_recv = time.time() - t_start
logger.debug(f" Ответ получен за {t_recv*1000:.1f} мс")
try:
data = json.loads(resp)
progress = data.get("progress", "?")
face_ok = data.get("face_ok", "?")
# Логируем прогресс
if i % 10 == 0 or (isinstance(progress, (int, float)) and progress >= 1.0):
logger.info(f" [{i+1}/{len(images)}] progress={progress} face_ok={face_ok}")
# Логируем полный ответ на DEBUG
logger.debug(f" Полный ответ: {json.dumps(data, ensure_ascii=False)}")
# Финальный результат
if isinstance(progress, (int, float)) and progress >= 1.0:
logger.info("=" * 60)
logger.info("ЭТАП 5: ФИНАЛЬНЫЙ РЕЗУЛЬТАТ")
logger.info("=" * 60)
logger.info(json.dumps(data, indent=2, ensure_ascii=False))
result = data
break
except json.JSONDecodeError:
logger.debug(f" Ответ не JSON: {resp[:200]}")
except asyncio.TimeoutError:
logger.debug(f" [{i+1}/{len(images)}] Таймаут ожидания ответа (5 сек)")
if result is None:
logger.warning("Финальный результат не получен (прогресс не достиг 1.0)")
return result
except Exception as e:
last_error = e
logger.warning(f"❌ Не удалось подключиться к {uri}: {type(e).__name__}: {e}")
logger.debug(f" Детали ошибки: {e}")
logger.error(f"❌ Все варианты WebSocket подключения не удались")
logger.error(f" Последняя ошибка: {type(last_error).__name__}: {last_error}")
return None
# ─── Этап 6: Удаление сессии ──────────────────────────────────
def delete_session(token: str, uid: str):
"""Удалить сессию."""
logger.info("=" * 60)
logger.info("ЭТАП 6: Удаление сессии")
logger.info("=" * 60)
url = f"{REST_BASE}/api/v1/predict/{uid}"
logger.info(f"DELETE {url}")
logger.debug(f" Authorization: Bearer {token[:30]}...")
try:
resp = requests.delete(
url,
headers={"Authorization": f"Bearer {token}"},
timeout=15,
)
logger.info(f"HTTP {resp.status_code}")
logger.debug(" Ответ:")
log_response(resp, " ")
if resp.status_code == 200:
logger.info(f"✅ Сессия {uid} удалена")
else:
logger.warning(f"⚠️ Сессия {uid} не удалена: {resp.status_code} {resp.text}")
except requests.exceptions.RequestException as e:
logger.error(f"❌ Ошибка удаления сессии: {e}")
if hasattr(e, "response") and e.response is not None:
logger.error(f" Статус: {e.response.status_code}")
logger.error(f" Ответ: {e.response.text}")
# ─── Создание тестового изображения ──────────────────────────
def create_test_image():
"""Создать тестовое JPEG-изображение если нет своих."""
try:
from PIL import Image
img = Image.new("RGB", (1280, 720), color=(128, 128, 128))
img.save("/tmp/test_face.jpg", "JPEG", quality=80)
logger.info("Создано тестовое изображение /tmp/test_face.jpg")
except ImportError:
logger.warning("PIL не установлен, тестовое изображение не создано")
# ─── Main ─────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="Тест SDK ФПГ (GigaDoc)")
parser.add_argument("--ws-host", default=WS_HOST, help=f"WebSocket host (default: {WS_HOST})")
parser.add_argument("--ws-port", type=int, default=WS_PORT, help=f"WebSocket port (default: {WS_PORT})")
parser.add_argument("--image-dir", default=".", help="Директория с JPEG-файлами (default: .)")
parser.add_argument("--log-file", default=LOG_FILE, help=f"Файл лога (default: {LOG_FILE})")
parser.add_argument("--rest-only", action="store_true", help="Только REST API (без WebSocket)")
args = parser.parse_args()
# Настройка логирования
setup_logging(args.log_file)
logger.info("=" * 60)
logger.info("🏥 Тестирование SDK ФПГ (GigaDoc)")
logger.info(f"Дата: {datetime.now().isoformat()}")
logger.info(f"REST: {REST_BASE}")
logger.info(f"WS: {args.ws_host}:{args.ws_port}")
logger.info("=" * 60)
# 0. Тестовое изображение
create_test_image()
# 1. Авторизация
try:
token = get_token()
except Exception:
logger.error("Тест прерван: не удалось авторизоваться")
return 1
# 2. Создание сессии
try:
uid = create_session(token)
except Exception:
logger.error("Тест прерван: не удалось создать сессию")
return 1
# 3-5. WebSocket
result = None
if not args.rest_only:
try:
result = asyncio.run(
ws_scan(token, uid, args.ws_host, args.ws_port, args.image_dir)
)
except Exception as e:
logger.error(f"Ошибка WebSocket: {type(e).__name__}: {e}")
else:
logger.info("WebSocket тест пропущен (--rest-only)")
# 6. Удаление сессии
delete_session(token, uid)
# Итог
logger.info("=" * 60)
if result:
logger.info("✅ Тест завершён успешно — результат получен")
else:
logger.info("⚠️ REST API работает, WebSocket недоступен")
logger.info(f"Лог сохранён в: {args.log_file}")
logger.info("=" * 60)
return 0 if result else 2
if __name__ == "__main__":
sys.exit(main())