diff --git a/.env.example b/.env.example index 75d3255..cc6eda9 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,94 @@ -# Copy to .env and adjust. Used by local `start.sh` and Docker Compose. +# Copy to `.env` and adjust. Loaded by pydantic-settings (field names → UPPER_SNAKE_CASE env vars). +# Used by local `start.sh` and Docker Compose. See also docs/video-backends.md. +# --- PostgreSQL --- POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres POSTGRES_DB=operation_room +POSTGRES_HOST=localhost +POSTGRES_PORT=35432 -# Async SQLAlchemy URL (asyncpg). For local dev against docker-compose.dev.yml: -DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/operation_room +# Optional: full async SQLAlchemy URL (overrides POSTGRES_* when set and matches defaults logic — see Settings). +# DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:35432/operation_room + +# --- YOLO inference (internal, not exposed as HTTP) --- +# Weights default to bundled paths under app/resources/ if unset. +# CONSUMABLE_CLASSIFIER_WEIGHTS=/absolute/path/to/consumable_classifier.pt +CONSUMABLE_CLASSIFIER_IMGSZ=224 +CONSUMABLE_CLASSIFIER_DEVICE= +CONSUMABLE_CLASSIFIER_TOPK=5 +# TEAR_ACTION_WEIGHTS=/absolute/path/to/tear_action.pt +TEAR_ACTION_IMGSZ=224 +TEAR_ACTION_DEVICE= +TEAR_ACTION_TOPK=5 +# Device: empty → auto (macOS MPS if available; Linux CUDA if available). Docker image uses CPU torch unless you change it. + +# --- Surgery recording API retries --- +# SURGERY_RECORDING_MAX_ATTEMPTS=3 +# SURGERY_RECORDING_RETRY_DELAY_SECONDS=1.0 + +# --- Video: RTSP + optional Hikvision HCNetSDK (Linux x86_64 + glibc recommended) --- +# Client `camera_ids` must match keys in your RTSP map (sample IDs: or-cam-01, or-cam-02). +# +# VIDEO_DEFAULT_BACKEND=rtsp +# Values: rtsp | hikvision_sdk | auto (auto: SDK .so loaded and HIKVISION_SDK_ENABLED=true → prefer SDK) +# +# Per-camera backend override (JSON object): +# VIDEO_CAMERA_BACKEND_OVERRIDES_JSON={"or-cam-01":"rtsp","or-cam-02":"hikvision_sdk"} +# +# RTSP URL resolution (first match wins per camera_id): +# 1) VIDEO_RTSP_URLS_JSON_FILE — JSON file: {"or-cam-01":"rtsp://...","or-cam-02":"rtsp://..."} +# 2) merged with VIDEO_RTSP_URLS_JSON (inline JSON string; overrides same keys from file) +# 3) else VIDEO_RTSP_URL_TEMPLATE with {camera_id} +# Example file (committed): app/resources/camera_rtsp_urls.sample.json +# VIDEO_RTSP_URLS_JSON_FILE=app/resources/camera_rtsp_urls.sample.json +# In Docker (WORKDIR /app): VIDEO_RTSP_URLS_JSON_FILE=/app/app/resources/camera_rtsp_urls.sample.json +# WARNING: if VIDEO_RTSP_URLS_JSON_FILE is set but the path is missing, the app will fail to start. +# +# VIDEO_RTSP_URL_TEMPLATE=rtsp://user:pass@192.168.1.64:554/Streaming/Channels/101 +# VIDEO_RTSP_URLS_JSON={"or-cam-01":"rtsp://user:pass@192.168.1.101:554/Streaming/Channels/101","or-cam-02":"rtsp://user:pass@192.168.1.102:554/Streaming/Channels/101"} +# +# VIDEO_OPEN_TIMEOUT_SEC=15 +# 连续读帧失败次数达到阈值后释放 RTSP 并重连。 +# VIDEO_READ_FAILURE_RECONNECT_THRESHOLD=15 +# VIDEO_RECONNECT_BACKOFF_SECONDS=1.0 +# VIDEO_INFERENCE_INTERVAL_SEC=2 +# VIDEO_INFERENCE_CONFIDENCE_THRESHOLD=0.35 +# 置信度 >= 此值且命中候选清单时自动记账(vision)。 +# VIDEO_AUTO_CONFIRM_CONFIDENCE=0.55 +# 置信度处于 [VIDEO_VOICE_CONFIRM_MIN_CONFIDENCE, VIDEO_AUTO_CONFIRM_CONFIDENCE) 时入队待确认(客户端拉取 pending-confirmation)。 +# VIDEO_VOICE_CONFIRM_MIN_CONFIDENCE=0.35 +# 是否启用低置信度人工确认(客户端播报 + resolve 回传;服务端无麦克风/扬声器要求)。 +# VOICE_CONFIRMATION_ENABLED=true +# VIDEO_VOICE_CONFIRM_DOCTOR_ID=voice +# (已弃用)服务端本机录音 / ffmpeg 音频输入;当前闭环不依赖。 +# VOICE_RECORD_SECONDS=5 +# VOICE_FFMPEG_INPUT= +# 停录后写库失败时,后台重试落库间隔(秒)。 +# ARCHIVE_PERSIST_RETRY_INTERVAL_SECONDS=30 +# VIDEO_DETAIL_COOLDOWN_SEC=15 +# VIDEO_JPEG_QUALITY=85 +# VIDEO_RESULT_DOCTOR_ID=vision + +# --- Hikvision: mount vendor Linux x86_64 .so at runtime (do not commit proprietary binaries) --- +# HIKVISION_LIB_DIR=/opt/hikvision/lib +# Optional: single library path (overrides directory search in code) +# HIKVISION_LIB_PATH= +# HIKVISION_SDK_ENABLED=false +# HIKVISION_DEVICE_IP= +# HIKVISION_DEVICE_PORT=8000 +# HIKVISION_USER= +# HIKVISION_PASSWORD= +# HIKVISION_CHANNEL=1 +# After SDK login, OpenCV still pulls frames via RTSP; template placeholders: {ip} {user} {password} {channel} {camera_id} +# HIKVISION_PREVIEW_RTSP_TEMPLATE=rtsp://{user}:{password}@{ip}:554/Streaming/Channels/101 +# Per-camera RTSP when using SDK path (same shape as VIDEO_RTSP_URLS_JSON): +# HIKVISION_CAMERA_RTSP_URLS_JSON={"or-cam-01":"rtsp://...","or-cam-02":"rtsp://..."} +# HIKVISION_SDK_FALLBACK_TO_RTSP=true + +# --- Baidu Speech(可选,遗留;当前手术闭环由客户端完成 TTS/ASR,服务端可不配置)--- +# BAIDU_SPEECH_APP_ID= +# BAIDU_SPEECH_API_KEY= +# BAIDU_SPEECH_SECRET_KEY= +# BAIDU_SPEECH_CONNECTION_TIMEOUT_MS= +# BAIDU_SPEECH_SOCKET_TIMEOUT_MS= diff --git a/Dockerfile b/Dockerfile index 6c3a55a..8dce626 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,14 @@ FROM python:3.13-slim-bookworm +# OpenCV (pulled in by ultralytics) links against X11 client libs; slim images omit them. +RUN apt-get update && apt-get install -y --no-install-recommends \ + ffmpeg \ + libgl1 \ + libglib2.0-0 \ + libgomp1 \ + libxcb1 \ + && rm -rf /var/lib/apt/lists/* + COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv WORKDIR /app diff --git a/Ultralytics/persistent_cache.json b/Ultralytics/persistent_cache.json new file mode 100644 index 0000000..9df6e9a --- /dev/null +++ b/Ultralytics/persistent_cache.json @@ -0,0 +1,3 @@ +{ + "cpu_info": "arm" +} \ No newline at end of file diff --git a/Ultralytics/settings.json b/Ultralytics/settings.json new file mode 100644 index 0000000..d4aeea0 --- /dev/null +++ b/Ultralytics/settings.json @@ -0,0 +1,21 @@ +{ + "settings_version": "0.0.6", + "datasets_dir": "/Users/kevin/Codes/hgtk/operation-room-monitor-server/datasets", + "weights_dir": "/Users/kevin/Codes/hgtk/operation-room-monitor-server/weights", + "runs_dir": "/Users/kevin/Codes/hgtk/operation-room-monitor-server/runs", + "uuid": "d9d880555edc417ea917cbee7c9b11ddc98c4fc80cb7aa6f377c74fa8ed5b7c8", + "sync": true, + "api_key": "", + "openai_api_key": "", + "clearml": true, + "comet": true, + "dvc": true, + "hub": true, + "mlflow": true, + "neptune": true, + "raytune": true, + "tensorboard": false, + "wandb": false, + "vscode_msg": true, + "openvino_msg": true +} \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py index e69de29..8b13789 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -0,0 +1 @@ + diff --git a/app/api.py b/app/api.py new file mode 100644 index 0000000..28b6887 --- /dev/null +++ b/app/api.py @@ -0,0 +1,419 @@ +import asyncio +from collections.abc import Awaitable, Callable +from typing import Annotated + +from fastapi import APIRouter, Depends, File, HTTPException, Path, UploadFile, status +from fastapi.responses import JSONResponse +from loguru import logger +from sqlalchemy.exc import SQLAlchemyError + +from app.config import settings +from app.database import check_database +from app.dependencies import get_surgery_pipeline +from app.schemas import ( + HealthResponse, + SurgeryApiResponse, + SurgeryClientErrorResponse, + SurgeryEndRequest, + SurgeryPendingConfirmationResolveResponse, + SurgeryPendingConfirmationResponse, + SurgeryResultResponse, + SurgeryStartRequest, + SurgeryVoiceStatusResponse, + build_consumption_summary, +) +from app.services.surgery_pipeline import SurgeryPipeline +from app.surgery_errors import SurgeryPipelineError + +router = APIRouter() + + +def _raise_surgery_pipeline_http(exc: SurgeryPipelineError, surgery_id: str) -> None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail={ + "code": exc.code, + "message": exc.message, + "surgery_id": surgery_id, + }, + ) from exc + + +def _raise_confirmation_http(exc: SurgeryPipelineError, surgery_id: str) -> None: + status_map = { + "CONFIRMATION_NOT_FOUND": status.HTTP_404_NOT_FOUND, + "CONFIRMATION_NOT_ACTIVE": status.HTTP_404_NOT_FOUND, + "CONFIRMATION_ALREADY_RESOLVED": status.HTTP_409_CONFLICT, + "CONFIRMATION_INVALID": status.HTTP_422_UNPROCESSABLE_CONTENT, + "VOICE_ASR_FAILED": status.HTTP_422_UNPROCESSABLE_CONTENT, + "VOICE_PARSE_FAILED": status.HTTP_422_UNPROCESSABLE_CONTENT, + "VOICE_AUDIO_INVALID": status.HTTP_422_UNPROCESSABLE_CONTENT, + "MINIO_NOT_CONFIGURED": status.HTTP_503_SERVICE_UNAVAILABLE, + "MINIO_UPLOAD_FAILED": status.HTTP_503_SERVICE_UNAVAILABLE, + "BAIDU_NOT_CONFIGURED": status.HTTP_503_SERVICE_UNAVAILABLE, + } + st = status_map.get(exc.code, status.HTTP_500_INTERNAL_SERVER_ERROR) + raise HTTPException( + status_code=st, + detail={ + "code": exc.code, + "message": exc.message, + "surgery_id": surgery_id, + }, + ) from exc + + +async def _call_recording_with_retries( + factory: Callable[[], Awaitable[None]], + *, + max_attempts: int, + delay_seconds: float, + log_prefix: str, +) -> None: + """录制相关操作失败时按配置重试;全部失败后抛出最后一次错误(message 会附带重试说明)。""" + last_exc: SurgeryPipelineError | None = None + for attempt in range(1, max_attempts + 1): + try: + await factory() + return + except SurgeryPipelineError as exc: + last_exc = exc + if attempt < max_attempts: + logger.warning( + "{} attempt {}/{} failed ({}), retrying in {}s", + log_prefix, + attempt, + max_attempts, + exc.code, + delay_seconds, + ) + await asyncio.sleep(delay_seconds) + if last_exc is None: + return + raise SurgeryPipelineError( + last_exc.code, + f"{last_exc.message}(已重试 {max_attempts} 次仍失败)", + ) from last_exc + + +@router.get("/health", response_model=HealthResponse, tags=["health"]) +async def health() -> HealthResponse | JSONResponse: + logger.debug("Health check") + try: + await check_database() + except SQLAlchemyError as exc: + logger.warning("Health check: database unavailable: {}", exc) + return JSONResponse( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + content={"status": "degraded", "database": "unavailable"}, + ) + return HealthResponse(status="ok", database="connected") + + +@router.post( + "/client/surgeries/start", + response_model=SurgeryApiResponse, + responses={ + status.HTTP_503_SERVICE_UNAVAILABLE: { + "description": ( + "未能在确认摄像头已开始录制后完成请求;" + "录制子系统未就绪、开录未确认或发生故障。" + ), + "model": SurgeryClientErrorResponse, + }, + }, + tags=["client"], + summary="开始手术", + description=( + "开始一台手术:服务端启动关联摄像头录制。" + "仅在确认开录完成后返回 HTTP 200;否则按配置重试,仍失败则返回 503。" + ), +) +async def start_surgery( + payload: SurgeryStartRequest, + pipeline: Annotated[SurgeryPipeline, Depends(get_surgery_pipeline)], +) -> SurgeryApiResponse: + logger.info( + "Start surgery: surgery_id={}, cameras={}, candidates={}", + payload.surgery_id, + payload.camera_ids, + payload.candidate_consumables, + ) + try: + async def _start() -> None: + await pipeline.start_recording( + payload.surgery_id, + payload.camera_ids, + payload.candidate_consumables, + ) + + await _call_recording_with_retries( + _start, + max_attempts=settings.surgery_recording_max_attempts, + delay_seconds=settings.surgery_recording_retry_delay_seconds, + log_prefix=f"Start surgery {payload.surgery_id}", + ) + except SurgeryPipelineError as exc: + _raise_surgery_pipeline_http(exc, payload.surgery_id) + return SurgeryApiResponse( + surgery_id=payload.surgery_id, + status="accepted", + message="摄像头录制已开始,手术已启动。", + ) + + +@router.post( + "/client/surgeries/end", + response_model=SurgeryApiResponse, + responses={ + status.HTTP_503_SERVICE_UNAVAILABLE: { + "description": ( + "未能在确认摄像头已全部停止录制后完成请求;" + "停录未确认、录制子系统未就绪或发生故障。" + ), + "model": SurgeryClientErrorResponse, + }, + }, + tags=["client"], + summary="结束手术", + description=( + "结束一台手术:服务端停止关联摄像头录制。" + "仅在确认停录完成后返回 HTTP 200;否则按配置重试,仍失败则返回 503。" + ), +) +async def end_surgery( + payload: SurgeryEndRequest, + pipeline: Annotated[SurgeryPipeline, Depends(get_surgery_pipeline)], +) -> SurgeryApiResponse: + logger.info("End surgery: surgery_id={}", payload.surgery_id) + try: + async def _stop() -> None: + await pipeline.stop_recording(payload.surgery_id) + + await _call_recording_with_retries( + _stop, + max_attempts=settings.surgery_recording_max_attempts, + delay_seconds=settings.surgery_recording_retry_delay_seconds, + log_prefix=f"End surgery {payload.surgery_id}", + ) + except SurgeryPipelineError as exc: + _raise_surgery_pipeline_http(exc, payload.surgery_id) + return SurgeryApiResponse( + surgery_id=payload.surgery_id, + status="accepted", + message="摄像头录制已停止,手术已结束。", + ) + + +@router.get( + "/client/surgeries/{surgery_id}/result", + response_model=SurgeryResultResponse, + responses={ + status.HTTP_503_SERVICE_UNAVAILABLE: { + "description": ( + "结果尚不可查询:未同时满足「已开录」且「算法已产生可返回的实时计算结果」。" + ), + "model": SurgeryClientErrorResponse, + }, + }, + tags=["client"], + summary="查询手术结果", + description=( + "根据手术 6 位号查询该台手术的耗材消耗明细(多行)及按物品汇总。" + "手术进行中返回当前内存已记账结果;结束后返回数据库持久化结果。" + "若手术从未开始或尚无可查的最终归档,返回 503。" + "使用 GET:只读、幂等。" + ), +) +async def get_surgery_result( + surgery_id: Annotated[ + str, + Path( + min_length=6, + max_length=6, + pattern=r"^\d{6}$", + description="手术 6 位号,仅允许 6 位数字。", + ), + ], + pipeline: Annotated[SurgeryPipeline, Depends(get_surgery_pipeline)], +) -> SurgeryResultResponse: + logger.info("Query surgery result: surgery_id={}", surgery_id) + details = await pipeline.get_consumption_details_for_client(surgery_id) + if details is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail={ + "code": "RESULT_NOT_READY", + "message": ( + "当前无该手术的可查询结果:手术未开始、未成功开录或尚未产生可返回的数据。" + ), + "surgery_id": surgery_id, + }, + ) + return SurgeryResultResponse( + surgery_id=surgery_id, + status="completed", + message="查询成功。", + details=details, + summary=build_consumption_summary(details), + ) + + +@router.get( + "/client/surgeries/{surgery_id}/pending-confirmation", + response_model=SurgeryPendingConfirmationResponse, + responses={ + status.HTTP_404_NOT_FOUND: { + "description": "当前无待确认项或手术未在进行。", + "model": SurgeryClientErrorResponse, + }, + }, + tags=["client"], + summary="拉取待确认耗材", + description=( + "返回当前 FIFO 队首的一条低置信度识别。" + "客户端应播报 prompt_text 并由医生确认后调用 resolve 接口。" + "无待确认项时返回 404。" + ), +) +async def get_pending_consumable_confirmation( + surgery_id: Annotated[ + str, + Path( + min_length=6, + max_length=6, + pattern=r"^\d{6}$", + description="手术 6 位号,仅允许 6 位数字。", + ), + ], + pipeline: Annotated[SurgeryPipeline, Depends(get_surgery_pipeline)], +) -> SurgeryPendingConfirmationResponse: + payload = pipeline.get_pending_confirmation_for_client(surgery_id) + if payload is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "code": "NO_PENDING_CONFIRMATION", + "message": "当前无待确认的耗材识别,或手术未在进行。", + "surgery_id": surgery_id, + }, + ) + return payload + + +@router.post( + "/client/surgeries/{surgery_id}/pending-confirmation/{confirmation_id}/resolve", + response_model=SurgeryPendingConfirmationResolveResponse, + responses={ + status.HTTP_404_NOT_FOUND: {"model": SurgeryClientErrorResponse}, + status.HTTP_409_CONFLICT: {"model": SurgeryClientErrorResponse}, + status.HTTP_422_UNPROCESSABLE_CONTENT: {"model": SurgeryClientErrorResponse}, + status.HTTP_503_SERVICE_UNAVAILABLE: {"model": SurgeryClientErrorResponse}, + }, + tags=["client"], + summary="提交耗材确认结果(上传医生语音 WAV)", + description=( + "multipart/form-data 上传单个 WAV 文件(字段名 `audio`)。" + "服务端将音频存入 MinIO、调用百度 ASR 识别、解析候选项并完成确认。" + "记一条 source=voice 的消耗;若语音表示否认全部候选则不记消耗。" + ), +) +async def resolve_pending_consumable_confirmation( + surgery_id: Annotated[ + str, + Path( + min_length=6, + max_length=6, + pattern=r"^\d{6}$", + description="手术 6 位号,仅允许 6 位数字。", + ), + ], + confirmation_id: Annotated[str, Path(min_length=1, max_length=128)], + audio: Annotated[ + UploadFile, + File( + ..., + description="医生语音 WAV 文件(建议 16kHz 单声道 PCM,其他格式将尝试 ffmpeg 转码)", + ), + ], + pipeline: Annotated[SurgeryPipeline, Depends(get_surgery_pipeline)], +) -> SurgeryPendingConfirmationResolveResponse: + raw = await audio.read() + if not raw: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail={ + "code": "VOICE_AUDIO_INVALID", + "message": "音频文件为空。", + "surgery_id": surgery_id, + }, + ) + filename = (audio.filename or "voice.wav").strip() + if not filename.lower().endswith(".wav"): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail={ + "code": "VOICE_AUDIO_INVALID", + "message": "仅支持上传 .wav 文件。", + "surgery_id": surgery_id, + }, + ) + try: + result = await pipeline.resolve_pending_confirmation_from_audio( + surgery_id=surgery_id, + confirmation_id=confirmation_id, + wav_bytes=raw, + filename=filename, + content_type=audio.content_type, + ) + except SurgeryPipelineError as exc: + _raise_confirmation_http(exc, surgery_id) + return SurgeryPendingConfirmationResolveResponse( + surgery_id=surgery_id, + confirmation_id=confirmation_id, + status="accepted", + message=result.message, + resolved_label=result.resolved_label, + rejected=result.rejected, + asr_text=result.asr_text, + audio_object_key=result.audio_object_key, + ) + + +@router.get( + "/internal/surgeries/{surgery_id}/voice-status", + response_model=SurgeryVoiceStatusResponse, + tags=["internal"], + summary="人工确认队列状态(联调)", + description="查询指定进行中手术的待确认队列长度与最近话术摘要。手术未在进行返回 404。", +) +async def get_surgery_voice_status( + surgery_id: Annotated[ + str, + Path( + min_length=6, + max_length=6, + pattern=r"^\d{6}$", + description="手术 6 位号,仅允许 6 位数字。", + ), + ], + pipeline: Annotated[SurgeryPipeline, Depends(get_surgery_pipeline)], +) -> SurgeryVoiceStatusResponse: + payload = pipeline.voice_status(surgery_id) + if payload is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "code": "SURGERY_NOT_ACTIVE", + "message": "该手术当前不在进行中,无实时语音状态。", + "surgery_id": surgery_id, + }, + ) + return SurgeryVoiceStatusResponse( + surgery_id=surgery_id, + voice_enabled=bool(payload["voice_enabled"]), + pending_queue_approx=int(payload["pending_queue_approx"]), + last_prompt_snippet=payload.get("last_prompt_snippet"), + last_asr_text=payload.get("last_asr_text"), + last_error=payload.get("last_error"), + ) diff --git a/app/config.py b/app/config.py index 09f4781..ab9e2e1 100644 --- a/app/config.py +++ b/app/config.py @@ -1,12 +1,149 @@ +import json +from pathlib import Path +from urllib.parse import quote_plus +from typing import Any, Literal + +from pydantic import Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict +_PACKAGE_DIR = Path(__file__).resolve().parent + + +def _default_consumable_classifier_weights() -> str: + """耗材识别与分类(YOLO-cls):`app/resources/consumable_classifier.pt`。""" + return str(_PACKAGE_DIR / "resources" / "consumable_classifier.pt") + + +def _default_tear_action_weights() -> str: + """撕扯耗材动作识别:`app/resources/tear_action.pt`。""" + return str(_PACKAGE_DIR / "resources" / "tear_action.pt") + + +def _default_camera_rtsp_urls_sample_path() -> str: + """示例映射路径(可复制为自有 `camera_rtsp_urls.json` 后在环境变量中引用)。""" + return str(_PACKAGE_DIR / "resources" / "camera_rtsp_urls.sample.json") + class Settings(BaseSettings): """Application configuration loaded from environment / .env.""" - database_url: str = ( - "postgresql+asyncpg://postgres:postgres@localhost:5432/operation_room" + database_url: str | None = None + postgres_user: str = "postgres" + postgres_password: str = "postgres" + postgres_db: str = "operation_room" + postgres_host: str = "localhost" + postgres_port: int = 35432 + consumable_classifier_weights: str | None = None + consumable_classifier_imgsz: int = 224 + #: Explicit Ultralytics device (e.g. cpu, mps, cuda:0). Empty -> macOS prefers MPS; Linux prefers CUDA if available. + consumable_classifier_device: str = "" + consumable_classifier_topk: int = 5 + tear_action_weights: str | None = None + tear_action_imgsz: int = 224 + tear_action_device: str = "" + tear_action_topk: int = 5 + #: 开始/结束手术时调用录制流水线的最大尝试次数(含首次)。 + surgery_recording_max_attempts: int = Field(default=3, ge=1, le=20) + #: 两次尝试之间的等待秒数。 + surgery_recording_retry_delay_seconds: float = Field(default=1.0, ge=0.0, le=60.0) + + # --- 视频:RTSP / 海康 SDK 双后端 --- + #: 默认后端:`rtsp` | `hikvision_sdk` | `auto`(auto:SDK 动态库可用且 HIKVISION_SDK_ENABLED 时优先 SDK)。 + video_default_backend: Literal["rtsp", "hikvision_sdk", "auto"] = "rtsp" + #: 按摄像头覆盖后端,JSON 对象,例如 `{"or-cam-01":"rtsp","or-cam-02":"hikvision_sdk"}`。 + video_camera_backend_overrides_json: str = "" + #: 单 URL 模板,例如 `rtsp://user:pass@192.168.1.64:554/Streaming/Channels/101`(可用 `{camera_id}`)。 + video_rtsp_url_template: str = "" + #: 每路 RTSP 完整 URL,JSON 对象;与 `video_rtsp_urls_json_file` 合并时,**本字段覆盖同键**。 + video_rtsp_urls_json: str = "" + #: 从文件加载 camera_id -> rtsp_url(UTF-8 JSON 对象)。示例见 app/resources/camera_rtsp_urls.sample.json。 + video_rtsp_urls_json_file: str = "" + #: 打开 RTSP 并读到首帧的超时(秒)。 + video_open_timeout_sec: float = Field(default=15.0, ge=1.0, le=120.0) + #: 连续读帧失败达到该次数后释放连接并尝试重连。 + video_read_failure_reconnect_threshold: int = Field(default=15, ge=1, le=500) + #: 重连前等待秒数(亦用于 open 失败后的退避)。 + video_reconnect_backoff_seconds: float = Field(default=1.0, ge=0.1, le=60.0) + #: 推理抽帧间隔(秒)。 + video_inference_interval_sec: float = Field(default=2.0, ge=0.2, le=60.0) + #: 分类置信度阈值(兼容旧逻辑):低于 `video_voice_confirm_min_confidence` 的帧不参与自动确认或语音追问。 + video_inference_confidence_threshold: float = Field( + default=0.35, ge=0.0, le=1.0 ) + #: 达到或超过该置信度时,自动记一条耗材消耗(需通过候选清单校验)。 + video_auto_confirm_confidence: float = Field(default=0.55, ge=0.0, le=1.0) + #: 置信度处于 [本值, video_auto_confirm_confidence) 时尝试语音追问(需有可播报的 top 候选)。 + video_voice_confirm_min_confidence: float = Field(default=0.35, ge=0.0, le=1.0) + #: 是否启用低置信度时的人工确认(客户端拉取待确认项并回传结果;不依赖服务端麦克风/扬声器)。 + voice_confirmation_enabled: bool = True + #: 语音确认记帐时的 doctor_id。 + video_voice_confirm_doctor_id: str = "voice" + #: (已弃用)服务端本机录音秒数;当前闭环由客户端采集语音,此项仅保留兼容旧配置。 + voice_record_seconds: float = Field(default=5.0, ge=1.0, le=30.0) + #: (已弃用)服务端 ffmpeg 音频输入;当前闭环不依赖服务端录音。 + voice_ffmpeg_input: str = "" + #: 手术结束后归档写库失败时,后台重试落库的间隔(秒)。 + archive_persist_retry_interval_seconds: float = Field( + default=30.0, ge=5.0, le=3600.0 + ) + #: 同一物品重复记一条消耗的最短间隔(秒)。 + video_detail_cooldown_sec: float = Field(default=15.0, ge=0.0, le=3600.0) + #: 送模型 JPEG 质量。 + video_jpeg_quality: int = Field(default=85, ge=40, le=100) + #: 写入消耗明细时的 doctor_id(无外部医生 ID 来源时的占位)。 + video_result_doctor_id: str = "vision" + + #: 海康 SDK `.so` 所在目录(容器内可挂载 `/opt/hikvision/lib`)。 + hikvision_lib_dir: str = "/opt/hikvision/lib" + #: 为 true 时 `auto` 模式才会优先走 SDK;亦为 SDK 登录的前提之一。 + hikvision_sdk_enabled: bool = False + hikvision_device_ip: str = "" + hikvision_device_port: int = Field(default=8000, ge=1, le=65535) + hikvision_user: str = "" + hikvision_password: str = "" + #: 预览 URL 模板中的通道号等(如 101 主码流常写作 channel 拼接)。 + hikvision_channel: int = Field(default=1, ge=1, le=512) + #: SDK 登录成功后用于拉流的 RTSP 模板;占位符如 `{ip} {user} {password} {channel} {camera_id}`。 + hikvision_preview_rtsp_template: str = "" + #: 与 VIDEO_RTSP_URLS_JSON 类似,按 camera_id 指定 SDK 路径下的预览 RTSP。 + hikvision_camera_rtsp_urls_json: str = "" + #: SDK 登录失败时是否仍尝试用通用 RTSP 映射拉流(仅当能解析到 RTSP URL 时)。 + hikvision_sdk_fallback_to_rtsp: bool = True + #: 百度语音(`baidu-aip` AipSpeech:短语音识别 + 在线合成)。在控制台创建应用后填写。 + baidu_speech_app_id: str = "" + baidu_speech_api_key: str = "" + baidu_speech_secret_key: str = "" + #: 建立连接超时(毫秒)。未设置则使用 SDK 默认。 + baidu_speech_connection_timeout_ms: int | None = None + #: 传输数据超时(毫秒)。未设置则使用 SDK 默认。 + baidu_speech_socket_timeout_ms: int | None = None + + # --- MinIO:语音确认原始 WAV 追溯存储 --- + #: 为空则视为未配置 MinIO,语音确认接口将返回业务错误(联调需配置)。 + minio_endpoint: str = "" + minio_access_key: str = "" + minio_secret_key: str = "" + minio_bucket: str = "operation-room-voice" + #: 是否使用 HTTPS(MinIO 常见为 false,走 9000 明文或 TLS)。 + minio_secure: bool = False + #: 可选区域(部分 S3 兼容实现需要)。 + minio_region: str = "" + #: 上传医生语音 WAV 的最大字节数(默认 10MB)。 + voice_upload_max_bytes: int = Field(default=10 * 1024 * 1024, ge=64, le=50 * 1024 * 1024) + + @field_validator("consumable_classifier_weights", mode="before") + @classmethod + def consumable_classifier_weights_default(cls, value: object) -> str: + if value is None or value == "": + return _default_consumable_classifier_weights() + return str(value) + + @field_validator("tear_action_weights", mode="before") + @classmethod + def tear_action_weights_default(cls, value: object) -> str: + if value is None or value == "": + return _default_tear_action_weights() + return str(value) model_config = SettingsConfigDict( env_file=".env", @@ -14,5 +151,92 @@ class Settings(BaseSettings): extra="ignore", ) + @property + def sqlalchemy_database_url(self) -> str: + component_values = ( + self.postgres_user, + self.postgres_password, + self.postgres_db, + self.postgres_host, + self.postgres_port, + ) + default_component_values = ( + "postgres", + "postgres", + "operation_room", + "localhost", + 35432, + ) + + if component_values != default_component_values or not self.database_url: + user = quote_plus(self.postgres_user) + password = quote_plus(self.postgres_password) + database = quote_plus(self.postgres_db) + return ( + "postgresql+asyncpg://" + f"{user}:{password}@{self.postgres_host}:{self.postgres_port}/{database}" + ) + + return self.database_url + + @property + def baidu_speech_configured(self) -> bool: + return bool( + self.baidu_speech_app_id.strip() + and self.baidu_speech_api_key.strip() + and self.baidu_speech_secret_key.strip() + ) + + @property + def minio_configured(self) -> bool: + return bool( + self.minio_endpoint.strip() + and self.minio_access_key.strip() + and self.minio_secret_key.strip() + and self.minio_bucket.strip() + ) + + @staticmethod + def _parse_rtsp_urls_object(raw: str) -> dict[str, str]: + raw = (raw or "").strip() + if not raw: + return {} + try: + data: Any = json.loads(raw) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid VIDEO_RTSP_URLS_JSON: {exc}") from exc + if not isinstance(data, dict): + raise ValueError("VIDEO_RTSP_URLS_JSON must be a JSON object") + return {str(k): str(v) for k, v in data.items()} + + def video_rtsp_url_map(self) -> dict[str, str]: + """合并文件与内联 JSON;内联键覆盖文件。""" + merged: dict[str, str] = {} + path_raw = (self.video_rtsp_urls_json_file or "").strip() + if path_raw: + path = Path(path_raw).expanduser() + if not path.is_file(): + raise ValueError( + f"VIDEO_RTSP_URLS_JSON_FILE is set but file not found: {path}" + ) + try: + file_obj: Any = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ValueError( + f"Invalid JSON in VIDEO_RTSP_URLS_JSON_FILE {path}: {exc}" + ) from exc + if not isinstance(file_obj, dict): + raise ValueError( + f"VIDEO_RTSP_URLS_JSON_FILE must contain a JSON object: {path}" + ) + merged = {str(k): str(v) for k, v in file_obj.items()} + merged.update(self._parse_rtsp_urls_object(self.video_rtsp_urls_json)) + return merged + + @property + def camera_rtsp_urls_sample_path(self) -> str: + """仓库内示例映射路径(供文档与联调引用)。""" + return _default_camera_rtsp_urls_sample_path() + settings = Settings() diff --git a/app/database.py b/app/database.py index 20ba8f6..3c8d55a 100644 --- a/app/database.py +++ b/app/database.py @@ -4,9 +4,10 @@ from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from app.config import settings +from app.db.base import Base engine = create_async_engine( - settings.database_url, + settings.sqlalchemy_database_url, pool_pre_ping=True, ) @@ -27,3 +28,11 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]: async def check_database() -> None: async with engine.connect() as conn: await conn.execute(text("SELECT 1")) + + +async def init_db_schema() -> None: + """创建缺失的表(开发/首次部署;生产可改为 Alembic 迁移)。""" + import app.db.models # noqa: F401 - register ORM tables on Base.metadata + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..b0f0d93 --- /dev/null +++ b/app/db/__init__.py @@ -0,0 +1,6 @@ +"""Database models and schema helpers.""" + +from app.db.base import Base +from app.db.models import SurgeryFinalResult, SurgeryResultDetailRow + +__all__ = ["Base", "SurgeryFinalResult", "SurgeryResultDetailRow"] diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..94acd25 --- /dev/null +++ b/app/db/base.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + """SQLAlchemy declarative base.""" diff --git a/app/db/models.py b/app/db/models.py new file mode 100644 index 0000000..9cf2143 --- /dev/null +++ b/app/db/models.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +class SurgeryFinalResult(Base): + """一台手术结束后的最终结果元数据(明细在子表)。""" + + __tablename__ = "surgery_final_results" + + surgery_id: Mapped[str] = mapped_column(String(6), primary_key=True) + completed_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + details: Mapped[list["SurgeryResultDetailRow"]] = relationship( + "SurgeryResultDetailRow", + back_populates="surgery", + cascade="all, delete-orphan", + passive_deletes=True, + ) + + +class SurgeryResultDetailRow(Base): + """客户端查询用的耗材消耗明细行。""" + + __tablename__ = "surgery_result_details" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + surgery_id: Mapped[str] = mapped_column( + String(6), + ForeignKey("surgery_final_results.surgery_id", ondelete="CASCADE"), + index=True, + nullable=False, + ) + item_id: Mapped[str] = mapped_column(String(256), nullable=False) + item_name: Mapped[str] = mapped_column(String(256), nullable=False) + quantity: Mapped[int] = mapped_column(Integer, nullable=False) + doctor_id: Mapped[str] = mapped_column(String(128), nullable=False) + recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + #: 可选:来源标记,如 vision / voice;便于排查,客户端可忽略。 + source: Mapped[str] = mapped_column(String(32), default="vision", nullable=False) + + surgery: Mapped["SurgeryFinalResult"] = relationship( + "SurgeryFinalResult", back_populates="details" + ) + + +class VoiceConfirmationAudit(Base): + """医生语音确认上传:原始音频对象键、ASR 文本与解析结果(追溯)。""" + + __tablename__ = "voice_confirmation_audits" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + surgery_id: Mapped[str] = mapped_column(String(6), index=True, nullable=False) + confirmation_id: Mapped[str] = mapped_column(String(128), index=True, nullable=False) + #: recognized | rejected | asr_failed | parse_failed | invalid_audio | upload_failed + status: Mapped[str] = mapped_column(String(32), nullable=False) + audio_object_key: Mapped[str | None] = mapped_column(String(512), nullable=True) + audio_content_type: Mapped[str | None] = mapped_column(String(128), nullable=True) + audio_size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True) + audio_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True) + asr_text: Mapped[str | None] = mapped_column(String(2048), nullable=True) + resolved_label: Mapped[str | None] = mapped_column(String(256), nullable=True) + options_snapshot_json: Mapped[str | None] = mapped_column(Text, nullable=True) + error_message: Mapped[str | None] = mapped_column(String(1024), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) diff --git a/app/dependencies.py b/app/dependencies.py new file mode 100644 index 0000000..6df4520 --- /dev/null +++ b/app/dependencies.py @@ -0,0 +1,73 @@ +from loguru import logger + +from app.config import settings +from app.repositories.surgery_results import SurgeryResultRepository +from app.repositories.voice_audits import VoiceAuditRepository +from app.services.consumable_classifier import ConsumableClassifierService +from app.services.baidu_speech import BaiduSpeechService +from app.services.minio_audio_storage import MinioAudioStorageService +from app.services.surgery_pipeline import SurgeryPipeline +from app.services.voice_resolution import VoiceConfirmationService +from app.services.tear_action import TearActionService +from app.services.video.hikvision_runtime import HikvisionRuntime +from app.services.video.session_manager import CameraSessionManager + +consumable_classifier_service = ConsumableClassifierService() +tear_action_service = TearActionService() + +hikvision_runtime = HikvisionRuntime.try_load(settings.hikvision_lib_dir) +if settings.hikvision_sdk_enabled and hikvision_runtime is None: + logger.warning( + "HIKVISION_SDK_ENABLED=true but no HCNetSDK library loaded " + "(check HIKVISION_LIB_DIR / mount /opt/hikvision/lib)" + ) + +surgery_result_repository = SurgeryResultRepository() +voice_audit_repository = VoiceAuditRepository() +baidu_speech_service = BaiduSpeechService() +minio_audio_storage_service = MinioAudioStorageService(settings) + +camera_session_manager = CameraSessionManager( + settings=settings, + consumable_classifier=consumable_classifier_service, + tear_action=tear_action_service, + hikvision_runtime=hikvision_runtime, + result_repository=surgery_result_repository, +) +voice_confirmation_service = VoiceConfirmationService( + settings=settings, + sessions=camera_session_manager, + baidu=baidu_speech_service, + minio=minio_audio_storage_service, + audits=voice_audit_repository, +) + +surgery_pipeline = SurgeryPipeline( + camera_session_manager, + result_repository=surgery_result_repository, + voice_confirmation=voice_confirmation_service, +) + + +def get_consumable_classifier_service() -> ConsumableClassifierService: + return consumable_classifier_service + + +def get_tear_action_service() -> TearActionService: + return tear_action_service + + +def get_surgery_pipeline() -> SurgeryPipeline: + return surgery_pipeline + + +def get_camera_session_manager() -> CameraSessionManager: + return camera_session_manager + + +def get_surgery_result_repository() -> SurgeryResultRepository: + return surgery_result_repository + + +def get_voice_confirmation_service() -> VoiceConfirmationService: + return voice_confirmation_service diff --git a/app/repositories/__init__.py b/app/repositories/__init__.py new file mode 100644 index 0000000..4a990ae --- /dev/null +++ b/app/repositories/__init__.py @@ -0,0 +1,3 @@ +from app.repositories.surgery_results import SurgeryResultRepository + +__all__ = ["SurgeryResultRepository"] diff --git a/app/repositories/surgery_results.py b/app/repositories/surgery_results.py new file mode 100644 index 0000000..bc90b8e --- /dev/null +++ b/app/repositories/surgery_results.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import SurgeryFinalResult, SurgeryResultDetailRow +from app.schemas import SurgeryConsumptionDetail + + +class SurgeryResultRepository: + """持久化 / 读取手术结束后的最终结果(仅客户端返回结构)。""" + + async def save_final_result( + self, + session: AsyncSession, + *, + surgery_id: str, + details: list[SurgeryConsumptionDetail], + completed_at: datetime | None = None, + ) -> None: + when = completed_at or datetime.now(timezone.utc) + await session.execute( + delete(SurgeryResultDetailRow).where( + SurgeryResultDetailRow.surgery_id == surgery_id + ) + ) + await session.execute( + delete(SurgeryFinalResult).where(SurgeryFinalResult.surgery_id == surgery_id) + ) + row = SurgeryFinalResult(surgery_id=surgery_id, completed_at=when) + session.add(row) + for d in details: + session.add( + SurgeryResultDetailRow( + surgery_id=surgery_id, + item_id=d.item_id, + item_name=d.item_name, + quantity=d.quantity, + doctor_id=d.doctor_id, + recorded_at=d.timestamp, + source=d.source, + ) + ) + await session.flush() + + async def load_final_details( + self, session: AsyncSession, surgery_id: str + ) -> list[SurgeryConsumptionDetail] | None: + res = await session.execute( + select(SurgeryFinalResult).where(SurgeryFinalResult.surgery_id == surgery_id) + ) + meta = res.scalar_one_or_none() + if meta is None: + return None + q = await session.execute( + select(SurgeryResultDetailRow) + .where(SurgeryResultDetailRow.surgery_id == surgery_id) + .order_by(SurgeryResultDetailRow.id) + ) + rows = q.scalars().all() + return [ + SurgeryConsumptionDetail( + item_id=r.item_id, + item_name=r.item_name, + quantity=r.quantity, + doctor_id=r.doctor_id, + timestamp=r.recorded_at, + source=r.source, + ) + for r in rows + ] diff --git a/app/repositories/voice_audits.py b/app/repositories/voice_audits.py new file mode 100644 index 0000000..494e459 --- /dev/null +++ b/app/repositories/voice_audits.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import VoiceConfirmationAudit + + +class VoiceAuditRepository: + """Persist voice confirmation audit rows.""" + + async def save_audit( + self, + session: AsyncSession, + *, + surgery_id: str, + confirmation_id: str, + status: str, + audio_object_key: str | None, + audio_content_type: str | None, + audio_size_bytes: int | None, + audio_sha256: str | None, + asr_text: str | None, + resolved_label: str | None, + options_snapshot_json: str | None, + error_message: str | None, + created_at: datetime | None = None, + ) -> None: + when = created_at or datetime.now(timezone.utc) + row = VoiceConfirmationAudit( + surgery_id=surgery_id, + confirmation_id=confirmation_id, + status=status, + audio_object_key=audio_object_key, + audio_content_type=audio_content_type, + audio_size_bytes=audio_size_bytes, + audio_sha256=audio_sha256, + asr_text=asr_text, + resolved_label=resolved_label, + options_snapshot_json=options_snapshot_json, + error_message=error_message, + created_at=when, + ) + session.add(row) + await session.flush() diff --git a/app/resources/camera_rtsp_urls.sample.json b/app/resources/camera_rtsp_urls.sample.json new file mode 100644 index 0000000..c13e8ae --- /dev/null +++ b/app/resources/camera_rtsp_urls.sample.json @@ -0,0 +1,4 @@ +{ + "or-cam-01": "rtsp://admin:ChangeMe@192.168.1.101:554/Streaming/Channels/101", + "or-cam-02": "rtsp://admin:ChangeMe@192.168.1.102:554/Streaming/Channels/101" +} diff --git a/app/resources/consumable_classifier.pt b/app/resources/consumable_classifier.pt new file mode 100644 index 0000000..161804d Binary files /dev/null and b/app/resources/consumable_classifier.pt differ diff --git a/app/resources/consumable_classifier_labels.yaml b/app/resources/consumable_classifier_labels.yaml new file mode 100644 index 0000000..3b92b3f --- /dev/null +++ b/app/resources/consumable_classifier_labels.yaml @@ -0,0 +1,48 @@ +# 耗材分类器 consumable_classifier.pt 的 YOLO-cls 类别名(与训练侧 data.yaml 对齐)。 +# 推理时标签以权重内嵌 names 为准;本文件仅供文档与类别清单对照。 +names: + 0: MCuⅡ功能性宫内节育器 + 1: 一次性中性电极板 + 2: 一次性使用乳胶导尿管 + 3: 一次性使用冲洗袋 + 4: 一次性使用医疗卫生用品 + 5: 一次性使用单极手术电极 + 6: 一次性使用导尿管 + 7: 一次性使用手术单 + 8: 一次性使用手术单(一次性医用垫单) + 9: 一次性使用手术衣 + 10: 一次性使用无菌敷贴 + 11: 一次性使用无菌气管插管Tracheal Tube + 12: 一次性使用无菌注射器带针 + 13: 一次性使用无菌采样拭子 + 14: 一次性使用气管插管 + 15: 一次性使用灭菌棉签 + 16: 一次性使用灭菌橡胶外科手套 + 17: 一次性使用牙垫 + 18: 一次性使用精密过滤输液器 带针 + 19: 一次性使用肛门管 + 20: 一次性使用胃管 + 21: 一次性使用血液透析管路 + 22: 一次性使用输卵管导管 + 23: 一次性使用雾化器 + 24: 一次性使用静脉留置针 + 25: 一次性使用静脉输液针 + 26: 一次性使用麻醉面罩 + 27: 一次性内窥镜护套 + 28: 一次性医用灭菌棉签 + 29: 一次性无菌喉罩 + 30: 医用凡士林敷料 + 31: 医用纱布敷料 + 32: 医用缝合针 + 33: 医用脱脂棉纱布块 + 34: 可吸收性外科缝线 + 35: 密闭式防针刺伤型静脉留置针 + 36: 导管固定器 + 37: 气管切开插管 + 38: 结扎夹Ligating Clips + 39: 自粘性薄膜敷料 + 40: 血液净化装置的体外循环血路 + 41: 负压引流器 + 42: 非吸收性外科缝线 + 43: 非吸收性外科缝线(蚕丝线) +nc: 44 diff --git a/app/resources/tear_action.pt b/app/resources/tear_action.pt new file mode 100644 index 0000000..d9f787b Binary files /dev/null and b/app/resources/tear_action.pt differ diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..2274bf2 --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class HealthResponse(BaseModel): + status: str + database: str + + +class SurgeryStartRequest(BaseModel): + model_config = ConfigDict( + json_schema_extra={ + "example": { + "surgery_id": "123456", + "camera_ids": ["or-cam-01", "or-cam-02"], + "candidate_consumables": ["纱布", "缝线", "止血钳"], + } + } + ) + + surgery_id: str = Field( + min_length=6, + max_length=6, + pattern=r"^\d{6}$", + description="手术6位号,只允许6位数字。", + ) + camera_ids: list[str] = Field( + min_length=1, + description="本次手术需要接入的摄像头 ID 列表。", + ) + candidate_consumables: list[str] = Field( + default_factory=list, + description=( + "本次手术可能使用到的耗材清单。" + "服务端仅对该清单内的耗材做自动记账与待确认追问;" + "若为空则不会写入任何消耗(仅拉流推理)。" + ), + ) + + +class SurgeryEndRequest(BaseModel): + model_config = ConfigDict( + json_schema_extra={"example": {"surgery_id": "123456"}} + ) + + surgery_id: str = Field( + min_length=6, + max_length=6, + pattern=r"^\d{6}$", + description="手术6位号,只允许6位数字。", + ) + + +class SurgeryApiResponse(BaseModel): + surgery_id: str = Field(description="手术6位号。") + status: str = Field(description="接口处理状态。") + message: str = Field(description="返回说明。") + + +class SurgeryClientErrorDetail(BaseModel): + """与 `HTTPException(detail={...})` 对应;最终 JSON 为 `{"detail": {...}}`。""" + + code: str = Field(description="业务错误码,如 RECORDING_CANNOT_START、RECORDING_NOT_STOPPED、RESULT_NOT_READY。") + message: str = Field(description="人类可读说明。") + surgery_id: str = Field(description="手术 6 位号。") + + +class SurgeryClientErrorResponse(BaseModel): + """FastAPI/Starlette 对 HTTPException 序列化后的常见外形(`detail` 为对象时)。""" + + detail: SurgeryClientErrorDetail + + +class SurgeryConsumptionDetail(BaseModel): + """单条消耗明细(按事件发生,可能多行)。""" + + item_id: str = Field(description="物品 ID。") + item_name: str = Field(description="物品名称。") + quantity: int = Field(ge=0, description="本条记录对应的消耗数量。") + doctor_id: str = Field(description="医生 ID。") + timestamp: datetime = Field(description="记录时间(ISO 8601)。") + source: str = Field( + default="vision", + description="记录来源:vision 自动识别;voice 语音确认。", + ) + + +class SurgeryConsumptionSummary(BaseModel): + """按物品汇总:该手术下该物品消耗数量合计。""" + + item_id: str = Field(description="物品 ID。") + item_name: str = Field(description="物品名称。") + total_quantity: int = Field(ge=0, description="该物品在本台手术中的消耗数量合计。") + + +def build_consumption_summary( + details: list[SurgeryConsumptionDetail], +) -> list[SurgeryConsumptionSummary]: + """按 item_id 汇总 total_quantity;名称取该物品首条出现时的 item_name。""" + totals: dict[str, tuple[str, int]] = {} + for row in details: + if row.item_id not in totals: + totals[row.item_id] = (row.item_name, 0) + name, acc = totals[row.item_id] + totals[row.item_id] = (name, acc + row.quantity) + return [ + SurgeryConsumptionSummary( + item_id=iid, + item_name=name, + total_quantity=qty, + ) + for iid, (name, qty) in sorted(totals.items(), key=lambda x: x[0]) + ] + + +class SurgeryVoiceStatusResponse(BaseModel): + """手术进行中人工确认(客户端播报)联调状态。""" + + surgery_id: str = Field(description="手术 6 位号。") + voice_enabled: bool = Field( + description="是否启用了低置信度人工确认(客户端拉取待确认项)。", + ) + pending_queue_approx: int = Field( + ge=0, + description="待医生确认的追问任务数量(FIFO 队列长度)。", + ) + last_prompt_snippet: str | None = Field( + default=None, + description="最近一次生成的待确认话术摘要。", + ) + last_asr_text: str | None = Field( + default=None, + description="最近一次语音确认接口产生的 ASR 文本。", + ) + last_error: str | None = Field( + default=None, + description="最近一次语音确认错误说明(如 ASR/解析失败)。", + ) + + +class PendingConfirmationOption(BaseModel): + label: str + confidence: float + + +class SurgeryPendingConfirmationResponse(BaseModel): + """当前待医生确认的一条低置信度识别。""" + + surgery_id: str + confirmation_id: str + prompt_text: str = Field(description="可直接用于 TTS 播报的话术。") + options: list[PendingConfirmationOption] + model_top1_label: str = Field(description="模型原始 Top1 标签(可能不在候选清单内)。") + model_top1_confidence: float + created_at: datetime + + +class SurgeryPendingConfirmationResolveResponse(BaseModel): + surgery_id: str + confirmation_id: str + status: str = Field(description="accepted") + message: str + resolved_label: str | None = Field( + default=None, + description="解析并确认后的耗材名称;否认全部候选时为 null。", + ) + rejected: bool = Field( + default=False, + description="是否为否认全部候选(不记消耗)。", + ) + asr_text: str | None = Field( + default=None, + description="服务端语音识别得到的文本。", + ) + audio_object_key: str | None = Field( + default=None, + description="MinIO 中原始 WAV 的对象键,用于追溯。", + ) + + +class SurgeryResultResponse(BaseModel): + model_config = ConfigDict( + json_schema_extra={ + "example": { + "surgery_id": "123456", + "status": "completed", + "message": "结果查询成功。", + "details": [ + { + "item_id": "HC001", + "item_name": "纱布", + "quantity": 2, + "doctor_id": "D1001", + "timestamp": "2026-04-21T10:30:00+08:00", + }, + { + "item_id": "HC001", + "item_name": "纱布", + "quantity": 1, + "doctor_id": "D1002", + "timestamp": "2026-04-21T11:05:00+08:00", + }, + { + "item_id": "HC002", + "item_name": "缝线", + "quantity": 1, + "doctor_id": "D1001", + "timestamp": "2026-04-21T10:45:00+08:00", + }, + ], + "summary": [ + {"item_id": "HC001", "item_name": "纱布", "total_quantity": 3}, + {"item_id": "HC002", "item_name": "缝线", "total_quantity": 1}, + ], + } + } + ) + + surgery_id: str = Field(description="手术6位号。") + status: str = Field(description="结果状态,例如 pending / completed / failed。") + message: str = Field(description="返回说明。") + details: list[SurgeryConsumptionDetail] = Field( + default_factory=list, + description="消耗明细行:每条含物品、数量、医生与时间;同一物品可多次出现。", + ) + summary: list[SurgeryConsumptionSummary] = Field( + default_factory=list, + description="按物品汇总的消耗合计,应与 details 按 item_id 汇总一致。", + ) diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ + diff --git a/app/services/audio_wav.py b/app/services/audio_wav.py new file mode 100644 index 0000000..e25cf89 --- /dev/null +++ b/app/services/audio_wav.py @@ -0,0 +1,101 @@ +"""Decode WAV bytes to 16 kHz mono 16-bit PCM for Baidu short ASR.""" + +from __future__ import annotations + +import io +import shutil +import subprocess +import wave +from typing import Final + +_BAIDU_RATE: Final[int] = 16000 + + +class WavDecodeError(ValueError): + """Uploaded bytes are not a valid WAV or cannot be converted.""" + + +def wav_bytes_to_pcm16k_mono_s16le(wav_bytes: bytes) -> bytes: + """ + Prefer ffmpeg for arbitrary channel count / sample rate. + Falls back to stdlib `wave` when ffmpeg is unavailable (16-bit PCM only). + """ + if not wav_bytes: + raise WavDecodeError("Empty audio payload") + + ffmpeg = shutil.which("ffmpeg") + if ffmpeg: + return _ffmpeg_to_pcm16k(wav_bytes, ffmpeg) + + return _stdlib_wave_to_pcm16k(wav_bytes) + + +def _ffmpeg_to_pcm16k(wav_bytes: bytes, ffmpeg: str) -> bytes: + proc = subprocess.run( + [ + ffmpeg, + "-nostdin", + "-loglevel", + "error", + "-i", + "pipe:0", + "-f", + "s16le", + "-ac", + "1", + "-ar", + str(_BAIDU_RATE), + "pipe:1", + ], + input=wav_bytes, + capture_output=True, + timeout=120, + check=False, + ) + if proc.returncode != 0: + err = (proc.stderr or b"").decode("utf-8", errors="replace") + raise WavDecodeError(f"ffmpeg wav decode failed: {err or proc.returncode}") + if not proc.stdout: + raise WavDecodeError("ffmpeg produced empty PCM") + return proc.stdout + + +def _stdlib_wave_to_pcm16k(wav_bytes: bytes) -> bytes: + try: + with wave.open(io.BytesIO(wav_bytes), "rb") as wf: + nchannels = wf.getnchannels() + sampwidth = wf.getsampwidth() + framerate = wf.getframerate() + nframes = wf.getnframes() + raw = wf.readframes(nframes) + except wave.Error as exc: + raise WavDecodeError(f"Invalid WAV: {exc}") from exc + + if sampwidth != 2: + raise WavDecodeError( + f"WAV sample width {sampwidth * 8} bit not supported without ffmpeg" + ) + if nchannels not in (1, 2): + raise WavDecodeError( + f"WAV channels={nchannels} not supported without ffmpeg" + ) + if framerate != _BAIDU_RATE: + raise WavDecodeError( + f"WAV rate {framerate} requires ffmpeg for resampling to {_BAIDU_RATE} Hz" + ) + + if nchannels == 2: + # de-interleave stereo s16le -> mono average + import struct + + out = bytearray() + for i in range(0, len(raw), 4): + chunk = raw[i : i + 4] + if len(chunk) < 4: + break + l_s, r_s = struct.unpack(" None: + self._client: AipSpeech | None = None + self._lock = Lock() + + @property + def configured(self) -> bool: + return settings.baidu_speech_configured + + def _client_or_raise(self) -> AipSpeech: + if not self.configured: + raise BaiduSpeechNotConfiguredError( + "百度语音未配置:请设置 BAIDU_SPEECH_APP_ID、BAIDU_SPEECH_API_KEY、" + "BAIDU_SPEECH_SECRET_KEY" + ) + with self._lock: + if self._client is None: + client = AipSpeech( + settings.baidu_speech_app_id, + settings.baidu_speech_api_key, + settings.baidu_speech_secret_key, + ) + if settings.baidu_speech_connection_timeout_ms is not None: + client.setConnectionTimeoutInMillis( + settings.baidu_speech_connection_timeout_ms + ) + if settings.baidu_speech_socket_timeout_ms is not None: + client.setSocketTimeoutInMillis(settings.baidu_speech_socket_timeout_ms) + self._client = client + return self._client + + def asr( + self, + speech: bytes | None = None, + format: str = "pcm", + rate: int = 16000, + options: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """短语音识别。返回百度 JSON(含 `err_no`、`result` 等)。""" + return self._client_or_raise().asr(speech, format, rate, options) + + def synthesis( + self, + text: str, + lang: str = "zh", + ctp: int = 1, + options: dict[str, Any] | None = None, + ) -> bytes | dict[str, Any]: + """在线语音合成。成功为音频二进制;失败为错误信息 dict。""" + return self._client_or_raise().synthesis(text, lang, ctp, options) diff --git a/app/services/consumable_classifier.py b/app/services/consumable_classifier.py new file mode 100644 index 0000000..d49431d --- /dev/null +++ b/app/services/consumable_classifier.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +from dataclasses import dataclass +from io import BytesIO +import os +import sys +from pathlib import Path +from threading import Lock + +import numpy as np +from fastapi.concurrency import run_in_threadpool +from loguru import logger +from PIL import Image, UnidentifiedImageError + +os.environ["YOLO_CONFIG_DIR"] = "/tmp" + +from ultralytics import YOLO + +from app.config import settings + + +def resolve_classifier_inference_device(explicit: str) -> str | None: + """Ultralytics `device` string. If unset: macOS prefers MPS; Linux/Windows prefer CUDA when available.""" + configured = (explicit or "").strip() + if configured: + return configured + try: + import torch + except Exception: + return None + if sys.platform == "darwin": + if torch.backends.mps.is_available(): + return "mps" + return None + if torch.cuda.is_available(): + return "cuda:0" + return None + + +@dataclass(frozen=True) +class PredictionCandidate: + label: str + confidence: float + + +@dataclass(frozen=True) +class PredictionResult: + label: str + confidence: float + topk: list[PredictionCandidate] + + +class ModelNotConfiguredError(RuntimeError): + """Raised when the model weights are not configured or missing.""" + + +class InvalidImageError(ValueError): + """Raised when uploaded bytes cannot be decoded as an image.""" + + +class PredictionError(RuntimeError): + """Raised when the model cannot produce a prediction.""" + + +class ConsumableClassifierService: + """耗材识别与分类(YOLO-cls):判断画面中的耗材类别;与撕扯动作模型 `TearActionService` 分离。内部流水线调用,不对外 HTTP。""" + + def __init__(self) -> None: + self._model: YOLO | None = None + self._model_lock = Lock() + + @property + def weights_path(self) -> Path | None: + if not settings.consumable_classifier_weights: + return None + return Path(settings.consumable_classifier_weights).expanduser() + + @property + def configured(self) -> bool: + return self.weights_path is not None + + @property + def weights_found(self) -> bool: + path = self.weights_path + return path is not None and path.is_file() + + @property + def model_loaded(self) -> bool: + return self._model is not None + + async def predict_image_bytes( + self, + payload: bytes, + *, + topk: int | None = None, + ) -> PredictionResult: + return await run_in_threadpool(self._predict_image_bytes, payload, topk) + + def _predict_image_bytes( + self, + payload: bytes, + topk: int | None, + ) -> PredictionResult: + model = self._get_model() + image = self._decode_image(payload) + + try: + result = model.predict( + image, + imgsz=settings.consumable_classifier_imgsz, + device=resolve_classifier_inference_device( + settings.consumable_classifier_device + ), + verbose=False, + )[0] + except Exception as exc: # pragma: no cover - ultralytics runtime errors vary. + raise PredictionError( + f"Failed to run consumable classifier inference: {exc}" + ) from exc + + return self._build_prediction_result(result, model, topk=topk) + + def _get_model(self) -> YOLO: + path = self.weights_path + if path is None: + raise ModelNotConfiguredError( + "Consumable classifier weights are not configured. " + "Set CONSUMABLE_CLASSIFIER_WEIGHTS." + ) + + path = path.resolve() + if not path.is_file(): + raise ModelNotConfiguredError( + f"Consumable classifier weights not found: {path}" + ) + + if self._model is None: + with self._model_lock: + if self._model is None: + logger.info("Loading consumable classifier weights from {}", path) + self._model = YOLO(str(path)) + + return self._model + + def _decode_image(self, payload: bytes) -> np.ndarray: + if not payload: + raise InvalidImageError("Uploaded image is empty.") + + try: + with Image.open(BytesIO(payload)) as image: + return np.asarray(image.convert("RGB")) + except (UnidentifiedImageError, OSError) as exc: + raise InvalidImageError("Uploaded file is not a valid image.") from exc + + def _build_prediction_result( + self, + result: object, + model: YOLO, + *, + topk: int | None, + ) -> PredictionResult: + probs = getattr(result, "probs", None) + data = getattr(probs, "data", None) + if probs is None or data is None: + raise PredictionError("Model did not return classification probabilities.") + + scores = data.tolist() + if not isinstance(scores, list): + scores = [float(scores)] + + names = self._names(model) + limit = max(1, topk or settings.consumable_classifier_topk) + ranked = sorted( + ((index, float(score)) for index, score in enumerate(scores)), + key=lambda item: item[1], + reverse=True, + )[:limit] + + if not ranked: + raise PredictionError("Model returned an empty prediction result.") + + candidates = [ + PredictionCandidate( + label=names.get(index, str(index)), + confidence=confidence, + ) + for index, confidence in ranked + ] + return PredictionResult( + label=candidates[0].label, + confidence=candidates[0].confidence, + topk=candidates, + ) + + def _names(self, model: YOLO) -> dict[int, str]: + raw = getattr(model.model, "names", None) or {} + return {int(key): str(value) for key, value in raw.items()} diff --git a/app/services/minio_audio_storage.py b/app/services/minio_audio_storage.py new file mode 100644 index 0000000..58154e2 --- /dev/null +++ b/app/services/minio_audio_storage.py @@ -0,0 +1,90 @@ +"""Upload voice confirmation WAV objects to MinIO (S3-compatible).""" + +from __future__ import annotations + +import hashlib +import io +from dataclasses import dataclass + +from loguru import logger +from minio import Minio +from minio.error import S3Error + +from app.config import Settings + + +@dataclass(frozen=True) +class StoredAudio: + object_key: str + sha256_hex: str + size_bytes: int + + +class MinioAudioStorageService: + """Stores raw doctor voice WAV for audit trail.""" + + def __init__(self, settings: Settings) -> None: + self._s = settings + self._client: Minio | None = None + if settings.minio_configured: + endpoint = settings.minio_endpoint.strip() + self._client = Minio( + endpoint, + access_key=settings.minio_access_key.strip(), + secret_key=settings.minio_secret_key.strip(), + secure=bool(settings.minio_secure), + region=(settings.minio_region or "").strip() or None, + ) + + @property + def configured(self) -> bool: + return self._client is not None + + def ensure_bucket(self) -> None: + if self._client is None: + return + name = self._s.minio_bucket.strip() + try: + if not self._client.bucket_exists(name): + self._client.make_bucket(name) + logger.info("MinIO bucket created: {}", name) + except S3Error as exc: + logger.warning("MinIO ensure_bucket failed: {}", exc) + raise + + def upload_voice_wav( + self, + *, + surgery_id: str, + confirmation_id: str, + data: bytes, + content_type: str | None, + ) -> StoredAudio: + if self._client is None: + raise RuntimeError("MinIO is not configured") + + digest = hashlib.sha256(data).hexdigest() + suffix = digest[:12] + object_key = ( + f"surgeries/{surgery_id}/confirmations/{confirmation_id}/{suffix}.wav" + ) + bucket = self._s.minio_bucket.strip() + ct = content_type or "audio/wav" + stream = io.BytesIO(data) + try: + self._client.put_object( + bucket, + object_key, + stream, + length=len(data), + content_type=ct, + ) + except S3Error as exc: + logger.warning("MinIO put_object failed: {}", exc) + raise + + return StoredAudio( + object_key=object_key, + sha256_hex=digest, + size_bytes=len(data), + ) diff --git a/app/services/surgery_pipeline.py b/app/services/surgery_pipeline.py new file mode 100644 index 0000000..e664682 --- /dev/null +++ b/app/services/surgery_pipeline.py @@ -0,0 +1,116 @@ +"""手术录制与实时算法流水线(待接入真实子系统)。""" + +from __future__ import annotations + +from app.database import AsyncSessionLocal +from app.repositories.surgery_results import SurgeryResultRepository +from app.schemas import ( + PendingConfirmationOption, + SurgeryConsumptionDetail, + SurgeryPendingConfirmationResponse, +) +from app.services.video.session_manager import CameraSessionManager +from app.services.voice_resolution import VoiceConfirmationService, VoiceResolveResult +from app.surgery_errors import SurgeryPipelineError + + +class SurgeryPipeline: + """协调开录、停录与算法产出。路由仅在子系统确认后返回 HTTP 200。""" + + def __init__( + self, + sessions: CameraSessionManager, + *, + result_repository: SurgeryResultRepository, + voice_confirmation: VoiceConfirmationService, + ) -> None: + self._sessions = sessions + self._repo = result_repository + self._voice = voice_confirmation + + async def start_recording( + self, + surgery_id: str, + camera_ids: list[str], + candidate_consumables: list[str], + ) -> None: + """启动关联摄像头录制。仅在确认已开录时返回;否则抛出 SurgeryPipelineError。""" + try: + await self._sessions.start_surgery( + surgery_id, + camera_ids, + candidate_consumables, + ) + except SurgeryPipelineError: + raise + except ValueError as exc: + raise SurgeryPipelineError( + "RECORDING_CANNOT_START", + f"开录未能确认:{exc}", + ) from exc + except RuntimeError as exc: + raise SurgeryPipelineError( + "RECORDING_CANNOT_START", + f"开录未能确认:{exc}", + ) from exc + + async def stop_recording(self, surgery_id: str) -> None: + """停止该手术关联的摄像头录制。仅在确认已全部停录时返回。""" + try: + await self._sessions.stop_surgery(surgery_id, require_active=True) + except SurgeryPipelineError: + raise + + async def get_consumption_details_for_client( + self, + surgery_id: str, + ) -> list[SurgeryConsumptionDetail] | None: + """进行中:返回内存明细;已结束:返回数据库最终结果;持久化失败时回退内存归档。""" + live = self._sessions.live_consumption_if_active(surgery_id) + if live is not None: + return live + async with AsyncSessionLocal() as session: + async with session.begin(): + persisted = await self._repo.load_final_details(session, surgery_id) + if persisted is not None: + return persisted + return self._sessions.archived_consumption_fallback(surgery_id) + + def voice_status(self, surgery_id: str) -> dict[str, object] | None: + return self._sessions.voice_status(surgery_id) + + def get_pending_confirmation_for_client( + self, surgery_id: str + ) -> SurgeryPendingConfirmationResponse | None: + pending = self._sessions.next_pending_confirmation(surgery_id) + if pending is None: + return None + return SurgeryPendingConfirmationResponse( + surgery_id=surgery_id, + confirmation_id=pending.id, + prompt_text=pending.prompt_text, + options=[ + PendingConfirmationOption(label=a, confidence=b) + for a, b in pending.options + ], + model_top1_label=pending.model_top1_label, + model_top1_confidence=pending.model_top1_confidence, + created_at=pending.created_at, + ) + + async def resolve_pending_confirmation_from_audio( + self, + surgery_id: str, + confirmation_id: str, + wav_bytes: bytes, + filename: str, + content_type: str | None, + ) -> VoiceResolveResult: + """上传医生语音 WAV:MinIO 追溯 + 百度 ASR + 解析候选项并完成确认。""" + return await self._voice.resolve_from_wav( + surgery_id=surgery_id, + confirmation_id=confirmation_id, + wav_bytes=wav_bytes, + filename=filename, + content_type=content_type, + ) diff --git a/app/services/tear_action.py b/app/services/tear_action.py new file mode 100644 index 0000000..3fd2a77 --- /dev/null +++ b/app/services/tear_action.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +from io import BytesIO +import os +from pathlib import Path +from threading import Lock + +import numpy as np +from fastapi.concurrency import run_in_threadpool +from loguru import logger +from PIL import Image, UnidentifiedImageError + +os.environ["YOLO_CONFIG_DIR"] = "/tmp" + +from ultralytics import YOLO + +from app.config import settings +from app.services.consumable_classifier import ( + InvalidImageError, + ModelNotConfiguredError, + PredictionCandidate, + PredictionError, + PredictionResult, + resolve_classifier_inference_device, +) + + +class TearActionService: + """撕扯耗材动作识别(独立权重):判断是否存在/如何撕扯耗材等行为;与耗材分类 `ConsumableClassifierService` 分离。内部流水线调用,不对外 HTTP。""" + + def __init__(self) -> None: + self._model: YOLO | None = None + self._model_lock = Lock() + + @property + def weights_path(self) -> Path | None: + if not settings.tear_action_weights: + return None + return Path(settings.tear_action_weights).expanduser() + + @property + def configured(self) -> bool: + return self.weights_path is not None + + @property + def weights_found(self) -> bool: + path = self.weights_path + return path is not None and path.is_file() + + @property + def model_loaded(self) -> bool: + return self._model is not None + + async def predict_image_bytes( + self, + payload: bytes, + *, + topk: int | None = None, + ) -> PredictionResult: + return await run_in_threadpool(self._predict_image_bytes, payload, topk) + + def _predict_image_bytes( + self, + payload: bytes, + topk: int | None, + ) -> PredictionResult: + model = self._get_model() + image = self._decode_image(payload) + + try: + result = model.predict( + image, + imgsz=settings.tear_action_imgsz, + device=resolve_classifier_inference_device(settings.tear_action_device), + verbose=False, + )[0] + except Exception as exc: # pragma: no cover + raise PredictionError( + f"Failed to run tear-action inference: {exc}" + ) from exc + + return self._build_prediction_result(result, model, topk=topk) + + def _get_model(self) -> YOLO: + path = self.weights_path + if path is None: + raise ModelNotConfiguredError( + "Tear-action weights are not configured. Set TEAR_ACTION_WEIGHTS." + ) + + path = path.resolve() + if not path.is_file(): + raise ModelNotConfiguredError(f"Tear-action weights not found: {path}") + + if self._model is None: + with self._model_lock: + if self._model is None: + logger.info("Loading tear-action weights from {}", path) + self._model = YOLO(str(path)) + + return self._model + + def _decode_image(self, payload: bytes) -> np.ndarray: + if not payload: + raise InvalidImageError("Uploaded image is empty.") + + try: + with Image.open(BytesIO(payload)) as image: + return np.asarray(image.convert("RGB")) + except (UnidentifiedImageError, OSError) as exc: + raise InvalidImageError("Uploaded file is not a valid image.") from exc + + def _build_prediction_result( + self, + result: object, + model: YOLO, + *, + topk: int | None, + ) -> PredictionResult: + probs = getattr(result, "probs", None) + data = getattr(probs, "data", None) + if probs is None or data is None: + raise PredictionError("Model did not return classification probabilities.") + + scores = data.tolist() + if not isinstance(scores, list): + scores = [float(scores)] + + names = self._names(model) + limit = max(1, topk or settings.tear_action_topk) + ranked = sorted( + ((index, float(score)) for index, score in enumerate(scores)), + key=lambda item: item[1], + reverse=True, + )[:limit] + + if not ranked: + raise PredictionError("Model returned an empty prediction result.") + + candidates = [ + PredictionCandidate( + label=names.get(index, str(index)), + confidence=confidence, + ) + for index, confidence in ranked + ] + return PredictionResult( + label=candidates[0].label, + confidence=candidates[0].confidence, + topk=candidates, + ) + + def _names(self, model: YOLO) -> dict[int, str]: + raw = getattr(model.model, "names", None) or {} + return {int(key): str(value) for key, value in raw.items()} diff --git a/app/services/video/__init__.py b/app/services/video/__init__.py new file mode 100644 index 0000000..53de40f --- /dev/null +++ b/app/services/video/__init__.py @@ -0,0 +1,5 @@ +"""Video capture backends: RTSP (OpenCV) and optional Hikvision HCNetSDK (Linux .so).""" + +from app.services.video.session_manager import CameraSessionManager + +__all__ = ["CameraSessionManager"] diff --git a/app/services/video/backend_resolver.py b/app/services/video/backend_resolver.py new file mode 100644 index 0000000..6780e9b --- /dev/null +++ b/app/services/video/backend_resolver.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import json +from typing import Any + +from loguru import logger + +from app.config import Settings +from app.services.video.hikvision_runtime import HikvisionRuntime +from app.services.video.types import VideoBackendKind + + +class BackendResolver: + """Resolve per-camera backend (RTSP vs Hikvision SDK) and RTSP URL.""" + + def __init__( + self, + settings: Settings, + *, + hikvision_runtime: HikvisionRuntime | None, + ) -> None: + self._s = settings + self._hik = hikvision_runtime + self._rtsp_urls_map = settings.video_rtsp_url_map() + + def _parse_json_object(self, raw: str) -> dict[str, Any]: + raw = (raw or "").strip() + if not raw: + return {} + try: + data = json.loads(raw) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid JSON mapping: {exc}") from exc + if not isinstance(data, dict): + raise ValueError("JSON mapping must be an object") + return {str(k): v for k, v in data.items()} + + def backend_for_camera(self, camera_id: str) -> VideoBackendKind: + overrides = self._parse_json_object(self._s.video_camera_backend_overrides_json) + if camera_id in overrides: + v = str(overrides[camera_id]).lower() + if v in ("rtsp", "hikvision_sdk", "sdk"): + return ( + VideoBackendKind.HIKVISION_SDK + if v in ("hikvision_sdk", "sdk") + else VideoBackendKind.RTSP + ) + default = self._s.video_default_backend.strip().lower() + if default in ("auto", ""): + if self._hik is not None and self._s.hikvision_sdk_enabled: + return VideoBackendKind.HIKVISION_SDK + return VideoBackendKind.RTSP + if default in ("hikvision_sdk", "sdk"): + return VideoBackendKind.HIKVISION_SDK + return VideoBackendKind.RTSP + + def rtsp_url_for_camera(self, camera_id: str) -> str: + if camera_id in self._rtsp_urls_map: + return self._rtsp_urls_map[camera_id] + tpl = (self._s.video_rtsp_url_template or "").strip() + if tpl: + try: + return tpl.format(camera_id=camera_id) + except KeyError as exc: + raise ValueError( + f"video_rtsp_url_template missing placeholder: {exc}" + ) from exc + raise ValueError( + f"No RTSP URL for camera_id={camera_id!r}: set VIDEO_RTSP_URLS_JSON_FILE, " + f"VIDEO_RTSP_URLS_JSON, or VIDEO_RTSP_URL_TEMPLATE" + ) + + def rtsp_url_after_hikvision_login(self, camera_id: str) -> str: + """RTSP URL used after SDK login (often same as device preview URL).""" + urls = self._parse_json_object(self._s.hikvision_camera_rtsp_urls_json) + if camera_id in urls: + return str(urls[camera_id]) + tpl = (self._s.hikvision_preview_rtsp_template or "").strip() + if not tpl: + logger.warning( + "Hikvision backend without HIKVISION_PREVIEW_RTSP_TEMPLATE / " + "HIKVISION_CAMERA_RTSP_URLS_JSON — falling back to generic RTSP map" + ) + return self.rtsp_url_for_camera(camera_id) + return self._format_hikvision_rtsp(tpl, camera_id) + + def _format_hikvision_rtsp(self, template: str, camera_id: str) -> str: + ip = self._s.hikvision_device_ip.strip() + user = self._s.hikvision_user.strip() + password = self._s.hikvision_password.strip() + channel = self._s.hikvision_channel + try: + return template.format( + camera_id=camera_id, + ip=ip, + user=user, + password=password, + channel=channel, + ) + except KeyError as exc: + raise ValueError( + f"hikvision_preview_rtsp_template missing key: {exc}" + ) from exc diff --git a/app/services/video/frame_encode.py b/app/services/video/frame_encode.py new file mode 100644 index 0000000..74ffef9 --- /dev/null +++ b/app/services/video/frame_encode.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +import cv2 +import numpy as np + + +def frame_to_jpeg_bytes(frame: np.ndarray, *, quality: int = 85) -> bytes: + """Encode BGR frame to JPEG bytes for model services expecting image bytes.""" + params = [int(cv2.IMWRITE_JPEG_QUALITY), int(quality)] + ok, buf = cv2.imencode(".jpg", frame, params) + if not ok or buf is None: + raise RuntimeError("cv2.imencode failed for JPEG") + return buf.tobytes() diff --git a/app/services/video/hikvision_runtime.py b/app/services/video/hikvision_runtime.py new file mode 100644 index 0000000..21e8a3e --- /dev/null +++ b/app/services/video/hikvision_runtime.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import os +import threading +from ctypes import ( + CDLL, + POINTER, + RTLD_GLOBAL, + byref, + c_byte, + c_char_p, + c_int, + c_uint16, +) +from ctypes import Structure +from dataclasses import dataclass +from pathlib import Path + +from loguru import logger + + +@dataclass +class HikvisionLoginResult: + user_id: int + device_info_raw: bytes + + +class NET_DVR_DEVICEINFO_V30(Structure): + """Opaque device info buffer for NET_DVR_Login_V30 (layout varies by SDK version).""" + + _fields_ = [("data", c_byte * 512)] + + +@dataclass +class HikvisionRuntime: + """Loaded HCNetSDK with Init/Login/Logout/Cleanup.""" + + lib: CDLL + _inited: bool = False + + @classmethod + def try_load(cls, lib_dir: str | None) -> HikvisionRuntime | None: + candidates: list[Path] = [] + if lib_dir and lib_dir.strip(): + base = Path(lib_dir).expanduser() + candidates.append(base / "libhcnetsdk.so") + candidates.append(base / "libHCNetSDK.so") + env_path = os.environ.get("HIKVISION_LIB_PATH", "").strip() + if env_path: + candidates.append(Path(env_path).expanduser()) + candidates.append(Path("/opt/hikvision/lib/libhcnetsdk.so")) + candidates.append(Path("/opt/hikvision/lib/libHCNetSDK.so")) + + for path in candidates: + if path.is_file(): + try: + lib = CDLL(str(path), mode=RTLD_GLOBAL) + logger.info("Loaded Hikvision SDK: {}", path) + return cls(lib=lib) + except OSError as exc: + logger.warning("Failed CDLL {}: {}", path, exc) + return None + + def init(self) -> None: + if self._inited: + return + fn = getattr(self.lib, "NET_DVR_Init", None) + if fn is None: + raise RuntimeError("NET_DVR_Init not found in HCNetSDK") + fn.restype = c_int + fn.argtypes = [] + ret = int(fn()) + if ret == 0: + raise RuntimeError("NET_DVR_Init returned false") + self._inited = True + + def cleanup(self) -> None: + fn = getattr(self.lib, "NET_DVR_Cleanup", None) + if fn is None: + self._inited = False + return + fn.restype = None + fn.argtypes = [] + fn() + self._inited = False + + def login_v30( + self, + *, + ip: str, + port: int, + username: str, + password: str, + ) -> HikvisionLoginResult: + login = getattr(self.lib, "NET_DVR_Login_V30", None) + if login is None: + raise RuntimeError("NET_DVR_Login_V30 not found in HCNetSDK") + login.restype = c_int + login.argtypes = [ + c_char_p, + c_uint16, + c_char_p, + c_char_p, + POINTER(NET_DVR_DEVICEINFO_V30), + ] + dev = NET_DVR_DEVICEINFO_V30() + user_id = login( + ip.encode("utf-8"), + c_uint16(port), + username.encode("utf-8"), + password.encode("utf-8"), + byref(dev), + ) + if user_id < 0: + err = self._last_error() + raise RuntimeError(f"NET_DVR_Login_V30 failed (user_id={user_id}, err={err})") + raw = bytes(dev.data) + return HikvisionLoginResult(user_id=int(user_id), device_info_raw=raw) + + def logout(self, user_id: int) -> None: + fn = getattr(self.lib, "NET_DVR_Logout", None) + if fn is None: + return + fn.restype = c_int + fn.argtypes = [c_int] + fn(c_int(user_id)) + + def _last_error(self) -> str: + get_err = getattr(self.lib, "NET_DVR_GetLastError", None) + if get_err is None: + return "unknown" + get_err.restype = c_int + get_err.argtypes = [] + return str(int(get_err())) + + +class HikvisionInitRefCount: + """Process-wide NET_DVR_Init / NET_DVR_Cleanup pairing.""" + + _lock = threading.Lock() + _count = 0 + + @classmethod + def retain(cls, rt: HikvisionRuntime) -> None: + with cls._lock: + cls._count += 1 + if cls._count == 1: + rt.init() + + @classmethod + def release(cls, rt: HikvisionRuntime) -> None: + with cls._lock: + if cls._count <= 0: + return + cls._count -= 1 + if cls._count == 0: + rt.cleanup() diff --git a/app/services/video/rtsp_capture.py b/app/services/video/rtsp_capture.py new file mode 100644 index 0000000..21f768a --- /dev/null +++ b/app/services/video/rtsp_capture.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import time +from dataclasses import dataclass +from typing import Any + +import cv2 +import numpy as np +from loguru import logger + + +@dataclass +class RtspCapture: + """Thin OpenCV RTSP wrapper (blocking). Use from asyncio via to_thread.""" + + url: str + open_timeout_sec: float + + def __post_init__(self) -> None: + self._cap: cv2.VideoCapture | None = None + + def open(self) -> None: + self._cap = cv2.VideoCapture(self.url, cv2.CAP_FFMPEG) + if not self._cap.isOpened(): + raise RuntimeError(f"RTSP open failed (isOpened=False): {self.url!r}") + # Reduce internal buffering where supported + try: + self._cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) + except Exception: + pass + deadline = time.monotonic() + self.open_timeout_sec + while time.monotonic() < deadline: + ok, frame = self._cap.read() + if ok and frame is not None: + return + time.sleep(0.05) + raise TimeoutError( + f"RTSP first frame timeout after {self.open_timeout_sec}s: {self.url!r}" + ) + + def read(self) -> tuple[bool, np.ndarray | None]: + if self._cap is None: + return False, None + return self._cap.read() + + def release(self) -> None: + if self._cap is not None: + try: + self._cap.release() + except Exception as exc: + logger.debug("VideoCapture.release: {}", exc) + self._cap = None + + @property + def cap(self) -> Any: + return self._cap diff --git a/app/services/video/session_manager.py b/app/services/video/session_manager.py new file mode 100644 index 0000000..2fb42b7 --- /dev/null +++ b/app/services/video/session_manager.py @@ -0,0 +1,762 @@ +from __future__ import annotations + +import asyncio +import time +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Literal + +from loguru import logger + +from app.config import Settings +from app.database import AsyncSessionLocal +from app.repositories.surgery_results import SurgeryResultRepository +from app.schemas import SurgeryConsumptionDetail +from app.services.consumable_classifier import ( + ConsumableClassifierService, + PredictionCandidate, + PredictionResult, +) +from app.services.tear_action import TearActionService +from app.services.video.backend_resolver import BackendResolver +from app.services.video.frame_encode import frame_to_jpeg_bytes +from app.services.video.hikvision_runtime import HikvisionInitRefCount, HikvisionRuntime +from app.services.video.rtsp_capture import RtspCapture +from app.services.video.types import VideoBackendKind +from app.services.voice_confirm import build_prompt_text +from app.surgery_errors import SurgeryPipelineError + + +@dataclass +class PendingConsumableConfirmation: + """待客户端确认的一条低置信度识别(不阻塞后续帧推理)。""" + + id: str + status: Literal["pending", "confirmed", "rejected"] + options: list[tuple[str, float]] + prompt_text: str + created_at: datetime + model_top1_label: str + model_top1_confidence: float + + +@dataclass +class SurgerySessionState: + candidate_consumables: list[str] + details: list[SurgeryConsumptionDetail] = field(default_factory=list) + lock: asyncio.Lock = field(default_factory=asyncio.Lock) + ready: asyncio.Event = field(default_factory=asyncio.Event) + last_detail_monotonic: dict[str, float] = field(default_factory=dict) + #: 仅含 status=pending 的确认任务 id,FIFO。 + pending_fifo: list[str] = field(default_factory=list) + pending_by_id: dict[str, PendingConsumableConfirmation] = field(default_factory=dict) + last_pending_prompt_snippet: str | None = None + #: 最近一次语音确认 ASR 文本(成功识别时写入)。 + last_asr_text: str | None = None + #: 最近一次语音确认错误说明(ASR/解析失败等)。 + last_voice_error: str | None = None + + +@dataclass +class RunningSurgery: + stop_event: asyncio.Event + state: SurgerySessionState + tasks: list[asyncio.Task[None]] + + +@dataclass +class ArchivedSurgery: + details: list[SurgeryConsumptionDetail] + + +def _rank_topk_for_candidates( + topk: list[PredictionCandidate], + ordered_candidates: list[str], + *, + limit: int = 5, +) -> list[PredictionCandidate]: + if not topk: + return [] + stripped_order = [c.strip() for c in ordered_candidates if c.strip()] + if not stripped_order: + return topk[:limit] + order_index = {name: i for i, name in enumerate(stripped_order)} + picked = [c for c in topk if c.label.strip() in order_index] + picked.sort(key=lambda c: order_index[c.label.strip()]) + return picked[:limit] + + +class CameraSessionManager: + """Per-surgery camera streams, RTSP + optional Hikvision SDK login, inference, client-side human confirm.""" + + def __init__( + self, + *, + settings: Settings, + consumable_classifier: ConsumableClassifierService, + tear_action: TearActionService, + hikvision_runtime: HikvisionRuntime | None, + result_repository: SurgeryResultRepository | None = None, + ) -> None: + self._s = settings + self._classifier = consumable_classifier + self._tear = tear_action + self._hik = hikvision_runtime + self._repo = result_repository + self._resolver = BackendResolver(settings, hikvision_runtime=hikvision_runtime) + self._active: dict[str, RunningSurgery] = {} + self._archive: dict[str, ArchivedSurgery] = {} + self._manager_lock = asyncio.Lock() + self._retry_task: asyncio.Task[None] | None = None + self._retry_stop = asyncio.Event() + + async def start_archive_retry_loop(self) -> None: + if self._retry_task is not None and not self._retry_task.done(): + return + self._retry_stop.clear() + self._retry_task = asyncio.create_task( + self._archive_persist_retry_loop(), + name="archive_persist_retry", + ) + + async def shutdown(self) -> None: + self._retry_stop.set() + if self._retry_task is not None: + self._retry_task.cancel() + try: + await self._retry_task + except asyncio.CancelledError: + pass + except Exception as exc: + logger.debug("retry task shutdown: {}", exc) + self._retry_task = None + async with self._manager_lock: + ids = list(self._active.keys()) + for sid in ids: + try: + await self.stop_surgery(sid, require_active=False) + except Exception as exc: + logger.warning("shutdown stop_surgery {}: {}", sid, exc) + + async def _archive_persist_retry_loop(self) -> None: + while not self._retry_stop.is_set(): + try: + await asyncio.wait_for( + self._retry_stop.wait(), + timeout=self._s.archive_persist_retry_interval_seconds, + ) + break + except TimeoutError: + pass + ids = list(self._archive.keys()) + for sid in ids: + if self._retry_stop.is_set(): + break + await self._try_persist_archive(sid) + + async def _try_persist_archive(self, surgery_id: str) -> bool: + if self._repo is None: + return False + async with self._manager_lock: + arch = self._archive.get(surgery_id) + if arch is None: + return True + try: + async with AsyncSessionLocal() as session: + async with session.begin(): + await self._repo.save_final_result( + session, + surgery_id=surgery_id, + details=list(arch.details), + ) + except Exception as exc: + logger.warning( + "Archive persist retry failed surgery_id={}: {}", + surgery_id, + exc, + ) + return False + async with self._manager_lock: + self._archive.pop(surgery_id, None) + logger.info("Archive persisted after retry surgery_id={}", surgery_id) + return True + + async def start_surgery( + self, + surgery_id: str, + camera_ids: list[str], + candidate_consumables: list[str], + ) -> None: + stale_archive: ArchivedSurgery | None = None + async with self._manager_lock: + if surgery_id in self._active: + raise SurgeryPipelineError( + "RECORDING_CANNOT_START", + "该手术已在录制中,请勿重复开始。", + ) + if surgery_id in self._archive: + logger.warning( + "surgery_id={} 仍有未落库归档,尝试写入数据库后再开始新会话", + surgery_id, + ) + stale_archive = self._archive.pop(surgery_id) + + if stale_archive is not None: + if self._repo is None: + logger.error( + "surgery_id={} 有内存归档但未配置数据库仓库,无法持久化;" + "开始新会话将丢弃该归档(仅开发/无库模式)", + surgery_id, + ) + else: + ok = await self._persist_archived_details( + surgery_id, list(stale_archive.details) + ) + if not ok: + async with self._manager_lock: + self._archive[surgery_id] = stale_archive + raise SurgeryPipelineError( + "RECORDING_CANNOT_START", + "该手术号存在尚未写入数据库的历史结果,请修复数据库或等待自动重试成功后再开始。", + ) + + state = SurgerySessionState( + candidate_consumables=list(candidate_consumables), + ) + stop_event = asyncio.Event() + readies = [asyncio.Event() for _ in camera_ids] + tasks: list[asyncio.Task[None]] = [] + open_timeout = self._s.video_open_timeout_sec + 5.0 + + for cam_id, ready in zip(camera_ids, readies, strict=True): + tasks.append( + asyncio.create_task( + self._camera_worker( + surgery_id=surgery_id, + camera_id=cam_id, + stream_ready=ready, + stop_event=stop_event, + state=state, + ), + name=f"camera:{surgery_id}:{cam_id}", + ) + ) + + run = RunningSurgery(stop_event=stop_event, state=state, tasks=tasks) + async with self._manager_lock: + self._active[surgery_id] = run + + try: + await asyncio.wait_for( + asyncio.gather(*(r.wait() for r in readies)), + timeout=open_timeout, + ) + state.ready.set() + except TimeoutError as exc: + logger.error( + "Surgery {} cameras not all ready within {}s", + surgery_id, + open_timeout, + ) + await self.stop_surgery(surgery_id, require_active=True) + raise SurgeryPipelineError( + "RECORDING_CANNOT_START", + "开录未能确认:部分摄像头在超时内未成功拉到首帧。", + ) from exc + except Exception: + await self.stop_surgery(surgery_id, require_active=True) + raise + + async def _persist_archived_details( + self, + surgery_id: str, + details: list[SurgeryConsumptionDetail], + ) -> bool: + if self._repo is None: + return True + try: + async with AsyncSessionLocal() as session: + async with session.begin(): + await self._repo.save_final_result( + session, + surgery_id=surgery_id, + details=details, + ) + except Exception as exc: + logger.exception( + "Persist archived surgery {} failed (will keep archive): {}", + surgery_id, + exc, + ) + return False + return True + + async def stop_surgery(self, surgery_id: str, *, require_active: bool = True) -> None: + async with self._manager_lock: + run = self._active.pop(surgery_id, None) + if run is None: + if require_active: + raise SurgeryPipelineError( + "RECORDING_NOT_STOPPED", + "停录未能完成:当前没有该手术的活跃录制会话。", + ) + return + + run.stop_event.set() + results = await asyncio.gather(*run.tasks, return_exceptions=True) + for res in results: + if isinstance(res, BaseException): + logger.warning("surgery task finished with error: {}", res) + + details = list(run.state.details) + + persisted = False + if self._repo is not None: + try: + async with AsyncSessionLocal() as session: + async with session.begin(): + await self._repo.save_final_result( + session, + surgery_id=surgery_id, + details=details, + ) + persisted = True + except Exception as exc: + logger.exception("Persist surgery {} failed: {}", surgery_id, exc) + + async with self._manager_lock: + if not persisted: + self._archive[surgery_id] = ArchivedSurgery(details=details) + logger.error( + "Surgery {} final result kept in memory archive only; " + "background retry will attempt persist", + surgery_id, + ) + + def live_consumption_if_active(self, surgery_id: str) -> list[SurgeryConsumptionDetail] | None: + if surgery_id not in self._active: + return None + if not self._active[surgery_id].state.ready.is_set(): + return None + rows = list(self._active[surgery_id].state.details) + if not rows: + return None + return rows + + def archived_consumption_fallback(self, surgery_id: str) -> list[SurgeryConsumptionDetail] | None: + arch = self._archive.get(surgery_id) + if arch is None: + return None + return list(arch.details) + + def voice_status(self, surgery_id: str) -> dict[str, object] | None: + if surgery_id not in self._active: + return None + st = self._active[surgery_id].state + return { + "surgery_id": surgery_id, + "voice_enabled": bool(self._s.voice_confirmation_enabled), + "pending_queue_approx": len(st.pending_fifo), + "last_prompt_snippet": st.last_pending_prompt_snippet, + "last_asr_text": st.last_asr_text, + "last_error": st.last_voice_error, + } + + def record_voice_trace( + self, + surgery_id: str, + *, + asr_text: str | None, + error: str | None, + ) -> None: + if surgery_id not in self._active: + return + st = self._active[surgery_id].state + st.last_asr_text = asr_text + st.last_voice_error = error + + def get_pending_confirmation_by_id( + self, + surgery_id: str, + confirmation_id: str, + ) -> PendingConsumableConfirmation | None: + if surgery_id not in self._active: + return None + p = self._active[surgery_id].state.pending_by_id.get(confirmation_id) + if p is None or p.status != "pending": + return None + return p + + def next_pending_confirmation( + self, surgery_id: str + ) -> PendingConsumableConfirmation | None: + if surgery_id not in self._active: + return None + st = self._active[surgery_id].state + for cid in st.pending_fifo: + p = st.pending_by_id.get(cid) + if p is not None and p.status == "pending": + return p + return None + + async def resolve_pending_confirmation( + self, + surgery_id: str, + confirmation_id: str, + *, + chosen_label: str | None, + rejected: bool, + ) -> None: + if surgery_id not in self._active: + raise SurgeryPipelineError( + "CONFIRMATION_NOT_ACTIVE", + "该手术当前不在进行中,无法提交确认。", + ) + st = self._active[surgery_id].state + async with st.lock: + pending = st.pending_by_id.get(confirmation_id) + if pending is None: + raise SurgeryPipelineError( + "CONFIRMATION_NOT_FOUND", + "未找到该待确认项或已处理。", + ) + if pending.status != "pending": + raise SurgeryPipelineError( + "CONFIRMATION_ALREADY_RESOLVED", + "该待确认项已处理。", + ) + if rejected and chosen_label: + raise SurgeryPipelineError( + "CONFIRMATION_INVALID", + "拒绝确认时不应同时提供 chosen_label。", + ) + if not rejected and not chosen_label: + raise SurgeryPipelineError( + "CONFIRMATION_INVALID", + "请提供 chosen_label 或设置 rejected=true。", + ) + allowed = {lbl.strip() for lbl, _ in pending.options if lbl.strip()} + if rejected: + pending.status = "rejected" + else: + label = chosen_label.strip() if chosen_label else "" + if label not in allowed: + raise SurgeryPipelineError( + "CONFIRMATION_INVALID", + f"所选耗材不在候选列表中:{chosen_label!r}", + ) + pending.status = "confirmed" + self._append_confirmed_detail_locked( + state=st, + item_id=label, + item_name=label, + doctor_id=self._s.video_voice_confirm_doctor_id, + source="voice", + ) + try: + idx = st.pending_fifo.index(confirmation_id) + st.pending_fifo.pop(idx) + except ValueError: + pass + st.pending_by_id.pop(confirmation_id, None) + + def _append_confirmed_detail_locked( + self, + *, + state: SurgerySessionState, + item_id: str, + item_name: str, + doctor_id: str, + source: str, + ) -> None: + """在已持有 `state.lock` 时追加一条消耗明细。""" + now_m = time.monotonic() + cooldown = self._s.video_detail_cooldown_sec + prev = state.last_detail_monotonic.get(item_id) + if prev is not None and (now_m - prev) < cooldown: + return + state.last_detail_monotonic[item_id] = now_m + state.details.append( + SurgeryConsumptionDetail( + item_id=item_id, + item_name=item_name, + quantity=1, + doctor_id=doctor_id, + timestamp=datetime.now(timezone.utc), + source=source, + ) + ) + + async def _append_confirmed_detail( + self, + *, + state: SurgerySessionState, + item_id: str, + item_name: str, + doctor_id: str, + source: str, + ) -> None: + async with state.lock: + self._append_confirmed_detail_locked( + state=state, + item_id=item_id, + item_name=item_name, + doctor_id=doctor_id, + source=source, + ) + + async def _camera_worker( + self, + *, + surgery_id: str, + camera_id: str, + stream_ready: asyncio.Event, + stop_event: asyncio.Event, + state: SurgerySessionState, + ) -> None: + kind = self._resolver.backend_for_camera(camera_id) + cap: RtspCapture | None = None + hik_user_id: int | None = None + hik_init_retained = False + url: str | None = None + consecutive_failures = 0 + first_ready = True + + try: + url, hik_user_id, hik_init_retained = await self._resolve_rtsp_url( + camera_id=camera_id, + kind=kind, + ) + assert url is not None + last_infer = 0.0 + while not stop_event.is_set(): + if cap is None: + try: + cap = RtspCapture(url, open_timeout_sec=self._s.video_open_timeout_sec) + await asyncio.to_thread(cap.open) + consecutive_failures = 0 + if first_ready: + stream_ready.set() + first_ready = False + logger.info( + "RTSP stream opened camera={} surgery={}", + camera_id, + surgery_id, + ) + except Exception as exc: + logger.warning( + "RTSP open failed camera={} surgery={}: {}", + camera_id, + surgery_id, + exc, + ) + if cap is not None: + await asyncio.to_thread(cap.release) + cap = None + await asyncio.sleep(self._s.video_reconnect_backoff_seconds) + continue + + ok, frame = await asyncio.to_thread(cap.read) + if not ok or frame is None: + consecutive_failures += 1 + if consecutive_failures >= self._s.video_read_failure_reconnect_threshold: + logger.warning( + "RTSP reconnect camera={} surgery={} after {} read failures", + camera_id, + surgery_id, + consecutive_failures, + ) + await asyncio.to_thread(cap.release) + cap = None + consecutive_failures = 0 + await asyncio.sleep(self._s.video_reconnect_backoff_seconds) + else: + await asyncio.sleep(0.05) + continue + + consecutive_failures = 0 + now = time.monotonic() + if now - last_infer < self._s.video_inference_interval_sec: + await asyncio.sleep(0.01) + continue + last_infer = now + try: + jpeg = await asyncio.to_thread( + frame_to_jpeg_bytes, + frame, + quality=self._s.video_jpeg_quality, + ) + cls_res = await self._classifier.predict_image_bytes(jpeg) + tear_res = await self._tear.predict_image_bytes(jpeg) + except Exception as exc: + logger.debug( + "Inference skip camera={} surgery={}: {}", + camera_id, + surgery_id, + exc, + ) + continue + + await self._handle_classification_result( + state=state, + cls_res=cls_res, + tear_label=tear_res.label, + ) + finally: + if cap is not None: + await asyncio.to_thread(cap.release) + if hik_user_id is not None and self._hik is not None: + await asyncio.to_thread(self._hik.logout, hik_user_id) + if hik_init_retained and self._hik is not None: + HikvisionInitRefCount.release(self._hik) + + async def _handle_classification_result( + self, + *, + state: SurgerySessionState, + cls_res: PredictionResult, + tear_label: str, + ) -> None: + _ = tear_label + conf = cls_res.confidence + label = (cls_res.label or "").strip() + voice_floor = self._s.video_voice_confirm_min_confidence + if conf < voice_floor: + return + + cand_order = [c.strip() for c in state.candidate_consumables if c.strip()] + if not cand_order: + return + + cand_set = set(cand_order) + ranked = _rank_topk_for_candidates(cls_res.topk, cand_order) + auto_th = self._s.video_auto_confirm_confidence + + def in_allowed(name: str) -> bool: + return name in cand_set + + if conf >= auto_th and in_allowed(label): + await self._append_confirmed_detail( + state=state, + item_id=label or "unknown", + item_name=label or "unknown", + doctor_id=self._s.video_result_doctor_id, + source="vision", + ) + return + + if conf >= auto_th and not in_allowed(label): + if ranked and self._s.voice_confirmation_enabled: + await self._maybe_enqueue_pending_confirmation( + state, ranked, top_key=label, top_confidence=conf + ) + return + + if not self._s.voice_confirmation_enabled: + return + + if ranked: + await self._maybe_enqueue_pending_confirmation( + state, ranked, top_key=label, top_confidence=conf + ) + elif in_allowed(label): + await self._maybe_enqueue_pending_confirmation( + state, + [PredictionCandidate(label=label, confidence=conf)], + top_key=label, + top_confidence=conf, + ) + + async def _maybe_enqueue_pending_confirmation( + self, + state: SurgerySessionState, + ranked: list[PredictionCandidate], + *, + top_key: str, + top_confidence: float, + ) -> None: + opts = [(c.label.strip(), float(c.confidence)) for c in ranked if c.label.strip()] + if not opts: + return + now_m = time.monotonic() + cooldown = self._s.video_detail_cooldown_sec + dedupe_key = f"pending_confirm:{top_key}:{opts[0][0]}" + async with state.lock: + prev = state.last_detail_monotonic.get(dedupe_key) + if prev is not None and (now_m - prev) < cooldown: + return + state.last_detail_monotonic[dedupe_key] = now_m + + confirm_id = str(uuid.uuid4()) + prompt = build_prompt_text(opts) + pending = PendingConsumableConfirmation( + id=confirm_id, + status="pending", + options=list(opts), + prompt_text=prompt, + created_at=datetime.now(timezone.utc), + model_top1_label=top_key, + model_top1_confidence=top_confidence, + ) + state.pending_by_id[confirm_id] = pending + state.pending_fifo.append(confirm_id) + state.last_pending_prompt_snippet = prompt[:200] + + logger.info( + "Enqueued pending consumable confirmation id={} top_key={}", + confirm_id, + top_key, + ) + + async def _resolve_rtsp_url( + self, + *, + camera_id: str, + kind: VideoBackendKind, + ) -> tuple[str, int | None, bool]: + """Returns (url, hikvision_user_id, whether NET_DVR_Init refcount was retained).""" + if kind != VideoBackendKind.HIKVISION_SDK: + return self._resolver.rtsp_url_for_camera(camera_id), None, False + + if self._hik is None: + if self._s.hikvision_sdk_fallback_to_rtsp: + logger.warning( + "Hikvision SDK not loaded; fallback to RTSP for camera {}", + camera_id, + ) + return self._resolver.rtsp_url_for_camera(camera_id), None, False + raise RuntimeError("Hikvision SDK requested but libhcnetsdk.so not loaded") + + if not ( + self._s.hikvision_device_ip.strip() + and self._s.hikvision_user.strip() + and self._s.hikvision_password.strip() + ): + if self._s.hikvision_sdk_fallback_to_rtsp: + logger.warning( + "Hikvision credentials incomplete; fallback to RTSP for camera {}", + camera_id, + ) + return self._resolver.rtsp_url_for_camera(camera_id), None, False + raise RuntimeError("Hikvision SDK requires HIKVISION_DEVICE_IP, user, password") + + HikvisionInitRefCount.retain(self._hik) + try: + login = await asyncio.to_thread( + lambda: self._hik.login_v30( + ip=self._s.hikvision_device_ip.strip(), + port=int(self._s.hikvision_device_port), + username=self._s.hikvision_user.strip(), + password=self._s.hikvision_password.strip(), + ) + ) + except Exception as exc: + HikvisionInitRefCount.release(self._hik) + if self._s.hikvision_sdk_fallback_to_rtsp: + logger.warning("Hikvision login failed ({}); fallback to RTSP", exc) + return self._resolver.rtsp_url_for_camera(camera_id), None, False + raise + + url = self._resolver.rtsp_url_after_hikvision_login(camera_id) + return url, login.user_id, True diff --git a/app/services/video/types.py b/app/services/video/types.py new file mode 100644 index 0000000..42eebba --- /dev/null +++ b/app/services/video/types.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from enum import StrEnum +from typing import Protocol, runtime_checkable + + +class VideoBackendKind(StrEnum): + """Which transport is used for a camera stream.""" + + RTSP = "rtsp" + HIKVISION_SDK = "hikvision_sdk" + + +@runtime_checkable +class StreamStopHandle(Protocol): + """Handle returned after a stream is started; call to release resources.""" + + async def stop(self) -> None: + ... diff --git a/app/services/voice_confirm.py b/app/services/voice_confirm.py new file mode 100644 index 0000000..b568153 --- /dev/null +++ b/app/services/voice_confirm.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +import asyncio +import os +import platform +import re +import shutil +import subprocess +import tempfile +from dataclasses import dataclass + +from fastapi.concurrency import run_in_threadpool +from loguru import logger + +from app.config import Settings +from app.services.baidu_speech import BaiduSpeechNotConfiguredError, BaiduSpeechService + + +_CN_DIGITS = { + "零": 0, + "一": 1, + "二": 2, + "两": 2, + "三": 3, + "四": 4, + "五": 5, + "六": 6, + "七": 7, + "八": 8, + "九": 9, + "十": 10, +} + + +def parse_voice_choice(asr_text: str, options: list[str]) -> str | None: + """ + 从识别文本中解析医生选择的耗材名称。 + 支持:完全匹配、子串匹配、第 N 个(1/一/第一个)。 + """ + raw = (asr_text or "").strip() + if not raw: + return None + normalized = raw.replace(" ", "").lower() + + for opt in options: + if opt and opt in raw: + return opt + + m_num = re.search(r"(\d+)", raw) + if m_num: + idx = int(m_num.group(1)) - 1 + if 0 <= idx < len(options): + return options[idx] + + m_cn = re.search(r"第([一二两三四五六七八九十\d]+)个", raw) + if m_cn: + token = m_cn.group(1) + if token.isdigit(): + idx = int(token) - 1 + elif token in _CN_DIGITS: + idx = _CN_DIGITS[token] - 1 + else: + idx = -1 + if 0 <= idx < len(options): + return options[idx] + + for i, opt in enumerate(options): + if not opt: + continue + aliases = [f"第{i + 1}个", f"第{i + 1}", f"{i + 1}号"] + if any(a in normalized for a in aliases): + return opt + + negatives = ("不是", "没有", "否", "无", "错") + if any(n in raw for n in negatives): + return None + + return None + + +def is_rejection_phrase(asr_text: str) -> bool: + """医生明确否认全部候选时返回 True(须在 parse_voice_choice 之前调用)。""" + raw = (asr_text or "").strip() + if not raw: + return False + negatives = ("不是", "没有", "否", "无", "错") + return any(n in raw for n in negatives) + + +def build_prompt_text(options: list[tuple[str, float]]) -> str: + parts = ["请确认刚才使用的耗材是下面哪一项,可以说序号或名称。"] + for i, (name, _conf) in enumerate(options, start=1): + parts.append(f"第{i}个,{name}。") + parts.append("若都不是请说不是。") + return "".join(parts) + + +@dataclass +class VoiceAttemptResult: + chosen_label: str | None + asr_text: str | None + error: str | None + + +class VoiceConfirmationOrchestrator: + """服务端 TTS 播报 + ffmpeg 采集 + 百度 ASR + 文本解析。""" + + def __init__(self, settings: Settings, baidu: BaiduSpeechService) -> None: + self._s = settings + self._baidu = baidu + self._lock = asyncio.Lock() + + def _ffplay_path(self) -> str | None: + return shutil.which("ffplay") + + def _ffmpeg_path(self) -> str | None: + return shutil.which("ffmpeg") + + def _record_pcm_ffmpeg(self, seconds: float) -> tuple[bytes | None, str | None]: + ffmpeg = self._ffmpeg_path() + if not ffmpeg: + return None, "ffmpeg not found in PATH" + system = platform.system() + if system == "Darwin": + dev = self._s.voice_ffmpeg_input.strip() or ":0" + input_args = ["-f", "avfoundation", "-i", dev] + else: + dev = self._s.voice_ffmpeg_input.strip() or "default" + input_args = ["-f", "alsa", "-i", dev] + + cmd = [ + ffmpeg, + "-nostdin", + "-loglevel", + "error", + "-y", + *input_args, + "-t", + str(seconds), + "-ar", + "16000", + "-ac", + "1", + "-f", + "s16le", + "-acodec", + "pcm_s16le", + "pipe:1", + ] + try: + proc = subprocess.run( + cmd, + capture_output=True, + timeout=seconds + 5.0, + check=False, + ) + except subprocess.TimeoutExpired: + return None, "ffmpeg record timeout" + if proc.returncode != 0: + err = (proc.stderr or b"").decode("utf-8", errors="replace") + return None, f"ffmpeg failed: {err or proc.returncode}" + return proc.stdout, None + + def _play_mp3_file(self, path: str) -> str | None: + ffplay = self._ffplay_path() + if not ffplay: + return "ffplay not found in PATH" + try: + proc = subprocess.run( + [ + ffplay, + "-nodisp", + "-autoexit", + "-loglevel", + "quiet", + path, + ], + capture_output=True, + timeout=120.0, + check=False, + ) + except subprocess.TimeoutExpired: + return "ffplay timeout" + if proc.returncode != 0: + return f"ffplay exit {proc.returncode}" + return None + + def _synthesize_to_temp_mp3(self, text: str) -> tuple[str | None, str | None]: + try: + audio = self._baidu.synthesis( + text, + "zh", + 1, + {"spd": 5, "pit": 5, "vol": 9, "per": 0}, + ) + except BaiduSpeechNotConfiguredError as exc: + return None, str(exc) + if isinstance(audio, dict): + return None, f"TTS error: {audio!r}" + tmp = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) + try: + tmp.write(audio) + tmp.flush() + path = tmp.name + finally: + tmp.close() + return path, None + + async def run_confirmation( + self, + *, + surgery_id: str, + options: list[tuple[str, float]], + ) -> VoiceAttemptResult: + if not self._s.voice_confirmation_enabled: + return VoiceAttemptResult(None, None, "voice_confirmation_disabled") + if not options: + return VoiceAttemptResult(None, None, "no_options") + if not self._baidu.configured: + return VoiceAttemptResult(None, None, "baidu_speech_not_configured") + + labels = [o[0] for o in options] + prompt = build_prompt_text(options) + logger.info("Voice confirm surgery={} prompt_len={}", surgery_id, len(prompt)) + + async with self._lock: + mp3_path, err = await run_in_threadpool(self._synthesize_to_temp_mp3, prompt) + if err or not mp3_path: + return VoiceAttemptResult(None, None, err or "tts_failed") + try: + play_err = await run_in_threadpool(self._play_mp3_file, mp3_path) + if play_err: + return VoiceAttemptResult(None, None, play_err) + finally: + try: + os.unlink(mp3_path) + except OSError: + pass + + pcm, rec_err = await run_in_threadpool( + self._record_pcm_ffmpeg, float(self._s.voice_record_seconds) + ) + if rec_err or not pcm: + return VoiceAttemptResult(None, None, rec_err or "empty_audio") + + asr_payload = await run_in_threadpool(self._baidu.asr, pcm, "pcm", 16000, None) + if not isinstance(asr_payload, dict): + return VoiceAttemptResult(None, None, "asr_invalid_response") + if asr_payload.get("err_no") != 0: + return VoiceAttemptResult( + None, + None, + f"asr_err_{asr_payload.get('err_no')}: {asr_payload.get('err_msg')}", + ) + results = asr_payload.get("result") + text: str | None = None + if isinstance(results, list) and results: + text = str(results[0]) + elif isinstance(results, str): + text = results + if not text: + return VoiceAttemptResult(None, None, "asr_empty_text") + + chosen = parse_voice_choice(text, labels) + return VoiceAttemptResult(chosen, text, None) diff --git a/app/services/voice_resolution.py b/app/services/voice_resolution.py new file mode 100644 index 0000000..9cb4cab --- /dev/null +++ b/app/services/voice_resolution.py @@ -0,0 +1,349 @@ +"""Resolve pending consumable confirmation from uploaded WAV: MinIO + Baidu ASR + parse.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass + +from fastapi.concurrency import run_in_threadpool +from loguru import logger + +from app.config import Settings +from app.database import AsyncSessionLocal +from app.repositories.voice_audits import VoiceAuditRepository +from app.services.audio_wav import WavDecodeError, wav_bytes_to_pcm16k_mono_s16le +from app.services.baidu_speech import BaiduSpeechNotConfiguredError, BaiduSpeechService +from app.services.minio_audio_storage import MinioAudioStorageService, StoredAudio +from app.services.video.session_manager import CameraSessionManager +from app.services.voice_confirm import is_rejection_phrase, parse_voice_choice +from app.surgery_errors import SurgeryPipelineError + + +@dataclass(frozen=True) +class VoiceResolveResult: + resolved_label: str | None + rejected: bool + asr_text: str | None + audio_object_key: str | None + message: str + + +class VoiceConfirmationService: + """Upload audio to MinIO, run Baidu ASR, parse choice, resolve pending queue entry.""" + + def __init__( + self, + settings: Settings, + sessions: CameraSessionManager, + baidu: BaiduSpeechService, + minio: MinioAudioStorageService, + audits: VoiceAuditRepository, + ) -> None: + self._s = settings + self._sessions = sessions + self._baidu = baidu + self._minio = minio + self._audits = audits + + async def resolve_from_wav( + self, + *, + surgery_id: str, + confirmation_id: str, + wav_bytes: bytes, + filename: str, + content_type: str | None, + ) -> VoiceResolveResult: + _ = filename # reserved for logging / future MIME sniff + + if len(wav_bytes) > self._s.voice_upload_max_bytes: + await self._persist_audit( + surgery_id=surgery_id, + confirmation_id=confirmation_id, + status="invalid_audio", + audio_object_key=None, + audio_content_type=content_type, + audio_size_bytes=len(wav_bytes), + audio_sha256=None, + asr_text=None, + resolved_label=None, + options_snapshot_json=None, + error_message="音频超过大小限制", + ) + raise SurgeryPipelineError( + "VOICE_AUDIO_INVALID", + f"音频大小超过限制(最大 {self._s.voice_upload_max_bytes} 字节)。", + ) + + if not self._minio.configured: + raise SurgeryPipelineError( + "MINIO_NOT_CONFIGURED", + "服务端未配置 MinIO,无法保存语音追溯文件。", + ) + + if not self._baidu.configured: + raise SurgeryPipelineError( + "BAIDU_NOT_CONFIGURED", + "服务端未配置百度语音,无法进行语音识别。", + ) + + pending = self._sessions.get_pending_confirmation_by_id( + surgery_id, confirmation_id + ) + if pending is None: + raise SurgeryPipelineError( + "CONFIRMATION_NOT_FOUND", + "未找到该待确认项或已处理。", + ) + + option_labels = [a.strip() for a, _ in pending.options if a.strip()] + options_snapshot = json.dumps( + [{"label": a, "confidence": b} for a, b in pending.options], + ensure_ascii=False, + ) + + stored: StoredAudio | None = None + try: + await run_in_threadpool(self._minio.ensure_bucket) + stored = await run_in_threadpool( + lambda: self._minio.upload_voice_wav( + surgery_id=surgery_id, + confirmation_id=confirmation_id, + data=wav_bytes, + content_type=content_type, + ) + ) + except Exception as exc: + logger.warning("MinIO upload failed: {}", exc) + await self._persist_audit( + surgery_id=surgery_id, + confirmation_id=confirmation_id, + status="upload_failed", + audio_object_key=None, + audio_content_type=content_type, + audio_size_bytes=len(wav_bytes), + audio_sha256=None, + asr_text=None, + resolved_label=None, + options_snapshot_json=options_snapshot, + error_message=str(exc), + ) + self._sessions.record_voice_trace(surgery_id, asr_text=None, error=str(exc)) + raise SurgeryPipelineError( + "MINIO_UPLOAD_FAILED", + f"语音文件上传失败:{exc}", + ) from exc + + try: + pcm = await run_in_threadpool(wav_bytes_to_pcm16k_mono_s16le, wav_bytes) + except WavDecodeError as exc: + await self._persist_audit( + surgery_id=surgery_id, + confirmation_id=confirmation_id, + status="invalid_audio", + audio_object_key=stored.object_key, + audio_content_type=content_type, + audio_size_bytes=stored.size_bytes, + audio_sha256=stored.sha256_hex, + asr_text=None, + resolved_label=None, + options_snapshot_json=options_snapshot, + error_message=str(exc), + ) + self._sessions.record_voice_trace(surgery_id, asr_text=None, error=str(exc)) + raise SurgeryPipelineError( + "VOICE_AUDIO_INVALID", + f"无法解析 WAV 音频:{exc}", + ) from exc + + try: + asr_payload = await run_in_threadpool( + self._baidu.asr, pcm, "pcm", 16000, None + ) + except BaiduSpeechNotConfiguredError as exc: + raise SurgeryPipelineError( + "BAIDU_NOT_CONFIGURED", + str(exc), + ) from exc + except Exception as exc: + await self._persist_audit( + surgery_id=surgery_id, + confirmation_id=confirmation_id, + status="asr_failed", + audio_object_key=stored.object_key, + audio_content_type=content_type, + audio_size_bytes=stored.size_bytes, + audio_sha256=stored.sha256_hex, + asr_text=None, + resolved_label=None, + options_snapshot_json=options_snapshot, + error_message=str(exc), + ) + self._sessions.record_voice_trace(surgery_id, asr_text=None, error=str(exc)) + raise SurgeryPipelineError( + "VOICE_ASR_FAILED", + f"语音识别调用失败:{exc}", + ) from exc + + if not isinstance(asr_payload, dict): + msg = "ASR 返回格式异常" + await self._persist_audit( + surgery_id=surgery_id, + confirmation_id=confirmation_id, + status="asr_failed", + audio_object_key=stored.object_key, + audio_content_type=content_type, + audio_size_bytes=stored.size_bytes, + audio_sha256=stored.sha256_hex, + asr_text=None, + resolved_label=None, + options_snapshot_json=options_snapshot, + error_message=msg, + ) + self._sessions.record_voice_trace(surgery_id, asr_text=None, error=msg) + raise SurgeryPipelineError("VOICE_ASR_FAILED", msg) + + if asr_payload.get("err_no") != 0: + msg = ( + f"asr_err_{asr_payload.get('err_no')}: " + f"{asr_payload.get('err_msg')}" + ) + await self._persist_audit( + surgery_id=surgery_id, + confirmation_id=confirmation_id, + status="asr_failed", + audio_object_key=stored.object_key, + audio_content_type=content_type, + audio_size_bytes=stored.size_bytes, + audio_sha256=stored.sha256_hex, + asr_text=None, + resolved_label=None, + options_snapshot_json=options_snapshot, + error_message=msg, + ) + self._sessions.record_voice_trace(surgery_id, asr_text=None, error=msg) + raise SurgeryPipelineError("VOICE_ASR_FAILED", msg) + + results = asr_payload.get("result") + text: str | None = None + if isinstance(results, list) and results: + text = str(results[0]) + elif isinstance(results, str): + text = results + text = (text or "").strip() + + if not text: + msg = "语音识别结果为空" + await self._persist_audit( + surgery_id=surgery_id, + confirmation_id=confirmation_id, + status="asr_failed", + audio_object_key=stored.object_key, + audio_content_type=content_type, + audio_size_bytes=stored.size_bytes, + audio_sha256=stored.sha256_hex, + asr_text=None, + resolved_label=None, + options_snapshot_json=options_snapshot, + error_message=msg, + ) + self._sessions.record_voice_trace(surgery_id, asr_text=None, error=msg) + raise SurgeryPipelineError("VOICE_ASR_FAILED", msg) + + self._sessions.record_voice_trace(surgery_id, asr_text=text, error=None) + + rejected = is_rejection_phrase(text) + chosen: str | None = None + if not rejected: + chosen = parse_voice_choice(text, option_labels) + + if not rejected and not chosen: + msg = "无法从语音中匹配候选项,请重试或说「不是」否认全部" + await self._persist_audit( + surgery_id=surgery_id, + confirmation_id=confirmation_id, + status="parse_failed", + audio_object_key=stored.object_key, + audio_content_type=content_type, + audio_size_bytes=stored.size_bytes, + audio_sha256=stored.sha256_hex, + asr_text=text, + resolved_label=None, + options_snapshot_json=options_snapshot, + error_message=msg, + ) + self._sessions.record_voice_trace(surgery_id, asr_text=text, error=msg) + raise SurgeryPipelineError("VOICE_PARSE_FAILED", msg) + + await self._sessions.resolve_pending_confirmation( + surgery_id, + confirmation_id, + chosen_label=chosen, + rejected=rejected, + ) + + final_status = "rejected" if rejected else "recognized" + await self._persist_audit( + surgery_id=surgery_id, + confirmation_id=confirmation_id, + status=final_status, + audio_object_key=stored.object_key, + audio_content_type=content_type, + audio_size_bytes=stored.size_bytes, + audio_sha256=stored.sha256_hex, + asr_text=text, + resolved_label=chosen if not rejected else None, + options_snapshot_json=options_snapshot, + error_message=None, + ) + + if rejected: + return VoiceResolveResult( + resolved_label=None, + rejected=True, + asr_text=text, + audio_object_key=stored.object_key, + message="已否认全部候选,未记消耗。", + ) + return VoiceResolveResult( + resolved_label=chosen, + rejected=False, + asr_text=text, + audio_object_key=stored.object_key, + message="已确认并记一条消耗。", + ) + + async def _persist_audit( + self, + *, + surgery_id: str, + confirmation_id: str, + status: str, + audio_object_key: str | None, + audio_content_type: str | None, + audio_size_bytes: int | None, + audio_sha256: str | None, + asr_text: str | None, + resolved_label: str | None, + options_snapshot_json: str | None, + error_message: str | None, + ) -> None: + try: + async with AsyncSessionLocal() as session: + async with session.begin(): + await self._audits.save_audit( + session, + surgery_id=surgery_id, + confirmation_id=confirmation_id, + status=status, + audio_object_key=audio_object_key, + audio_content_type=audio_content_type, + audio_size_bytes=audio_size_bytes, + audio_sha256=audio_sha256, + asr_text=asr_text, + resolved_label=resolved_label, + options_snapshot_json=options_snapshot_json, + error_message=error_message, + ) + except Exception as exc: + logger.error("Persist voice audit failed: {}", exc) diff --git a/app/surgery_errors.py b/app/surgery_errors.py new file mode 100644 index 0000000..a67764b --- /dev/null +++ b/app/surgery_errors.py @@ -0,0 +1,10 @@ +"""Errors surfaced by the surgery recording / result pipeline.""" + + +class SurgeryPipelineError(Exception): + """录制未能按约定完成启动或停止。""" + + def __init__(self, code: str, message: str) -> None: + self.code = code + self.message = message + super().__init__(message) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 7c412aa..e685897 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,4 +1,10 @@ -# Local development: PostgreSQL only. Run the API on the host with ./start.sh or `uv run`. +# Local development stack. You can either: +# 1. run `docker compose -f docker-compose.dev.yml up --build` for API + DB in containers, or +# 2. run `./start.sh` to start only DB and keep the API on the host with hot reload. +# +# Default host ports avoid common 5432/8000 clashes when many services run in parallel: +# Postgres published: POSTGRES_PORT -> 35432 (container still listens on 5432) +# API published: API_PORT -> 38080 (uvicorn inside container still listens on 8000) services: db: image: postgres:16-alpine @@ -7,7 +13,7 @@ services: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} POSTGRES_DB: ${POSTGRES_DB:-operation_room} ports: - - "${POSTGRES_PORT:-5432}:5432" + - "${POSTGRES_PORT:-35432}:5432" volumes: - pgdata_dev:/var/lib/postgresql/data healthcheck: @@ -17,5 +23,54 @@ services: retries: 20 start_period: 5s + api: + build: + context: . + dockerfile: Dockerfile + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + POSTGRES_DB: ${POSTGRES_DB:-operation_room} + POSTGRES_HOST: db + POSTGRES_PORT: 5432 + CONSUMABLE_CLASSIFIER_IMGSZ: ${CONSUMABLE_CLASSIFIER_IMGSZ:-224} + CONSUMABLE_CLASSIFIER_DEVICE: ${CONSUMABLE_CLASSIFIER_DEVICE:-} + CONSUMABLE_CLASSIFIER_TOPK: ${CONSUMABLE_CLASSIFIER_TOPK:-5} + TEAR_ACTION_IMGSZ: ${TEAR_ACTION_IMGSZ:-224} + TEAR_ACTION_DEVICE: ${TEAR_ACTION_DEVICE:-} + TEAR_ACTION_TOPK: ${TEAR_ACTION_TOPK:-5} + VIDEO_DEFAULT_BACKEND: ${VIDEO_DEFAULT_BACKEND:-rtsp} + VIDEO_RTSP_URL_TEMPLATE: ${VIDEO_RTSP_URL_TEMPLATE:-} + VIDEO_RTSP_URLS_JSON_FILE: ${VIDEO_RTSP_URLS_JSON_FILE:-} + VIDEO_RTSP_URLS_JSON: ${VIDEO_RTSP_URLS_JSON:-} + VIDEO_CAMERA_BACKEND_OVERRIDES_JSON: ${VIDEO_CAMERA_BACKEND_OVERRIDES_JSON:-} + HIKVISION_SDK_ENABLED: ${HIKVISION_SDK_ENABLED:-false} + HIKVISION_LIB_DIR: ${HIKVISION_LIB_DIR:-/opt/hikvision/lib} + HIKVISION_DEVICE_IP: ${HIKVISION_DEVICE_IP:-} + HIKVISION_USER: ${HIKVISION_USER:-} + HIKVISION_PASSWORD: ${HIKVISION_PASSWORD:-} + HIKVISION_PREVIEW_RTSP_TEMPLATE: ${HIKVISION_PREVIEW_RTSP_TEMPLATE:-} + ports: + - "${API_PORT:-38080}:8000" + command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] + volumes: + - ./app:/app/app + - ./main.py:/app/main.py + depends_on: + db: + condition: service_healthy + healthcheck: + test: + [ + "CMD", + "python", + "-c", + "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)", + ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + volumes: pgdata_dev: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index ad21cbd..abab7eb 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,4 +1,11 @@ -# Production: PostgreSQL + API. Set strong secrets via environment or `.env` (not committed). +# Production stack for the current codebase: FastAPI + PostgreSQL. +# The API hard-fails on startup if the database is not reachable, so DB health is required. +# +# Published API port defaults to 38080 on the host (override with API_PORT). +# +# GPU (NVIDIA) inference: uv.lock pins torch/torchvision from the PyTorch *CPU* index (see pyproject.toml). +# For CUDA on Linux, use a separate image/lockfile that installs torch+cu*, NVIDIA Container Toolkit on the host, +# and assign GPUs to the api service (deploy.resources.reservations.devices). services: db: image: postgres:16-alpine @@ -21,13 +28,47 @@ services: context: . dockerfile: Dockerfile environment: - DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-operation_room} + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB:-operation_room} + POSTGRES_HOST: db + POSTGRES_PORT: 5432 + CONSUMABLE_CLASSIFIER_IMGSZ: ${CONSUMABLE_CLASSIFIER_IMGSZ:-224} + CONSUMABLE_CLASSIFIER_DEVICE: ${CONSUMABLE_CLASSIFIER_DEVICE:-} + CONSUMABLE_CLASSIFIER_TOPK: ${CONSUMABLE_CLASSIFIER_TOPK:-5} + TEAR_ACTION_IMGSZ: ${TEAR_ACTION_IMGSZ:-224} + TEAR_ACTION_DEVICE: ${TEAR_ACTION_DEVICE:-} + TEAR_ACTION_TOPK: ${TEAR_ACTION_TOPK:-5} + # Video backends (RTSP / optional Hikvision SDK) — see docs/video-backends.md + VIDEO_DEFAULT_BACKEND: ${VIDEO_DEFAULT_BACKEND:-rtsp} + VIDEO_RTSP_URL_TEMPLATE: ${VIDEO_RTSP_URL_TEMPLATE:-} + VIDEO_RTSP_URLS_JSON_FILE: ${VIDEO_RTSP_URLS_JSON_FILE:-} + VIDEO_RTSP_URLS_JSON: ${VIDEO_RTSP_URLS_JSON:-} + VIDEO_CAMERA_BACKEND_OVERRIDES_JSON: ${VIDEO_CAMERA_BACKEND_OVERRIDES_JSON:-} + HIKVISION_SDK_ENABLED: ${HIKVISION_SDK_ENABLED:-false} + HIKVISION_LIB_DIR: ${HIKVISION_LIB_DIR:-/opt/hikvision/lib} + HIKVISION_DEVICE_IP: ${HIKVISION_DEVICE_IP:-} + HIKVISION_USER: ${HIKVISION_USER:-} + HIKVISION_PASSWORD: ${HIKVISION_PASSWORD:-} + HIKVISION_PREVIEW_RTSP_TEMPLATE: ${HIKVISION_PREVIEW_RTSP_TEMPLATE:-} ports: - - "${API_PORT:-8000}:8000" + - "${API_PORT:-38080}:8000" depends_on: db: condition: service_healthy restart: unless-stopped + healthcheck: + test: + [ + "CMD", + "python", + "-c", + "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)", + ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s volumes: pgdata_prod: diff --git a/docs/staging-regression-checklist.md b/docs/staging-regression-checklist.md new file mode 100644 index 0000000..c7918dc --- /dev/null +++ b/docs/staging-regression-checklist.md @@ -0,0 +1,46 @@ +# 预发 / 联调回归清单 + +在具备 **Postgres**、**MinIO**、可访问 **RTSP**(或海康 SDK 环境)、**百度语音** 的条件下,按下列顺序手工或脚本验证核心闭环。自动化测试见 `tests/`。 + +## 环境 + +- [ ] `GET /health` 返回 `200`,`database: connected` +- [ ] 环境变量:`VIDEO_RTSP_URLS_JSON` 或 `VIDEO_RTSP_URLS_JSON_FILE` 与客户端 `camera_ids` 一致 +- [ ] `MINIO_*`、`BAIDU_SPEECH_*` 已配置(语音确认链路) +- [ ] 模型权重路径可读(容器内挂载 `app/resources/*.pt`) + +## 主流程 + +1. **开始手术** `POST /client/surgeries/start` + - [ ] 请求体含 6 位 `surgery_id`、`camera_ids`、`candidate_consumables`(非空才会记账) + - [ ] 返回 `200`,日志中各路 RTSP 首帧就绪 + +2. **进行中查询(可选)** `GET /client/surgeries/{id}/result` + - [ ] 在已有至少一条明细后返回 `200`;仅开录尚无明细时可能 `503 RESULT_NOT_READY`(与实现一致) + +3. **低置信追问** + - [ ] `GET /client/surgeries/{id}/pending-confirmation` 有任务时 `200`,含 `prompt_text`、`options` + - [ ] 客户端对 `prompt_text` **TTS 播报**,采集医生回答为 **WAV** + - [ ] `POST .../pending-confirmation/{confirmation_id}/resolve`,`multipart` 字段名 `audio` + - [ ] 确认后明细中出现 `source=voice`;否认不增加明细 + - [ ] (可选)`GET /internal/surgeries/{id}/voice-status` 查看队列与最近 ASR 摘要 + +4. **结束手术** `POST /client/surgeries/end` + - [ ] 返回 `200`,摄像头任务停止 + +5. **最终结果** `GET /client/surgeries/{id}/result` + - [ ] 返回 `200`,`details` / `summary` 与术中所见一致 + +6. **数据库** + - [ ] `surgery_final_results` / `surgery_result_details` 有对应 `surgery_id` + - [ ] `voice_confirmation_audits` 在语音确认路径有追溯行(成功/失败分支视联调覆盖而定) + +## 失败与重试(抽样) + +- [ ] RTSP 不可达:`start` 最终 `503`,消息含开录失败说明 +- [ ] MinIO 不可用:`resolve` 返回 `503` 或业务码 `MINIO_*` +- [ ] 停录后写库失败:服务日志提示归档;后台重试或修复 DB 后可再次 `start` 同号前会先尝试落归档(见接口说明) + +## 与文档 + +- 客户端集成以 **OpenAPI**(`/docs`)与 [客户端手术通信接口说明](./客户端手术通信接口说明.md) 为准;**待确认 resolve 为 multipart WAV**,非 JSON `chosen_label`。 diff --git a/docs/video-backends.md b/docs/video-backends.md new file mode 100644 index 0000000..fa6d0d6 --- /dev/null +++ b/docs/video-backends.md @@ -0,0 +1,45 @@ +# 视频双后端说明(RTSP / 海康 HCNetSDK) + +## 目标容器 + +- **推荐**:`Linux x86_64` + **glibc**(与当前 `python:3.13-slim-bookworm` 一致)。 +- **不推荐**:Alpine(musl)加载海康预编译 `.so` 往往失败。 +- 镜像已安装 **ffmpeg** 与 OpenCV 常用系统库,便于 `cv2.VideoCapture(..., cv2.CAP_FFMPEG)` 拉 RTSP。 + +## RTSP 模式(默认) + +1. 配置 **`camera_id` → RTSP URL** 映射,任选其一或组合使用: + - **`VIDEO_RTSP_URLS_JSON_FILE`**:指向 UTF-8 JSON 文件(对象键为与请求一致的 `camera_id`)。仓库示例:[`app/resources/camera_rtsp_urls.sample.json`](../app/resources/camera_rtsp_urls.sample.json)(示例 ID:`or-cam-01`、`or-cam-02`)。 + - **`VIDEO_RTSP_URLS_JSON`**:内联 JSON 字符串;与文件合并时**覆盖同键**。 + - **`VIDEO_RTSP_URL_TEMPLATE`**:单模板,可用 `{camera_id}`。 +2. 调用 `POST /client/surgeries/start` 时,`camera_ids` 必须能在上述配置中解析出 RTSP 地址。 +3. **开录确认**:每路摄像头在超时内成功打开并读到**首帧**后,才认为该路已开录。 + +## 海康官方 SDK 模式(可选) + +SDK **不作为构建期依赖**:将厂商提供的 Linux x86_64 动态库挂载到容器内(例如 `/opt/hikvision/lib/libhcnetsdk.so`),并设置: + +- `HIKVISION_SDK_ENABLED=true` +- `HIKVISION_DEVICE_IP` / `HIKVISION_USER` / `HIKVISION_PASSWORD`(以及可选端口) +- `HIKVISION_PREVIEW_RTSP_TEMPLATE` 或 `HIKVISION_CAMERA_RTSP_URLS_JSON`:登录成功后仍通过 **RTSP** 取帧送模型(与常见部署一致:SDK 负责设备会话,码流仍走 RTSP)。 + +行为概要: + +1. 进程内对 `NET_DVR_Init` 使用引用计数;每路使用 SDK 的工作线程在登录后 `NET_DVR_Logout`,线程结束时配对 `NET_DVR_Cleanup`。 +2. 若 `HIKVISION_SDK_FALLBACK_TO_RTSP=true`(默认),在**无法加载动态库**、**登录失败**或**未配置凭据**时,自动回退到 `VIDEO_RTSP_*` 映射拉流。 + +**注意**:`NET_DVR_Login_V30` 的设备信息结构体在不同 SDK 版本上可能存在差异;若登录异常,请优先使用 RTSP 回退或按厂商文档校对 ctypes 绑定。 + +## 推理与结果查询 + +- 开录后按 `VIDEO_INFERENCE_INTERVAL_SEC` 抽帧,依次调用耗材分类与撕扯动作模型。 +- **候选耗材清单**(开始手术请求体中的 `candidate_consumables`)为**硬约束**:若为空,服务端**不会**写入任何消耗明细(仅拉流推理);非空时仅允许清单内标签自动记账。 +- 当分类 Top1 置信度 ≥ `VIDEO_AUTO_CONFIRM_CONFIDENCE` 且标签在候选清单内时,自动写入一条 `source=vision` 的消耗明细。 +- 置信度在 \[`VIDEO_VOICE_CONFIRM_MIN_CONFIDENCE`, `VIDEO_AUTO_CONFIRM_CONFIDENCE`\) 且存在可向医生展示的候选时,会生成一条**待确认**任务(不阻塞后续帧);客户端通过 `GET /client/surgeries/{surgery_id}/pending-confirmation` 拉取话术并播报,确认后 `POST .../pending-confirmation/{id}/resolve`。 +- 已有至少一条消耗明细后,`GET /client/surgeries/{surgery_id}/result` 返回 200;若已开录但尚未产生任何明细,返回 503 `RESULT_NOT_READY`。 +- 同类物品写入受 `VIDEO_DETAIL_COOLDOWN_SEC` 节流。 +- RTSP 读帧连续失败达到 `VIDEO_READ_FAILURE_RECONNECT_THRESHOLD` 时会 `release` 并尝试重连,间隔 `VIDEO_RECONNECT_BACKOFF_SECONDS`。 + +## 相关环境变量 + +详见仓库根目录 `.env.example` 中「视频:RTSP + 可选海康 HCNetSDK」一节。 diff --git a/docs/客户端手术通信接口说明.md b/docs/客户端手术通信接口说明.md new file mode 100644 index 0000000..5dad5ae --- /dev/null +++ b/docs/客户端手术通信接口说明.md @@ -0,0 +1,378 @@ +# 手术室监控服务 · 客户端手术通信接口说明 + +本文档描述客户端与手术室监控服务端之间,围绕「单台手术生命周期」进行通信的 HTTP 接口。便于联调、评审与对外同步;与 OpenAPI(Swagger)中的定义一致。 + +--- + +## 1. 概述 + + +| 能力 | 说明 | +| ---- | ---------------------------------------------------- | +| 开始手术 | 请求开始手术;服务端启动摄像头录制,**仅在确认开录完成后**返回 HTTP 200。 | +| 结束手术 | 请求结束手术;服务端停止摄像头录制,**仅在确认停录完成后**返回 HTTP 200。 | +| 查询结果 | 根据手术 6 位号查询消耗明细与汇总;**仅在已开录且至少已有一条消耗明细后**返回 HTTP 200。 | +| 待确认耗材 | 低置信度时服务端排队一条待确认任务;客户端拉取话术(TTS)并在医生确认后回传,**不阻塞**后续视频推理。 | + + +**约定:** + +- **开始 / 结束** 使用 `POST`,请求体为 **JSON**(`Content-Type: application/json`)。 +- **查询结果** 使用 `GET`,**无请求体**;手术号放在 **URL 路径** 中(见 4.3),符合「只读资源用 GET」的惯例。 +- 手术标识 `**surgery_id`**:必须为 **恰好 6 位数字**(正则 `^\d{6}$`),例如 `123456`。 + +--- + +## 2. 基础信息 + + +| 项目 | 说明 | +| ---------- | ------------------------------------------------------- | +| 协议 | HTTP/HTTPS | +| 请求体格式 | 开始/结束:`application/json`;查询结果:无 body | +| 响应体格式 | JSON | +| 路径前缀 | 服务端根路径下直接挂载,例如 `https://<主机>:<端口>/client/surgeries/...` | +| 默认服务端口(开发) | `38080`(以实际部署为准) | + + +> **说明:** 若生产环境存在网关或反向代理,请将上表中的「主机、端口、是否 HTTPS」替换为对外统一入口地址。 + +--- + +## 3. 接口列表 + + +| 序号 | 方法 | 路径 | 说明 | +| --- | ------ | --------------------------------------- | ------ | +| 1 | `POST` | `/client/surgeries/start` | 开始手术 | +| 2 | `POST` | `/client/surgeries/end` | 结束手术 | +| 3 | `GET` | `/client/surgeries/{surgery_id}/result` | 查询手术结果 | +| 4 | `GET` | `/client/surgeries/{surgery_id}/pending-confirmation` | 拉取一条待确认耗材 | +| 5 | `POST` | `/client/surgeries/{surgery_id}/pending-confirmation/{confirmation_id}/resolve` | 提交医生确认结果 | + + +--- + +## 4. 接口详情 + +### 4.1 开始手术 + +**用途:** 在手术开始时,由客户端向服务端上报手术编号、参与采集的摄像头,以及本台手术可能涉及的耗材清单;**服务端启动关联摄像头录制**。 + +**成功条件(HTTP 200):** 仅在服务端**确认摄像头已开始录制**之后,才返回 **HTTP 200**。不得在「仅收到请求、尚未开录」时返回 200。 + + +| 项目 | 内容 | +| --- | ------------------------- | +| 方法 | `POST` | +| 路径 | `/client/surgeries/start` | + + +**请求体字段:** + + +| 字段名 | 类型 | 必填 | 说明 | +| ----------------------- | ---------- | --- | ---------------------- | +| `surgery_id` | `string` | 是 | 手术 6 位号,必须为 6 位数字。 | +| `camera_ids` | `string[]` | 是 | 摄像头 ID 列表,至少 1 个元素;须与服务端配置的 RTSP 映射键一致(示例见 `app/resources/camera_rtsp_urls.sample.json`)。 | +| `candidate_consumables` | `string[]` | 否 | 本台手术允许记账的耗材名称清单。**为空或未传则不会写入任何消耗**(仅拉流推理);非空时自动记账与待确认仅针对清单内名称。 | + +**说明:** 若该 `surgery_id` 在服务端仍存在**尚未写入数据库**的上一台手术内存归档,开始新会话前会先尝试落库;落库失败则返回 **503**(`RECORDING_CANNOT_START`),避免静默丢失数据。 + + +**请求示例:** + +```json +{ + "surgery_id": "123456", + "camera_ids": ["or-cam-01", "or-cam-02"], + "candidate_consumables": ["纱布", "缝线", "止血钳"] +} +``` + +**成功响应(HTTP 200):** 表示开录已确认。 + + +| 字段名 | 类型 | 说明 | +| ------------ | -------- | ---------------------------- | +| `surgery_id` | `string` | 回显手术 6 位号。 | +| `status` | `string` | 处理状态(例如 `accepted` 表示开录已确认)。 | +| `message` | `string` | 人类可读的说明文案。 | + + +**响应示例:** + +```json +{ + "surgery_id": "123456", + "status": "accepted", + "message": "摄像头录制已开始,手术已启动。" +} +``` + +**重试:** 开录调用失败时,服务端会按配置**自动重试**若干次(间隔若干秒);**全部尝试仍失败**后再返回 **HTTP 503**。环境变量:`SURGERY_RECORDING_MAX_ATTEMPTS`(默认 3,含首次)、`SURGERY_RECORDING_RETRY_DELAY_SECONDS`(默认 `1.0`)。 + +**失败响应(HTTP 503):** 重试用尽仍无法在约定条件下确认开录时返回。响应体见 **§5.2**(OpenAPI 模型 `SurgeryClientErrorResponse`,错误码示例:`RECORDING_CANNOT_START`);`detail.message` 中会注明已重试次数。 + +--- + +### 4.2 结束手术 + +**用途:** 在手术结束时,由客户端请求服务端结束该 `surgery_id` 对应手术:**服务端须停止关联摄像头的录制**。 + +**成功条件(HTTP 200):** 仅在服务端**确认所有关联摄像头已停止录制**之后,才返回 **HTTP 200**。不得在「仅收到请求、尚未停录」时返回 200。 + + +| 项目 | 内容 | +| --- | ----------------------- | +| 方法 | `POST` | +| 路径 | `/client/surgeries/end` | + + +**请求体字段:** + + +| 字段名 | 类型 | 必填 | 说明 | +| ------------ | -------- | --- | ------------------ | +| `surgery_id` | `string` | 是 | 手术 6 位号,必须为 6 位数字。 | + + +**请求示例:** + +```json +{ + "surgery_id": "123456" +} +``` + +**成功响应(HTTP 200):** 表示停录已完成。 + + +| 字段名 | 类型 | 说明 | +| ------------ | -------- | ---------------------------- | +| `surgery_id` | `string` | 回显手术 6 位号。 | +| `status` | `string` | 处理状态(例如 `accepted` 表示停录已确认)。 | +| `message` | `string` | 人类可读的说明文案。 | + + +**响应示例:** + +```json +{ + "surgery_id": "123456", + "status": "accepted", + "message": "摄像头录制已停止,手术已结束。" +} +``` + +**重试:** 停录调用失败时,服务端会按配置**自动重试**(与开始手术相同的环境变量);**全部尝试仍失败**后再返回 **HTTP 503**。 + +**失败响应(HTTP 503):** 重试用尽仍无法确认停录完成时返回。响应体见 **§5.2**(错误码示例:`RECORDING_NOT_STOPPED`);`detail.message` 中会注明已重试次数。 + +--- + +### 4.3 查询手术结果 + +**用途:** 根据 `surgery_id` 查询该台手术下耗材消耗明细及按物品汇总。 + +**成功条件(HTTP 200):** 仅在**已开录**且**至少已有一条消耗明细**(自动识别或医生确认)之后返回 **HTTP 200** 及 `details` / `summary`。若已开录但尚无明细,返回 **503**(见 **§5.2**,错误码 `RESULT_NOT_READY`)。 + + +| 项目 | 内容 | +| --- | --------------------------------------- | +| 方法 | `GET` | +| 路径 | `/client/surgeries/{surgery_id}/result` | + + +**路径参数:** + + +| 参数名 | 类型 | 必填 | 说明 | +| ------------ | -------- | --- | ------------------------------ | +| `surgery_id` | `string` | 是 | 手术 6 位号,必须为 6 位数字,出现在 URL 路径中。 | + + +**请求示例:** + +```http +GET /client/surgeries/123456/result HTTP/1.1 +Host: <主机>:<端口> +``` + +(浏览器或客户端直接访问完整 URL 即可,例如 `https://<主机>:<端口>/client/surgeries/123456/result`。) + +**成功响应(HTTP 200):** + + +| 字段名 | 类型 | 说明 | +| ------------ | -------- | ---------------------------------------------------------------- | +| `surgery_id` | `string` | 手术 6 位号。 | +| `status` | `string` | 成功时一般为 `completed`(以服务端约定为准)。 | +| `message` | `string` | 说明信息。 | +| `details` | 数组 | **消耗明细**:按事件发生,可能有多行;每行含物品、数量、医生、时间。 | +| `summary` | 数组 | **按物品汇总**:同一 `item_id` 在 `details` 中 `quantity` 的合计,便于客户端直接展示总计。 | + + +`**details[]` 中每一项(明细行):** + + +| 字段名 | 类型 | 必填 | 说明 | +| ----------- | --------- | --- | ---------------------------------------------------------------- | +| `item_id` | `string` | 是 | 物品 ID。 | +| `item_name` | `string` | 是 | 物品名称。 | +| `quantity` | `integer` | 是 | 本条记录对应的消耗数量(非负整数)。 | +| `doctor_id` | `string` | 是 | 医生 ID。 | +| `timestamp` | `string` | 是 | 记录时间,**ISO 8601**(JSON 中为 ISO 格式字符串,与 OpenAPI 中 `date-time` 一致)。 | +| `source` | `string` | 否 | `vision` 自动识别;`voice` 医生通过待确认接口确认。 | + + +`**summary[]` 中每一项(汇总行):** + + +| 字段名 | 类型 | 必填 | 说明 | +| ---------------- | --------- | --- | ------------------------------------- | +| `item_id` | `string` | 是 | 物品 ID。 | +| `item_name` | `string` | 是 | 物品名称(与明细中该 ID 首次出现时的名称一致,具体规则以服务端为准)。 | +| `total_quantity` | `integer` | 是 | 该物品在本台手术中的消耗数量**合计**。 | + + +**约定:** `summary` 应由服务端根据 `details` 按 `item_id` 汇总得到,保证与明细一致。 + +**响应示例:** + +```json +{ + "surgery_id": "123456", + "status": "completed", + "message": "查询成功。", + "details": [ + { + "item_id": "HC001", + "item_name": "纱布", + "quantity": 2, + "doctor_id": "D1001", + "timestamp": "2026-04-21T10:30:00+08:00" + }, + { + "item_id": "HC001", + "item_name": "纱布", + "quantity": 1, + "doctor_id": "D1002", + "timestamp": "2026-04-21T11:05:00+08:00" + }, + { + "item_id": "HC002", + "item_name": "缝线", + "quantity": 1, + "doctor_id": "D1001", + "timestamp": "2026-04-21T10:45:00+08:00" + } + ], + "summary": [ + { "item_id": "HC001", "item_name": "纱布", "total_quantity": 3 }, + { "item_id": "HC002", "item_name": "缝线", "total_quantity": 1 } + ] +} +``` + +--- + +### 4.4 拉取待确认耗材 + +**用途:** 当模型置信度不足但存在候选时,服务端将任务放入 FIFO 队列。客户端轮询本接口获取**队首**一条待确认项,使用 `prompt_text` 进行 TTS 播报,并由医生口述选择;**服务端视频推理不等待本步骤**。 + +**成功条件(HTTP 200):** 当前手术进行中且队列非空。 + +**失败(HTTP 404):** 无待确认项或手术未在进行。`detail.code` 示例:`NO_PENDING_CONFIRMATION`。 + +| 项目 | 内容 | +| --- | --- | +| 方法 | `GET` | +| 路径 | `/client/surgeries/{surgery_id}/pending-confirmation` | + +**响应字段(节选):** `confirmation_id`、`prompt_text`、`options[]`(`label` + `confidence`)、`model_top1_label`、`model_top1_confidence`、`created_at`。 + +--- + +### 4.5 提交耗材确认结果 + +**用途:** 客户端采集医生回答的 **WAV 音频**并上传;服务端将音频存入 MinIO、调用百度 ASR 识别、解析 4.4 返回的候选项;**确认**则记一条 `source=voice` 的消耗明细,**否认**则关闭该待确认项且不记账。 + +| 项目 | 内容 | +| --- | --- | +| 方法 | `POST` | +| 路径 | `/client/surgeries/{surgery_id}/pending-confirmation/{confirmation_id}/resolve` | +| `Content-Type` | `multipart/form-data` | + +**请求体(multipart):** + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `audio` | 文件 | 是 | 医生语音 **`.wav`**;建议 16 kHz 单声道 PCM,其他格式服务端可尝试用 ffmpeg 转码。 | + +**成功响应(HTTP 200):** `SurgeryPendingConfirmationResolveResponse`:`resolved_label`、`rejected`、`asr_text`、`audio_object_key` 等(与 OpenAPI 一致)。 + +**错误:** `404`(项不存在或手术未活跃)、`409`(已处理)、`422`(空文件、非 `.wav`、ASR/解析失败等业务码见 `detail.code`)、`503`(MinIO/百度未配置或上传失败等)。 + +> **说明:** 人工追问的 **TTS 播报由客户端**根据 4.4 的 `prompt_text` 完成;服务端不要求部署扬声器/麦克风。 + +--- + +## 5. 错误与校验 + +### 5.1 参数校验(HTTP 422) + +当参数不符合约束时(例如 `surgery_id` 不是 6 位数字、开始手术时 `camera_ids` 为空数组等),服务端通常返回 **HTTP 422**,响应体为 FastAPI/Pydantic 风格的校验错误详情。 + +建议在客户端侧对 `surgery_id` 先做本地校验,减少无效请求。 + +### 5.2 业务未就绪(HTTP 503) + +当**成功条件未满足**(开录/停录未确认、或查询结果时算法结果尚未就绪)时,服务端返回 **HTTP 503**,响应体为 JSON,且 `**detail` 为对象**(与 OpenAPI 中的 `**SurgeryClientErrorResponse`** / `**SurgeryClientErrorDetail`** 一致): + + +| 字段 | 类型 | 说明 | +| ------------------- | -------- | ---------------------------------------------------------------------------- | +| `detail.code` | `string` | 业务错误码,如 `RECORDING_CANNOT_START`、`RECORDING_NOT_STOPPED`、`RESULT_NOT_READY`。 | +| `detail.message` | `string` | 人类可读说明。 | +| `detail.surgery_id` | `string` | 手术 6 位号。 | + + +**示例:** + +```json +{ + "detail": { + "code": "RESULT_NOT_READY", + "message": "仅在已开录且算法已产生可查询的实时计算结果后返回 HTTP 200;当前条件不满足。", + "surgery_id": "123456" + } +} +``` + +--- + +## 6. 实现与演进说明(给阅读者) + +- **开始 / 结束 / 查询结果** 与录制、算法流水线的具体绑定以实现为准;**未满足约定条件时不返回 200**(见各节成功条件),与 **OpenAPI(`/docs` 或 `/openapi.json`)** 中声明的 **200 / 503 / 422** 一致。 +- **人工确认**由客户端完成 TTS 与拾音(ASR);服务端只提供结构化候选与话术,不要求部署环境具备扬声器/麦克风。 +- 接入真实子系统后,仍应保持:成功响应体与 `SurgeryApiResponse`、`SurgeryResultResponse` 模型一致;503 与 `SurgeryClientErrorResponse` 一致。 + +联调时请以 **OpenAPI 文档**(如 `/docs`)为准,本文档与之同步维护。 + +--- + +## 7. 文档修订 + + +| 版本 | 日期 | 说明 | +| --- | ---------- | ---------------------------------------------------------------- | +| 1.6 | 2026-04-21 | 待确认耗材接口;候选清单硬约束;查询结果需至少一条明细;客户端侧人工确认。 | +| 1.5 | 2026-04-21 | 开始/结束手术:录制流水线失败时重试,仍失败再 503;可配置 `SURGERY_RECORDING_`*。 | +| 1.4 | 2026-04-21 | 与 OpenAPI 对齐:开始/结束/查询的 200/503 条件及 `SurgeryClientErrorResponse`。 | +| 1.3 | 2026-04-21 | 结束手术:仅在实际停录确认后返回 HTTP 200;否则 503。 | +| 1.2 | 2026-04-21 | 查询结果响应增加 `details`(物品 id/名称/数量/医生/时间)与 `summary`(按物品汇总)。 | +| 1.1 | 2026-04-21 | 查询结果改为 `GET /client/surgeries/{surgery_id}/result`。 | +| 1.0 | 2026-04-21 | 初版,`POST /client/surgeries/start`、`POST /client/surgeries/end`。 | + + diff --git a/main.py b/main.py index cd2d9a2..6a5a34a 100644 --- a/main.py +++ b/main.py @@ -3,11 +3,11 @@ from contextlib import asynccontextmanager import uvicorn from fastapi import FastAPI -from fastapi.responses import JSONResponse from loguru import logger -from sqlalchemy.exc import SQLAlchemyError -from app.database import check_database, engine +from app.api import router as api_router +from app.database import check_database, engine, init_db_schema +from app.dependencies import camera_session_manager logger.remove() logger.add( @@ -19,37 +19,32 @@ logger.add( @asynccontextmanager async def lifespan(app: FastAPI): await check_database() - logger.info("Database connection verified") + await init_db_schema() + logger.info("Database connection verified and schema ensured") + await camera_session_manager.start_archive_retry_loop() yield + await camera_session_manager.shutdown() await engine.dispose() logger.info("Database engine disposed") -app = FastAPI( - title="Operation Room Monitor", - lifespan=lifespan, -) +def create_app() -> FastAPI: + application = FastAPI( + title="Operation Room Monitor", + lifespan=lifespan, + ) + application.include_router(api_router) + return application -@app.get("/health") -async def health(): - logger.debug("Health check") - try: - await check_database() - except SQLAlchemyError as exc: - logger.warning("Health check: database unavailable: {}", exc) - return JSONResponse( - status_code=503, - content={"status": "degraded", "database": "unavailable"}, - ) - return {"status": "ok", "database": "connected"} +app = create_app() def main() -> None: uvicorn.run( "main:app", host="0.0.0.0", - port=8000, + port=38080, reload=True, ) diff --git a/pyproject.toml b/pyproject.toml index 1de6fbd..bc71d38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,12 +5,46 @@ description = "Operation room monitor API server" requires-python = ">=3.13" dependencies = [ "asyncpg>=0.31.0", + "greenlet>=3.1.0", + "minio>=7.2.15", + "baidu-aip>=4.16.13", + "chardet>=7.4.3", "fastapi>=0.136.0", "loguru>=0.7.3", + "pillow>=12.2.0", "pydantic-settings>=2.13.1", + "python-multipart>=0.0.26", "sqlalchemy>=2.0.49", + "ultralytics>=8.4.40", "uvicorn[standard]>=0.44.0", ] [project.scripts] operation-room-monitor-server = "main:main" + +# Use PyTorch CPU wheels from the official index so: +# - Linux Docker builds (incl. Docker Desktop on Mac) do not install NVIDIA CUDA pip bundles. +# - Native macOS still resolves to the correct macosx_* wheels from the same index. +# For NVIDIA servers, use a separate CUDA torch install or override in a dedicated prod Dockerfile. +[tool.uv] +index-strategy = "unsafe-best-match" + +[[tool.uv.index]] +name = "pytorch-cpu" +url = "https://download.pytorch.org/whl/cpu" + +[tool.uv.sources] +torch = { index = "pytorch-cpu" } +torchvision = { index = "pytorch-cpu" } + +[dependency-groups] +dev = [ + "httpx>=0.28.0", + "pytest>=8.3.0", + "pytest-asyncio>=0.25.0", + "aiosqlite>=0.21.0", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/start.sh b/start.sh index 6f29075..abbc278 100755 --- a/start.sh +++ b/start.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash -# Start PostgreSQL (docker-compose.dev.yml) and run the FastAPI app with hot reload. +# Start PostgreSQL from docker-compose.dev.yml and run the FastAPI app on the host. # Usage: ./start.sh -# Optional: SKIP_DOCKER=1 to skip Compose (use external DB). Set DATABASE_URL accordingly. +# Optional: SKIP_DOCKER=1 to skip Compose and use an existing PostgreSQL instance. set -euo pipefail @@ -23,9 +23,13 @@ if [[ "${SKIP_DOCKER:-0}" != "1" ]]; then sleep 1 done else - echo "SKIP_DOCKER=1: not starting Docker Compose; using DATABASE_URL from the environment." + echo "SKIP_DOCKER=1: not starting Docker Compose; using POSTGRES_* or DATABASE_URL from the environment." fi -export DATABASE_URL="${DATABASE_URL:-postgresql+asyncpg://postgres:postgres@localhost:5432/operation_room}" +export POSTGRES_USER="${POSTGRES_USER:-postgres}" +export POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-postgres}" +export POSTGRES_DB="${POSTGRES_DB:-operation_room}" +export POSTGRES_HOST="${POSTGRES_HOST:-localhost}" +export POSTGRES_PORT="${POSTGRES_PORT:-35432}" -exec uv run uvicorn main:app --host "${HOST:-0.0.0.0}" --port "${PORT:-8000}" --reload +exec uv run uvicorn main:app --host "${HOST:-0.0.0.0}" --port "${PORT:-38080}" --reload diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..d4839a6 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Tests package diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b825ab3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,74 @@ +"""Shared test fixtures (SQLite memory DB, AsyncSessionLocal monkeypatch).""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncGenerator, Generator + +import pytest +import pytest_asyncio +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +import app.db.models # noqa: F401 # register ORM tables on Base.metadata +from app.db.base import Base + + +@pytest_asyncio.fixture +async def sqlite_session_factory() -> AsyncGenerator[async_sessionmaker[AsyncSession], None]: + """In-memory SQLite + create_all; yields async_sessionmaker.""" + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + factory = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, + autoflush=False, + autobegin=False, + ) + yield factory + await engine.dispose() + + +@pytest.fixture +def patched_async_session_local( + monkeypatch: pytest.MonkeyPatch, +) -> Generator[async_sessionmaker[AsyncSession], None, None]: + """ + Replace AsyncSessionLocal in modules that open DB sessions, for sync tests + (e.g. TestClient) that use asyncio.run internally. + """ + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + + async def _init() -> None: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + asyncio.run(_init()) + factory = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, + autoflush=False, + autobegin=False, + ) + + monkeypatch.setattr( + "app.services.video.session_manager.AsyncSessionLocal", + factory, + ) + monkeypatch.setattr( + "app.services.surgery_pipeline.AsyncSessionLocal", + factory, + ) + monkeypatch.setattr( + "app.services.voice_resolution.AsyncSessionLocal", + factory, + ) + + yield factory + + async def _dispose() -> None: + await engine.dispose() + + asyncio.run(_dispose()) diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py new file mode 100644 index 0000000..62f9628 --- /dev/null +++ b/tests/test_api_contract.py @@ -0,0 +1,257 @@ +"""HTTP contract tests for surgery client API (dependency overrides, no real DB on lifespan).""" + +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api import router as api_router +from app.dependencies import get_surgery_pipeline +from app.schemas import ( + SurgeryConsumptionDetail, + SurgeryPendingConfirmationResponse, +) +from app.services.voice_resolution import VoiceResolveResult +from app.surgery_errors import SurgeryPipelineError + + +@pytest.fixture +def instant_sleep(monkeypatch: pytest.MonkeyPatch) -> None: + async def _noop(_delay: float) -> None: + return None + + monkeypatch.setattr("app.api.asyncio.sleep", _noop) + + +@pytest.fixture +def api_app(monkeypatch: pytest.MonkeyPatch) -> FastAPI: + async def _check_db_ok() -> None: + return None + + monkeypatch.setattr("app.api.check_database", _check_db_ok) + + app = FastAPI() + app.include_router(api_router) + return app + + +def test_health_ok(api_app: FastAPI) -> None: + client = TestClient(api_app) + r = client.get("/health") + assert r.status_code == 200 + assert r.json()["status"] == "ok" + + +def test_start_surgery_accepted(api_app: FastAPI, instant_sleep: None) -> None: + pipeline = MagicMock() + pipeline.start_recording = AsyncMock(return_value=None) + api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline + client = TestClient(api_app) + r = client.post( + "/client/surgeries/start", + json={ + "surgery_id": "123456", + "camera_ids": ["cam1"], + "candidate_consumables": ["纱布"], + }, + ) + assert r.status_code == 200 + body = r.json() + assert body["surgery_id"] == "123456" + assert body["status"] == "accepted" + pipeline.start_recording.assert_awaited_once() + + +def test_start_surgery_503_on_pipeline_error( + api_app: FastAPI, instant_sleep: None +) -> None: + pipeline = MagicMock() + pipeline.start_recording = AsyncMock( + side_effect=SurgeryPipelineError("RECORDING_CANNOT_START", "cannot") + ) + api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline + client = TestClient(api_app) + r = client.post( + "/client/surgeries/start", + json={"surgery_id": "123456", "camera_ids": ["c1"], "candidate_consumables": []}, + ) + assert r.status_code == 503 + d = r.json()["detail"] + assert d["code"] == "RECORDING_CANNOT_START" + assert d["surgery_id"] == "123456" + + +def test_start_surgery_422_invalid_surgery_id(api_app: FastAPI) -> None: + pipeline = MagicMock() + pipeline.start_recording = AsyncMock() + api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline + client = TestClient(api_app) + r = client.post( + "/client/surgeries/start", + json={"surgery_id": "12", "camera_ids": ["c1"], "candidate_consumables": []}, + ) + assert r.status_code == 422 + + +def test_end_surgery_accepted(api_app: FastAPI, instant_sleep: None) -> None: + pipeline = MagicMock() + pipeline.stop_recording = AsyncMock(return_value=None) + api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline + client = TestClient(api_app) + r = client.post("/client/surgeries/end", json={"surgery_id": "123456"}) + assert r.status_code == 200 + pipeline.stop_recording.assert_awaited_once_with("123456") + + +def test_get_result_200(api_app: FastAPI) -> None: + ts = datetime(2026, 4, 21, 12, 0, tzinfo=timezone.utc) + pipeline = MagicMock() + pipeline.get_consumption_details_for_client = AsyncMock( + return_value=[ + SurgeryConsumptionDetail( + item_id="纱布", + item_name="纱布", + quantity=1, + doctor_id="vision", + timestamp=ts, + source="vision", + ), + ] + ) + api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline + client = TestClient(api_app) + r = client.get("/client/surgeries/123456/result") + assert r.status_code == 200 + body = r.json() + assert body["surgery_id"] == "123456" + assert len(body["details"]) == 1 + assert body["summary"][0]["total_quantity"] == 1 + + +def test_get_result_503_not_ready(api_app: FastAPI) -> None: + pipeline = MagicMock() + pipeline.get_consumption_details_for_client = AsyncMock(return_value=None) + api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline + client = TestClient(api_app) + r = client.get("/client/surgeries/123456/result") + assert r.status_code == 503 + assert r.json()["detail"]["code"] == "RESULT_NOT_READY" + + +def test_pending_confirmation_200_and_404(api_app: FastAPI) -> None: + ts = datetime(2026, 4, 21, 12, 0, tzinfo=timezone.utc) + payload = SurgeryPendingConfirmationResponse( + surgery_id="123456", + confirmation_id="cid", + prompt_text="请确认", + options=[], + model_top1_label="x", + model_top1_confidence=0.4, + created_at=ts, + ) + pipeline_ok = MagicMock() + pipeline_ok.get_pending_confirmation_for_client = MagicMock(return_value=payload) + api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline_ok + client = TestClient(api_app) + r = client.get("/client/surgeries/123456/pending-confirmation") + assert r.status_code == 200 + assert r.json()["confirmation_id"] == "cid" + + pipeline_none = MagicMock() + pipeline_none.get_pending_confirmation_for_client = MagicMock(return_value=None) + api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline_none + client2 = TestClient(api_app) + r2 = client2.get("/client/surgeries/123456/pending-confirmation") + assert r2.status_code == 404 + assert r2.json()["detail"]["code"] == "NO_PENDING_CONFIRMATION" + + +def test_resolve_empty_audio_422(api_app: FastAPI) -> None: + pipeline = MagicMock() + api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline + client = TestClient(api_app) + r = client.post( + "/client/surgeries/123456/pending-confirmation/cid/resolve", + files={"audio": ("a.wav", b"", "audio/wav")}, + ) + assert r.status_code == 422 + assert r.json()["detail"]["code"] == "VOICE_AUDIO_INVALID" + + +def test_resolve_non_wav_422(api_app: FastAPI) -> None: + pipeline = MagicMock() + api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline + client = TestClient(api_app) + r = client.post( + "/client/surgeries/123456/pending-confirmation/cid/resolve", + files={"audio": ("a.mp3", b"abc", "audio/mpeg")}, + ) + assert r.status_code == 422 + + +def test_resolve_200(api_app: FastAPI) -> None: + pipeline = MagicMock() + pipeline.resolve_pending_confirmation_from_audio = AsyncMock( + return_value=VoiceResolveResult( + resolved_label="纱布", + rejected=False, + asr_text="第一个", + audio_object_key="k.wav", + message="ok", + ) + ) + api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline + client = TestClient(api_app) + r = client.post( + "/client/surgeries/123456/pending-confirmation/cid/resolve", + files={"audio": ("a.wav", b"RIFF", "audio/wav")}, + ) + assert r.status_code == 200 + body = r.json() + assert body["resolved_label"] == "纱布" + assert body["rejected"] is False + assert body["asr_text"] == "第一个" + + +def test_resolve_maps_surgery_pipeline_error_to_http(api_app: FastAPI) -> None: + pipeline = MagicMock() + pipeline.resolve_pending_confirmation_from_audio = AsyncMock( + side_effect=SurgeryPipelineError("CONFIRMATION_NOT_FOUND", "missing") + ) + api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline + client = TestClient(api_app) + r = client.post( + "/client/surgeries/123456/pending-confirmation/cid/resolve", + files={"audio": ("a.wav", b"x", "audio/wav")}, + ) + assert r.status_code == 404 + assert r.json()["detail"]["code"] == "CONFIRMATION_NOT_FOUND" + + +def test_internal_voice_status_404_and_200(api_app: FastAPI) -> None: + p_none = MagicMock() + p_none.voice_status = MagicMock(return_value=None) + api_app.dependency_overrides[get_surgery_pipeline] = lambda: p_none + client = TestClient(api_app) + r = client.get("/internal/surgeries/123456/voice-status") + assert r.status_code == 404 + + p_ok = MagicMock() + p_ok.voice_status = MagicMock( + return_value={ + "voice_enabled": True, + "pending_queue_approx": 2, + "last_prompt_snippet": "hi", + "last_asr_text": "纱布", + "last_error": None, + } + ) + api_app.dependency_overrides[get_surgery_pipeline] = lambda: p_ok + client2 = TestClient(api_app) + r2 = client2.get("/internal/surgeries/123456/voice-status") + assert r2.status_code == 200 + assert r2.json()["pending_queue_approx"] == 2 diff --git a/tests/test_session_manager_unit.py b/tests/test_session_manager_unit.py new file mode 100644 index 0000000..0aa9a1b --- /dev/null +++ b/tests/test_session_manager_unit.py @@ -0,0 +1,448 @@ +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone +from unittest.mock import MagicMock + +import pytest + +from app.config import Settings +from app.services.consumable_classifier import PredictionCandidate, PredictionResult +from app.surgery_errors import SurgeryPipelineError +from app.services.video.session_manager import ( + CameraSessionManager, + PendingConsumableConfirmation, + RunningSurgery, + SurgerySessionState, +) + + +def test_live_consumption_requires_non_empty_details() -> None: + settings = Settings() + mgr = CameraSessionManager( + settings=settings, + consumable_classifier=MagicMock(), + tear_action=MagicMock(), + hikvision_runtime=None, + result_repository=None, + ) + st = SurgerySessionState(candidate_consumables=["纱布"]) + run = RunningSurgery(stop_event=asyncio.Event(), state=st, tasks=[]) + mgr._active["123456"] = run + st.ready.set() + assert mgr.live_consumption_if_active("123456") is None + + +@pytest.mark.asyncio +async def test_resolve_pending_appends_voice_detail() -> None: + settings = Settings() + mgr = CameraSessionManager( + settings=settings, + consumable_classifier=MagicMock(), + tear_action=MagicMock(), + hikvision_runtime=None, + result_repository=None, + ) + st = SurgerySessionState(candidate_consumables=["纱布", "缝线"]) + pid = "test-confirm-id" + st.pending_by_id[pid] = PendingConsumableConfirmation( + id=pid, + status="pending", + options=[("纱布", 0.4), ("缝线", 0.3)], + prompt_text="请确认", + created_at=datetime.now(timezone.utc), + model_top1_label="unknown", + model_top1_confidence=0.41, + ) + st.pending_fifo.append(pid) + run = RunningSurgery(stop_event=asyncio.Event(), state=st, tasks=[]) + mgr._active["123456"] = run + + await mgr.resolve_pending_confirmation( + "123456", pid, chosen_label="纱布", rejected=False + ) + + assert len(st.details) == 1 + assert st.details[0].item_name == "纱布" + assert st.details[0].source == "voice" + assert pid not in st.pending_by_id + assert st.pending_fifo == [] + + +@pytest.mark.asyncio +async def test_resolve_reject_closes_without_detail() -> None: + settings = Settings() + mgr = CameraSessionManager( + settings=settings, + consumable_classifier=MagicMock(), + tear_action=MagicMock(), + hikvision_runtime=None, + result_repository=None, + ) + st = SurgerySessionState(candidate_consumables=["纱布"]) + pid = "r1" + st.pending_by_id[pid] = PendingConsumableConfirmation( + id=pid, + status="pending", + options=[("纱布", 0.4)], + prompt_text="x", + created_at=datetime.now(timezone.utc), + model_top1_label="x", + model_top1_confidence=0.4, + ) + st.pending_fifo.append(pid) + mgr._active["123456"] = RunningSurgery( + stop_event=asyncio.Event(), state=st, tasks=[] + ) + + await mgr.resolve_pending_confirmation( + "123456", pid, chosen_label=None, rejected=True + ) + + assert st.details == [] + assert pid not in st.pending_by_id + + +@pytest.mark.asyncio +async def test_handle_skips_when_candidate_list_empty() -> None: + settings = Settings() + mgr = CameraSessionManager( + settings=settings, + consumable_classifier=MagicMock(), + tear_action=MagicMock(), + hikvision_runtime=None, + result_repository=None, + ) + state = SurgerySessionState(candidate_consumables=[]) + res = PredictionResult( + label="纱布", + confidence=0.99, + topk=[PredictionCandidate(label="纱布", confidence=0.99)], + ) + await mgr._handle_classification_result( + state=state, cls_res=res, tear_label="" + ) + assert state.details == [] + assert state.pending_fifo == [] + + +@pytest.mark.asyncio +async def test_archive_retry_loop_starts() -> None: + settings = Settings() + mgr = CameraSessionManager( + settings=settings, + consumable_classifier=MagicMock(), + tear_action=MagicMock(), + hikvision_runtime=None, + result_repository=None, + ) + await mgr.start_archive_retry_loop() + assert mgr._retry_task is not None + mgr._retry_stop.set() + mgr._retry_task.cancel() + try: + await mgr._retry_task + except asyncio.CancelledError: + pass + mgr._retry_task = None + + +@pytest.mark.asyncio +async def test_handle_skips_below_voice_floor() -> None: + settings = Settings() + settings.video_voice_confirm_min_confidence = 0.5 + mgr = CameraSessionManager( + settings=settings, + consumable_classifier=MagicMock(), + tear_action=MagicMock(), + hikvision_runtime=None, + result_repository=None, + ) + state = SurgerySessionState(candidate_consumables=["纱布"]) + res = PredictionResult( + label="纱布", + confidence=0.4, + topk=[PredictionCandidate(label="纱布", confidence=0.4)], + ) + await mgr._handle_classification_result( + state=state, cls_res=res, tear_label="" + ) + assert state.details == [] + assert state.pending_fifo == [] + + +@pytest.mark.asyncio +async def test_handle_auto_vision_confirm() -> None: + settings = Settings() + mgr = CameraSessionManager( + settings=settings, + consumable_classifier=MagicMock(), + tear_action=MagicMock(), + hikvision_runtime=None, + result_repository=None, + ) + state = SurgerySessionState(candidate_consumables=["纱布"]) + res = PredictionResult( + label="纱布", + confidence=0.99, + topk=[PredictionCandidate(label="纱布", confidence=0.99)], + ) + await mgr._handle_classification_result( + state=state, cls_res=res, tear_label="" + ) + assert len(state.details) == 1 + assert state.details[0].source == "vision" + assert state.details[0].item_id == "纱布" + + +@pytest.mark.asyncio +async def test_handle_high_conf_top1_not_in_candidates_enqueues_pending() -> None: + settings = Settings() + mgr = CameraSessionManager( + settings=settings, + consumable_classifier=MagicMock(), + tear_action=MagicMock(), + hikvision_runtime=None, + result_repository=None, + ) + state = SurgerySessionState(candidate_consumables=["缝线"]) + res = PredictionResult( + label="纱布", + confidence=0.9, + topk=[ + PredictionCandidate(label="纱布", confidence=0.9), + PredictionCandidate(label="缝线", confidence=0.2), + ], + ) + await mgr._handle_classification_result( + state=state, cls_res=res, tear_label="" + ) + assert state.details == [] + assert len(state.pending_fifo) == 1 + pid = state.pending_fifo[0] + assert "缝线" in state.pending_by_id[pid].prompt_text + + +@pytest.mark.asyncio +async def test_handle_mid_confidence_enqueues_pending() -> None: + settings = Settings() + settings.video_auto_confirm_confidence = 0.8 + settings.video_voice_confirm_min_confidence = 0.3 + mgr = CameraSessionManager( + settings=settings, + consumable_classifier=MagicMock(), + tear_action=MagicMock(), + hikvision_runtime=None, + result_repository=None, + ) + state = SurgerySessionState(candidate_consumables=["纱布", "缝线"]) + res = PredictionResult( + label="纱布", + confidence=0.5, + topk=[ + PredictionCandidate(label="纱布", confidence=0.5), + PredictionCandidate(label="缝线", confidence=0.3), + ], + ) + await mgr._handle_classification_result( + state=state, cls_res=res, tear_label="" + ) + assert len(state.pending_fifo) == 1 + + +@pytest.mark.asyncio +async def test_handle_voice_disabled_no_pending_for_mid_conf() -> None: + settings = Settings() + settings.voice_confirmation_enabled = False + settings.video_auto_confirm_confidence = 0.8 + mgr = CameraSessionManager( + settings=settings, + consumable_classifier=MagicMock(), + tear_action=MagicMock(), + hikvision_runtime=None, + result_repository=None, + ) + state = SurgerySessionState(candidate_consumables=["纱布"]) + res = PredictionResult( + label="纱布", + confidence=0.5, + topk=[PredictionCandidate(label="纱布", confidence=0.5)], + ) + await mgr._handle_classification_result( + state=state, cls_res=res, tear_label="" + ) + assert state.pending_fifo == [] + assert state.details == [] + + +@pytest.mark.asyncio +async def test_handle_vision_cooldown_skips_duplicate() -> None: + settings = Settings() + settings.video_detail_cooldown_sec = 3600.0 + mgr = CameraSessionManager( + settings=settings, + consumable_classifier=MagicMock(), + tear_action=MagicMock(), + hikvision_runtime=None, + result_repository=None, + ) + state = SurgerySessionState(candidate_consumables=["纱布"]) + res = PredictionResult( + label="纱布", + confidence=0.99, + topk=[PredictionCandidate(label="纱布", confidence=0.99)], + ) + await mgr._handle_classification_result( + state=state, cls_res=res, tear_label="" + ) + await mgr._handle_classification_result( + state=state, cls_res=res, tear_label="" + ) + assert len(state.details) == 1 + + +@pytest.mark.asyncio +async def test_handle_pending_dedupe_cooldown() -> None: + settings = Settings() + settings.video_detail_cooldown_sec = 3600.0 + mgr = CameraSessionManager( + settings=settings, + consumable_classifier=MagicMock(), + tear_action=MagicMock(), + hikvision_runtime=None, + result_repository=None, + ) + state = SurgerySessionState(candidate_consumables=["缝线"]) + res = PredictionResult( + label="纱布", + confidence=0.9, + topk=[ + PredictionCandidate(label="纱布", confidence=0.9), + PredictionCandidate(label="缝线", confidence=0.2), + ], + ) + await mgr._handle_classification_result( + state=state, cls_res=res, tear_label="" + ) + await mgr._handle_classification_result( + state=state, cls_res=res, tear_label="" + ) + assert len(state.pending_fifo) == 1 + + +@pytest.mark.asyncio +async def test_resolve_invalid_chosen_label() -> None: + settings = Settings() + mgr = CameraSessionManager( + settings=settings, + consumable_classifier=MagicMock(), + tear_action=MagicMock(), + hikvision_runtime=None, + result_repository=None, + ) + st = SurgerySessionState(candidate_consumables=["纱布"]) + pid = "p1" + st.pending_by_id[pid] = PendingConsumableConfirmation( + id=pid, + status="pending", + options=[("纱布", 0.4)], + prompt_text="x", + created_at=datetime.now(timezone.utc), + model_top1_label="x", + model_top1_confidence=0.4, + ) + st.pending_fifo.append(pid) + mgr._active["123456"] = RunningSurgery( + stop_event=asyncio.Event(), state=st, tasks=[] + ) + with pytest.raises(SurgeryPipelineError) as excinfo: + await mgr.resolve_pending_confirmation( + "123456", pid, chosen_label="止血钳", rejected=False + ) + assert excinfo.value.code == "CONFIRMATION_INVALID" + + +@pytest.mark.asyncio +async def test_resolve_not_active() -> None: + settings = Settings() + mgr = CameraSessionManager( + settings=settings, + consumable_classifier=MagicMock(), + tear_action=MagicMock(), + hikvision_runtime=None, + result_repository=None, + ) + with pytest.raises(SurgeryPipelineError) as excinfo: + await mgr.resolve_pending_confirmation( + "999999", "p1", chosen_label="纱布", rejected=False + ) + assert excinfo.value.code == "CONFIRMATION_NOT_ACTIVE" + + +@pytest.mark.asyncio +async def test_resolve_second_time_not_found() -> None: + settings = Settings() + mgr = CameraSessionManager( + settings=settings, + consumable_classifier=MagicMock(), + tear_action=MagicMock(), + hikvision_runtime=None, + result_repository=None, + ) + st = SurgerySessionState(candidate_consumables=["纱布"]) + pid = "p2" + st.pending_by_id[pid] = PendingConsumableConfirmation( + id=pid, + status="pending", + options=[("纱布", 0.4)], + prompt_text="x", + created_at=datetime.now(timezone.utc), + model_top1_label="x", + model_top1_confidence=0.4, + ) + st.pending_fifo.append(pid) + mgr._active["123456"] = RunningSurgery( + stop_event=asyncio.Event(), state=st, tasks=[] + ) + await mgr.resolve_pending_confirmation( + "123456", pid, chosen_label="纱布", rejected=False + ) + with pytest.raises(SurgeryPipelineError) as excinfo: + await mgr.resolve_pending_confirmation( + "123456", pid, chosen_label="纱布", rejected=False + ) + assert excinfo.value.code == "CONFIRMATION_NOT_FOUND" + + +@pytest.mark.asyncio +async def test_resolve_already_resolved_status() -> None: + settings = Settings() + mgr = CameraSessionManager( + settings=settings, + consumable_classifier=MagicMock(), + tear_action=MagicMock(), + hikvision_runtime=None, + result_repository=None, + ) + st = SurgerySessionState(candidate_consumables=["纱布"]) + pid = "p3" + pending = PendingConsumableConfirmation( + id=pid, + status="pending", + options=[("纱布", 0.4)], + prompt_text="x", + created_at=datetime.now(timezone.utc), + model_top1_label="x", + model_top1_confidence=0.4, + ) + st.pending_by_id[pid] = pending + st.pending_fifo.append(pid) + mgr._active["123456"] = RunningSurgery( + stop_event=asyncio.Event(), state=st, tasks=[] + ) + pending.status = "confirmed" + with pytest.raises(SurgeryPipelineError) as excinfo: + await mgr.resolve_pending_confirmation( + "123456", pid, chosen_label="纱布", rejected=False + ) + assert excinfo.value.code == "CONFIRMATION_ALREADY_RESOLVED" diff --git a/tests/test_session_rank.py b/tests/test_session_rank.py new file mode 100644 index 0000000..1766823 --- /dev/null +++ b/tests/test_session_rank.py @@ -0,0 +1,21 @@ +from app.services.consumable_classifier import PredictionCandidate +from app.services.video.session_manager import _rank_topk_for_candidates + + +def test_rank_respects_candidate_order() -> None: + topk = [ + PredictionCandidate(label="缝线", confidence=0.9), + PredictionCandidate(label="纱布", confidence=0.5), + ] + ordered = ["纱布", "缝线"] + ranked = _rank_topk_for_candidates(topk, ordered) + assert [c.label for c in ranked] == ["纱布", "缝线"] + + +def test_rank_without_candidates_keeps_model_order() -> None: + topk = [ + PredictionCandidate(label="a", confidence=0.9), + PredictionCandidate(label="b", confidence=0.5), + ] + ranked = _rank_topk_for_candidates(topk, []) + assert [c.label for c in ranked] == ["a", "b"] diff --git a/tests/test_surgery_pipeline_persistence.py b/tests/test_surgery_pipeline_persistence.py new file mode 100644 index 0000000..4bb52a1 --- /dev/null +++ b/tests/test_surgery_pipeline_persistence.py @@ -0,0 +1,206 @@ +"""Surgery stop -> DB persist, archive retry, and SurgeryPipeline result resolution order.""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone +from unittest.mock import MagicMock + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.config import Settings +from app.repositories.surgery_results import SurgeryResultRepository +from app.schemas import SurgeryConsumptionDetail +from app.services.surgery_pipeline import SurgeryPipeline +from app.services.video.session_manager import ( + ArchivedSurgery, + CameraSessionManager, + RunningSurgery, + SurgerySessionState, +) +from app.services.voice_resolution import VoiceConfirmationService + + +def _patch_db_sessions( + sqlite_session_factory: async_sessionmaker[AsyncSession], + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "app.services.video.session_manager.AsyncSessionLocal", + sqlite_session_factory, + ) + monkeypatch.setattr( + "app.services.surgery_pipeline.AsyncSessionLocal", + sqlite_session_factory, + ) + + +@pytest.mark.asyncio +async def test_stop_surgery_persists_final_result( + sqlite_session_factory: async_sessionmaker[AsyncSession], + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_db_sessions(sqlite_session_factory, monkeypatch) + repo = SurgeryResultRepository() + settings = Settings() + mgr = CameraSessionManager( + settings=settings, + consumable_classifier=MagicMock(), + tear_action=MagicMock(), + hikvision_runtime=None, + result_repository=repo, + ) + ts = datetime(2026, 4, 21, 12, 0, tzinfo=timezone.utc) + st = SurgerySessionState(candidate_consumables=["纱布"]) + st.details.append( + SurgeryConsumptionDetail( + item_id="纱布", + item_name="纱布", + quantity=1, + doctor_id="vision", + timestamp=ts, + source="vision", + ) + ) + st.ready.set() + mgr._active["123456"] = RunningSurgery( + stop_event=asyncio.Event(), state=st, tasks=[] + ) + + await mgr.stop_surgery("123456", require_active=True) + + async with sqlite_session_factory() as session: + async with session.begin(): + loaded = await repo.load_final_details(session, "123456") + assert loaded is not None + assert len(loaded) == 1 + assert loaded[0].item_id == "纱布" + assert mgr._archive.get("123456") is None + + +class _FlakyResultRepo(SurgeryResultRepository): + def __init__(self) -> None: + super().__init__() + self.calls = 0 + + async def save_final_result(self, session: AsyncSession, **kwargs: object) -> None: + self.calls += 1 + if self.calls == 1: + raise RuntimeError("db unavailable") + return await super().save_final_result(session, **kwargs) + + +@pytest.mark.asyncio +async def test_stop_surgery_failed_persist_goes_to_archive_then_retry_persists( + sqlite_session_factory: async_sessionmaker[AsyncSession], + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_db_sessions(sqlite_session_factory, monkeypatch) + repo = _FlakyResultRepo() + settings = Settings() + mgr = CameraSessionManager( + settings=settings, + consumable_classifier=MagicMock(), + tear_action=MagicMock(), + hikvision_runtime=None, + result_repository=repo, + ) + ts = datetime(2026, 4, 21, 12, 0, tzinfo=timezone.utc) + st = SurgerySessionState(candidate_consumables=[]) + st.details.append( + SurgeryConsumptionDetail( + item_id="缝线", + item_name="缝线", + quantity=1, + doctor_id="vision", + timestamp=ts, + source="vision", + ) + ) + mgr._active["654321"] = RunningSurgery( + stop_event=asyncio.Event(), state=st, tasks=[] + ) + + await mgr.stop_surgery("654321", require_active=True) + assert "654321" in mgr._archive + assert repo.calls == 1 + + ok = await mgr._try_persist_archive("654321") + assert ok is True + assert "654321" not in mgr._archive + assert repo.calls == 2 + + async with sqlite_session_factory() as session: + async with session.begin(): + loaded = await repo.load_final_details(session, "654321") + assert loaded is not None + assert len(loaded) == 1 + assert loaded[0].item_id == "缝线" + + +@pytest.mark.asyncio +async def test_pipeline_prefers_live_then_db_then_archive( + sqlite_session_factory: async_sessionmaker[AsyncSession], + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_db_sessions(sqlite_session_factory, monkeypatch) + repo = SurgeryResultRepository() + settings = Settings() + mgr = CameraSessionManager( + settings=settings, + consumable_classifier=MagicMock(), + tear_action=MagicMock(), + hikvision_runtime=None, + result_repository=repo, + ) + voice = MagicMock(spec=VoiceConfirmationService) + pipeline = SurgeryPipeline( + mgr, + result_repository=repo, + voice_confirmation=voice, + ) + + ts = datetime(2026, 4, 21, 12, 0, tzinfo=timezone.utc) + st = SurgerySessionState(candidate_consumables=["纱布"]) + st.details.append( + SurgeryConsumptionDetail( + item_id="纱布", + item_name="纱布", + quantity=1, + doctor_id="vision", + timestamp=ts, + source="vision", + ) + ) + st.ready.set() + mgr._active["111111"] = RunningSurgery( + stop_event=asyncio.Event(), state=st, tasks=[] + ) + + live = await pipeline.get_consumption_details_for_client("111111") + assert live is not None + assert live[0].item_id == "纱布" + + await mgr.stop_surgery("111111", require_active=True) + + from_db = await pipeline.get_consumption_details_for_client("111111") + assert from_db is not None + assert len(from_db) == 1 + assert from_db[0].item_id == "纱布" + + mgr._archive["333333"] = ArchivedSurgery( + details=[ + SurgeryConsumptionDetail( + item_id="归档项", + item_name="归档项", + quantity=1, + doctor_id="vision", + timestamp=ts, + source="vision", + ) + ] + ) + only_archive = await pipeline.get_consumption_details_for_client("333333") + assert only_archive is not None + assert only_archive[0].item_id == "归档项" diff --git a/tests/test_surgery_repository.py b/tests/test_surgery_repository.py new file mode 100644 index 0000000..8c65952 --- /dev/null +++ b/tests/test_surgery_repository.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +import app.db.models # noqa: F401 +from app.db.base import Base +from app.repositories.surgery_results import SurgeryResultRepository +from app.schemas import SurgeryConsumptionDetail + + +@pytest.fixture +async def db_session() -> AsyncSession: + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + session = factory() + yield session + await session.close() + await engine.dispose() + + +@pytest.mark.asyncio +async def test_save_empty_then_load(db_session: AsyncSession) -> None: + repo = SurgeryResultRepository() + async with db_session.begin(): + await repo.save_final_result(db_session, surgery_id="123456", details=[]) + async with db_session.begin(): + loaded = await repo.load_final_details(db_session, "123456") + assert loaded == [] + + +@pytest.mark.asyncio +async def test_save_roundtrip(db_session: AsyncSession) -> None: + repo = SurgeryResultRepository() + ts = datetime(2026, 4, 21, 10, 0, tzinfo=timezone.utc) + details = [ + SurgeryConsumptionDetail( + item_id="纱布", + item_name="纱布", + quantity=1, + doctor_id="D1", + timestamp=ts, + source="vision", + ), + SurgeryConsumptionDetail( + item_id="纱布", + item_name="纱布", + quantity=1, + doctor_id="voice", + timestamp=ts, + source="voice", + ), + ] + async with db_session.begin(): + await repo.save_final_result(db_session, surgery_id="654321", details=details) + async with db_session.begin(): + loaded = await repo.load_final_details(db_session, "654321") + assert loaded is not None + assert len(loaded) == 2 + assert loaded[0].source == "vision" + assert loaded[1].source == "voice" + + +@pytest.mark.asyncio +async def test_missing_surgery_returns_none(db_session: AsyncSession) -> None: + repo = SurgeryResultRepository() + async with db_session.begin(): + missing = await repo.load_final_details(db_session, "000000") + assert missing is None + + +@pytest.mark.asyncio +async def test_save_overwrites_previous_final_result(db_session: AsyncSession) -> None: + repo = SurgeryResultRepository() + ts1 = datetime(2026, 4, 21, 9, 0, tzinfo=timezone.utc) + ts2 = datetime(2026, 4, 21, 10, 0, tzinfo=timezone.utc) + async with db_session.begin(): + await repo.save_final_result( + db_session, + surgery_id="888888", + details=[ + SurgeryConsumptionDetail( + item_id="旧", + item_name="旧", + quantity=1, + doctor_id="D1", + timestamp=ts1, + source="vision", + ), + ], + ) + async with db_session.begin(): + await repo.save_final_result( + db_session, + surgery_id="888888", + details=[ + SurgeryConsumptionDetail( + item_id="新", + item_name="新", + quantity=2, + doctor_id="D2", + timestamp=ts2, + source="voice", + ), + ], + ) + async with db_session.begin(): + loaded = await repo.load_final_details(db_session, "888888") + assert loaded is not None + assert len(loaded) == 1 + assert loaded[0].item_id == "新" + assert loaded[0].quantity == 2 + assert loaded[0].source == "voice" diff --git a/tests/test_voice_audit_repository.py b/tests/test_voice_audit_repository.py new file mode 100644 index 0000000..c8b731f --- /dev/null +++ b/tests/test_voice_audit_repository.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import json + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +import app.db.models # noqa: F401 +from app.db.base import Base +from app.db.models import VoiceConfirmationAudit +from app.repositories.voice_audits import VoiceAuditRepository + + +@pytest.fixture +async def db_session() -> AsyncSession: + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + session = factory() + yield session + await session.close() + await engine.dispose() + + +@pytest.mark.asyncio +async def test_save_audit_persists_fields(db_session: AsyncSession) -> None: + repo = VoiceAuditRepository() + opts = json.dumps([{"label": "纱布", "confidence": 0.4}], ensure_ascii=False) + async with db_session.begin(): + await repo.save_audit( + db_session, + surgery_id="123456", + confirmation_id="cid-1", + status="recognized", + audio_object_key="surgeries/123456/x.wav", + audio_content_type="audio/wav", + audio_size_bytes=100, + audio_sha256="a" * 64, + asr_text="纱布", + resolved_label="纱布", + options_snapshot_json=opts, + error_message=None, + ) + async with db_session.begin(): + res = await db_session.execute(select(VoiceConfirmationAudit)) + rows = res.scalars().all() + assert len(rows) == 1 + r = rows[0] + assert r.surgery_id == "123456" + assert r.confirmation_id == "cid-1" + assert r.status == "recognized" + assert r.asr_text == "纱布" + assert r.resolved_label == "纱布" + assert r.options_snapshot_json == opts + assert r.error_message is None diff --git a/tests/test_voice_confirm.py b/tests/test_voice_confirm.py new file mode 100644 index 0000000..12471c7 --- /dev/null +++ b/tests/test_voice_confirm.py @@ -0,0 +1,19 @@ +from app.services.voice_confirm import build_prompt_text, parse_voice_choice + + +def test_parse_voice_choice_substring() -> None: + assert parse_voice_choice("用的是纱布对吧", ["纱布", "缝线"]) == "纱布" + + +def test_parse_voice_choice_numeric() -> None: + assert parse_voice_choice("第2个", ["纱布", "缝线", "钳子"]) == "缝线" + + +def test_parse_voice_choice_negative() -> None: + assert parse_voice_choice("不是", ["纱布", "缝线"]) is None + + +def test_build_prompt_contains_options() -> None: + text = build_prompt_text([("纱布", 0.4), ("缝线", 0.3)]) + assert "纱布" in text + assert "缝线" in text diff --git a/tests/test_voice_resolution_service.py b/tests/test_voice_resolution_service.py new file mode 100644 index 0000000..e227245 --- /dev/null +++ b/tests/test_voice_resolution_service.py @@ -0,0 +1,416 @@ +"""Tests for VoiceConfirmationService branches and audit persistence.""" + +from __future__ import annotations + +import asyncio +import io +import wave +from datetime import datetime, timezone +from unittest.mock import MagicMock + +import pytest +from sqlalchemy import func, select + +from app.config import Settings +from app.db.models import VoiceConfirmationAudit +from app.repositories.voice_audits import VoiceAuditRepository +from app.services.minio_audio_storage import StoredAudio +from app.services.video.session_manager import ( + CameraSessionManager, + PendingConsumableConfirmation, + RunningSurgery, + SurgerySessionState, +) +from app.services.voice_resolution import VoiceConfirmationService +from app.surgery_errors import SurgeryPipelineError + + +def _minimal_wav_16k_mono() -> bytes: + buf = io.BytesIO() + with wave.open(buf, "wb") as wf: + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(16000) + wf.writeframes(b"\x00\x00" * 200) + return buf.getvalue() + + +def _make_service( + *, + settings: Settings, + sessions: CameraSessionManager, + minio: MagicMock, + baidu: MagicMock, + sqlite_factory, + monkeypatch: pytest.MonkeyPatch, +) -> VoiceConfirmationService: + monkeypatch.setattr( + "app.services.voice_resolution.AsyncSessionLocal", + sqlite_factory, + ) + audits = VoiceAuditRepository() + return VoiceConfirmationService( + settings=settings, + sessions=sessions, + baidu=baidu, + minio=minio, + audits=audits, + ) + + +def _active_session_with_pending( + surgery_id: str = "123456", + confirmation_id: str = "cid-a", +) -> tuple[CameraSessionManager, str]: + settings = Settings() + mgr = CameraSessionManager( + settings=settings, + consumable_classifier=MagicMock(), + tear_action=MagicMock(), + hikvision_runtime=None, + result_repository=None, + ) + st = SurgerySessionState(candidate_consumables=["纱布", "缝线"]) + st.pending_by_id[confirmation_id] = PendingConsumableConfirmation( + id=confirmation_id, + status="pending", + options=[("纱布", 0.4), ("缝线", 0.3)], + prompt_text="请确认", + created_at=datetime.now(timezone.utc), + model_top1_label="x", + model_top1_confidence=0.41, + ) + st.pending_fifo.append(confirmation_id) + + mgr._active[surgery_id] = RunningSurgery( + stop_event=asyncio.Event(), state=st, tasks=[] + ) + return mgr, confirmation_id + + +async def _audit_count(sqlite_factory, *, surgery_id: str) -> int: + async with sqlite_factory() as session: + async with session.begin(): + res = await session.execute( + select(func.count()).select_from(VoiceConfirmationAudit).where( + VoiceConfirmationAudit.surgery_id == surgery_id + ) + ) + return int(res.scalar_one()) + + +@pytest.mark.asyncio +async def test_resolve_recognized_appends_voice_detail_and_audit( + sqlite_session_factory, + monkeypatch: pytest.MonkeyPatch, +) -> None: + settings = Settings() + settings.voice_upload_max_bytes = 10 * 1024 * 1024 + sessions, cid = _active_session_with_pending() + minio = MagicMock() + minio.configured = True + minio.ensure_bucket = MagicMock() + minio.upload_voice_wav = MagicMock( + return_value=StoredAudio( + object_key="surgeries/123456/confirmations/cid-a/abc.wav", + sha256_hex="b" * 64, + size_bytes=100, + ) + ) + baidu = MagicMock() + baidu.configured = True + baidu.asr = MagicMock(return_value={"err_no": 0, "result": ["第一个"]}) + + svc = _make_service( + settings=settings, + sessions=sessions, + minio=minio, + baidu=baidu, + sqlite_factory=sqlite_session_factory, + monkeypatch=monkeypatch, + ) + wav = _minimal_wav_16k_mono() + result = await svc.resolve_from_wav( + surgery_id="123456", + confirmation_id=cid, + wav_bytes=wav, + filename="a.wav", + content_type="audio/wav", + ) + assert result.rejected is False + assert result.resolved_label == "纱布" + assert result.asr_text == "第一个" + assert result.audio_object_key is not None + st = sessions._active["123456"].state + assert len(st.details) == 1 + assert st.details[0].source == "voice" + assert await _audit_count(sqlite_session_factory, surgery_id="123456") == 1 + + +@pytest.mark.asyncio +async def test_resolve_rejected_audit( + sqlite_session_factory, + monkeypatch: pytest.MonkeyPatch, +) -> None: + settings = Settings() + sessions, cid = _active_session_with_pending() + minio = MagicMock() + minio.configured = True + minio.ensure_bucket = MagicMock() + minio.upload_voice_wav = MagicMock( + return_value=StoredAudio( + object_key="k.wav", + sha256_hex="c" * 64, + size_bytes=10, + ) + ) + baidu = MagicMock() + baidu.configured = True + baidu.asr = MagicMock(return_value={"err_no": 0, "result": ["不是"]}) + + svc = _make_service( + settings=settings, + sessions=sessions, + minio=minio, + baidu=baidu, + sqlite_factory=sqlite_session_factory, + monkeypatch=monkeypatch, + ) + result = await svc.resolve_from_wav( + surgery_id="123456", + confirmation_id=cid, + wav_bytes=_minimal_wav_16k_mono(), + filename="a.wav", + content_type="audio/wav", + ) + assert result.rejected is True + assert result.resolved_label is None + assert len(sessions._active["123456"].state.details) == 0 + async with sqlite_session_factory() as session: + async with session.begin(): + res = await session.execute(select(VoiceConfirmationAudit)) + row = res.scalars().one() + assert row.status == "rejected" + + +@pytest.mark.asyncio +async def test_audio_too_large_audit( + sqlite_session_factory, + monkeypatch: pytest.MonkeyPatch, +) -> None: + settings = Settings() + settings.voice_upload_max_bytes = 10 + sessions, cid = _active_session_with_pending() + minio = MagicMock() + minio.configured = True + baidu = MagicMock() + svc = _make_service( + settings=settings, + sessions=sessions, + minio=minio, + baidu=baidu, + sqlite_factory=sqlite_session_factory, + monkeypatch=monkeypatch, + ) + with pytest.raises(SurgeryPipelineError) as ei: + await svc.resolve_from_wav( + surgery_id="123456", + confirmation_id=cid, + wav_bytes=b"x" * 20, + filename="a.wav", + content_type="audio/wav", + ) + assert ei.value.code == "VOICE_AUDIO_INVALID" + async with sqlite_session_factory() as session: + async with session.begin(): + res = await session.execute(select(VoiceConfirmationAudit)) + row = res.scalars().one() + assert row.status == "invalid_audio" + + +@pytest.mark.asyncio +async def test_minio_not_configured_no_audit( + sqlite_session_factory, + monkeypatch: pytest.MonkeyPatch, +) -> None: + settings = Settings() + sessions, cid = _active_session_with_pending() + minio = MagicMock() + minio.configured = False + baidu = MagicMock() + baidu.configured = True + svc = _make_service( + settings=settings, + sessions=sessions, + minio=minio, + baidu=baidu, + sqlite_factory=sqlite_session_factory, + monkeypatch=monkeypatch, + ) + with pytest.raises(SurgeryPipelineError) as ei: + await svc.resolve_from_wav( + surgery_id="123456", + confirmation_id=cid, + wav_bytes=_minimal_wav_16k_mono(), + filename="a.wav", + content_type="audio/wav", + ) + assert ei.value.code == "MINIO_NOT_CONFIGURED" + assert await _audit_count(sqlite_session_factory, surgery_id="123456") == 0 + + +@pytest.mark.asyncio +async def test_upload_failed_audit( + sqlite_session_factory, + monkeypatch: pytest.MonkeyPatch, +) -> None: + settings = Settings() + sessions, cid = _active_session_with_pending() + minio = MagicMock() + minio.configured = True + minio.ensure_bucket = MagicMock() + minio.upload_voice_wav = MagicMock(side_effect=RuntimeError("s3 down")) + baidu = MagicMock() + baidu.configured = True + svc = _make_service( + settings=settings, + sessions=sessions, + minio=minio, + baidu=baidu, + sqlite_factory=sqlite_session_factory, + monkeypatch=monkeypatch, + ) + with pytest.raises(SurgeryPipelineError) as ei: + await svc.resolve_from_wav( + surgery_id="123456", + confirmation_id=cid, + wav_bytes=_minimal_wav_16k_mono(), + filename="a.wav", + content_type="audio/wav", + ) + assert ei.value.code == "MINIO_UPLOAD_FAILED" + async with sqlite_session_factory() as session: + async with session.begin(): + res = await session.execute(select(VoiceConfirmationAudit)) + row = res.scalars().one() + assert row.status == "upload_failed" + + +@pytest.mark.asyncio +async def test_asr_failed_audit( + sqlite_session_factory, + monkeypatch: pytest.MonkeyPatch, +) -> None: + settings = Settings() + sessions, cid = _active_session_with_pending() + minio = MagicMock() + minio.configured = True + minio.ensure_bucket = MagicMock() + minio.upload_voice_wav = MagicMock( + return_value=StoredAudio(object_key="k", sha256_hex="d" * 64, size_bytes=1) + ) + baidu = MagicMock() + baidu.configured = True + baidu.asr = MagicMock(return_value={"err_no": 3300, "err_msg": "bad"}) + svc = _make_service( + settings=settings, + sessions=sessions, + minio=minio, + baidu=baidu, + sqlite_factory=sqlite_session_factory, + monkeypatch=monkeypatch, + ) + with pytest.raises(SurgeryPipelineError) as ei: + await svc.resolve_from_wav( + surgery_id="123456", + confirmation_id=cid, + wav_bytes=_minimal_wav_16k_mono(), + filename="a.wav", + content_type="audio/wav", + ) + assert ei.value.code == "VOICE_ASR_FAILED" + async with sqlite_session_factory() as session: + async with session.begin(): + res = await session.execute(select(VoiceConfirmationAudit)) + row = res.scalars().one() + assert row.status == "asr_failed" + + +@pytest.mark.asyncio +async def test_parse_failed_audit( + sqlite_session_factory, + monkeypatch: pytest.MonkeyPatch, +) -> None: + settings = Settings() + sessions, cid = _active_session_with_pending() + minio = MagicMock() + minio.configured = True + minio.ensure_bucket = MagicMock() + minio.upload_voice_wav = MagicMock( + return_value=StoredAudio(object_key="k", sha256_hex="e" * 64, size_bytes=1) + ) + baidu = MagicMock() + baidu.configured = True + # Avoid substrings like 「无」that trigger `is_rejection_phrase`. + baidu.asr = MagicMock(return_value={"err_no": 0, "result": ["西红柿土豆"]}) + svc = _make_service( + settings=settings, + sessions=sessions, + minio=minio, + baidu=baidu, + sqlite_factory=sqlite_session_factory, + monkeypatch=monkeypatch, + ) + with pytest.raises(SurgeryPipelineError) as ei: + await svc.resolve_from_wav( + surgery_id="123456", + confirmation_id=cid, + wav_bytes=_minimal_wav_16k_mono(), + filename="a.wav", + content_type="audio/wav", + ) + assert ei.value.code == "VOICE_PARSE_FAILED" + async with sqlite_session_factory() as session: + async with session.begin(): + res = await session.execute(select(VoiceConfirmationAudit)) + row = res.scalars().one() + assert row.status == "parse_failed" + + +@pytest.mark.asyncio +async def test_invalid_wav_decode_audit( + sqlite_session_factory, + monkeypatch: pytest.MonkeyPatch, +) -> None: + settings = Settings() + sessions, cid = _active_session_with_pending() + minio = MagicMock() + minio.configured = True + minio.ensure_bucket = MagicMock() + minio.upload_voice_wav = MagicMock( + return_value=StoredAudio(object_key="k", sha256_hex="f" * 64, size_bytes=1) + ) + baidu = MagicMock() + baidu.configured = True + svc = _make_service( + settings=settings, + sessions=sessions, + minio=minio, + baidu=baidu, + sqlite_factory=sqlite_session_factory, + monkeypatch=monkeypatch, + ) + with pytest.raises(SurgeryPipelineError) as ei: + await svc.resolve_from_wav( + surgery_id="123456", + confirmation_id=cid, + wav_bytes=b"not a wav", + filename="a.wav", + content_type="audio/wav", + ) + assert ei.value.code == "VOICE_AUDIO_INVALID" + async with sqlite_session_factory() as session: + async with session.begin(): + res = await session.execute(select(VoiceConfirmationAudit)) + row = res.scalars().one() + assert row.status == "invalid_audio" diff --git a/uv.lock b/uv.lock index 429ed39..88ad83b 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,23 @@ version = 1 revision = 3 requires-python = ">=3.13" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version >= '3.14' and sys_platform == 'darwin'", + "python_full_version < '3.14' and sys_platform == 'darwin'", +] + +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] [[package]] name = "annotated-doc" @@ -32,6 +49,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" }, + { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" }, + { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" }, + { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" }, + { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, + { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, + { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, + { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, +] + [[package]] name = "asyncpg" version = "0.31.0" @@ -64,6 +124,153 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, ] +[[package]] +name = "baidu-aip" +version = "4.16.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/18/823a605bcb66f1a6463d59e3230ba45ff292e3f932c999704d90632f73a2/baidu_aip-4.16.13-py3-none-any.whl", hash = "sha256:63a3cd37e293574e056c33d9a9a1e5f32fa0938c7d47ac5d24eea36e79e9e3f6", size = 29797, upload-time = "2023-11-15T08:07:13.785Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "chardet" +version = "7.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/b6/9df434a8eeba2e6628c465a1dfa31034228ef79b26f76f46278f4ef7e49d/chardet-7.4.3.tar.gz", hash = "sha256:cc1d4eb92a4ec1c2df3b490836ffa46922e599d34ce0bb75cf41fd2bf6303d56", size = 784800, upload-time = "2026-04-13T21:33:39.803Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/43/79ac9b4db5bc87020c9dbc419125371d80882d1d197e9c4765ba8682b605/chardet-7.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a9e4486df251b8962e86ea9f139ca235aa6e0542a00f7844c9a04160afb99aa9", size = 873769, upload-time = "2026-04-13T21:33:14.002Z" }, + { url = "https://files.pythonhosted.org/packages/55/5f/25bdec773905bff0ff6cf35ca73b17bd05593b4f87bd8c5fa43705f7167d/chardet-7.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fbff1907925b0c5a1064cffb5e040cd5e338585c9c552625f30de6bc2f3107a", size = 853991, upload-time = "2026-04-13T21:33:15.564Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/a29380ee0b215d23d77733b5ad60c5c0c7969650e080c667acdf9462040d/chardet-7.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:365135eaf37ba65a828f8e668eb0a8c38c479dcbec724dc25f4dfd781049c357", size = 874024, upload-time = "2026-04-13T21:33:16.915Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b1/3338e121cbd4c8a126b8ccb1061170c2ce51a53f678c502793ea49c6fd6d/chardet-7.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfc134b70c846c21ead8e43ada3ae1a805fff732f6922f8abcf2ff27b8f6493d", size = 887410, upload-time = "2026-04-13T21:33:18.368Z" }, + { url = "https://files.pythonhosted.org/packages/63/1c/44a9a9e0c59c185a5d307ceaeee8768afa1558f0a24f7a4b5fa11b67586b/chardet-7.4.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9acd9988a93e09390f3cd231201ea7166c415eb8da1b735928990ffc05cb9fbb", size = 879269, upload-time = "2026-04-13T21:33:20.377Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b3/5d0e77ea774bd3224321c248880ea0c0379000ac5c2bb6d77609549de247/chardet-7.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:e1b98790c284ff813f18f7cf7de5f05ea2435a080030c7f1a8318f3a4f80b131", size = 944155, upload-time = "2026-04-13T21:33:21.694Z" }, + { url = "https://files.pythonhosted.org/packages/70/a8/bf0811d859e13801279a2ae64f37a408027b282f2047bc0001c75dd356ad/chardet-7.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d892d3dcd652fdef53e3d6327d39b17c0df40a899dfc919abaeb64c974497531", size = 872887, upload-time = "2026-04-13T21:33:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/51/ac/b9d68ebddfe1b02c77af5bf81120e12b036b4432dc6af7a303d90e2bc38b/chardet-7.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:acc46d1b8b7d5783216afe15db56d1c179b9a40e5a1558bc13164c4fd20674c4", size = 853964, upload-time = "2026-04-13T21:33:24.724Z" }, + { url = "https://files.pythonhosted.org/packages/2a/81/17fa103ea9caf5d325a5e4051ab2ba65996fd66baa60b81ee41af1f54e10/chardet-7.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ac3bf11c645734a1701a3804e43eabd98851838192267d08c353a834ab79fea", size = 876006, upload-time = "2026-04-13T21:33:26.098Z" }, + { url = "https://files.pythonhosted.org/packages/c2/20/193faab46a68ea550587331a698c3dca8099f8901d10937c4443135c7ed9/chardet-7.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e3bd9f936e04bae89c254262af08d9e5b98f805175ba1e29d454e6cba3107b7", size = 887680, upload-time = "2026-04-13T21:33:27.49Z" }, + { url = "https://files.pythonhosted.org/packages/40/c6/94a3c673327392652ee8bdea9a45bc8a5f5365197a7387d68f0eed007115/chardet-7.4.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:27cc23da03630cdecc9aa81a895aa86629c211f995cd57651f0fbc280717bf93", size = 879865, upload-time = "2026-04-13T21:33:29.052Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2c/cad8b5e3623a987f3c930b68e2bdd06cfc388cd91cd42ed05f1227701b73/chardet-7.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:b95c934b9ad59e2ba8abb9be49df70d3ad1b0d95d864b9fdb7588d4fa8bd921c", size = 939594, upload-time = "2026-04-13T21:33:31.391Z" }, + { url = "https://files.pythonhosted.org/packages/33/e0/d06e42fd6f02a58e5e227e5106587751cb38adcff0aaf949add744b78b6e/chardet-7.4.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c77867f0c1cb8bd819502249fcdc500364aedb07881e11b743726fa2148e7b6e", size = 889714, upload-time = "2026-04-13T21:33:32.772Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ed/40d091954d48abea037baae6be8fb79905e5f78d34d12ea955132c7d8011/chardet-7.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cf1efeaf65a6ef2f5b9cc3a1df6f08ba2831b369ccaa4c7018eaf90aa757bb11", size = 872319, upload-time = "2026-04-13T21:33:34.427Z" }, + { url = "https://files.pythonhosted.org/packages/bb/77/82a46821dbfbdfe062710d2bf2ede13426304e3567a23c57d919c0c31630/chardet-7.4.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f3504c139a2ad544077dd2d9e412cd08b01786843d76997cd43bb6de311723c", size = 892021, upload-time = "2026-04-13T21:33:35.766Z" }, + { url = "https://files.pythonhosted.org/packages/49/57/42d30c562bda5b4a839766c1aad8d5856b798ad2a1c3247b72a679afec94/chardet-7.4.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457f619882ba66327d4d8d14c6c342269bdb1e4e1c38e8117df941d14d351b04", size = 902509, upload-time = "2026-04-13T21:33:37.096Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6c/0a40afdb50a0fe041ab95553b835a8160b6cf0e81edf2ae2fe9f5224cbf9/chardet-7.4.3-py3-none-any.whl", hash = "sha256:1173b74051570cf08099d7429d92e4882d375ad4217f92a6e5240ccfb26f231e", size = 626562, upload-time = "2026-04-13T21:33:38.559Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + [[package]] name = "click" version = "8.3.2" @@ -79,10 +286,73 @@ wheels = [ [[package]] name = "colorama" version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +source = { registry = "https://download.pytorch.org/whl/cpu" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, + { url = "https://download.pytorch.org/whl/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, ] [[package]] @@ -101,6 +371,57 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/a3/0bd5f0cdb0bbc92650e8dc457e9250358411ee5d1b65e42b6632387daf81/fastapi-0.136.0-py3-none-any.whl", hash = "sha256:8793d44ec7378e2be07f8a013cf7f7aa47d6327d0dfe9804862688ec4541a6b4", size = 117556, upload-time = "2026-04-16T11:47:11.922Z" }, ] +[[package]] +name = "filelock" +version = "3.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, +] + +[[package]] +name = "fonttools" +version = "4.62.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/08/7012b00a9a5874311b639c3920270c36ee0c445b69d9989a85e5c92ebcb0/fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d", size = 3580737, upload-time = "2026-03-13T13:54:25.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/56/6f389de21c49555553d6a5aeed5ac9767631497ac836c4f076273d15bd72/fonttools-4.62.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c22b1014017111c401469e3acc5433e6acf6ebcc6aa9efb538a533c800971c79", size = 2865155, upload-time = "2026-03-13T13:53:16.132Z" }, + { url = "https://files.pythonhosted.org/packages/03/c5/0e3966edd5ec668d41dfe418787726752bc07e2f5fd8c8f208615e61fa89/fonttools-4.62.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68959f5fc58ed4599b44aad161c2837477d7f35f5f79402d97439974faebfebe", size = 2412802, upload-time = "2026-03-13T13:53:18.878Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/e6ac4b44026de7786fe46e3bfa0c87e51d5d70a841054065d49cd62bb909/fonttools-4.62.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef46db46c9447103b8f3ff91e8ba009d5fe181b1920a83757a5762551e32bb68", size = 5013926, upload-time = "2026-03-13T13:53:21.379Z" }, + { url = "https://files.pythonhosted.org/packages/e2/98/8b1e801939839d405f1f122e7d175cebe9aeb4e114f95bfc45e3152af9a7/fonttools-4.62.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6706d1cb1d5e6251a97ad3c1b9347505c5615c112e66047abbef0f8545fa30d1", size = 4964575, upload-time = "2026-03-13T13:53:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/46/76/7d051671e938b1881670528fec69cc4044315edd71a229c7fd712eaa5119/fonttools-4.62.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e7abd2b1e11736f58c1de27819e1955a53267c21732e78243fa2fa2e5c1e069", size = 4953693, upload-time = "2026-03-13T13:53:26.569Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ae/b41f8628ec0be3c1b934fc12b84f4576a5c646119db4d3bdd76a217c90b5/fonttools-4.62.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9", size = 5094920, upload-time = "2026-03-13T13:53:29.329Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/53a1e9469331a23dcc400970a27a4caa3d9f6edbf5baab0260285238b884/fonttools-4.62.1-cp313-cp313-win32.whl", hash = "sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24", size = 2279928, upload-time = "2026-03-13T13:53:32.352Z" }, + { url = "https://files.pythonhosted.org/packages/38/60/35186529de1db3c01f5ad625bde07c1f576305eab6d86bbda4c58445f721/fonttools-4.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056", size = 2330514, upload-time = "2026-03-13T13:53:34.991Z" }, + { url = "https://files.pythonhosted.org/packages/36/f0/2888cdac391807d68d90dcb16ef858ddc1b5309bfc6966195a459dd326e2/fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca", size = 2864442, upload-time = "2026-03-13T13:53:37.509Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b2/e521803081f8dc35990816b82da6360fa668a21b44da4b53fc9e77efcd62/fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca", size = 2410901, upload-time = "2026-03-13T13:53:40.55Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/8c3511ff06e53110039358dbbdc1a65d72157a054638387aa2ada300a8b8/fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782", size = 4999608, upload-time = "2026-03-13T13:53:42.798Z" }, + { url = "https://files.pythonhosted.org/packages/28/63/cd0c3b26afe60995a5295f37c246a93d454023726c3261cfbb3559969bb9/fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae", size = 4912726, upload-time = "2026-03-13T13:53:45.405Z" }, + { url = "https://files.pythonhosted.org/packages/70/b9/ac677cb07c24c685cf34f64e140617d58789d67a3dd524164b63648c6114/fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7", size = 4951422, upload-time = "2026-03-13T13:53:48.326Z" }, + { url = "https://files.pythonhosted.org/packages/e6/10/11c08419a14b85b7ca9a9faca321accccc8842dd9e0b1c8a72908de05945/fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a", size = 5060979, upload-time = "2026-03-13T13:53:51.366Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3c/12eea4a4cf054e7ab058ed5ceada43b46809fce2bf319017c4d63ae55bb4/fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800", size = 2283733, upload-time = "2026-03-13T13:53:53.606Z" }, + { url = "https://files.pythonhosted.org/packages/6b/67/74b070029043186b5dd13462c958cb7c7f811be0d2e634309d9a1ffb1505/fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e", size = 2335663, upload-time = "2026-03-13T13:53:56.23Z" }, + { url = "https://files.pythonhosted.org/packages/42/c5/4d2ed3ca6e33617fc5624467da353337f06e7f637707478903c785bd8e20/fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82", size = 2947288, upload-time = "2026-03-13T13:53:59.397Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e9/7ab11ddfda48ed0f89b13380e5595ba572619c27077be0b2c447a63ff351/fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260", size = 2449023, upload-time = "2026-03-13T13:54:01.642Z" }, + { url = "https://files.pythonhosted.org/packages/b2/10/a800fa090b5e8819942e54e19b55fc7c21fe14a08757c3aa3ca8db358939/fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4", size = 5137599, upload-time = "2026-03-13T13:54:04.495Z" }, + { url = "https://files.pythonhosted.org/packages/37/dc/8ccd45033fffd74deb6912fa1ca524643f584b94c87a16036855b498a1ed/fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b", size = 4920933, upload-time = "2026-03-13T13:54:07.557Z" }, + { url = "https://files.pythonhosted.org/packages/99/eb/e618adefb839598d25ac8136cd577925d6c513dc0d931d93b8af956210f0/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87", size = 5016232, upload-time = "2026-03-13T13:54:10.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5f/9b5c9bfaa8ec82def8d8168c4f13615990d6ce5996fe52bd49bfb5e05134/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c", size = 5042987, upload-time = "2026-03-13T13:54:13.569Z" }, + { url = "https://files.pythonhosted.org/packages/90/aa/dfbbe24c6a6afc5c203d90cc0343e24bcbb09e76d67c4d6eef8c2558d7ba/fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a", size = 2348021, upload-time = "2026-03-13T13:54:16.98Z" }, + { url = "https://files.pythonhosted.org/packages/13/6f/ae9c4e4dd417948407b680855c2c7790efb52add6009aaecff1e3bc50e8e/fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e", size = 2414147, upload-time = "2026-03-13T13:54:19.416Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647, upload-time = "2026-03-13T13:54:22.735Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/cf/b50ddf667c15276a9ab15a70ef5f257564de271957933ffea49d2cdbcdfb/fsspec-2026.3.0.tar.gz", hash = "sha256:1ee6a0e28677557f8c2f994e3eea77db6392b4de9cd1f5d7a9e87a0ae9d01b41", size = 313547, upload-time = "2026-03-27T19:11:14.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl", hash = "sha256:d2ceafaad1b3457968ed14efa28798162f1638dbb5d2a6868a2db002a5ee39a4", size = 202595, upload-time = "2026-03-27T19:11:13.595Z" }, +] + [[package]] name = "greenlet" version = "3.4.0" @@ -110,7 +431,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/75/7e9cd1126a1e1f0cd67b0eda02e5221b28488d352684704a78ed505bd719/greenlet-3.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43748988b097f9c6f09364f260741aa73c80747f63389824435c7a50bfdfd5c1", size = 285856, upload-time = "2026-04-08T15:52:45.82Z" }, { url = "https://files.pythonhosted.org/packages/9d/c4/3e2df392e5cb199527c4d9dbcaa75c14edcc394b45040f0189f649631e3c/greenlet-3.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5566e4e2cd7a880e8c27618e3eab20f3494452d12fd5129edef7b2f7aa9a36d1", size = 610208, upload-time = "2026-04-08T16:24:39.674Z" }, { url = "https://files.pythonhosted.org/packages/da/af/750cdfda1d1bd30a6c28080245be8d0346e669a98fdbae7f4102aa95fff3/greenlet-3.4.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1054c5a3c78e2ab599d452f23f7adafef55062a783a8e241d24f3b633ba6ff82", size = 621269, upload-time = "2026-04-08T16:30:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/e0/93/c8c508d68ba93232784bbc1b5474d92371f2897dfc6bc281b419f2e0d492/greenlet-3.4.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98eedd1803353daf1cd9ef23eef23eda5a4d22f99b1f998d273a8b78b70dd47f", size = 628455, upload-time = "2026-04-08T16:40:40.698Z" }, { url = "https://files.pythonhosted.org/packages/54/78/0cbc693622cd54ebe25207efbb3a0eb07c2639cb8594f6e3aaaa0bb077a8/greenlet-3.4.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f82cb6cddc27dd81c96b1506f4aa7def15070c3b2a67d4e46fd19016aacce6cf", size = 617549, upload-time = "2026-04-08T15:56:34.893Z" }, + { url = "https://files.pythonhosted.org/packages/7f/46/cfaaa0ade435a60550fd83d07dfd5c41f873a01da17ede5c4cade0b9bab8/greenlet-3.4.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:b7857e2202aae67bc5725e0c1f6403c20a8ff46094ece015e7d474f5f7020b55", size = 426238, upload-time = "2026-04-08T16:43:06.865Z" }, { url = "https://files.pythonhosted.org/packages/ba/c0/8966767de01343c1ff47e8b855dc78e7d1a8ed2b7b9c83576a57e289f81d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:227a46251ecba4ff46ae742bc5ce95c91d5aceb4b02f885487aff269c127a729", size = 1575310, upload-time = "2026-04-08T16:26:21.671Z" }, { url = "https://files.pythonhosted.org/packages/b8/38/bcdc71ba05e9a5fda87f63ffc2abcd1f15693b659346df994a48c968003d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5b99e87be7eba788dd5b75ba1cde5639edffdec5f91fe0d734a249535ec3408c", size = 1640435, upload-time = "2026-04-08T15:57:32.572Z" }, { url = "https://files.pythonhosted.org/packages/a1/c2/19b664b7173b9e4ef5f77e8cef9f14c20ec7fce7920dc1ccd7afd955d093/greenlet-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:849f8bc17acd6295fcb5de8e46d55cc0e52381c56eaf50a2afd258e97bc65940", size = 238760, upload-time = "2026-04-08T17:04:03.878Z" }, @@ -118,7 +441,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/02/bde66806e8f169cf90b14d02c500c44cdbe02c8e224c9c67bafd1b8cadd1/greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e", size = 286291, upload-time = "2026-04-08T17:09:34.307Z" }, { url = "https://files.pythonhosted.org/packages/05/1f/39da1c336a87d47c58352fb8a78541ce63d63ae57c5b9dae1fe02801bbc2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d", size = 656749, upload-time = "2026-04-08T16:24:41.721Z" }, { url = "https://files.pythonhosted.org/packages/d3/6c/90ee29a4ee27af7aa2e2ec408799eeb69ee3fcc5abcecac6ddd07a5cd0f2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615", size = 669084, upload-time = "2026-04-08T16:31:01.372Z" }, + { url = "https://files.pythonhosted.org/packages/d2/4a/74078d3936712cff6d3c91a930016f476ce4198d84e224fe6d81d3e02880/greenlet-3.4.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:06c2d3b89e0c62ba50bd7adf491b14f39da9e7e701647cb7b9ff4c99bee04b19", size = 673405, upload-time = "2026-04-08T16:40:42.527Z" }, { url = "https://files.pythonhosted.org/packages/07/49/d4cad6e5381a50947bb973d2f6cf6592621451b09368b8c20d9b8af49c5b/greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf", size = 665621, upload-time = "2026-04-08T15:56:35.995Z" }, + { url = "https://files.pythonhosted.org/packages/79/3e/df8a83ab894751bc31e1106fdfaa80ca9753222f106b04de93faaa55feb7/greenlet-3.4.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:070b8bac2ff3b4d9e0ff36a0d19e42103331d9737e8504747cd1e659f76297bd", size = 471670, upload-time = "2026-04-08T16:43:08.512Z" }, { url = "https://files.pythonhosted.org/packages/37/31/d1edd54f424761b5d47718822f506b435b6aab2f3f93b465441143ea5119/greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf", size = 1622259, upload-time = "2026-04-08T16:26:23.201Z" }, { url = "https://files.pythonhosted.org/packages/b0/c6/6d3f9cdcb21c4e12a79cb332579f1c6aa1af78eb68059c5a957c7812d95e/greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda", size = 1686916, upload-time = "2026-04-08T15:57:34.282Z" }, { url = "https://files.pythonhosted.org/packages/63/45/c1ca4a1ad975de4727e52d3ffe641ae23e1d7a8ffaa8ff7a0477e1827b92/greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d", size = 239821, upload-time = "2026-04-08T17:03:48.423Z" }, @@ -126,7 +451,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/8f/18d72b629783f5e8d045a76f5325c1e938e659a9e4da79c7dcd10169a48d/greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece", size = 294681, upload-time = "2026-04-08T15:52:35.778Z" }, { url = "https://files.pythonhosted.org/packages/9e/ad/5fa86ec46769c4153820d58a04062285b3b9e10ba3d461ee257b68dcbf53/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8", size = 658899, upload-time = "2026-04-08T16:24:43.32Z" }, { url = "https://files.pythonhosted.org/packages/43/f0/4e8174ca0e87ae748c409f055a1ba161038c43cc0a5a6f1433a26ac2e5bf/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2", size = 665284, upload-time = "2026-04-08T16:31:02.833Z" }, + { url = "https://files.pythonhosted.org/packages/ef/92/466b0d9afd44b8af623139a3599d651c7564fa4152f25f117e1ee5949ffb/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4cd56a9eb7a6444edbc19062f7b6fbc8f287c663b946e3171d899693b1c19fa", size = 665872, upload-time = "2026-04-08T16:40:43.912Z" }, { url = "https://files.pythonhosted.org/packages/19/da/991cf7cd33662e2df92a1274b7eb4d61769294d38a1bba8a45f31364845e/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed", size = 661861, upload-time = "2026-04-08T15:56:37.269Z" }, + { url = "https://files.pythonhosted.org/packages/0d/14/3395a7ef3e260de0325152ddfe19dffb3e49fe10873b94654352b53ad48e/greenlet-3.4.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1f85f204c4d54134ae850d401fa435c89cd667d5ce9dc567571776b45941af72", size = 489237, upload-time = "2026-04-08T16:43:09.993Z" }, { url = "https://files.pythonhosted.org/packages/36/c5/6c2c708e14db3d9caea4b459d8464f58c32047451142fe2cfd90e7458f41/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f", size = 1622182, upload-time = "2026-04-08T16:26:24.777Z" }, { url = "https://files.pythonhosted.org/packages/7a/4c/50c5fed19378e11a29fabab1f6be39ea95358f4a0a07e115a51ca93385d8/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a", size = 1685050, upload-time = "2026-04-08T15:57:36.453Z" }, { url = "https://files.pythonhosted.org/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705", size = 245554, upload-time = "2026-04-08T17:03:50.044Z" }, @@ -141,6 +468,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + [[package]] name = "httptools" version = "0.7.1" @@ -163,6 +503,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, ] +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -172,6 +527,93 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://download.pytorch.org/whl/cpu" } +dependencies = [ + { name = "markupsafe" }, +] +wheels = [ + { url = "https://download.pytorch.org/whl/jinja2-3.1.6-py3-none-any.whl" }, +] + +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, + { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, + { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, + { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, + { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, + { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, + { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, + { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, + { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, + { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, + { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, + { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, + { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, + { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, + { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, +] + [[package]] name = "loguru" version = "0.7.3" @@ -185,29 +627,402 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, ] +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://download.pytorch.org/whl/cpu" } +wheels = [ + { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl" }, + { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl" }, + { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" }, + { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp313-cp313-win_amd64.whl" }, + { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl" }, + { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl" }, + { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" }, + { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl" }, + { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl" }, + { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl" }, + { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" }, + { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp314-cp314-win_amd64.whl" }, + { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl" }, + { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl" }, + { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" }, + { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl" }, +] + +[[package]] +name = "matplotlib" +version = "3.10.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/b9/15fd5541ef4f5b9a17eefd379356cf12175fe577424e7b1d80676516031a/matplotlib-3.10.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6", size = 8261076, upload-time = "2025-12-10T22:55:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a0/2ba3473c1b66b9c74dc7107c67e9008cb1782edbe896d4c899d39ae9cf78/matplotlib-3.10.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1", size = 8148794, upload-time = "2025-12-10T22:55:46.252Z" }, + { url = "https://files.pythonhosted.org/packages/75/97/a471f1c3eb1fd6f6c24a31a5858f443891d5127e63a7788678d14e249aea/matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486", size = 8718474, upload-time = "2025-12-10T22:55:47.864Z" }, + { url = "https://files.pythonhosted.org/packages/01/be/cd478f4b66f48256f42927d0acbcd63a26a893136456cd079c0cc24fbabf/matplotlib-3.10.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce", size = 9549637, upload-time = "2025-12-10T22:55:50.048Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8dc289776eae5109e268c4fb92baf870678dc048a25d4ac903683b86d5bf/matplotlib-3.10.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6", size = 9613678, upload-time = "2025-12-10T22:55:52.21Z" }, + { url = "https://files.pythonhosted.org/packages/64/40/37612487cc8a437d4dd261b32ca21fe2d79510fe74af74e1f42becb1bdb8/matplotlib-3.10.8-cp313-cp313-win_amd64.whl", hash = "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149", size = 8142686, upload-time = "2025-12-10T22:55:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/66/52/8d8a8730e968185514680c2a6625943f70269509c3dcfc0dcf7d75928cb8/matplotlib-3.10.8-cp313-cp313-win_arm64.whl", hash = "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645", size = 8012917, upload-time = "2025-12-10T22:55:56.268Z" }, + { url = "https://files.pythonhosted.org/packages/b5/27/51fe26e1062f298af5ef66343d8ef460e090a27fea73036c76c35821df04/matplotlib-3.10.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077", size = 8305679, upload-time = "2025-12-10T22:55:57.856Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/4de865bc591ac8e3062e835f42dd7fe7a93168d519557837f0e37513f629/matplotlib-3.10.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22", size = 8198336, upload-time = "2025-12-10T22:55:59.371Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cb/2f7b6e75fb4dce87ef91f60cac4f6e34f4c145ab036a22318ec837971300/matplotlib-3.10.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39", size = 8731653, upload-time = "2025-12-10T22:56:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/46/b3/bd9c57d6ba670a37ab31fb87ec3e8691b947134b201f881665b28cc039ff/matplotlib-3.10.8-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565", size = 9561356, upload-time = "2025-12-10T22:56:02.95Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3d/8b94a481456dfc9dfe6e39e93b5ab376e50998cddfd23f4ae3b431708f16/matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a", size = 9614000, upload-time = "2025-12-10T22:56:05.411Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cd/bc06149fe5585ba800b189a6a654a75f1f127e8aab02fd2be10df7fa500c/matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958", size = 8220043, upload-time = "2025-12-10T22:56:07.551Z" }, + { url = "https://files.pythonhosted.org/packages/e3/de/b22cf255abec916562cc04eef457c13e58a1990048de0c0c3604d082355e/matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5", size = 8062075, upload-time = "2025-12-10T22:56:09.178Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/9c0ff7a2f11615e516c3b058e1e6e8f9614ddeca53faca06da267c48345d/matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f", size = 8262481, upload-time = "2025-12-10T22:56:10.885Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ca/e8ae28649fcdf039fda5ef554b40a95f50592a3c47e6f7270c9561c12b07/matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b", size = 8151473, upload-time = "2025-12-10T22:56:12.377Z" }, + { url = "https://files.pythonhosted.org/packages/f1/6f/009d129ae70b75e88cbe7e503a12a4c0670e08ed748a902c2568909e9eb5/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d", size = 9553896, upload-time = "2025-12-10T22:56:14.432Z" }, + { url = "https://files.pythonhosted.org/packages/f5/26/4221a741eb97967bc1fd5e4c52b9aa5a91b2f4ec05b59f6def4d820f9df9/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008", size = 9824193, upload-time = "2025-12-10T22:56:16.29Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/3abf75f38605772cf48a9daf5821cd4f563472f38b4b828c6fba6fa6d06e/matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c", size = 9615444, upload-time = "2025-12-10T22:56:18.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/a5/de89ac80f10b8dc615807ee1133cd99ac74082581196d4d9590bea10690d/matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11", size = 8272719, upload-time = "2025-12-10T22:56:20.366Z" }, + { url = "https://files.pythonhosted.org/packages/69/ce/b006495c19ccc0a137b48083168a37bd056392dee02f87dba0472f2797fe/matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8", size = 8144205, upload-time = "2025-12-10T22:56:22.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/d9/b31116a3a855bd313c6fcdb7226926d59b041f26061c6c5b1be66a08c826/matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50", size = 8305785, upload-time = "2025-12-10T22:56:24.218Z" }, + { url = "https://files.pythonhosted.org/packages/1e/90/6effe8103f0272685767ba5f094f453784057072f49b393e3ea178fe70a5/matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908", size = 8198361, upload-time = "2025-12-10T22:56:26.787Z" }, + { url = "https://files.pythonhosted.org/packages/d7/65/a73188711bea603615fc0baecca1061429ac16940e2385433cc778a9d8e7/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a", size = 9561357, upload-time = "2025-12-10T22:56:28.953Z" }, + { url = "https://files.pythonhosted.org/packages/f4/3d/b5c5d5d5be8ce63292567f0e2c43dde9953d3ed86ac2de0a72e93c8f07a1/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1", size = 9823610, upload-time = "2025-12-10T22:56:31.455Z" }, + { url = "https://files.pythonhosted.org/packages/4d/4b/e7beb6bbd49f6bae727a12b270a2654d13c397576d25bd6786e47033300f/matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c", size = 9614011, upload-time = "2025-12-10T22:56:33.85Z" }, + { url = "https://files.pythonhosted.org/packages/7c/e6/76f2813d31f032e65f6f797e3f2f6e4aab95b65015924b1c51370395c28a/matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b", size = 8362801, upload-time = "2025-12-10T22:56:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/5d/49/d651878698a0b67f23aa28e17f45a6d6dd3d3f933fa29087fa4ce5947b5a/matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", size = 8192560, upload-time = "2025-12-10T22:56:38.008Z" }, +] + +[[package]] +name = "minio" +version = "7.2.20" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi" }, + { name = "certifi" }, + { name = "pycryptodome" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/df/6dfc6540f96a74125a11653cce717603fd5b7d0001a8e847b3e54e72d238/minio-7.2.20.tar.gz", hash = "sha256:95898b7a023fbbfde375985aa77e2cd6a0762268db79cf886f002a9ea8e68598", size = 136113, upload-time = "2025-11-27T00:37:15.569Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/9a/b697530a882588a84db616580f2ba5d1d515c815e11c30d219145afeec87/minio-7.2.20-py3-none-any.whl", hash = "sha256:eb33dd2fb80e04c3726a76b13241c6be3c4c46f8d81e1d58e757786f6501897e", size = 93751, upload-time = "2025-11-27T00:37:13.993Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://download.pytorch.org/whl/cpu" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://download.pytorch.org/whl/cpu" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762" }, +] + +[[package]] +name = "numpy" +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, + { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, + { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, + { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, + { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, + { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, + { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, + { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, + { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, + { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, + { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, + { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, + { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, + { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, + { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, +] + +[[package]] +name = "opencv-python" +version = "4.13.0.92" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19", size = 46247052, upload-time = "2026-02-05T07:01:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9", size = 32568781, upload-time = "2026-02-05T07:01:41.379Z" }, + { url = "https://files.pythonhosted.org/packages/3e/51/82fed528b45173bf629fa44effb76dff8bc9f4eeaee759038362dfa60237/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bc2596e68f972ca452d80f444bc404e08807d021fbba40df26b61b18e01838a", size = 47685527, upload-time = "2026-02-05T06:59:11.24Z" }, + { url = "https://files.pythonhosted.org/packages/db/07/90b34a8e2cf9c50fe8ed25cac9011cde0676b4d9d9c973751ac7616223a2/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:402033cddf9d294693094de5ef532339f14ce821da3ad7df7c9f6e8316da32cf", size = 70460872, upload-time = "2026-02-05T06:59:19.162Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/7a9cc719b3eaf4377b9c2e3edeb7ed3a81de41f96421510c0a169ca3cfd4/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bccaabf9eb7f897ca61880ce2869dcd9b25b72129c28478e7f2a5e8dee945616", size = 46708208, upload-time = "2026-02-05T06:59:15.419Z" }, + { url = "https://files.pythonhosted.org/packages/fd/55/b3b49a1b97aabcfbbd6c7326df9cb0b6fa0c0aefa8e89d500939e04aa229/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:620d602b8f7d8b8dab5f4b99c6eb353e78d3fb8b0f53db1bd258bb1aa001c1d5", size = 72927042, upload-time = "2026-02-05T06:59:23.389Z" }, + { url = "https://files.pythonhosted.org/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59", size = 30932638, upload-time = "2026-02-05T07:02:14.98Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" }, +] + [[package]] name = "operation-room-monitor-server" version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "asyncpg" }, + { name = "baidu-aip" }, + { name = "chardet" }, { name = "fastapi" }, + { name = "greenlet" }, { name = "loguru" }, + { name = "minio" }, + { name = "pillow" }, { name = "pydantic-settings" }, + { name = "python-multipart" }, { name = "sqlalchemy" }, + { name = "ultralytics" }, { name = "uvicorn", extra = ["standard"] }, ] +[package.dev-dependencies] +dev = [ + { name = "aiosqlite" }, + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + [package.metadata] requires-dist = [ { name = "asyncpg", specifier = ">=0.31.0" }, + { name = "baidu-aip", specifier = ">=4.16.13" }, + { name = "chardet", specifier = ">=7.4.3" }, { name = "fastapi", specifier = ">=0.136.0" }, + { name = "greenlet", specifier = ">=3.1.0" }, { name = "loguru", specifier = ">=0.7.3" }, + { name = "minio", specifier = ">=7.2.15" }, + { name = "pillow", specifier = ">=12.2.0" }, { name = "pydantic-settings", specifier = ">=2.13.1" }, + { name = "python-multipart", specifier = ">=0.0.26" }, { name = "sqlalchemy", specifier = ">=2.0.49" }, + { name = "ultralytics", specifier = ">=8.4.40" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.44.0" }, ] +[package.metadata.requires-dev] +dev = [ + { name = "aiosqlite", specifier = ">=0.21.0" }, + { name = "httpx", specifier = ">=0.28.0" }, + { name = "pytest", specifier = ">=8.3.0" }, + { name = "pytest-asyncio", specifier = ">=0.25.0" }, +] + +[[package]] +name = "packaging" +version = "26.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "polars" +version = "1.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "polars-runtime-32" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/1b/eea7d6fe6daafc1d784cc0f76c729b28051837ccb2d51ae64a0a3f798142/polars-1.40.0.tar.gz", hash = "sha256:711dd50dcbc35ba42a2625fcadc2a1349e2e9abf48e35631bdabafb90d89874b", size = 732943, upload-time = "2026-04-18T05:25:26.077Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/ad/d5ed79269b7fe59a3dbbfbdbecbe1e59a0b56e38d36491e57d2bfb5846c1/polars-1.40.0-py3-none-any.whl", hash = "sha256:60b1d677ca363e2fc6fdea8c3d16c0653fd52cc37f0249e0f29d9536d5aa45ef", size = 828012, upload-time = "2026-04-18T05:23:39.055Z" }, +] + +[[package]] +name = "polars-runtime-32" +version = "1.40.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/b2/eae6c1b3d16c7a64ff382f557985ff939cce13455e8c9d056ab8e1e0fc87/polars_runtime_32-1.40.0.tar.gz", hash = "sha256:e31bff8bd37492c714e155e2e1429ac2d9ddf2dd6ec6474cc1cc70ac0b2bd6af", size = 2935285, upload-time = "2026-04-18T05:25:28.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/e4/2325689d2af4f9e70699ff98e8a2543707bebc34af78a5fe0e654107d9ed/polars_runtime_32-1.40.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:cab3ac7ff5bc9e0f4b3b146015569e9417cf0eaff8d3fb71004d73d67b6f09c7", size = 52092528, upload-time = "2026-04-18T05:23:42.341Z" }, + { url = "https://files.pythonhosted.org/packages/19/a6/82157b19c5c40b2c1ed0493b87b9eaf9b4863cdedca5575ee083488b45ba/polars_runtime_32-1.40.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:d29624c75c4049253300786d00882fce620b3677ce495ebc4199292de8c2ba02", size = 46365073, upload-time = "2026-04-18T05:23:46.7Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/5c4f1f2545f56c664cc57bbdd1aa66fcfcb129aa137ed72cc81d58eb480f/polars_runtime_32-1.40.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a034dc0d8481fc1ca0456ab33e98e53a4c6d6cc6a2edb36246cc81c936b925dc", size = 50250561, upload-time = "2026-04-18T05:23:51.316Z" }, + { url = "https://files.pythonhosted.org/packages/8e/51/cb5eb75394f39c0ec14fddcc9b11adb707e1f28224a552ecbfa72d39b61b/polars_runtime_32-1.40.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70e78c2f13a54a9d92ae30d2625bda759173cc4867ad6a39f85f140058d899c6", size = 56243695, upload-time = "2026-04-18T05:23:55.932Z" }, + { url = "https://files.pythonhosted.org/packages/16/3a/be1437c0fbecbb07d81b151456089c3cf054eea5a791f849ed39b67611ca/polars_runtime_32-1.40.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1843272c0ef49f4a07435888f0059eca08ec16ab9880219c457195a081df0281", size = 50427843, upload-time = "2026-04-18T05:24:00.159Z" }, + { url = "https://files.pythonhosted.org/packages/be/c7/ea6449a2161816a13ed1d8aa02177d5a0594e011f0df5ddd2fad8e5bf20e/polars_runtime_32-1.40.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:081237dba07f15d61fc151825f203165480e9503ebe72a474a8c99aa78021962", size = 54153077, upload-time = "2026-04-18T05:24:05.066Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1a/0b239138afe8b80a1a0b4c95db3884e6afbbe82ec3318918ab03bc57f231/polars_runtime_32-1.40.0-cp310-abi3-win_amd64.whl", hash = "sha256:a916040e0b7f461ce987e4551fed9eea5914b4fbb5af907b1d9e80db71fadeb5", size = 51822748, upload-time = "2026-04-18T05:24:09.384Z" }, + { url = "https://files.pythonhosted.org/packages/06/ce/c16ef8fd3030b7342032b040fab21a42f6fee57e47ee7f41e2f1a1e36f01/polars_runtime_32-1.40.0-cp310-abi3-win_arm64.whl", hash = "sha256:719c64eecde24a95aa3599eb9c8efc98c1499bab7ef9c01cbbe8939cd583e654", size = 45819617, upload-time = "2026-04-18T05:24:13.214Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pycryptodome" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" }, + { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" }, + { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" }, + { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" }, + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, +] + [[package]] name = "pydantic" version = "2.13.2" @@ -293,6 +1108,64 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, ] +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -302,6 +1175,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -338,6 +1220,90 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "setuptools" +version = "81.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/1c/73e719955c59b8e424d015ab450f51c0af856ae46ea2da83eba51cc88de1/setuptools-81.0.0.tar.gz", hash = "sha256:487b53915f52501f0a79ccfd0c02c165ffe06631443a886740b91af4b7a5845a", size = 1198299, upload-time = "2026-02-06T21:10:39.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "sqlalchemy" version = "2.0.49" @@ -389,13 +1355,136 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, ] +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://download.pytorch.org/whl/cpu" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5" }, +] + +[[package]] +name = "torch" +version = "2.11.0" +source = { registry = "https://download.pytorch.org/whl/cpu" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'darwin'", + "python_full_version < '3.14' and sys_platform == 'darwin'", +] +dependencies = [ + { name = "filelock", marker = "sys_platform == 'darwin'" }, + { name = "fsspec", marker = "sys_platform == 'darwin'" }, + { name = "jinja2", marker = "sys_platform == 'darwin'" }, + { name = "networkx", marker = "sys_platform == 'darwin'" }, + { name = "setuptools", marker = "sys_platform == 'darwin'" }, + { name = "sympy", marker = "sys_platform == 'darwin'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin'" }, +] +wheels = [ + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:442ec9dc78592564fdad69cf0beaa9da2f82ab810ccb4f13903869a90bf3f15d" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cc3a195701bba2239c313ee311487f80f8aaebe9e89b9073dddbcf2f93b5a0ba" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:072a0d6e4865e8b0dc0dbfe6ebed68fae235124222835ef03e5814d414d8c012" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:23ec7789017da9d95b6d543d790814785e6f30905c5443efa8257d1490d73f79" }, +] + +[[package]] +name = "torch" +version = "2.11.0+cpu" +source = { registry = "https://download.pytorch.org/whl/cpu" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform != 'darwin' and sys_platform != 'win32'", +] +dependencies = [ + { name = "filelock", marker = "sys_platform != 'darwin'" }, + { name = "fsspec", marker = "sys_platform != 'darwin'" }, + { name = "jinja2", marker = "sys_platform != 'darwin'" }, + { name = "networkx", marker = "sys_platform != 'darwin'" }, + { name = "setuptools", marker = "sys_platform != 'darwin'" }, + { name = "sympy", marker = "sys_platform != 'darwin'" }, + { name = "typing-extensions", marker = "sys_platform != 'darwin'" }, +] +wheels = [ + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313-linux_s390x.whl" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313-win_amd64.whl" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313t-linux_s390x.whl" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313t-win_amd64.whl" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314-linux_s390x.whl" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314-win_amd64.whl" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314t-linux_s390x.whl" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314t-win_amd64.whl" }, +] + +[[package]] +name = "torchvision" +version = "0.26.0" +source = { registry = "https://download.pytorch.org/whl/cpu" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'darwin'", + "python_full_version < '3.14' and sys_platform == 'darwin'", +] +dependencies = [ + { name = "numpy", marker = "sys_platform == 'darwin'" }, + { name = "pillow", marker = "sys_platform == 'darwin'" }, + { name = "torch", version = "2.11.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" }, +] +wheels = [ + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5d63dd43162691258b1b3529b9041bac7d54caa37eae0925f997108268cbf7c4" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:358fc4726d0c08615b6d83b3149854f11efb2a564ed1acb6fce882e151412d23" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:eb61804eb9dbe88c5a2a6c4da8dec1d80d2d0a6f18c999c524e32266cb1ebcd3" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:b7d3e295624a28b3b1769228ce1345d94cf4d390dd31136766f76f2d20f718da" }, +] + +[[package]] +name = "torchvision" +version = "0.26.0+cpu" +source = { registry = "https://download.pytorch.org/whl/cpu" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform != 'darwin' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", marker = "sys_platform != 'darwin'" }, + { name = "pillow", marker = "sys_platform != 'darwin'" }, + { name = "torch", version = "2.11.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform != 'darwin'" }, +] +wheels = [ + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:2e932af123a39137815dfd152c64cc683fa7cbd327c965e807c9728c7aa4971a" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:16c4f11eda096dc377e82238c8ebb26c7013622c0f1b2c4dcf85fc70f96c0ea7" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp313-cp313-win_amd64.whl", hash = "sha256:34ac55a1f614baca2e0f5cef20ddb36184ee3503423871260e1ddd72caf9cb5f" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:3d30ce3444698807d4b18b199645cd7a95e0b16a4cd0909b8aab47c562a7673a" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:870a97101168d4da68039d3d51f0c781047065e82dc4c19b2eb0ddff08486180" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp313-cp313t-win_amd64.whl", hash = "sha256:050aaf28cff9c2981ec72dc3f9b4ef77bcf9c9c99330ce426cb06c5bb9e6e726" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:78576c8d5a8665de6caaa6e7c3a3fb7caa5dc112032ba60e129a9e78a446a03b" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:78e88d0a57bfadcd17042aa92fe4dd1059e48fcaa2e54a10ac7f438c2eca10d5" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp314-cp314-win_amd64.whl", hash = "sha256:93144d0997c51b27996c8305df4d9104efb0d38c9a9b6b05c8bc20ebdf7193b5" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:93a11b159613ad920b1d42c4eb4e585f48e5dff895f3e08f517ef482fe84e130" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:99f86ec0a83b9e4b5428a452bf667f99a9ae27d4c32bd4b2081fe917303e7710" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp314-cp314t-win_amd64.whl", hash = "sha256:6139108231a29ffb607931360ee24594553a939467c65530f734a2ed9918f011" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +source = { registry = "https://download.pytorch.org/whl/cpu" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://download.pytorch.org/whl/typing_extensions-4.15.0-py3-none-any.whl" }, ] [[package]] @@ -410,6 +1499,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "ultralytics" +version = "8.4.40" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy" }, + { name = "opencv-python" }, + { name = "pillow" }, + { name = "polars" }, + { name = "psutil" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "scipy" }, + { name = "torch", version = "2.11.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" }, + { name = "torch", version = "2.11.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform != 'darwin'" }, + { name = "torchvision", version = "0.26.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" }, + { name = "torchvision", version = "0.26.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform != 'darwin'" }, + { name = "ultralytics-thop" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/ca/585359705a9f7ffa64bab0e71bd2636fd9cfcfc729a033e83639de6b9e82/ultralytics-8.4.40.tar.gz", hash = "sha256:093fc2286ce7a405bd943de29d5f540e3c3c7a8008439f5d87ca9759fa3ca191", size = 1036635, upload-time = "2026-04-20T12:10:00.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/20/3ff18c04e603ac29fcfbabfeee663e0bd470029624875750e3d3c0117f0b/ultralytics-8.4.40-py3-none-any.whl", hash = "sha256:6e051f7fa3eca7d347e1f1707a761b25281555f0ad963cb3934676b085463206", size = 1228208, upload-time = "2026-04-20T12:09:56.586Z" }, +] + +[[package]] +name = "ultralytics-thop" +version = "2.0.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "torch", version = "2.11.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" }, + { name = "torch", version = "2.11.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform != 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/63/21a32e1facfeee245dbdfb7b4669faf7a36ff7c00b50987932bdab126f4b/ultralytics_thop-2.0.18.tar.gz", hash = "sha256:21103bcd39cc9928477dc3d9374561749b66a1781b35f46256c8d8c4ac01d9cf", size = 34557, upload-time = "2025-10-29T16:58:13.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/c7/fb42228bb05473d248c110218ffb8b1ad2f76728ed8699856e5af21112ad/ultralytics_thop-2.0.18-py3-none-any.whl", hash = "sha256:2bb44851ad224b116c3995b02dd5e474a5ccf00acf237fe0edb9e1506ede04ec", size = 28941, upload-time = "2025-10-29T16:58:12.093Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + [[package]] name = "uvicorn" version = "0.44.0"