From e112680ad815b5159bc368ed44876aa9bd69ae8c Mon Sep 17 00:00:00 2001
From: SayQuan1 <15246906705@163.com>
Date: Mon, 28 Sep 2026 19:22:13 +0800
Subject: [PATCH] feat(i-01a): add isolated Python 3.11 development skeleton
Unify the business and AI interpreter baseline on Python 3.11 while preserving separate dependencies and processes. Replace framework fallbacks, align the authenticated fixture with generated AI contracts, reject production, and document ADR-PY-01 and I-01A acceptance.
Validation: 25 application/protocol tests; clean AI-only environment; Ruff check and format; npm ci and dev build; production rejection; wheel/resource smoke; 228 synthetic readiness/release/D-FINE checks; generated artifacts and staged whitespace checks.
Scope: development skeleton only. MySQL, durable jobs, real spawn computation, D-FINE/OCR/GPU inference, and production deployment remain later milestones. Runtime and auth changes require independent review.
Rollback: revert this commit. No database migration or production data changes. Do not rewrite main history.
---
.env.example | 15 +
.github/workflows/ci.yml | 89 +
.gitignore | 7 +
DEVELOPMENT.md | 151 ++
apps/__init__.py | 1 +
apps/ai_inference/__init__.py | 1 +
apps/ai_inference/app/__init__.py | 1 +
apps/ai_inference/app/fixture.py | 125 ++
apps/ai_inference/app/main.py | 136 ++
apps/ai_inference/app/settings.py | 95 +
apps/ai_inference/fixtures/dictionary.json | 5 +
apps/ai_inference/fixtures/model.json | 7 +
apps/ai_inference/fixtures/runtime-lock.json | 14 +
apps/ai_inference/run.py | 19 +
apps/api/__init__.py | 1 +
apps/api/app/__init__.py | 1 +
apps/api/app/main.py | 25 +
apps/api/run.py | 19 +
apps/web/index.html | 12 +
apps/web/package-lock.json | 1718 ++++++++++++++++
apps/web/package.json | 24 +
apps/web/src/App.vue | 11 +
apps/web/src/main.ts | 5 +
apps/web/tsconfig.json | 16 +
apps/web/vite.config.ts | 14 +
apps/worker/__init__.py | 1 +
apps/worker/app/__init__.py | 1 +
apps/worker/app/main.py | 28 +
apps/worker/run.py | 20 +
docs/00-document-control.md | 4 +-
.../03-ai-inference-process.md | 4 +-
docs/02-architecture/04-coding-baseline.md | 8 +-
docs/02-architecture/07-python-runtime.md | 38 +
docs/02-architecture/README.md | 1 +
docs/04-ai-rules/02-model-data-plan.md | 18 +-
.../02-deployment-operations.md | 2 +
docs/08-delivery/01-implementation-plan.md | 6 +-
docs/08-delivery/04-remediation-plan.md | 2 +-
docs/08-delivery/07-i01a-acceptance.md | 57 +
docs/08-delivery/README.md | 1 +
.../design-validation-results.json | 4 +-
packages/__init__.py | 1 +
packages/application/__init__.py | 1 +
packages/domain/__init__.py | 1 +
packages/inference_protocol/__init__.py | 1 +
packages/inference_protocol/contract.json | 1730 +++++++++++++++++
packages/inference_protocol/contract.py | 36 +
packages/inference_protocol/hashing.py | 34 +
packages/persistence/__init__.py | 1 +
packages/rules/__init__.py | 1 +
packages/shared/__init__.py | 1 +
packages/shared/environment.py | 13 +
packages/shared/json_hash.py | 5 +
pyproject.toml | 60 +
requirements/common.txt | 7 +
requirements/dev-ai.txt | 2 +
requirements/dev.txt | 2 +
requirements/py311-ai.txt | 2 +
requirements/py311-business.txt | 7 +
requirements/test.txt | 3 +
tests/__init__.py | 1 +
tests/ai/__init__.py | 1 +
tests/ai/test_fixture.py | 320 +++
tests/business/__init__.py | 1 +
tests/business/test_bootstrap.py | 77 +
tests/helpers.py | 119 ++
tests/protocol/__init__.py | 1 +
tests/protocol/test_contract.py | 39 +
tools/design/README.md | 2 +-
tools/design/build_specs.py | 5 +
70 files changed, 5160 insertions(+), 21 deletions(-)
create mode 100644 .env.example
create mode 100644 .github/workflows/ci.yml
create mode 100644 DEVELOPMENT.md
create mode 100644 apps/__init__.py
create mode 100644 apps/ai_inference/__init__.py
create mode 100644 apps/ai_inference/app/__init__.py
create mode 100644 apps/ai_inference/app/fixture.py
create mode 100644 apps/ai_inference/app/main.py
create mode 100644 apps/ai_inference/app/settings.py
create mode 100644 apps/ai_inference/fixtures/dictionary.json
create mode 100644 apps/ai_inference/fixtures/model.json
create mode 100644 apps/ai_inference/fixtures/runtime-lock.json
create mode 100644 apps/ai_inference/run.py
create mode 100644 apps/api/__init__.py
create mode 100644 apps/api/app/__init__.py
create mode 100644 apps/api/app/main.py
create mode 100644 apps/api/run.py
create mode 100644 apps/web/index.html
create mode 100644 apps/web/package-lock.json
create mode 100644 apps/web/package.json
create mode 100644 apps/web/src/App.vue
create mode 100644 apps/web/src/main.ts
create mode 100644 apps/web/tsconfig.json
create mode 100644 apps/web/vite.config.ts
create mode 100644 apps/worker/__init__.py
create mode 100644 apps/worker/app/__init__.py
create mode 100644 apps/worker/app/main.py
create mode 100644 apps/worker/run.py
create mode 100644 docs/02-architecture/07-python-runtime.md
create mode 100644 docs/08-delivery/07-i01a-acceptance.md
create mode 100644 packages/__init__.py
create mode 100644 packages/application/__init__.py
create mode 100644 packages/domain/__init__.py
create mode 100644 packages/inference_protocol/__init__.py
create mode 100644 packages/inference_protocol/contract.json
create mode 100644 packages/inference_protocol/contract.py
create mode 100644 packages/inference_protocol/hashing.py
create mode 100644 packages/persistence/__init__.py
create mode 100644 packages/rules/__init__.py
create mode 100644 packages/shared/__init__.py
create mode 100644 packages/shared/environment.py
create mode 100644 packages/shared/json_hash.py
create mode 100644 pyproject.toml
create mode 100644 requirements/common.txt
create mode 100644 requirements/dev-ai.txt
create mode 100644 requirements/dev.txt
create mode 100644 requirements/py311-ai.txt
create mode 100644 requirements/py311-business.txt
create mode 100644 requirements/test.txt
create mode 100644 tests/__init__.py
create mode 100644 tests/ai/__init__.py
create mode 100644 tests/ai/test_fixture.py
create mode 100644 tests/business/__init__.py
create mode 100644 tests/business/test_bootstrap.py
create mode 100644 tests/helpers.py
create mode 100644 tests/protocol/__init__.py
create mode 100644 tests/protocol/test_contract.py
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..2e1091c
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,15 @@
+APP_ENV=dev
+API_HOST=127.0.0.1
+API_PORT=8000
+AI_INFERENCE_HOST=127.0.0.1
+AI_INFERENCE_PORT=8001
+REDIS_URL=redis://localhost:6379/0
+DATABASE_URL=mysql+pymysql://labsafe:change-me@localhost:3306/labsafe
+AI_MODE=mock
+AI_TOKEN_FILE=.local-secrets/ai-token
+AI_ALLOWED_TENANTS=11111111-1111-4111-8111-111111111111
+AI_MAX_INFLIGHT=1
+AI_FIXTURE_SCENARIO=no_targets
+AI_FIXTURE_DELAY_MS=0
+SERVICE_COMMIT=local-development
+VITE_APP_ENV=dev
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..fb7f638
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,89 @@
+name: ci
+
+on:
+ pull_request:
+ push:
+ branches: [main, 'wsq/**']
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ci-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ PYTHONDONTWRITEBYTECODE: '1'
+ PYTHONUTF8: '1'
+
+jobs:
+ business-python311:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+ cache: pip
+ cache-dependency-path: requirements/*.txt
+ - run: python -m pip install -r requirements/dev.txt
+ - run: python -m pip check
+ - run: python -m unittest discover -s tests/business -t . -v
+ - run: python -m unittest discover -s tests/protocol -t . -v
+ - run: ruff check apps packages tests
+ - run: ruff format --check apps packages tests
+
+ ai-python311:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+ cache: pip
+ cache-dependency-path: requirements/*.txt
+ - run: python -m pip install -r requirements/dev-ai.txt
+ - run: python -m pip check
+ - name: AI environment must not install business or model dependencies
+ run: |
+ python -c "import importlib.util; names=['celery','redis','sqlalchemy','torch','paddle','onnxruntime']; assert all(importlib.util.find_spec(n) is None for n in names)"
+ - run: python -m unittest discover -s tests/ai -t . -v
+ - run: python -m unittest discover -s tests/protocol -t . -v
+
+ design-contracts:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+ - run: python -m pip install -r tools/design/requirements.txt
+ - run: python -B tools/design/build_specs.py --check
+ - run: python -B tools/design/test_readiness_design.py
+ - run: python -B tools/design/validate_specs.py
+ - run: git diff --exit-code
+
+ web:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: apps/web
+ env:
+ APP_ENV: dev
+ VITE_APP_ENV: dev
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: npm
+ cache-dependency-path: apps/web/package-lock.json
+ - run: npm ci
+ - run: npm run build
+ - name: Reject production fixture builds
+ run: |
+ if APP_ENV=production VITE_APP_ENV=production npm run build; then
+ echo "ERROR: I-01A must reject production builds"
+ exit 1
+ fi
+ - run: git diff --exit-code -- package.json package-lock.json
diff --git a/.gitignore b/.gitignore
index 264d825..d167b71 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,10 @@ node_modules/
dist/
build/
coverage/
+.venv/
+.venv-*/
+.ruff_cache/
+.mypy_cache/
+*.egg-info/
+.local-secrets/
+test-results/
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
new file mode 100644
index 0000000..0d8b5d0
--- /dev/null
+++ b/DEVELOPMENT.md
@@ -0,0 +1,151 @@
+# LabSafe 开发指南(I-01A)
+
+## 1. 本阶段边界
+
+所有 Python 服务统一使用 Python 3.11.x;业务与 AI 使用独立虚拟环境和独立进程。
+依据见 [运行时决策](docs/02-architecture/07-python-runtime.md)。前端 CI 使用 Node.js 20。
+
+本阶段提供真实 FastAPI/Celery 入口、同协议开发 fixture、配置保护和 CI。
+不包含数据库迁移、用户会话、业务 API、持久任务、真实图像处理、训练权重或生产部署。
+API 的 /ready 只检查 I-01A 进程配置,不代表数据库或完整系统已就绪。
+所有后端进程和前端构建均拒绝 production;AI_MODE 只接受 mock。
+
+## 2. 安装依赖
+
+在仓库根目录执行。Windows 示例:
+
+~~~powershell
+py -3.11 -m venv .venv-business
+.\.venv-business\Scripts\python.exe -m pip install -r requirements/dev.txt
+
+py -3.11 -m venv .venv-ai
+.\.venv-ai\Scripts\python.exe -m pip install -r requirements/dev-ai.txt
+~~~
+
+Linux 使用 python3.11 创建环境,并将 Scripts/python.exe 替换为 bin/python。
+不需安装模型、CUDA、数据库驱动到 AI 环境;AI 只安装 common.txt 与测试依赖。
+requirements 是顶层依赖约束,不是完整传递依赖锁或 GPU 兼容性证明。
+
+## 3. 配置和启动
+
+.env.example 是变量清单,当前程序不会自动读取 .env。请显式设置环境变量。
+以下每个终端均从仓库根目录启动,不能把三个前台进程串在同一个终端等待。
+
+### 3.1 API 终端
+
+~~~powershell
+$env:APP_ENV = 'dev'
+.\.venv-business\Scripts\python.exe -m apps.api.run
+~~~
+
+API 监听 127.0.0.1:8000;GET /health 为存活,GET /ready 返回开发骨架就绪范围。
+不提供假的登录、业务成功或数据库就绪结果。
+
+### 3.2 AI 终端
+
+首次创建本地随机令牌;文件在 .local-secrets/ 下,受 Git 忽略:
+
+~~~powershell
+.\.venv-ai\Scripts\python.exe -c "import pathlib,secrets; p=pathlib.Path('.local-secrets/ai-token'); p.parent.mkdir(exist_ok=True); p.write_text(secrets.token_urlsafe(32),encoding='ascii')"
+$env:APP_ENV = 'dev'
+$env:AI_MODE = 'mock'
+$env:AI_TOKEN_FILE = (Resolve-Path .local-secrets/ai-token).Path
+$env:AI_ALLOWED_TENANTS = '11111111-1111-4111-8111-111111111111'
+$env:AI_FIXTURE_SCENARIO = 'no_targets'
+.\.venv-ai\Scripts\python.exe -m apps.ai_inference.run
+~~~
+
+AI 监听 127.0.0.1:8001。五个接口均使用 /internal/inference/v1 前缀,并要求 Bearer 服务令牌:
+
+| 方法 | 路径 | 开发行为 |
+|---|---|---|
+| GET | /health | supervisor HTTP 进程存活与当前 attempt |
+| GET | /ready | 生命周期启动成功才 200;忙碌不等于未就绪 |
+| GET | /version | development、is_simulated、fixture 身份与真实文件摘要 |
+| POST | /quality | InferenceRequest → InferenceResult;通过或重拍分支 |
+| POST | /runs | 同协议;无目标返回 needs_review,不冒充安全通过 |
+
+不再提供 /fixture、裸 /version 或未鉴权的 AI /healthz。
+请求和响应按生成的契约校验,不给 InferenceResult 私加 is_simulated 字段;模拟身份来自 /version,后续由 Worker 固化到业务 run。
+
+AI_ALLOWED_TENANTS 必须是显式 UUID 清单;令牌为至少 32 字节的随机 ASCII 字符串。
+请求模型/词典版本、实际 fixture 文件 SHA、设备 cpu、request_hash、图片关联与 analysis key 必须匹配。
+fixture 不下载对象或读取图片;质量分数、空事实与阶段耗时均是合成值,不是性能或识别证据。
+
+AI_FIXTURE_SCENARIO 可设 no_targets、needs_retake、error、timeout;AI_FIXTURE_DELAY_MS 范围 0–10000。
+这些只能由开发者在服务启动环境配置,不能通过请求选择。
+同一实例只接收一个在途请求;重复活动 attempt 返回 409,其他请求返回 429;超时返回 504 并释放容量。
+当前模拟延迟可协作取消;真实 spawn 计算子进程及 kill/join 恢复在 I-03 实现,不能把本次 fixture 当成已完成的生产 supervisor。
+
+开发 fixture 位于 apps/ai_inference/fixtures/:model.json、dictionary.json 均为小型合成文件;runtime-lock.json 校验 Python minor 及直接依赖版本。
+该锁的 scope 明确为 fixture-direct-constraints-only,不声称传递依赖可复现,也不能用于真实模型激活。
+
+### 3.3 Worker 终端
+
+无需 Redis 的入口检查:
+
+~~~powershell
+$env:APP_ENV = 'dev'
+.\.venv-business\Scripts\python.exe -m apps.worker.run --check
+~~~
+
+实际 Celery 队列运行按设计使用 Linux/WSL 的独立业务环境及 Redis:
+
+~~~bash
+APP_ENV=dev REDIS_URL=redis://localhost:6379/0 .venv-business/bin/python -m apps.worker.run
+~~~
+
+--check 只验证真实 Celery 配置与任务注册,不证明 Redis 连通或任务消费。
+labsafe.fixture.echo 只是本地 smoke 任务,不是推理流程,不能用它绕过持久任务或写入成功状态。
+完整 Worker→AI→数据库链路及 Outbox/inbox/fencing 在 I-03 落地。
+
+### 3.4 Web 终端
+
+~~~powershell
+$env:APP_ENV = 'dev'
+$env:VITE_APP_ENV = 'dev'
+Set-Location apps/web
+npm ci
+npm run dev
+~~~
+
+两个环境变量必须相等且为 dev/test。开发页面始终显示“开发环境,结果不用于安全判断”。
+构建命令是 npm run build;production 或不一致环境必须失败。
+
+## 4. 验证
+
+根目录、业务环境执行:
+
+~~~powershell
+.\.venv-business\Scripts\python.exe -m unittest discover -s tests/business -t . -v
+.\.venv-business\Scripts\python.exe -m unittest discover -s tests/protocol -t . -v
+.\.venv-business\Scripts\python.exe -m ruff check apps packages tests
+.\.venv-business\Scripts\python.exe -m ruff format --check apps packages tests
+~~~
+
+AI 环境执行:
+
+~~~powershell
+.\.venv-ai\Scripts\python.exe -m unittest discover -s tests/ai -t . -v
+.\.venv-ai\Scripts\python.exe -m unittest discover -s tests/protocol -t . -v
+~~~
+
+测试自行在临时目录生成令牌和合成请求,不要求预设 APP_ENV,也不连接 Redis/MySQL/对象存储。
+包含实际 API/AI 子进程 HTTP smoke,不只是 import 非空检查。
+
+设计校验使用独立工具环境,安装 tools/design/requirements.txt 后执行:
+
+~~~powershell
+python -B tools/design/build_specs.py --check
+python -B tools/design/test_readiness_design.py
+python -B tools/design/validate_specs.py
+~~~
+
+修改契约时只改 tools/design 的生成源,再运行 build_specs.py;同一生成器输出 contracts 和运行时 packages/inference_protocol/contract.json。
+CI 必须先 --check,不能先生成来掩盖漂移。设计校验不是应用、模型或部署验收。
+
+## 5. 提交和下一阶段
+
+遵循 [Git 协作规范](CONTRIBUTING.md)。提交特性分支、创建 PR;不直接推送 main,不自行合并或代替独立审查。
+不提交虚拟环境、.local-secrets、真实图片、权重或 .env。
+下一阶段从 I-01B 专用空库迁移开始;真实模型主线可按 I-ML-01 并行,但不扩大本 PR 验收范围。
diff --git a/apps/__init__.py b/apps/__init__.py
new file mode 100644
index 0000000..af6e9fa
--- /dev/null
+++ b/apps/__init__.py
@@ -0,0 +1 @@
+"""LabSafe process entrypoints."""
diff --git a/apps/ai_inference/__init__.py b/apps/ai_inference/__init__.py
new file mode 100644
index 0000000..aa8dbb4
--- /dev/null
+++ b/apps/ai_inference/__init__.py
@@ -0,0 +1 @@
+"""Isolated AI inference process."""
diff --git a/apps/ai_inference/app/__init__.py b/apps/ai_inference/app/__init__.py
new file mode 100644
index 0000000..498905e
--- /dev/null
+++ b/apps/ai_inference/app/__init__.py
@@ -0,0 +1 @@
+"""AI supervisor application package."""
diff --git a/apps/ai_inference/app/fixture.py b/apps/ai_inference/app/fixture.py
new file mode 100644
index 0000000..4be6721
--- /dev/null
+++ b/apps/ai_inference/app/fixture.py
@@ -0,0 +1,125 @@
+"""Synthetic contract fixture. No image download, inference or business writes."""
+
+import asyncio
+from datetime import datetime, timezone
+from typing import Any
+
+from packages.inference_protocol.contract import DOCUMENT
+from packages.inference_protocol.hashing import request_hash
+
+from .settings import Settings
+
+
+class ProtocolError(Exception):
+ def __init__(self, code: str, message: str) -> None:
+ self.code = code
+ self.message = message
+ self.status = DOCUMENT["x-error-codes"][code]["http_status"]
+ self.retryable = DOCUMENT["x-error-codes"][code]["retryable"]
+ super().__init__(message)
+
+
+def verify_request(payload: dict[str, Any], settings: Settings) -> None:
+ if payload["tenant_id"] not in settings.allowed_tenants:
+ raise ProtocolError("FORBIDDEN", "Tenant is not allowed on this fixture instance")
+ for key in ("model_bundle_id", "dictionary_version_id", "pipeline_version", "device_profile"):
+ if payload[key] != settings.identity[key]:
+ raise ProtocolError(
+ "MODEL_VERSION_UNAVAILABLE", "Request does not match loaded fixture"
+ )
+ if (
+ payload["model_checksum"] != settings.model_checksum
+ or payload["dictionary_sha256"] != settings.dictionary_sha256
+ ):
+ raise ProtocolError("HASH_MISMATCH", "Fixture artifact hash mismatch")
+ if payload["request_hash"] != request_hash(payload):
+ raise ProtocolError("HASH_MISMATCH", "Request content hash mismatch")
+ refs = payload["image_refs"]
+ overview = refs[0]
+ if overview["role"] != "overview" or overview["parent_image_id"] is not None:
+ raise ProtocolError("VALIDATION_ERROR", "First image must be the unique overview")
+ if len({image["image_id"] for image in refs}) != len(refs):
+ raise ProtocolError("VALIDATION_ERROR", "Image IDs must be unique")
+ for index, image in enumerate(refs):
+ expected_key = (
+ f"tenant/{payload['tenant_id']}/lab/{payload['laboratory_id']}/analysis/"
+ f"{image['image_id']}/{image['sha256']}.png"
+ )
+ if image["object_key"] != expected_key:
+ raise ProtocolError("UNAUTHORIZED_REF", "Only exact analysis object keys are accepted")
+ if image["mime_type"] != "image/png":
+ raise ProtocolError("VALIDATION_ERROR", "Analysis images must be normalized PNG")
+ if image["location_id"] != overview["location_id"]:
+ raise ProtocolError("VALIDATION_ERROR", "Images must share a location")
+ if index and (
+ image["role"] != "detail" or image["parent_image_id"] != overview["image_id"]
+ ):
+ raise ProtocolError("VALIDATION_ERROR", "Detail must refer to the overview")
+
+
+async def compute(payload: dict[str, Any], settings: Settings, stage: str) -> dict[str, Any]:
+ remaining = (
+ datetime.fromisoformat(payload["deadline_at"].upper().replace("Z", "+00:00"))
+ - datetime.now(timezone.utc)
+ ).total_seconds()
+ if remaining <= 0:
+ raise ProtocolError("AI_TIMEOUT", "Request deadline has expired")
+ budget = min(remaining, 10 if stage == "quality" else 180)
+ delay = budget + 1 if settings.scenario == "timeout" else settings.delay_ms / 1000
+ try:
+ # Cooperative synthetic delay only. Real compute cancellation belongs to I-03.
+ await asyncio.wait_for(asyncio.sleep(delay), timeout=budget)
+ except TimeoutError as exc:
+ raise ProtocolError("AI_TIMEOUT", "Fixture deadline exceeded") from exc
+ if settings.scenario == "error":
+ raise ProtocolError("MODEL_ERROR", "Configured synthetic fixture failure")
+ retake = settings.scenario == "needs_retake"
+ echoed = (
+ "run_id",
+ "attempt_id",
+ "fencing_token",
+ "tenant_id",
+ "request_hash",
+ "model_bundle_id",
+ "model_checksum",
+ "dictionary_version_id",
+ "dictionary_sha256",
+ "pipeline_version",
+ )
+ return {
+ **{key: payload[key] for key in echoed},
+ "input_hashes": [
+ {"image_id": image["image_id"], "sha256": image["sha256"]}
+ for image in payload["image_refs"]
+ ],
+ "outcome": "needs_retake"
+ if retake
+ else ("facts_ready" if stage == "quality" else "needs_review"),
+ "quality": [
+ {
+ "image_id": image["image_id"],
+ "status": "needs_retake" if retake else "pass",
+ "reasons": ["blur"] if retake else [],
+ "blur_score": 0 if retake else 100,
+ "brightness": 0.5,
+ "glare_ratio": 0,
+ }
+ for image in payload["image_refs"]
+ ],
+ "detections": [],
+ "crops": [],
+ "ocr_fields": [],
+ "entities": [],
+ "relations": [],
+ "timing_ms": {
+ key: 0
+ for key in (
+ "download_ms",
+ "quality_ms",
+ "detection_ms",
+ "ocr_ms",
+ "normalization_ms",
+ "total_ms",
+ )
+ },
+ }
diff --git a/apps/ai_inference/app/main.py b/apps/ai_inference/app/main.py
new file mode 100644
index 0000000..507a576
--- /dev/null
+++ b/apps/ai_inference/app/main.py
@@ -0,0 +1,136 @@
+"""Authenticated, same-protocol development fixture; no production inference."""
+
+import asyncio
+import copy
+import hmac
+from contextlib import asynccontextmanager
+from typing import Annotated, Any
+from uuid import uuid4
+
+from fastapi import APIRouter, Depends, FastAPI, Request
+from fastapi.exceptions import RequestValidationError
+from fastapi.responses import JSONResponse
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+
+from packages.inference_protocol.contract import DOCUMENT, InferenceRequest, validate
+
+from .fixture import ProtocolError, compute, verify_request
+from .settings import Settings
+
+
+def create_app(settings: Settings | None = None) -> FastAPI:
+ settings = settings or Settings.from_env()
+ if settings.environment not in {"dev", "test"}:
+ raise RuntimeError("Production is disabled in I-01A")
+ validate("Version", settings.identity)
+ gate = asyncio.Lock()
+ ready = False
+ active_attempt: str | None = None
+
+ @asynccontextmanager
+ async def lifespan(app: FastAPI):
+ nonlocal ready
+ ready = True
+ try:
+ yield
+ finally:
+ ready = False
+
+ app = FastAPI(
+ title="LabSafe AI development fixture",
+ lifespan=lifespan,
+ docs_url=None,
+ redoc_url=None,
+ openapi_url=None,
+ )
+ # Exact checked-in wire schema, packaged by the existing design generator.
+ app.openapi = lambda: copy.deepcopy(DOCUMENT)
+ bearer = HTTPBearer(auto_error=False)
+
+ async def authenticate(
+ credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)],
+ ) -> None:
+ if credentials is None or not hmac.compare_digest(
+ credentials.credentials.encode("utf-8"), settings.token.encode("ascii")
+ ):
+ raise ProtocolError("UNAUTHENTICATED", "Valid service bearer token required")
+
+ def checked(name: str, payload: dict[str, Any]) -> dict[str, Any]:
+ validate(name, payload)
+ return payload
+
+ @app.exception_handler(ProtocolError)
+ async def protocol_error(request: Request, exc: ProtocolError) -> JSONResponse:
+ payload = checked(
+ "Error",
+ {
+ "request_id": str(uuid4()),
+ "error": {
+ "code": exc.code,
+ "message": exc.message,
+ "retryable": exc.retryable,
+ "details": [],
+ },
+ },
+ )
+ headers = {"WWW-Authenticate": "Bearer"} if exc.status == 401 else None
+ return JSONResponse(payload, status_code=exc.status, headers=headers)
+
+ @app.exception_handler(RequestValidationError)
+ async def validation_error(request: Request, exc: RequestValidationError) -> JSONResponse:
+ return await protocol_error(
+ request, ProtocolError("VALIDATION_ERROR", "Request violates the inference contract")
+ )
+
+ router = APIRouter(prefix=DOCUMENT["servers"][0]["url"], dependencies=[Depends(authenticate)])
+
+ def health_payload(status: str) -> dict[str, Any]:
+ return checked(
+ "Health",
+ {
+ "status": status,
+ "model_bundle_id": settings.identity["model_bundle_id"],
+ "active_attempt_id": active_attempt,
+ },
+ )
+
+ @router.get("/health", operation_id="aiHealth")
+ async def health() -> dict[str, Any]:
+ return health_payload("alive")
+
+ @router.get("/ready", operation_id="aiReady")
+ async def readiness() -> dict[str, Any]:
+ if not ready:
+ raise ProtocolError("MODEL_NOT_READY", "Fixture lifespan has not started")
+ return health_payload("ready")
+
+ @router.get("/version", operation_id="aiVersion")
+ async def version() -> dict[str, Any]:
+ return checked("Version", dict(settings.identity))
+
+ async def infer(payload: InferenceRequest, stage: str) -> dict[str, Any]:
+ nonlocal active_attempt
+ if not ready:
+ raise ProtocolError("MODEL_NOT_READY", "Fixture is not ready")
+ body = payload.root
+ verify_request(body, settings)
+ if gate.locked():
+ code = "RUN_IN_PROGRESS" if active_attempt == body["attempt_id"] else "AI_BUSY"
+ raise ProtocolError(code, "This instance already has an active attempt")
+ async with gate:
+ active_attempt = body["attempt_id"]
+ try:
+ return checked("InferenceResult", await compute(body, settings, stage))
+ finally:
+ active_attempt = None
+
+ @router.post("/quality", operation_id="aiQuality")
+ async def quality(payload: InferenceRequest) -> dict[str, Any]:
+ return await infer(payload, "quality")
+
+ @router.post("/runs", operation_id="aiRuns")
+ async def runs(payload: InferenceRequest) -> dict[str, Any]:
+ return await infer(payload, "runs")
+
+ app.include_router(router)
+ return app
diff --git a/apps/ai_inference/app/settings.py b/apps/ai_inference/app/settings.py
new file mode 100644
index 0000000..1b873fb
--- /dev/null
+++ b/apps/ai_inference/app/settings.py
@@ -0,0 +1,95 @@
+"""Explicit, development-only AI startup settings and verified fixture identity."""
+
+import hashlib
+import json
+import os
+import sys
+from dataclasses import dataclass, field
+from importlib.metadata import version
+from importlib.resources import files
+from pathlib import Path
+from typing import Any
+from uuid import UUID
+
+
+@dataclass(frozen=True)
+class Settings:
+ environment: str
+ token: str = field(repr=False)
+ allowed_tenants: frozenset[str]
+ scenario: str
+ delay_ms: int
+ identity: dict[str, Any]
+ model_checksum: str
+ dictionary_sha256: str
+
+ @classmethod
+ def from_env(cls) -> "Settings":
+ if sys.version_info[:2] != (3, 11):
+ raise RuntimeError("LabSafe requires Python 3.11")
+ environment = os.environ.get("APP_ENV")
+ if environment not in {"dev", "test"}:
+ raise RuntimeError("I-01A requires APP_ENV=dev or test; production is disabled")
+ if os.environ.get("AI_MODE") != "mock":
+ raise RuntimeError("I-01A implements AI_MODE=mock only; real inference is not ready")
+ if os.environ.get("AI_MAX_INFLIGHT", "1") != "1":
+ raise RuntimeError("AI_MAX_INFLIGHT must be 1")
+ token_path = os.environ.get("AI_TOKEN_FILE")
+ if not token_path:
+ raise RuntimeError("AI_TOKEN_FILE is required")
+ try:
+ token = Path(token_path).read_text(encoding="utf-8").strip()
+ except (OSError, UnicodeError) as exc:
+ raise RuntimeError("Cannot read AI_TOKEN_FILE") from exc
+ if not token.isascii() or len(token) < 32 or any(c.isspace() for c in token):
+ raise RuntimeError("AI_TOKEN_FILE must contain an ASCII token of at least 32 bytes")
+ raw_tenants = os.environ.get("AI_ALLOWED_TENANTS", "")
+ try:
+ allowed_tenants = frozenset(str(UUID(v.strip())) for v in raw_tenants.split(","))
+ except ValueError as exc:
+ raise RuntimeError("AI_ALLOWED_TENANTS requires explicit tenant UUIDs") from exc
+ scenario = os.environ.get("AI_FIXTURE_SCENARIO", "no_targets")
+ if scenario not in {"no_targets", "needs_retake", "error", "timeout"}:
+ raise RuntimeError("Unknown AI_FIXTURE_SCENARIO")
+ try:
+ delay_ms = int(os.environ.get("AI_FIXTURE_DELAY_MS", "0"))
+ except ValueError as exc:
+ raise RuntimeError("AI_FIXTURE_DELAY_MS must be an integer") from exc
+ if not 0 <= delay_ms <= 10000:
+ raise RuntimeError("AI_FIXTURE_DELAY_MS must be between 0 and 10000")
+ commit = os.environ.get("SERVICE_COMMIT", "local-development")
+ if not 1 <= len(commit) <= 40:
+ raise RuntimeError("SERVICE_COMMIT must contain 1 to 40 characters")
+ fixture_dir = files("apps.ai_inference").joinpath("fixtures")
+ model_bytes = fixture_dir.joinpath("model.json").read_bytes()
+ dictionary_bytes = fixture_dir.joinpath("dictionary.json").read_bytes()
+ lock_bytes = fixture_dir.joinpath("runtime-lock.json").read_bytes()
+ runtime_lock = json.loads(lock_bytes)
+ for name, expected in runtime_lock["packages"].items():
+ if version(name) != expected:
+ raise RuntimeError(f"Fixture dependency mismatch: {name} must be {expected}")
+ model, dictionary = json.loads(model_bytes), json.loads(dictionary_bytes)
+ identity = {
+ "service_commit": commit,
+ "pipeline_version": "vision-v1",
+ "model_bundle_id": model["model_bundle_id"],
+ "dictionary_version_id": dictionary["dictionary_version_id"],
+ "device_profile": "cpu",
+ "purpose": "development",
+ "is_simulated": True,
+ "adapter_id": "fixture-v1",
+ "runtime_profile": "fixture-v1",
+ "runtime_lock_sha256": hashlib.sha256(lock_bytes).hexdigest(),
+ "detector_device": "mock",
+ "ocr_device": "mock",
+ }
+ return cls(
+ environment,
+ token,
+ allowed_tenants,
+ scenario,
+ delay_ms,
+ identity,
+ hashlib.sha256(model_bytes).hexdigest(),
+ hashlib.sha256(dictionary_bytes).hexdigest(),
+ )
diff --git a/apps/ai_inference/fixtures/dictionary.json b/apps/ai_inference/fixtures/dictionary.json
new file mode 100644
index 0000000..0ad2cfb
--- /dev/null
+++ b/apps/ai_inference/fixtures/dictionary.json
@@ -0,0 +1,5 @@
+{
+ "purpose": "development",
+ "dictionary_version_id": "33333333-3333-4333-8333-333333333333",
+ "entries": []
+}
diff --git a/apps/ai_inference/fixtures/model.json b/apps/ai_inference/fixtures/model.json
new file mode 100644
index 0000000..10427d4
--- /dev/null
+++ b/apps/ai_inference/fixtures/model.json
@@ -0,0 +1,7 @@
+{
+ "purpose": "development",
+ "adapter_id": "fixture-v1",
+ "model_family": "mock_fixture",
+ "model_bundle_id": "22222222-2222-4222-8222-222222222222",
+ "description": "Synthetic no-target fixture. This is not a trained model."
+}
diff --git a/apps/ai_inference/fixtures/runtime-lock.json b/apps/ai_inference/fixtures/runtime-lock.json
new file mode 100644
index 0000000..8662527
--- /dev/null
+++ b/apps/ai_inference/fixtures/runtime-lock.json
@@ -0,0 +1,14 @@
+{
+ "purpose": "development",
+ "scope": "fixture-direct-constraints-only",
+ "python": "3.11",
+ "packages": {
+ "fastapi": "0.115.6",
+ "pydantic": "2.10.5",
+ "uvicorn": "0.34.0",
+ "PyYAML": "6.0.2",
+ "jsonschema": "4.23.0",
+ "rfc3339-validator": "0.1.4"
+ },
+ "limitations": "Checked direct fixture dependencies only; not a production transitive/model runtime lock."
+}
diff --git a/apps/ai_inference/run.py b/apps/ai_inference/run.py
new file mode 100644
index 0000000..4b5888a
--- /dev/null
+++ b/apps/ai_inference/run.py
@@ -0,0 +1,19 @@
+"""One HTTP process; the real computation child is an I-03 deliverable."""
+
+import os
+
+import uvicorn
+
+
+def main() -> None:
+ uvicorn.run(
+ "apps.ai_inference.app.main:create_app",
+ factory=True,
+ host=os.getenv("AI_INFERENCE_HOST", "127.0.0.1"),
+ port=int(os.getenv("AI_INFERENCE_PORT", "8001")),
+ workers=1,
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/apps/api/__init__.py b/apps/api/__init__.py
new file mode 100644
index 0000000..b3518a5
--- /dev/null
+++ b/apps/api/__init__.py
@@ -0,0 +1 @@
+"""Public API process."""
diff --git a/apps/api/app/__init__.py b/apps/api/app/__init__.py
new file mode 100644
index 0000000..d9c5b70
--- /dev/null
+++ b/apps/api/app/__init__.py
@@ -0,0 +1 @@
+"""FastAPI application package."""
diff --git a/apps/api/app/main.py b/apps/api/app/main.py
new file mode 100644
index 0000000..d93df13
--- /dev/null
+++ b/apps/api/app/main.py
@@ -0,0 +1,25 @@
+"""Development-only API shell. Business routes arrive in I-01C/I-02."""
+
+from fastapi import FastAPI
+
+from packages.shared.environment import development_environment
+
+
+def create_app() -> FastAPI:
+ environment = development_environment()
+ app = FastAPI(title="LabSafe API", version="0.1.0-dev")
+
+ @app.get("/health")
+ def health() -> dict[str, str | bool]:
+ return {"status": "alive", "environment": environment, "is_simulated": True}
+
+ @app.get("/ready")
+ def ready() -> dict[str, str | bool]:
+ return {
+ "status": "ready",
+ "environment": environment,
+ "is_simulated": True,
+ "scope": "I-01A process only; database and business readiness not implemented",
+ }
+
+ return app
diff --git a/apps/api/run.py b/apps/api/run.py
new file mode 100644
index 0000000..b9f44e8
--- /dev/null
+++ b/apps/api/run.py
@@ -0,0 +1,19 @@
+"""Run the development API with actual FastAPI/Uvicorn dependencies."""
+
+import os
+
+import uvicorn
+
+
+def main() -> None:
+ uvicorn.run(
+ "apps.api.app.main:create_app",
+ factory=True,
+ host=os.getenv("API_HOST", "127.0.0.1"),
+ port=int(os.getenv("API_PORT", "8000")),
+ workers=1,
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/apps/web/index.html b/apps/web/index.html
new file mode 100644
index 0000000..0e88a22
--- /dev/null
+++ b/apps/web/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ LabSafe 开发环境
+
+
+
+
+
+
diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json
new file mode 100644
index 0000000..e5e5d41
--- /dev/null
+++ b/apps/web/package-lock.json
@@ -0,0 +1,1718 @@
+{
+ "name": "@labsafe/web",
+ "version": "0.1.0-dev",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@labsafe/web",
+ "version": "0.1.0-dev",
+ "dependencies": {
+ "@tanstack/vue-query": "^5.66.8",
+ "element-plus": "^2.9.4",
+ "pinia": "^2.3.1",
+ "vue": "^3.5.13",
+ "vue-router": "^4.5.0"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^5.2.1",
+ "typescript": "^5.7.3",
+ "vite": "^6.0.11",
+ "vue-tsc": "^2.2.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.9",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz",
+ "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==",
+ "dependencies": {
+ "@babel/types": "^7.29.8"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
+ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@ctrl/tinycolor": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.1.tgz",
+ "integrity": "sha512-Vh5uy9Y1JBMId6J1f68TMXnZrTCvNdo9Fu2k0d1YnJngR+sM5kWTKl441c/3TFs+cG0NWOjRWgQ/iaWhbYx+7A==",
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@element-plus/icons-vue": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz",
+ "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==",
+ "peerDependencies": {
+ "vue": "^3.2.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@floating-ui/core": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
+ "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
+ "dependencies": {
+ "@floating-ui/utils": "^0.2.12"
+ }
+ },
+ "node_modules/@floating-ui/dom": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
+ "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
+ "dependencies": {
+ "@floating-ui/core": "^1.8.0",
+ "@floating-ui/utils": "^0.2.12"
+ }
+ },
+ "node_modules/@floating-ui/utils": {
+ "version": "0.2.12",
+ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
+ "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="
+ },
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^22.20 || ^24.12 || >=25"
+ }
+ },
+ "node_modules/@popperjs/core": {
+ "name": "@sxzz/popperjs-es",
+ "version": "2.11.8",
+ "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz",
+ "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/popperjs"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.63.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.5.tgz",
+ "integrity": "sha512-J25QJU+B78T4FhhBsNpLJyVWOi31mwtpcMwywHmOKH65Q9IWGA81gPj+dnwlhU8wktVriYE+tFAaQgrnJRzAZg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.63.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.5.tgz",
+ "integrity": "sha512-LDopB3zuZM5Ux9TT2luNEBJW/tYbGU2g1d+VpKk6I+gSKDb+/7sYE6M225gRQt4RbMX6MSwMsVR/phdjVUgRLg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.63.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.5.tgz",
+ "integrity": "sha512-wlJEERGfeuHeBavCL2qVnNacOK43NDoZM4sjkeRPymd04OAE9T1zBqDJgmZ+CIsPTYKwdzpUC8vmOw84dwY4Tg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.63.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.5.tgz",
+ "integrity": "sha512-4nJJGg5jbo2wwPP4JP+LfEBA3bvP8rU9CLuhp7jWvq9sxEyhjQFTFdrqi+/dHEin/pd8jpT0vcehIpnZtmEdcQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.63.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.5.tgz",
+ "integrity": "sha512-DrZbyCDF1hneuO6jRbvZ2D7+PIBM6yIwYnJpg2vIk58T+wuFpiaGZrfUr59lDWw45bg+IrpTGLPiNi/Fk4w3Cg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.63.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.5.tgz",
+ "integrity": "sha512-gqfUVMJMB3mehqywxp6hTBFfgtMQykZY19+cfiaYP0toIJLb/1DZRJHVkQQGP13W4TAwfZDWeg1qBcheTRioXQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.63.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.5.tgz",
+ "integrity": "sha512-CFmhpvAwzSaWMlN3VN7UtmoTihlZNzoP0juQib5TQRnYUyDV8dXeWOp29sobWAT6gXl/hQgAClLlEiYozQG3OQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.63.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.5.tgz",
+ "integrity": "sha512-Uc9H8eXCOayV6JLTH5bXKMId6qbhNHa818/BgYjm4jrlq3vZquC9cqyvHBw17xy5Mnj5f+I3gFK5JcEf3hSqrw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.63.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.5.tgz",
+ "integrity": "sha512-VcPr/szv/1BFw112Kt//fxulXt/JPqzzidU84iW68L2DdjnOO8QFUv2zTSYBEPHD6movBD4z+bbr5y60GYM7Jw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.63.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.5.tgz",
+ "integrity": "sha512-BnxtJ5/91BrIHYIkGrmjz/lbMhqEHt1dPFqIxIFR+jPn0xVc/oUSCtIT089zfp5ufwGDlYz2UC+Fe1SRBpYFbQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.63.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.5.tgz",
+ "integrity": "sha512-LrYcHZwF+fAMNKHYTOQ5osWM4AZF7YF6D+XtsjDyEvljtt11twc+zHVXBLNEjxVSUnKYsOhvVz4Z213eW02COQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.63.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.5.tgz",
+ "integrity": "sha512-nj7QKQePAAUpCpJHtg0pR0W/b92A9NO17JS3BAQmHDn/yhmkir2p8llrKY9TOhleKIaSzy1JhxS3T9FVld6coA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.63.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.5.tgz",
+ "integrity": "sha512-5ylkX6dWMeBKge9nTU+Rxfb+ZfaCIJ9lRqIFaK0eAMcWp7OJbYnLveLgXmm0VrvuLKb8qIK+mHyH0qu88RM+iA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.63.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.5.tgz",
+ "integrity": "sha512-oHK4ZHYFDKjZviK34I+NwgfbGxgI7ztrNxj2hPTSSNFgeq1a/lEd7dHV2fdGAuTH4Iym3RHJg+vAbWaWG4B7Zg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.63.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.5.tgz",
+ "integrity": "sha512-UcetmHZ6XOXuUByiKZyQmb55ZPr0LABr3Ec/HB9wKZn6CEAFWZkE+hsJErJ9hbPBC7nI0dKuELx7CoV6IM7TMg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.63.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.5.tgz",
+ "integrity": "sha512-C5CmDPQBtvjVo8cgQsBs+w6WB0JLkiixhgi6hVLV11hERWdn/p0XcPU2OUcZzac9BPOFq7SbaHFa8r3SWEysCQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.63.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.5.tgz",
+ "integrity": "sha512-lHVQHJFKsuuxLMi3MQO9XVL8Tje3JR82CzB+QDKC5NWBcsIWuwsn9uIM5e3lBhI+fF1/s63qnyYqsg65+8rV/w==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.63.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.5.tgz",
+ "integrity": "sha512-3W9bTFcQNJn71cSJVM9RKIiZOy8DO/XLDii8Uv/Pm6WKqDRj7JV3ZfuXIEfyuy5LXpIzAbB/1M4Ukp9GKNa7nA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.63.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.5.tgz",
+ "integrity": "sha512-VDC7rRJlee/scpki96GZ27Omf6yU87s1YXwVTpjE5841faVlDYYT565rgfmoR1U0sqL7z5ivQSDjcsF6VRXyBA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.63.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.5.tgz",
+ "integrity": "sha512-z86Ok2p4pTdv5xqCKZsTooO7yBEiaJR/HzU3Wx8RmWsPoLppnMKROhJusQob8B3IE1ghC343kUW9rC2r+Wf3ig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.63.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.5.tgz",
+ "integrity": "sha512-IzQmj+xXwQFGhMAMKMQVXkMwMZN3TqkJgAE0nSsqvVwWWciP4AIPMmWRqOQ2GfX7TUDZr+xqGFcBS36CRPGw0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.63.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.5.tgz",
+ "integrity": "sha512-F6qpTaPc9bwBH85kjy0/BLmLSW1uv7AoOXCoRIkg2arlgCYlWYcAbiMkvZuAcaWk9TpCRG//okznLAqLGshkMw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.63.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.5.tgz",
+ "integrity": "sha512-igoDsTFhhwECBeGbUuLeIk7t8Y1apa+cs6mDWpx2EZ0ch7oEQgzHbFUXN9euoHekCAQzXdXApAGkV6jznS7tWw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.63.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.5.tgz",
+ "integrity": "sha512-U3teMeMbXFmaM5D+OTJpsOXd+wV/qftIeYF9kBKL4v73641qyJmoXFtA28DQLsnmlyayEsTe72xpLHrArq6vHw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.63.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.5.tgz",
+ "integrity": "sha512-ypfC34F3RKXvCXBglGqGMsUSMKlgwd1HX9AOAlx9RoZZ6GaI42YHVeKpzg3JG+wpBUJYTG+NNZhqbDWL8tBZkw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@tanstack/match-sorter-utils": {
+ "version": "9.1.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-9.1.2.tgz",
+ "integrity": "sha512-aPkUQctDzpXLSWhlx7jUDi3mInsI18xcnrK1hvIyIa88ab1ax88cACr7xU+BKMCGB4NZYNwh4SoNSEbv3RY2NA==",
+ "dependencies": {
+ "remove-accents": "0.5.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/query-core": {
+ "version": "5.104.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.0.tgz",
+ "integrity": "sha512-JrC2r/JQlXt7khBSdUpsgxNvybzOg+aITa+ARRMlP2AFo93Y8vIqz077rp+e61mJetSZ0T6AJbwW1JHer1vrPQ==",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/vue-query": {
+ "version": "5.104.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/vue-query/-/vue-query-5.104.0.tgz",
+ "integrity": "sha512-Nxqsl0YsXrHZQAq9rNQWvVR+elPENBg+YXkQHnuDn7/YM48vts4aaKZYBkRzaU24ckEGLxC13o3GiM3kYUKfdw==",
+ "dependencies": {
+ "@tanstack/match-sorter-utils": "^9.1.2",
+ "@tanstack/query-core": "5.104.0",
+ "@vue/devtools-api": "^6.6.3",
+ "vue-demi": "^0.14.10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "@vue/composition-api": "^1.1.2",
+ "vue": "^2.6.0 || ^3.3.0"
+ },
+ "peerDependenciesMeta": {
+ "@vue/composition-api": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true
+ },
+ "node_modules/@types/lodash": {
+ "version": "4.17.25",
+ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz",
+ "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ=="
+ },
+ "node_modules/@types/lodash-es": {
+ "version": "4.17.12",
+ "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
+ "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
+ "dependencies": {
+ "@types/lodash": "*"
+ }
+ },
+ "node_modules/@types/web-bluetooth": {
+ "version": "0.0.21",
+ "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
+ "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="
+ },
+ "node_modules/@vitejs/plugin-vue": {
+ "version": "5.2.4",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
+ "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
+ "dev": true,
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^5.0.0 || ^6.0.0",
+ "vue": "^3.2.25"
+ }
+ },
+ "node_modules/@volar/language-core": {
+ "version": "2.4.15",
+ "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz",
+ "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==",
+ "dev": true,
+ "dependencies": {
+ "@volar/source-map": "2.4.15"
+ }
+ },
+ "node_modules/@volar/source-map": {
+ "version": "2.4.15",
+ "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz",
+ "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==",
+ "dev": true
+ },
+ "node_modules/@volar/typescript": {
+ "version": "2.4.15",
+ "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz",
+ "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==",
+ "dev": true,
+ "dependencies": {
+ "@volar/language-core": "2.4.15",
+ "path-browserify": "^1.0.1",
+ "vscode-uri": "^3.0.8"
+ }
+ },
+ "node_modules/@vue/compiler-core": {
+ "version": "3.5.43",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.43.tgz",
+ "integrity": "sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==",
+ "dependencies": {
+ "@babel/parser": "^7.29.8",
+ "@vue/shared": "3.5.43",
+ "entities": "^7.0.1",
+ "estree-walker": "^2.0.2",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-dom": {
+ "version": "3.5.43",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.43.tgz",
+ "integrity": "sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==",
+ "dependencies": {
+ "@vue/compiler-core": "3.5.43",
+ "@vue/shared": "3.5.43"
+ }
+ },
+ "node_modules/@vue/compiler-sfc": {
+ "version": "3.5.43",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.43.tgz",
+ "integrity": "sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==",
+ "dependencies": {
+ "@babel/parser": "^7.29.8",
+ "@vue/compiler-core": "3.5.43",
+ "@vue/compiler-dom": "3.5.43",
+ "@vue/compiler-ssr": "3.5.43",
+ "@vue/shared": "3.5.43",
+ "estree-walker": "^2.0.2",
+ "magic-string": "^0.30.21",
+ "postcss": "^8.5.28",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-ssr": {
+ "version": "3.5.43",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.43.tgz",
+ "integrity": "sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.43",
+ "@vue/shared": "3.5.43"
+ }
+ },
+ "node_modules/@vue/compiler-vue2": {
+ "version": "2.7.16",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz",
+ "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==",
+ "dev": true,
+ "dependencies": {
+ "de-indent": "^1.0.2",
+ "he": "^1.2.0"
+ }
+ },
+ "node_modules/@vue/devtools-api": {
+ "version": "6.6.4",
+ "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
+ "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
+ },
+ "node_modules/@vue/language-core": {
+ "version": "2.2.12",
+ "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz",
+ "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==",
+ "dev": true,
+ "dependencies": {
+ "@volar/language-core": "2.4.15",
+ "@vue/compiler-dom": "^3.5.0",
+ "@vue/compiler-vue2": "^2.7.16",
+ "@vue/shared": "^3.5.0",
+ "alien-signals": "^1.0.3",
+ "minimatch": "^9.0.3",
+ "muggle-string": "^0.4.1",
+ "path-browserify": "^1.0.1"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vue/reactivity": {
+ "version": "3.5.43",
+ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.43.tgz",
+ "integrity": "sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==",
+ "dependencies": {
+ "@vue/shared": "3.5.43"
+ }
+ },
+ "node_modules/@vue/runtime-core": {
+ "version": "3.5.43",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.43.tgz",
+ "integrity": "sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==",
+ "dependencies": {
+ "@vue/reactivity": "3.5.43",
+ "@vue/shared": "3.5.43"
+ }
+ },
+ "node_modules/@vue/runtime-dom": {
+ "version": "3.5.43",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.43.tgz",
+ "integrity": "sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==",
+ "dependencies": {
+ "@vue/reactivity": "3.5.43",
+ "@vue/runtime-core": "3.5.43",
+ "@vue/shared": "3.5.43",
+ "csstype": "^3.2.3"
+ }
+ },
+ "node_modules/@vue/server-renderer": {
+ "version": "3.5.43",
+ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.43.tgz",
+ "integrity": "sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==",
+ "dependencies": {
+ "@vue/compiler-ssr": "3.5.43",
+ "@vue/runtime-dom": "3.5.43",
+ "@vue/shared": "3.5.43"
+ }
+ },
+ "node_modules/@vue/shared": {
+ "version": "3.5.43",
+ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.43.tgz",
+ "integrity": "sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q=="
+ },
+ "node_modules/@vueuse/core": {
+ "version": "15.0.0",
+ "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-15.0.0.tgz",
+ "integrity": "sha512-Zv67h5mrk7YHDJsRObx+DXaCBz6RKBUpxqLesJ+3xZNPG6yuQs7ja7kg3veFrrouYLWAqydQ7CwGEbLVxB43Ng==",
+ "dependencies": {
+ "@types/web-bluetooth": "^0.0.21",
+ "@vueuse/metadata": "15.0.0",
+ "@vueuse/shared": "15.0.0"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "vue": "^3.5.0"
+ }
+ },
+ "node_modules/@vueuse/metadata": {
+ "version": "15.0.0",
+ "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-15.0.0.tgz",
+ "integrity": "sha512-sfWE5LADAKJ0KnjtGliMdSN8Ij/L7uP6rXIZlgPloAI4zvvefbw6H5Lt7KJA6RqQOYgVqWF5Guj50HHMIzygBg==",
+ "engines": {
+ "node": ">=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/@vueuse/shared": {
+ "version": "15.0.0",
+ "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-15.0.0.tgz",
+ "integrity": "sha512-vbNXhGrbV53xPOMSyOzMHQRvhMgZf7oNDw2RChUetVxwTIEkInQ1joZ9JbT0G3z2n2AJNjvmCWeM9ySYrnvogA==",
+ "engines": {
+ "node": ">=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "vue": "^3.5.0"
+ }
+ },
+ "node_modules/alien-signals": {
+ "version": "1.0.13",
+ "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz",
+ "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==",
+ "dev": true
+ },
+ "node_modules/async-validator": {
+ "version": "4.2.5",
+ "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
+ "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg=="
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true
+ },
+ "node_modules/brace-expansion": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
+ "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
+ "dev": true,
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
+ },
+ "node_modules/dayjs": {
+ "version": "1.11.23",
+ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
+ "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ=="
+ },
+ "node_modules/de-indent": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
+ "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
+ "dev": true
+ },
+ "node_modules/element-plus": {
+ "version": "2.14.6",
+ "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.6.tgz",
+ "integrity": "sha512-GH0fMoMgVEKkHimoaKvg5B00NmfPbQ+qxX6/JrO7F+dLAC3atrZr2Y2MoccHMxS22M7QE12/s0DG0Zs49iwJ/A==",
+ "dependencies": {
+ "@ctrl/tinycolor": "^4.2.1",
+ "@element-plus/icons-vue": "^2.3.2",
+ "@floating-ui/dom": "^1.8.0",
+ "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8",
+ "@types/lodash": "^4.17.24",
+ "@types/lodash-es": "^4.17.12",
+ "@vueuse/core": "15.0.0",
+ "async-validator": "^4.2.5",
+ "dayjs": "^1.11.20",
+ "lodash": "^4.18.1",
+ "lodash-es": "^4.18.1",
+ "lodash-unified": "^1.0.3",
+ "memoize-one": "^6.0.0",
+ "normalize-wheel-es": "^1.2.0",
+ "vue-component-type-helpers": "^3.3.11"
+ },
+ "peerDependencies": {
+ "vue": "^3.3.7"
+ }
+ },
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/he": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+ "dev": true,
+ "bin": {
+ "he": "bin/he"
+ }
+ },
+ "node_modules/lodash": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="
+ },
+ "node_modules/lodash-es": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
+ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="
+ },
+ "node_modules/lodash-unified": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz",
+ "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==",
+ "peerDependencies": {
+ "@types/lodash-es": "*",
+ "lodash": "*",
+ "lodash-es": "*"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/memoize-one": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
+ "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="
+ },
+ "node_modules/minimatch": {
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
+ "dev": true,
+ "dependencies": {
+ "brace-expansion": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/muggle-string": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
+ "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
+ "dev": true
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.19",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
+ "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/normalize-wheel-es": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
+ "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw=="
+ },
+ "node_modules/path-browserify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
+ "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+ "dev": true
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pinia": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz",
+ "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==",
+ "dependencies": {
+ "@vue/devtools-api": "^6.6.3",
+ "vue-demi": "^0.14.10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/posva"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.4.4",
+ "vue": "^2.7.0 || ^3.5.11"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.28",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
+ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "nanoid": "^3.3.18",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/remove-accents": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz",
+ "integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A=="
+ },
+ "node_modules/rollup": {
+ "version": "4.63.5",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.5.tgz",
+ "integrity": "sha512-KRWwmNLlPw5M7HcdYfm15oBv9n9LPtjzpzCIxS/phwqvPyxHSoKX6Y2YU3pxSPfy0CLquVgsx/j/hBi6OvH1Nw==",
+ "dev": true,
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+ "@rollup/rollup-android-arm-eabi": "4.63.5",
+ "@rollup/rollup-android-arm64": "4.63.5",
+ "@rollup/rollup-darwin-arm64": "4.63.5",
+ "@rollup/rollup-darwin-x64": "4.63.5",
+ "@rollup/rollup-freebsd-arm64": "4.63.5",
+ "@rollup/rollup-freebsd-x64": "4.63.5",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.63.5",
+ "@rollup/rollup-linux-arm-musleabihf": "4.63.5",
+ "@rollup/rollup-linux-arm64-gnu": "4.63.5",
+ "@rollup/rollup-linux-arm64-musl": "4.63.5",
+ "@rollup/rollup-linux-loong64-gnu": "4.63.5",
+ "@rollup/rollup-linux-loong64-musl": "4.63.5",
+ "@rollup/rollup-linux-ppc64-gnu": "4.63.5",
+ "@rollup/rollup-linux-ppc64-musl": "4.63.5",
+ "@rollup/rollup-linux-riscv64-gnu": "4.63.5",
+ "@rollup/rollup-linux-riscv64-musl": "4.63.5",
+ "@rollup/rollup-linux-s390x-gnu": "4.63.5",
+ "@rollup/rollup-linux-x64-gnu": "4.63.5",
+ "@rollup/rollup-linux-x64-musl": "4.63.5",
+ "@rollup/rollup-openbsd-x64": "4.63.5",
+ "@rollup/rollup-openharmony-arm64": "4.63.5",
+ "@rollup/rollup-win32-arm64-msvc": "4.63.5",
+ "@rollup/rollup-win32-ia32-msvc": "4.63.5",
+ "@rollup/rollup-win32-x64-gnu": "4.63.5",
+ "@rollup/rollup-win32-x64-msvc": "4.63.5",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "devOptional": true,
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/vite": {
+ "version": "6.4.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
+ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
+ "dev": true,
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vscode-uri": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz",
+ "integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==",
+ "dev": true
+ },
+ "node_modules/vue": {
+ "version": "3.5.43",
+ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.43.tgz",
+ "integrity": "sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.43",
+ "@vue/compiler-sfc": "3.5.43",
+ "@vue/runtime-dom": "3.5.43",
+ "@vue/server-renderer": "3.5.43",
+ "@vue/shared": "3.5.43"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue-component-type-helpers": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.11.tgz",
+ "integrity": "sha512-LwcxzeliO9fkQcpJG0PoX8X5kmAhKmH9wkpDLxNabwzkQ9Zeib2YVHwFV4pcWmMLfXVfjr/dSV+DaJ3cIPgSNA=="
+ },
+ "node_modules/vue-demi": {
+ "version": "0.14.10",
+ "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
+ "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
+ "hasInstallScript": true,
+ "bin": {
+ "vue-demi-fix": "bin/vue-demi-fix.js",
+ "vue-demi-switch": "bin/vue-demi-switch.js"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "@vue/composition-api": "^1.0.0-rc.1",
+ "vue": "^3.0.0-0 || ^2.6.0"
+ },
+ "peerDependenciesMeta": {
+ "@vue/composition-api": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue-router": {
+ "version": "4.6.4",
+ "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
+ "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
+ "dependencies": {
+ "@vue/devtools-api": "^6.6.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/posva"
+ },
+ "peerDependencies": {
+ "vue": "^3.5.0"
+ }
+ },
+ "node_modules/vue-tsc": {
+ "version": "2.2.12",
+ "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz",
+ "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==",
+ "dev": true,
+ "dependencies": {
+ "@volar/typescript": "2.4.15",
+ "@vue/language-core": "2.2.12"
+ },
+ "bin": {
+ "vue-tsc": "bin/vue-tsc.js"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.0.0"
+ }
+ }
+ }
+}
diff --git a/apps/web/package.json b/apps/web/package.json
new file mode 100644
index 0000000..23a8b4c
--- /dev/null
+++ b/apps/web/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "@labsafe/web",
+ "private": true,
+ "version": "0.1.0-dev",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vue-tsc --noEmit && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@tanstack/vue-query": "^5.66.8",
+ "element-plus": "^2.9.4",
+ "pinia": "^2.3.1",
+ "vue": "^3.5.13",
+ "vue-router": "^4.5.0"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^5.2.1",
+ "typescript": "^5.7.3",
+ "vite": "^6.0.11",
+ "vue-tsc": "^2.2.0"
+ }
+}
diff --git a/apps/web/src/App.vue b/apps/web/src/App.vue
new file mode 100644
index 0000000..ff50476
--- /dev/null
+++ b/apps/web/src/App.vue
@@ -0,0 +1,11 @@
+
+
+
+
+ LabSafe
+ 开发环境,结果不用于安全判断。
+ APP_ENV: {{ environment }}
+
+
diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts
new file mode 100644
index 0000000..1aa6519
--- /dev/null
+++ b/apps/web/src/main.ts
@@ -0,0 +1,5 @@
+import { createApp } from 'vue'
+import { createPinia } from 'pinia'
+import App from './App.vue'
+
+createApp(App).use(createPinia()).mount('#app')
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
new file mode 100644
index 0000000..6d01b47
--- /dev/null
+++ b/apps/web/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "strict": true,
+ "jsx": "preserve",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "types": ["vite/client"]
+ },
+ "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
+}
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
new file mode 100644
index 0000000..eeb3945
--- /dev/null
+++ b/apps/web/vite.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig, loadEnv } from 'vite'
+import vue from '@vitejs/plugin-vue'
+
+export default defineConfig(({ mode }) => {
+ const variables = { ...loadEnv(mode, process.cwd(), 'VITE_'), ...process.env }
+ const environment = variables.VITE_APP_ENV
+ if (!['dev', 'test'].includes(environment ?? '') || variables.APP_ENV !== environment) {
+ throw new Error('I-01A requires matching APP_ENV/VITE_APP_ENV=dev or test; production is disabled')
+ }
+ return {
+ plugins: [vue()],
+ server: { host: '127.0.0.1', port: 5173 }
+ }
+})
diff --git a/apps/worker/__init__.py b/apps/worker/__init__.py
new file mode 100644
index 0000000..ca60bca
--- /dev/null
+++ b/apps/worker/__init__.py
@@ -0,0 +1 @@
+"""Durable task worker process."""
diff --git a/apps/worker/app/__init__.py b/apps/worker/app/__init__.py
new file mode 100644
index 0000000..b85d90f
--- /dev/null
+++ b/apps/worker/app/__init__.py
@@ -0,0 +1 @@
+"""Worker application package."""
diff --git a/apps/worker/app/main.py b/apps/worker/app/main.py
new file mode 100644
index 0000000..8f4abbf
--- /dev/null
+++ b/apps/worker/app/main.py
@@ -0,0 +1,28 @@
+"""Real Celery entrypoint; durable routing and execution belong to I-03."""
+
+import os
+from typing import Any
+
+from celery import Celery
+
+from packages.shared.environment import development_environment
+
+
+def fixture_echo(payload: dict[str, Any]) -> dict[str, Any]:
+ """Local smoke task only, NOT an inference or persistence bypass."""
+ return {"fixture": True, "payload": payload}
+
+
+def create_celery_app() -> Celery:
+ development_environment()
+ celery = Celery("labsafe-worker", broker=os.getenv("REDIS_URL", "redis://localhost:6379/0"))
+ celery.conf.update(
+ task_serializer="json",
+ accept_content=["json"],
+ result_serializer="json",
+ task_acks_late=True,
+ task_reject_on_worker_lost=True,
+ worker_prefetch_multiplier=1,
+ )
+ celery.task(name="labsafe.fixture.echo")(fixture_echo)
+ return celery
diff --git a/apps/worker/run.py b/apps/worker/run.py
new file mode 100644
index 0000000..7a71388
--- /dev/null
+++ b/apps/worker/run.py
@@ -0,0 +1,20 @@
+"""Worker startup; --check validates configuration without connecting to Redis."""
+
+import argparse
+
+from .app.main import create_celery_app
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--check", action="store_true")
+ args = parser.parse_args()
+ app = create_celery_app()
+ if args.check:
+ print("Worker configuration OK (broker connectivity not tested)")
+ return
+ app.worker_main(["worker", "--loglevel=INFO"])
+
+
+if __name__ == "__main__":
+ main()
diff --git a/docs/00-document-control.md b/docs/00-document-control.md
index 3cbfd3a..4c07a1b 100644
--- a/docs/00-document-control.md
+++ b/docs/00-document-control.md
@@ -1,6 +1,6 @@
# 00 文档控制
-状态:2026-09-27 修订候选,版本 1.1.0;尚未人工签署。
+状态:设计版本1.1.0;2026-09-28进入I-01A实施。文档提交、实现验证和独立人工批准分别记录,不将代码自审等同于批准。
## 1. 规范边界
@@ -12,6 +12,8 @@
- ML-BASE-02将检测器固定为D-FINE-N;更新ModelManifest来源/运行配置和16字段content hash,内部/version增加适配器与实际运行信息;vision-v1事实语义和消息schema_version保持不变。旧未部署模型清单不兼容本次门禁,禁止改名复用;外部既有消费者另行迁移。
- IRR-01至06补齐跨模块实现边界:RelationFact.same_location改为必填boolean|null;重试、容量、对象身份/入口及确定性事实算法同步。仍属未部署1.1.0候选,消息schema_version=1.1不变;客户端重新生成nullable类型,禁止旧客户端把null当false。没有已部署消费者兼容性承诺,若外部已使用须先另行迁移。关闭证据见实现就绪清单,不代表人工批准或实际服务测试已完成。
+- ADR-PY-01统一业务/AI/训练的Python3.11基线;独立进程、依赖环境、GPU和协议边界不变。I-01A仅为开发骨架,真实模型与生产能力尚未验收。
+
## 2. 状态与审批
“结构校验通过”“设计可编码”“协作者批准”“系统可上线”分别判断。无签署记录不得称已批准。实际模型、依赖兼容性、数据许可、专家规则和性能成绩不可虚构。
diff --git a/docs/02-architecture/03-ai-inference-process.md b/docs/02-architecture/03-ai-inference-process.md
index bcb6dcf..60cf88a 100644
--- a/docs/02-architecture/03-ai-inference-process.md
+++ b/docs/02-architecture/03-ai-inference-process.md
@@ -10,7 +10,9 @@ AI 是单 Uvicorn worker supervisor + 一个 multiprocessing spawn 计算子进
AI 只能读授权 analysis/ 对象和只读模型目录,无 MySQL/Redis/业务 ORM/规则依赖。Worker 根据裁剪配方写证据对象。AI 不返回可写 URL、不持有写权限。
-模型和数据路线以[固定模型方案](../04-ai-rules/02-model-data-plan.md)为准:AI运行时Python3.11、D-FINE-N四类ONNX,业务仍Python3.12;RGB直接resize640并除255,qmax/无NMS,不套用其他模型的letterbox。建议生产检测CUDA FP32;OCR保持PP-OCRv4 CPU。APP_ENV/AI_MODE在启动校验;production禁止fixture和未批准bundle,dev/test可用同协议mock进程。/version除purpose/is_simulated外回显adapter_id、runtime_profile、runtime_lock_sha256、detector_device和ocr_device;这些值取实际通过启动校验的加载状态,Worker与受控manifest逐项核对。
+模型和数据路线以[固定模型方案](../04-ai-rules/02-model-data-plan.md)为准:AI运行时Python3.11、D-FINE-N四类ONNX,业务同为Python3.11但保留独立依赖环境;RGB直接resize640并除255,qmax/无NMS,不套用其他模型的letterbox。建议生产检测CUDA FP32;OCR保持PP-OCRv4 CPU。APP_ENV/AI_MODE在启动校验;production禁止fixture和未批准bundle,dev/test可用同协议mock进程。/version除purpose/is_simulated外回显adapter_id、runtime_profile、runtime_lock_sha256、detector_device和ocr_device;这些值取实际通过启动校验的加载状态,Worker与受控manifest逐项核对。
+
+I-01A只交付同协议开发fixture和HTTP进程入口;下面的真实spawn计算子进程、模型加载与故障恢复仍按I-03/I-ML实施,不能将fixture冒充完整supervisor。运行和验收入口见[开发指南](../../DEVELOPMENT.md)。
## 2. 生命周期
diff --git a/docs/02-architecture/04-coding-baseline.md b/docs/02-architecture/04-coding-baseline.md
index 05e68cb..a2fa77a 100644
--- a/docs/02-architecture/04-coding-baseline.md
+++ b/docs/02-architecture/04-coding-baseline.md
@@ -11,9 +11,9 @@
| 目录 | 实现职责 | 禁止事项 |
|---|---|---|
| apps/web | Vue 3、TypeScript、Vite、Element Plus、Vue Router、TanStack Query;Pinia 只管会话/UI | 复制状态机、长期对象凭据 |
-| apps/api | Python 3.12、FastAPI、Pydantic v2;路由调用命令/查询 | 加载模型,外部调用持有 DB 事务 |
+| apps/api | Python 3.11、FastAPI、Pydantic v2;路由调用命令/查询 | 加载模型,外部调用持有 DB 事务 |
| apps/worker | Celery、Redis、持久任务、AI client、规则、裁剪、导出 | 覆盖历史结果 |
-| apps/ai_inference | Python3.11独立FastAPI supervisor + spawn计算子进程;D-FINE-N四类ONNX/PP-OCRv4 CPU;建议检测CUDA FP32,CPU功能保留;业务API/Worker仍Python3.12 | 业务ORM、数据库、Redis、最终风险 |
+| apps/ai_inference | Python3.11独立FastAPI supervisor + spawn计算子进程;D-FINE-N四类ONNX/PP-OCRv4 CPU;建议检测CUDA FP32,CPU功能保留;业务API/Worker同为Python3.11,依赖环境仍隔离 | 业务ORM、数据库、Redis、最终风险 |
| packages/domain | 状态、guards、权限动作、不变事实类型 | 通用 status setter |
| packages/application | 命令处理器、查询、unit_of_work;每命令唯一入口 | 路由另写业务逻辑 |
| packages/persistence | SQLAlchemy 2、MySQL 8.0.16+、Alembic、Outbox repository | 省略租户条件 |
@@ -22,6 +22,8 @@
依赖:HTTP/任务入口 → application → domain;persistence 实现仓储接口。AI 只共享协议和图像变换包。应用依赖锁文件和镜像 digest 在实施环境验证后提交;没有完成依赖兼容性验证前不得宣称环境可复现。
+统一运行时依据见[ADR-PY-01](07-python-runtime.md);I-01A开发入口与测试见[开发指南](../../DEVELOPMENT.md)。统一minor不合并AI与业务依赖,也不改变进程/权限边界。
+
## 3. 事务模板
1. 会话给出租户,服务端授权;不接受客户端 tenant_id 作为权限依据。
@@ -36,7 +38,7 @@
- 资源 ID 默认 UUID v4 小写;确定性检测/裁剪 ID 按 AI 文档使用 UUIDv5。时间为 UTC RFC3339 毫秒,DB DATETIME(3)。reference_date 在提交时按 tenant.timezone 生成并冻结。
- 通用字符串 NFC 去首尾空白;密码和 OCR raw_text 不处理;账号 NFKC+casefold 后唯一。
-- JSON哈希:UTF-8、键字典序、无空格、禁止NaN、保留数组顺序;共享规范函数为json.dumps(sort_keys=True,separators=(',',':'),ensure_ascii=False,allow_nan=False)。Python3.11/3.12生产者使用同一函数和黄金样本测试;不是任意语言默认JSON字符串。原图/制品哈希直接对字节计算。
+- JSON哈希:UTF-8、键字典序、无空格、禁止NaN、保留数组顺序;共享规范函数为json.dumps(sort_keys=True,separators=(',',':'),ensure_ascii=False,allow_nan=False)。Python3.11生产者使用同一函数和黄金样本测试;不是任意语言默认JSON字符串。原图/制品哈希直接对字节计算。
- trace_id 为 32 位小写十六进制;request_id/attempt_id 是 UUID。
- 原图、分析图、裁剪图各自保留对象 key、version、SHA,禁止混用。
- 终态结果、事实正文、发布规则/词典正文不可覆盖;元数据只能通过规定命令变化;activation 只影响新 run。
diff --git a/docs/02-architecture/07-python-runtime.md b/docs/02-architecture/07-python-runtime.md
new file mode 100644
index 0000000..d8731e8
--- /dev/null
+++ b/docs/02-architecture/07-python-runtime.md
@@ -0,0 +1,38 @@
+# 02.7 Python 运行时决策(ADR-PY-01)
+
+日期:2026-09-28。依据:用户授权在双版本收益不足时统一 Python 3.11。
+本决策随 I-01A 特性分支提交,仍需按协作规范独立评审;不是生产上线批准。
+
+## 1. 决定
+
+业务 API、Worker、AI 推理及离线训练均以 Python 3.11.x 为唯一 minor 基线。
+替代原来的“业务 3.12、AI/训练 3.11”版本分离;不改变独立进程、HTTP 契约、服务权限或 GPU 边界。
+业务与 AI 继续使用不同虚拟环境、不同依赖集合和后续独立镜像;统一解释器版本不等于合并环境。
+
+## 2. 判断依据
+
+1. 当前骨架没有依赖 Python 3.12 专属语法或 API。
+2. 本次业务与 AI 顶层依赖已在本机 Python 3.11 验证安装和运行;CI 按 Python 3.11 分服务验收。
+3. 现阶段双 minor 版本没有对应的已验证性能或功能收益,却增加环境说明、测试矩阵与共享序列化核对工作。
+4. AI 原有 3.11 基线保持不变,不据此声称 D-FINE、CUDA、Paddle 的真实运行时已经验证。
+
+上述为当前工程的取舍,不是 Python 3.11 比 3.12 更快的结论。完整传递依赖锁、镜像 digest 与真实硬件验证仍归 I-ML/I-05。
+
+## 3. 实现约束
+
+| 边界 | 约束 |
+|---|---|
+| 业务 | requirements/py311-business.txt;FastAPI/Celery/SQLAlchemy/Alembic |
+| AI | requirements/py311-ai.txt;I-01A 无 Celery、Redis、业务 ORM 或真实模型包 |
+| 共用部分 | common.txt 固定当前直接依赖;inference_protocol 只包含 wire schema、DTO 校验和规范哈希 |
+| Python 版本 | pyproject 要求 >=3.11,<3.12;运行时显式校验 minor;CI 使用 3.11 |
+| 模型 | D-FINE-N、检测 CUDA FP32 建议、PP-OCRv4 CPU 及真实 CPU profile 均不改变 |
+| 环境安全 | I-01A 仅 dev/test,AI_MODE=mock;生产与 real 启动拒绝 |
+
+FastAPI/Celery 是必需运行依赖,缺失时必须失败,不在应用入口构造兼容外观的假框架。
+
+## 4. 后续变更与回滚
+
+后续若需要升级 Python,先在业务/AI 各自环境验证并提交新的运行时决策、依赖锁和测试结果,不能只修改镜像标签。
+I-01A 没有数据库迁移和生产部署,回滚通过 revert 对应实现提交恢复文档基线;不删除审计记录、不改写 main 历史。
+旧 3.12 文字仅保留在明确归档或本决策的历史说明中,现行操作命令不再引用 py312.txt。
diff --git a/docs/02-architecture/README.md b/docs/02-architecture/README.md
index 5b40847..c2b17a1 100644
--- a/docs/02-architecture/README.md
+++ b/docs/02-architecture/README.md
@@ -6,5 +6,6 @@
4. [独立AI](03-ai-inference-process.md)。
5. [持久任务](05-durable-jobs.md)。
6. [数据约束](06-data-persistence.md)。
+7. [Python运行时决策](07-python-runtime.md)。
本页仅导航;现行设计状态见 [文档控制](../00-document-control.md),不把候选设计视为已经批准。
diff --git a/docs/04-ai-rules/02-model-data-plan.md b/docs/04-ai-rules/02-model-data-plan.md
index 2b6e2e5..eac4529 100644
--- a/docs/04-ai-rules/02-model-data-plan.md
+++ b/docs/04-ai-rules/02-model-data-plan.md
@@ -4,14 +4,14 @@
## 1. 固定路线与变更边界
-| 能力 | 本期决定 | 不允许的替代 |
-|---|---|---|
-| 检测 | D-FINE-N,四类微调,640×640,300 queries | 把COCO权重直接当四类模型;未经确认换S或扩大输入 |
-| 适配器 | dfine-n4-rgb-stretch-v1;RGB、直接resize、qmax、无NMS | 旧BGR/0–255/letterbox适配器;混用上游两个推理示例 |
-| 生产建议 | ONNX Runtime CUDA、FP32、batch=1、并发1 | 默认TensorRT/FP16、跨主机GPU调度或静默CPU fallback |
-| OCR | 保留PaddleOCR2.10.0、PP-OCRv4及CPU | 本次不隐含批准PP-OCRv6、OCR上GPU或大模型 |
-| 开发 | fixture-v1联调;真实CPU profile做功能/导出检查 | 把mock、CPU结果当CUDA生产性能证明 |
-| 业务 | AI只出事实;Worker写证据并执行确定性规则;仍需人工复核 | 用模型置信度代替安全判断 |
+| 能力 | 本期决定 | 不允许的替代 |
+| ---- | ---------------------------------------------- | --------------------------------------- |
+| 检测 | D-FINE-N,四类微调,640×640,300 queries | 把COCO权重直接当四类模型;未经确认换S或扩大输入 |
+| 适配器 | dfine-n4-rgb-stretch-v1;RGB、直接resize、qmax、无NMS | 旧BGR/0–255/letterbox适配器;混用上游两个推理示例 |
+| 生产建议 | ONNX Runtime CUDA、FP32、batch=1、并发1 | 默认TensorRT/FP16、跨主机GPU调度或静默CPU fallback |
+| OCR | 保留PaddleOCR2.10.0、PP-OCRv4及CPU | 本次不隐含批准PP-OCRv6、OCR上GPU或大模型 |
+| 开发 | fixture-v1联调;真实CPU profile做功能/导出检查 | 把mock、CPU结果当CUDA生产性能证明 |
+| 业务 | AI只出事实;Worker写证据并执行确定性规则;仍需人工复核 | 用模型置信度代替安全判断 |
上游仓库为Peterande/D-FINE,固定提交956d1709314c2c6a4df6f34de232054578a7449f;模型结构从该提交configs/dfine/dfine_hgnetv2_n_coco.yml及其include展开。source_ref固定为https://github.com/Peterande/D-FINE,source_commit为上述40位提交;不在构建时追踪master。项目包装器和安全加载改动由项目git_commit及补丁摘要追踪,不伪装成上游原样实现。
@@ -19,7 +19,7 @@
## 2. 运行时和版本约束
-业务API/Worker保持Python3.12;AI和离线训练使用Python3.11、Linux x86_64。Windows开发通过相同Linux容器联调。训练框架不进入业务API,也不要求训练与推理安装在同一环境。
+依据[ADR-PY-01](../02-architecture/07-python-runtime.md),业务API/Worker、AI和离线训练统一Python3.11;各服务依赖环境仍独立,真实训练/推理以Linux x86_64为目标。Windows开发通过相同Linux容器联调。训练框架不进入业务API,也不要求训练与推理安装在同一环境。
| 环境 | 顶层约束 |
|---|---|
diff --git a/docs/07-quality-operations/02-deployment-operations.md b/docs/07-quality-operations/02-deployment-operations.md
index 40798fb..8059346 100644
--- a/docs/07-quality-operations/02-deployment-operations.md
+++ b/docs/07-quality-operations/02-deployment-operations.md
@@ -42,6 +42,8 @@ AI_DEVICE、路由device_profile、manifest.runtime_profile/runtime设备和/ver
单卡无双份显存余量时,停止领取新CUDA任务→排空在途任务→not_ready→卸载旧子进程→加载新bundle/lock→smoke→激活新route。新版本失败则恢复旧镜像/bundle/route并smoke,不改旧run。记录GPU已用/峰值显存、显存余量、重载耗时、CUDA EP失败次数及现有分阶段/队列指标;高基数GPU标识留环境快照,不按run生成监控标签。
+I-01A统一Python3.11,服务依赖环境仍隔离;开发骨架尚不实现本节生产拓扑,生产启动被明确拒绝。见[运行时决策](../02-architecture/07-python-runtime.md)。
+
## 2. 配置表
| 变量/secret | 默认/要求 | 使用者 |
diff --git a/docs/08-delivery/01-implementation-plan.md b/docs/08-delivery/01-implementation-plan.md
index 6796765..59e11c1 100644
--- a/docs/08-delivery/01-implementation-plan.md
+++ b/docs/08-delivery/01-implementation-plan.md
@@ -1,6 +1,6 @@
# 08.1 实施计划与首批任务
-状态:设计已给出执行顺序;下列均为未来业务开发任务,不能写成已实现。阶段标准见[阶段门禁](05-stage-gates.md)。
+状态:已进入I-01A骨架实现与验收;其余任务仍未实现,不能以骨架通过代替业务/模型/发布验收。阶段标准见[阶段门禁](05-stage-gates.md),当前入口见[开发指南](../../DEVELOPMENT.md)。
## 1. 从“只有文档”开始
@@ -8,7 +8,7 @@
| 编号 | 任务与输入 | 必须交付 | 完成检查 / 失败处理 |
|---|---|---|---|
-| I-01A | 由编码基线创建apps/api、worker、ai_inference、web和共享包;固定APP_ENV=dev | 项目骨架、各进程入口、环境变量样例、顶层依赖约束、CI入口 | 空环境import/build通过;不装真实模型也能启动mock;失败先修环境不改业务协议 |
+| I-01A | 由编码基线创建apps/api、worker、ai_inference、web和共享包;固定APP_ENV=dev | 项目骨架、各进程入口、环境变量样例、顶层依赖约束、CI入口 | 按声明依赖在干净Python3.11环境安装后import/HTTP smoke/build通过;缺框架须失败、不装真实模型可启动同协议mock;失败先修环境不改业务协议 |
| I-01B | 设计DDL与字典;专用空MySQL数据库 | 初始Alembic迁移、bootstrap-tenant/roles CLI、DB结构核对测试 | 空库upgrade到head;43表/列/FK/索引与字典一致,实际版本记日志;失败在可丢弃测试库重建,不触碰用户已有库 |
| I-01C | 数据约束和安全规范 | 仓储、session/RBAC、幂等、跨租户拒绝测试 | DB-01/02、API-02/03、SEC-01/02;失败事务回滚且无旁路权限 |
| I-02 | 状态机与公共契约 | 巡检、上传、事实、人工复核、整改等命令/查询 | 每个guard有正/反例;相同payload重发无重复副作用 |
@@ -40,4 +40,4 @@ IRR六项设计前置按[关闭清单](06-implementation-readiness.md)执行。
## 5. 协作和Push
-当前保留原分支和用户已有暂存改动;本轮只改设计、契约和设计校验工具,不创建业务工程,不commit/push。设计文档可以先形成独立docs PR,不应等待完整系统上线。推送时再依CONTRIBUTING检查远程基线、变更范围、生成一致性和保密要求;数据库/权限/模型变更须双人评审,不能把本次自审当批准。
+设计基线已通过文档PR形成提交。I-01A按用户授权在特性分支完成修正、验证后Push并提交PR;不直接推送main,不自动合并或替代独立审查。推送前依CONTRIBUTING核对远程基线、变更范围、生成一致性和保密要求;数据库/权限/模型变更仍须双人评审。
diff --git a/docs/08-delivery/04-remediation-plan.md b/docs/08-delivery/04-remediation-plan.md
index 16ec709..5158973 100644
--- a/docs/08-delivery/04-remediation-plan.md
+++ b/docs/08-delivery/04-remediation-plan.md
@@ -8,7 +8,7 @@
- ML-BASE-02选择D-FINE-N四类微调+PP-OCRv4 CPU,固定上游提交、初始化来源、数据/类别双向映射/微调/导出路线,不能拿COCO类别权重冒充项目模型。
- adapter明确640、RGB/0–1、PIL直接resize、orig_target_sizes=[W,H]、labels/boxes/scores、qmax/无NMS;全请求100结果上限与原图裁剪规则保持,源实现/参考语义/测试同步。
-- 独立AI/训练Python3.11,业务Python3.12,通过既有HTTP边界隔离;顶层版本基线已选择,实际lock和硬件证明由I-ML-01生成。
+- 依据ADR-PY-01,业务、独立AI/训练统一Python3.11,通过独立依赖环境与既有HTTP边界隔离;顶层版本基线已选择,实际lock和硬件证明由I-ML-01生成。
- CUDA FP32为建议生产profile,CPU功能仍保留;两者独立bundle/lock/报告。OCR不暗换模型或GPU化。先用同协议fixture和初始化checkpoint开发,production拒绝mock/未校准/未批准bundle。
- 清单新增来源提交和runtime;16字段内容摘要绑定代码/环境,/version回显实际设备/适配器。生成器与门禁不再仅接受旧模型,新负例覆盖错设备、精度和报告复用。
- 数据不足和目标不达标有继续采集/微调与ADR替换路径,不让开发者自行猜模型、输出字段或错误行为。
diff --git a/docs/08-delivery/07-i01a-acceptance.md b/docs/08-delivery/07-i01a-acceptance.md
new file mode 100644
index 0000000..aebae41
--- /dev/null
+++ b/docs/08-delivery/07-i01a-acceptance.md
@@ -0,0 +1,57 @@
+# 08.7 I-01A 工程骨架验收记录
+
+日期:2026-09-28。范围:本次特性分支中的开发骨架,不是完整业务系统或生产发布验收。
+运行时决策见 [ADR-PY-01](../02-architecture/07-python-runtime.md),操作入口见 [开发指南](../../DEVELOPMENT.md)。
+
+## 1. 六项修正关闭证据
+
+| 问题 | 修正 | 可重验依据 |
+|---|---|---|
+| CI 静态检查失败 | 修正超长行,统一 Ruff 格式 | ruff check / ruff format --check;没有关闭原 lint 规则 |
+| fixture 协议漂移 | 既有五个 AI 路由、请求/响应/错误 Schema;契约由同一生成器打包 | test_authenticated_health_ready_version_follow_contract、test_quality_and_runs_echo_identity_without_false_safe_result;build_specs --check |
+| production/mock 与鉴权缺失 | 后端仅 dev/test、AI_MODE=mock;随机 token 文件、租户白名单;Web 拒绝生产配置 | test_invalid_configuration_is_rejected、test_all_contract_routes_require_token、test_production_cli_fails;前端生产构建反例 |
+| 双 Python 环境不落实 | 统一 Python 3.11;保留业务与 AI 的依赖隔离;CI 分服务安装 | 干净 AI 环境不含 Celery/Redis/SQLAlchemy/真实模型包;独立 AI 套件通过 |
+| 假框架掩盖运行错误 | 移除框架 fallback;使用真实 FastAPI/Celery;实际 API/AI HTTP 子进程 smoke | test_missing_frameworks_do_not_fall_back、test_api_starts_as_an_independent_http_process、test_standalone_ai_http_process |
+| 虚拟环境未忽略 | 忽略 .venv*、工具缓存、.local-secrets、egg-info | git check-ignore 对虚拟环境、Ruff 缓存、令牌文件均匹配 |
+
+另有并发、相同 attempt、超时释放、版本/哈希/对象引用、JSON规范化及运行依赖漂移检查。
+日期校验显式安装 rfc3339-validator;缺少该能力立即失败,不能让 jsonschema 跳过 date-time 格式校验。
+
+## 2. 本机实际执行结果
+
+环境:Windows、Python 3.11.4、Node 24.21.0、npm 9.6.7;依赖安装在临时虚拟环境。
+远程 CI 使用 Linux/Python 3.11、Node 20;其成绩以本 PR 的对应提交检查为准,不用本机成绩代替。
+
+| 检查 | 本次结果 |
+|---|---|
+| python -m unittest discover -s tests -t . -v | 25 项通过;覆盖真实框架、HTTP 子进程、协议、启动保护和异常分支 |
+| ruff check apps packages tests | 通过 |
+| ruff format --check apps packages tests | 通过,34个Python文件 |
+| 独立 AI 环境安装 requirements/dev-ai.txt,pip check | 通过;未安装 Celery、Redis、SQLAlchemy、Torch、Paddle、ORT |
+| AI 单独套件 + 共用协议套件 | 干净环境15项AI测试和4项协议测试通过 |
+| Python wheel构建与已安装包资源检查 | 通过;隔离导入可找到运行时契约和fixture资源,无需源码目录 |
+| npm ci、npm run build(dev) | 通过;npm 安装时审计报告 0 vulnerabilities,不代表持续安全保证 |
+| production Web 构建 | 按预期失败,没有生成可用生产模式 |
+| build_specs.py --check | 通过,16 个生成物;其中运行时包契约来自同一源 |
+| test_readiness_design.py / validate_specs.py | 通过;148 IRR + 41 发布 + 39 D-FINE 合成设计检查 |
+
+设计检查输出只描述该工具调用的证据范围;其中“No application runtime tests”不能被读成仓库没有应用测试。
+本页的应用验证与设计合成验证分别记录,不能相互替代。
+
+## 3. 明确未完成的后续阶段
+
+- I-01B/C:专用 MySQL、43 表 Alembic 迁移、仓储、会话、RBAC、幂等与租户事务。
+- I-02/I-03:实际巡检命令、Outbox/inbox、Worker→AI→DB、租约/fencing/恢复。
+- I-03:真实 spawn 计算子进程、硬超时 kill/join 与故障重建;I-01A仅有可取消的合成延迟。
+- I-ML:D-FINE-N、PP-OCRv4 CPU、真实图像/数据/权重、CUDA 推理和完整运行时锁。
+- I-04/I-05/R:业务前端、真实对象存储权限/签名、Compose、备份恢复和人工批准。
+
+当前 fixture 不下载或验证图片字节,不执行真实质量算法;其分数、空事实和耗时都是合成值。
+API /ready 只表明骨架进程配置有效;worker --check 不验证 broker 连通,也不能视为完整异步链路。
+不得据此激活生产模型、宣称准确率/GPU性能达标,或把本阶段自审等同于协作者批准。
+
+## 4. 提交与回滚
+
+本次提交特性分支并创建 PR,base=main;不直接 push main、不自动合并。
+ADR、启动保护和开发 token 边界变更由协作者按 CONTRIBUTING 评审,未代替任何人签署。
+没有生产数据迁移;如需回滚,revert 本 PR 的实现提交即可,不改写已发布历史。
diff --git a/docs/08-delivery/README.md b/docs/08-delivery/README.md
index 13c45c7..f55f47f 100644
--- a/docs/08-delivery/README.md
+++ b/docs/08-delivery/README.md
@@ -6,5 +6,6 @@
4. [需求追踪](02-traceability-matrix.md)。
5. [设计、实施与发布门禁](05-stage-gates.md)。
6. [实现就绪评审六项关闭清单](06-implementation-readiness.md)。
+7. [I-01A工程骨架验收](07-i01a-acceptance.md)。
本页仅导航;现行设计状态见 [文档控制](../00-document-control.md),不把候选设计视为已经批准。
diff --git a/docs/08-delivery/design-validation-results.json b/docs/08-delivery/design-validation-results.json
index 6febfed..30bc889 100644
--- a/docs/08-delivery/design-validation-results.json
+++ b/docs/08-delivery/design-validation-results.json
@@ -1,6 +1,6 @@
{
"status": "PASS",
- "executed_at_utc": "2026-09-27T10:31:32.254226+00:00",
+ "executed_at_utc": "2026-09-28T11:19:31.719885+00:00",
"python_version": "3.11.4",
"counts": {
"openapi_specs": 2,
@@ -20,7 +20,7 @@
"truth_table_cases": 9,
"rule_semantic_negative_cases": 1,
"rule_scope_cases": 2,
- "doc_links": 140,
+ "doc_links": 150,
"public_operations": 94,
"traceable_test_ids": 42,
"synthetic_release_gate_checks": 41,
diff --git a/packages/__init__.py b/packages/__init__.py
new file mode 100644
index 0000000..e18386f
--- /dev/null
+++ b/packages/__init__.py
@@ -0,0 +1 @@
+"""Shared LabSafe packages."""
diff --git a/packages/application/__init__.py b/packages/application/__init__.py
new file mode 100644
index 0000000..cbf7c70
--- /dev/null
+++ b/packages/application/__init__.py
@@ -0,0 +1 @@
+"""Application command/query package placeholder."""
diff --git a/packages/domain/__init__.py b/packages/domain/__init__.py
new file mode 100644
index 0000000..d96aeeb
--- /dev/null
+++ b/packages/domain/__init__.py
@@ -0,0 +1 @@
+"""Domain package placeholder for immutable facts, guards and state transitions."""
diff --git a/packages/inference_protocol/__init__.py b/packages/inference_protocol/__init__.py
new file mode 100644
index 0000000..34e6376
--- /dev/null
+++ b/packages/inference_protocol/__init__.py
@@ -0,0 +1 @@
+"""Shared inference wire contract only; no domain, ORM, Redis or model imports."""
diff --git a/packages/inference_protocol/contract.json b/packages/inference_protocol/contract.json
new file mode 100644
index 0000000..0e49fa9
--- /dev/null
+++ b/packages/inference_protocol/contract.json
@@ -0,0 +1,1730 @@
+{
+ "openapi": "3.1.0",
+ "info": {
+ "title": "LabSafe 独立 AI 事实 API",
+ "version": "1.1.0",
+ "description": "设计修订版,由 tools/design/build_specs.py 生成;未声明已部署。"
+ },
+ "servers": [
+ {
+ "url": "/internal/inference/v1"
+ }
+ ],
+ "security": [
+ {
+ "serviceToken": []
+ }
+ ],
+ "paths": {
+ "/quality": {
+ "post": {
+ "operationId": "aiQuality",
+ "responses": {
+ "200": {
+ "description": "结构化事实或健康状态",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InferenceResult"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "413": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "415": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "503": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "504": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ },
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InferenceRequest"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/runs": {
+ "post": {
+ "operationId": "aiRuns",
+ "responses": {
+ "200": {
+ "description": "结构化事实或健康状态",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InferenceResult"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "413": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "415": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "503": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "504": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ },
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InferenceRequest"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/health": {
+ "get": {
+ "operationId": "aiHealth",
+ "responses": {
+ "200": {
+ "description": "结构化事实或健康状态",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Health"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "413": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "415": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "503": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "504": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/ready": {
+ "get": {
+ "operationId": "aiReady",
+ "responses": {
+ "200": {
+ "description": "结构化事实或健康状态",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Health"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "413": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "415": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "503": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "504": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/version": {
+ "get": {
+ "operationId": "aiVersion",
+ "responses": {
+ "200": {
+ "description": "结构化事实或健康状态",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Version"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "413": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "415": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "503": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "504": {
+ "description": "统一错误",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "components": {
+ "securitySchemes": {
+ "serviceToken": {
+ "type": "http",
+ "scheme": "bearer"
+ }
+ },
+ "schemas": {
+ "Error": {
+ "type": "object",
+ "properties": {
+ "request_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "error": {
+ "type": "object",
+ "properties": {
+ "code": {
+ "type": "string",
+ "enum": [
+ "VALIDATION_ERROR",
+ "UNAUTHENTICATED",
+ "FORBIDDEN",
+ "NOT_FOUND",
+ "STATE_CONFLICT",
+ "VERSION_CONFLICT",
+ "IDEMPOTENCY_CONFLICT",
+ "REQUEST_IN_PROGRESS",
+ "RATE_LIMITED",
+ "IMAGE_TOO_LARGE",
+ "UNSUPPORTED_MEDIA_TYPE",
+ "IMAGE_INVALID",
+ "UNAUTHORIZED_REF",
+ "OBJECT_NOT_FOUND",
+ "HASH_MISMATCH",
+ "MODEL_NOT_READY",
+ "MODEL_VERSION_UNAVAILABLE",
+ "AI_BUSY",
+ "RUN_IN_PROGRESS",
+ "AI_TIMEOUT",
+ "STAGE_TIMEOUT",
+ "MODEL_OOM",
+ "DEPENDENCY_UNAVAILABLE",
+ "MODEL_ERROR",
+ "INTERNAL_ERROR",
+ "SCHEMA_MISMATCH",
+ "RULESET_INVALID",
+ "LEASE_LOST"
+ ]
+ },
+ "message": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 500
+ },
+ "retryable": {
+ "type": "boolean"
+ },
+ "details": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ErrorDetail"
+ },
+ "minItems": 0,
+ "maxItems": 30
+ }
+ },
+ "required": [
+ "code",
+ "message",
+ "retryable",
+ "details"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "request_id",
+ "error"
+ ],
+ "additionalProperties": false
+ },
+ "ErrorDetail": {
+ "type": "object",
+ "properties": {
+ "field": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 160
+ },
+ "reason": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 500
+ }
+ },
+ "required": [
+ "field",
+ "reason"
+ ],
+ "additionalProperties": false
+ },
+ "ImageRef": {
+ "type": "object",
+ "properties": {
+ "image_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "object_key": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1024
+ },
+ "sha256": {
+ "type": "string",
+ "pattern": "^[a-f0-9]{64}$"
+ },
+ "mime_type": {
+ "type": "string",
+ "enum": [
+ "image/jpeg",
+ "image/png",
+ "image/webp"
+ ]
+ },
+ "role": {
+ "type": "string",
+ "enum": [
+ "overview",
+ "detail"
+ ]
+ },
+ "location_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "parent_image_id": {
+ "anyOf": [
+ {
+ "type": "string",
+ "format": "uuid"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "required": [
+ "image_id",
+ "object_key",
+ "sha256",
+ "mime_type",
+ "role",
+ "location_id",
+ "parent_image_id"
+ ],
+ "additionalProperties": false
+ },
+ "InferenceRequest": {
+ "type": "object",
+ "properties": {
+ "run_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "attempt_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "fencing_token": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 2147483647
+ },
+ "tenant_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "laboratory_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "item_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "submission_revision": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 2147483647
+ },
+ "model_bundle_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "model_checksum": {
+ "type": "string",
+ "pattern": "^[a-f0-9]{64}$"
+ },
+ "dictionary_version_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "dictionary_sha256": {
+ "type": "string",
+ "pattern": "^[a-f0-9]{64}$"
+ },
+ "pipeline_version": {
+ "type": "string",
+ "enum": [
+ "vision-v1"
+ ]
+ },
+ "device_profile": {
+ "type": "string",
+ "enum": [
+ "cpu",
+ "cuda"
+ ]
+ },
+ "deadline_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "request_hash": {
+ "type": "string",
+ "pattern": "^[a-f0-9]{64}$"
+ },
+ "image_refs": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ImageRef"
+ },
+ "minItems": 1,
+ "maxItems": 3
+ }
+ },
+ "required": [
+ "run_id",
+ "attempt_id",
+ "fencing_token",
+ "tenant_id",
+ "laboratory_id",
+ "item_id",
+ "submission_revision",
+ "model_bundle_id",
+ "model_checksum",
+ "dictionary_version_id",
+ "dictionary_sha256",
+ "pipeline_version",
+ "device_profile",
+ "deadline_at",
+ "request_hash",
+ "image_refs"
+ ],
+ "additionalProperties": false
+ },
+ "Point": {
+ "type": "object",
+ "properties": {
+ "x": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1
+ },
+ "y": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1
+ }
+ },
+ "required": [
+ "x",
+ "y"
+ ],
+ "additionalProperties": false
+ },
+ "Quality": {
+ "type": "object",
+ "properties": {
+ "image_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "pass",
+ "needs_retake"
+ ]
+ },
+ "reasons": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": [
+ "blur",
+ "dark",
+ "glare",
+ "occluded",
+ "unreadable"
+ ]
+ },
+ "minItems": 0,
+ "maxItems": 5
+ },
+ "blur_score": {
+ "type": "number",
+ "minimum": 0
+ },
+ "brightness": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1
+ },
+ "glare_ratio": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1
+ }
+ },
+ "required": [
+ "image_id",
+ "status",
+ "reasons",
+ "blur_score",
+ "brightness",
+ "glare_ratio"
+ ],
+ "additionalProperties": false
+ },
+ "Detection": {
+ "type": "object",
+ "properties": {
+ "detection_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "image_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "parent_detection_id": {
+ "anyOf": [
+ {
+ "type": "string",
+ "format": "uuid"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "bottle",
+ "label",
+ "shelf",
+ "cabinet"
+ ]
+ },
+ "bbox": {
+ "type": "array",
+ "items": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1
+ },
+ "minItems": 4,
+ "maxItems": 4
+ },
+ "confidence": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1
+ }
+ },
+ "required": [
+ "detection_id",
+ "image_id",
+ "parent_detection_id",
+ "type",
+ "bbox",
+ "confidence"
+ ],
+ "additionalProperties": false
+ },
+ "CropRecipe": {
+ "type": "object",
+ "properties": {
+ "crop_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "image_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "detection_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "quad": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Point"
+ },
+ "minItems": 4,
+ "maxItems": 4
+ },
+ "output_width": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 2048
+ },
+ "output_height": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 2048
+ },
+ "transform_version": {
+ "type": "string",
+ "enum": [
+ "perspective-rgb-v1"
+ ]
+ }
+ },
+ "required": [
+ "crop_id",
+ "image_id",
+ "detection_id",
+ "quad",
+ "output_width",
+ "output_height",
+ "transform_version"
+ ],
+ "additionalProperties": false
+ },
+ "OCRField": {
+ "type": "object",
+ "properties": {
+ "image_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "detection_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "crop_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "field": {
+ "type": "string",
+ "enum": [
+ "name",
+ "expiry",
+ "production",
+ "opened",
+ "date_unknown",
+ "concentration",
+ "hazard_mark"
+ ]
+ },
+ "raw_text": {
+ "type": "string",
+ "minLength": 0,
+ "maxLength": 2000
+ },
+ "normalized_text": {
+ "anyOf": [
+ {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 500
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "confidence": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1
+ }
+ },
+ "required": [
+ "image_id",
+ "detection_id",
+ "crop_id",
+ "field",
+ "raw_text",
+ "normalized_text",
+ "confidence"
+ ],
+ "additionalProperties": false
+ },
+ "Candidate": {
+ "type": "object",
+ "properties": {
+ "entity_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "canonical_name": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 200
+ },
+ "confidence": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1
+ },
+ "match_method": {
+ "type": "string",
+ "enum": [
+ "cas_exact",
+ "alias_exact",
+ "fuzzy"
+ ]
+ }
+ },
+ "required": [
+ "entity_id",
+ "canonical_name",
+ "confidence",
+ "match_method"
+ ],
+ "additionalProperties": false
+ },
+ "EntityCandidates": {
+ "type": "object",
+ "properties": {
+ "image_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "detection_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "candidates": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Candidate"
+ },
+ "minItems": 0,
+ "maxItems": 5
+ },
+ "resolution": {
+ "type": "string",
+ "enum": [
+ "resolved",
+ "candidate",
+ "unknown"
+ ]
+ }
+ },
+ "required": [
+ "image_id",
+ "detection_id",
+ "candidates",
+ "resolution"
+ ],
+ "additionalProperties": false
+ },
+ "Relation": {
+ "type": "object",
+ "properties": {
+ "image_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "source_detection_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "target_detection_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "relation": {
+ "type": "string",
+ "enum": [
+ "adjacent",
+ "not_adjacent",
+ "unknown"
+ ]
+ },
+ "confidence": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1
+ }
+ },
+ "required": [
+ "image_id",
+ "source_detection_id",
+ "target_detection_id",
+ "relation",
+ "confidence"
+ ],
+ "additionalProperties": false
+ },
+ "Timing": {
+ "type": "object",
+ "properties": {
+ "download_ms": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 2147483647
+ },
+ "quality_ms": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 2147483647
+ },
+ "detection_ms": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 2147483647
+ },
+ "ocr_ms": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 2147483647
+ },
+ "normalization_ms": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 2147483647
+ },
+ "total_ms": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 2147483647
+ }
+ },
+ "required": [
+ "download_ms",
+ "quality_ms",
+ "detection_ms",
+ "ocr_ms",
+ "normalization_ms",
+ "total_ms"
+ ],
+ "additionalProperties": false
+ },
+ "InferenceResult": {
+ "type": "object",
+ "properties": {
+ "run_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "attempt_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "fencing_token": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 2147483647
+ },
+ "tenant_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "request_hash": {
+ "type": "string",
+ "pattern": "^[a-f0-9]{64}$"
+ },
+ "input_hashes": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "image_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "sha256": {
+ "type": "string",
+ "pattern": "^[a-f0-9]{64}$"
+ }
+ },
+ "required": [
+ "image_id",
+ "sha256"
+ ],
+ "additionalProperties": false
+ },
+ "minItems": 1,
+ "maxItems": 3
+ },
+ "model_bundle_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "model_checksum": {
+ "type": "string",
+ "pattern": "^[a-f0-9]{64}$"
+ },
+ "dictionary_version_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "dictionary_sha256": {
+ "type": "string",
+ "pattern": "^[a-f0-9]{64}$"
+ },
+ "pipeline_version": {
+ "type": "string",
+ "enum": [
+ "vision-v1"
+ ]
+ },
+ "outcome": {
+ "type": "string",
+ "enum": [
+ "facts_ready",
+ "needs_retake",
+ "needs_review"
+ ]
+ },
+ "quality": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Quality"
+ },
+ "minItems": 1,
+ "maxItems": 3
+ },
+ "detections": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Detection"
+ },
+ "minItems": 0,
+ "maxItems": 100
+ },
+ "crops": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/CropRecipe"
+ },
+ "minItems": 0,
+ "maxItems": 100
+ },
+ "ocr_fields": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/OCRField"
+ },
+ "minItems": 0,
+ "maxItems": 500
+ },
+ "entities": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/EntityCandidates"
+ },
+ "minItems": 0,
+ "maxItems": 100
+ },
+ "relations": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Relation"
+ },
+ "minItems": 0,
+ "maxItems": 200
+ },
+ "timing_ms": {
+ "$ref": "#/components/schemas/Timing"
+ }
+ },
+ "required": [
+ "run_id",
+ "attempt_id",
+ "fencing_token",
+ "tenant_id",
+ "request_hash",
+ "input_hashes",
+ "model_bundle_id",
+ "model_checksum",
+ "dictionary_version_id",
+ "dictionary_sha256",
+ "pipeline_version",
+ "outcome",
+ "quality",
+ "detections",
+ "crops",
+ "ocr_fields",
+ "entities",
+ "relations",
+ "timing_ms"
+ ],
+ "additionalProperties": false
+ },
+ "Health": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "enum": [
+ "alive",
+ "ready",
+ "not_ready"
+ ]
+ },
+ "model_bundle_id": {
+ "anyOf": [
+ {
+ "type": "string",
+ "format": "uuid"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "active_attempt_id": {
+ "anyOf": [
+ {
+ "type": "string",
+ "format": "uuid"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "required": [
+ "status",
+ "model_bundle_id",
+ "active_attempt_id"
+ ],
+ "additionalProperties": false
+ },
+ "Version": {
+ "type": "object",
+ "properties": {
+ "service_commit": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 40
+ },
+ "pipeline_version": {
+ "type": "string",
+ "enum": [
+ "vision-v1"
+ ]
+ },
+ "model_bundle_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "dictionary_version_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "device_profile": {
+ "type": "string",
+ "enum": [
+ "cpu",
+ "cuda"
+ ]
+ },
+ "purpose": {
+ "type": "string",
+ "enum": [
+ "development",
+ "evaluation",
+ "production"
+ ]
+ },
+ "is_simulated": {
+ "type": "boolean"
+ },
+ "adapter_id": {
+ "type": "string",
+ "enum": [
+ "dfine-n4-rgb-stretch-v1",
+ "fixture-v1"
+ ]
+ },
+ "runtime_profile": {
+ "type": "string",
+ "enum": [
+ "dfine-cpu-fp32-ocrv4cpu-v1",
+ "dfine-cuda-fp32-ocrv4cpu-v1",
+ "fixture-v1"
+ ]
+ },
+ "runtime_lock_sha256": {
+ "type": "string",
+ "pattern": "^[a-f0-9]{64}$"
+ },
+ "detector_device": {
+ "type": "string",
+ "enum": [
+ "cpu",
+ "cuda",
+ "mock"
+ ]
+ },
+ "ocr_device": {
+ "type": "string",
+ "enum": [
+ "cpu",
+ "mock"
+ ]
+ }
+ },
+ "required": [
+ "service_commit",
+ "pipeline_version",
+ "model_bundle_id",
+ "dictionary_version_id",
+ "device_profile",
+ "purpose",
+ "is_simulated",
+ "adapter_id",
+ "runtime_profile",
+ "runtime_lock_sha256",
+ "detector_device",
+ "ocr_device"
+ ],
+ "additionalProperties": false
+ }
+ }
+ },
+ "x-error-codes": {
+ "VALIDATION_ERROR": {
+ "http_status": 422,
+ "retryable": false
+ },
+ "UNAUTHENTICATED": {
+ "http_status": 401,
+ "retryable": false
+ },
+ "FORBIDDEN": {
+ "http_status": 403,
+ "retryable": false
+ },
+ "NOT_FOUND": {
+ "http_status": 404,
+ "retryable": false
+ },
+ "STATE_CONFLICT": {
+ "http_status": 409,
+ "retryable": false
+ },
+ "VERSION_CONFLICT": {
+ "http_status": 409,
+ "retryable": false
+ },
+ "IDEMPOTENCY_CONFLICT": {
+ "http_status": 409,
+ "retryable": false
+ },
+ "REQUEST_IN_PROGRESS": {
+ "http_status": 409,
+ "retryable": true
+ },
+ "RATE_LIMITED": {
+ "http_status": 429,
+ "retryable": true
+ },
+ "IMAGE_TOO_LARGE": {
+ "http_status": 413,
+ "retryable": false
+ },
+ "UNSUPPORTED_MEDIA_TYPE": {
+ "http_status": 415,
+ "retryable": false
+ },
+ "IMAGE_INVALID": {
+ "http_status": 422,
+ "retryable": false
+ },
+ "UNAUTHORIZED_REF": {
+ "http_status": 403,
+ "retryable": false
+ },
+ "OBJECT_NOT_FOUND": {
+ "http_status": 404,
+ "retryable": false
+ },
+ "HASH_MISMATCH": {
+ "http_status": 422,
+ "retryable": false
+ },
+ "MODEL_NOT_READY": {
+ "http_status": 503,
+ "retryable": true
+ },
+ "MODEL_VERSION_UNAVAILABLE": {
+ "http_status": 409,
+ "retryable": false
+ },
+ "AI_BUSY": {
+ "http_status": 429,
+ "retryable": true
+ },
+ "RUN_IN_PROGRESS": {
+ "http_status": 409,
+ "retryable": true
+ },
+ "AI_TIMEOUT": {
+ "http_status": 504,
+ "retryable": true
+ },
+ "STAGE_TIMEOUT": {
+ "http_status": 504,
+ "retryable": true
+ },
+ "MODEL_OOM": {
+ "http_status": 503,
+ "retryable": true
+ },
+ "DEPENDENCY_UNAVAILABLE": {
+ "http_status": 503,
+ "retryable": true
+ },
+ "MODEL_ERROR": {
+ "http_status": 500,
+ "retryable": false
+ },
+ "INTERNAL_ERROR": {
+ "http_status": 500,
+ "retryable": false
+ },
+ "SCHEMA_MISMATCH": {
+ "http_status": 502,
+ "retryable": false
+ },
+ "RULESET_INVALID": {
+ "http_status": 422,
+ "retryable": false
+ },
+ "LEASE_LOST": {
+ "http_status": 409,
+ "retryable": false
+ }
+ }
+}
diff --git a/packages/inference_protocol/contract.py b/packages/inference_protocol/contract.py
new file mode 100644
index 0000000..09d5ef5
--- /dev/null
+++ b/packages/inference_protocol/contract.py
@@ -0,0 +1,36 @@
+"""Validate directly against generated schemas; never duplicate wire fields."""
+
+import json
+from functools import lru_cache
+from importlib.resources import files
+from typing import Any, Self
+
+from jsonschema import Draft202012Validator, FormatChecker
+from pydantic import RootModel, model_validator
+
+DOCUMENT = json.loads(files(__package__).joinpath("contract.json").read_text(encoding="utf-8"))
+FORMAT_CHECKER = FormatChecker()
+if "date-time" not in FORMAT_CHECKER.checkers:
+ raise RuntimeError("Install rfc3339-validator: timestamp validation must not be skipped")
+
+
+@lru_cache
+def validator(name: str) -> Draft202012Validator:
+ schema = {
+ "$ref": f"#/components/schemas/{name}",
+ "components": DOCUMENT["components"],
+ }
+ return Draft202012Validator(schema, format_checker=FORMAT_CHECKER)
+
+
+def validate(name: str, value: Any) -> None:
+ # Avoid leaking image refs, tenant IDs or raw request content in error messages.
+ if next(validator(name).iter_errors(value), None) is not None:
+ raise ValueError(f"Payload does not satisfy {name}")
+
+
+class InferenceRequest(RootModel[dict[str, Any]]):
+ @model_validator(mode="after")
+ def validate_contract(self) -> Self:
+ validate("InferenceRequest", self.root)
+ return self
diff --git a/packages/inference_protocol/hashing.py b/packages/inference_protocol/hashing.py
new file mode 100644
index 0000000..8f39667
--- /dev/null
+++ b/packages/inference_protocol/hashing.py
@@ -0,0 +1,34 @@
+"""Canonical JSON and request identity shared by producer and consumer."""
+
+import hashlib
+import json
+from typing import Any
+
+REQUEST_HASH_FIELDS = (
+ "tenant_id",
+ "laboratory_id",
+ "item_id",
+ "run_id",
+ "submission_revision",
+ "model_bundle_id",
+ "model_checksum",
+ "dictionary_version_id",
+ "dictionary_sha256",
+ "pipeline_version",
+ "device_profile",
+ "image_refs",
+)
+
+
+def canonical_json(value: Any) -> str:
+ return json.dumps(
+ value, ensure_ascii=False, allow_nan=False, sort_keys=True, separators=(",", ":")
+ )
+
+
+def sha256_json(value: Any) -> str:
+ return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
+
+
+def request_hash(payload: dict[str, Any]) -> str:
+ return sha256_json({name: payload[name] for name in REQUEST_HASH_FIELDS})
diff --git a/packages/persistence/__init__.py b/packages/persistence/__init__.py
new file mode 100644
index 0000000..536ba8f
--- /dev/null
+++ b/packages/persistence/__init__.py
@@ -0,0 +1 @@
+"""Persistence adapter package placeholder."""
diff --git a/packages/rules/__init__.py b/packages/rules/__init__.py
new file mode 100644
index 0000000..e032c24
--- /dev/null
+++ b/packages/rules/__init__.py
@@ -0,0 +1 @@
+"""Pure rule evaluation package placeholder."""
diff --git a/packages/shared/__init__.py b/packages/shared/__init__.py
new file mode 100644
index 0000000..d17fb4a
--- /dev/null
+++ b/packages/shared/__init__.py
@@ -0,0 +1 @@
+"""Cross-process utilities that do not contain business state."""
diff --git a/packages/shared/environment.py b/packages/shared/environment.py
new file mode 100644
index 0000000..3de4954
--- /dev/null
+++ b/packages/shared/environment.py
@@ -0,0 +1,13 @@
+"""Business-process startup guard; this milestone never enables production."""
+
+import os
+import sys
+
+
+def development_environment() -> str:
+ if sys.version_info[:2] != (3, 11):
+ raise RuntimeError("LabSafe I-01A requires Python 3.11")
+ environment = os.environ.get("APP_ENV")
+ if environment not in {"dev", "test"}:
+ raise RuntimeError("I-01A requires explicit APP_ENV=dev or test; production is disabled")
+ return environment
diff --git a/packages/shared/json_hash.py b/packages/shared/json_hash.py
new file mode 100644
index 0000000..548e52d
--- /dev/null
+++ b/packages/shared/json_hash.py
@@ -0,0 +1,5 @@
+"""Compatibility imports; canonical serialization has one implementation."""
+
+from packages.inference_protocol.hashing import canonical_json, sha256_json
+
+__all__ = ["canonical_json", "sha256_json"]
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..5245c8b
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,60 @@
+[build-system]
+requires = ["setuptools>=68", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "labsafe"
+version = "0.1.0.dev0"
+description = "LabSafe inspection and evidence workflow"
+requires-python = ">=3.11,<3.12"
+readme = "DEVELOPMENT.md"
+dependencies = []
+
+[project.optional-dependencies]
+ai = [
+ "fastapi==0.115.6",
+ "pydantic==2.10.5",
+ "uvicorn[standard]==0.34.0",
+ "PyYAML==6.0.2",
+ "jsonschema==4.23.0",
+ "rfc3339-validator==0.1.4",
+]
+api = [
+ "fastapi==0.115.6",
+ "pydantic==2.10.5",
+ "uvicorn[standard]==0.34.0",
+]
+worker = [
+ "celery==5.4.0",
+ "redis==5.2.1",
+]
+persistence = [
+ "alembic==1.14.1",
+ "sqlalchemy==2.0.37",
+ "pymysql==1.1.1",
+]
+dev = [
+ "pytest==8.3.4",
+ "ruff==0.9.3",
+ "mypy==1.14.1",
+]
+
+[tool.setuptools.packages.find]
+where = ["."]
+include = ["apps*", "packages*"]
+namespaces = false
+
+[tool.setuptools.package-data]
+"packages.inference_protocol" = ["contract.json"]
+"apps.ai_inference" = ["fixtures/*.json"]
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+addopts = "-ra"
+
+[tool.ruff]
+line-length = 100
+target-version = "py311"
+
+[tool.ruff.lint]
+select = ["E", "F", "I"]
diff --git a/requirements/common.txt b/requirements/common.txt
new file mode 100644
index 0000000..9cfb5da
--- /dev/null
+++ b/requirements/common.txt
@@ -0,0 +1,7 @@
+# Direct development constraints, not a production transitive runtime lock.
+fastapi==0.115.6
+pydantic==2.10.5
+uvicorn[standard]==0.34.0
+PyYAML==6.0.2
+jsonschema==4.23.0
+rfc3339-validator==0.1.4
diff --git a/requirements/dev-ai.txt b/requirements/dev-ai.txt
new file mode 100644
index 0000000..3a92659
--- /dev/null
+++ b/requirements/dev-ai.txt
@@ -0,0 +1,2 @@
+-r py311-ai.txt
+-r test.txt
diff --git a/requirements/dev.txt b/requirements/dev.txt
new file mode 100644
index 0000000..2049333
--- /dev/null
+++ b/requirements/dev.txt
@@ -0,0 +1,2 @@
+-r py311-business.txt
+-r test.txt
diff --git a/requirements/py311-ai.txt b/requirements/py311-ai.txt
new file mode 100644
index 0000000..1f93377
--- /dev/null
+++ b/requirements/py311-ai.txt
@@ -0,0 +1,2 @@
+# Isolated Python 3.11 AI service; no Celery, Redis or business ORM.
+-r common.txt
diff --git a/requirements/py311-business.txt b/requirements/py311-business.txt
new file mode 100644
index 0000000..7200597
--- /dev/null
+++ b/requirements/py311-business.txt
@@ -0,0 +1,7 @@
+# API, worker and persistence constraints. All services use Python 3.11.
+-r common.txt
+celery==5.4.0
+redis==5.2.1
+alembic==1.14.1
+sqlalchemy==2.0.37
+pymysql==1.1.1
diff --git a/requirements/test.txt b/requirements/test.txt
new file mode 100644
index 0000000..9f62f98
--- /dev/null
+++ b/requirements/test.txt
@@ -0,0 +1,3 @@
+pytest==8.3.4
+ruff==0.9.3
+httpx==0.28.1
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..fe24853
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1 @@
+"""I-01A smoke tests."""
diff --git a/tests/ai/__init__.py b/tests/ai/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/tests/ai/__init__.py
@@ -0,0 +1 @@
+
diff --git a/tests/ai/test_fixture.py b/tests/ai/test_fixture.py
new file mode 100644
index 0000000..139da86
--- /dev/null
+++ b/tests/ai/test_fixture.py
@@ -0,0 +1,320 @@
+import asyncio
+import copy
+import hashlib
+import os
+import subprocess
+import sys
+import unittest
+from importlib.resources import files
+from pathlib import Path
+from unittest.mock import patch
+from uuid import uuid4
+
+import httpx
+from fastapi.testclient import TestClient
+
+from apps.ai_inference.app.main import create_app
+from apps.ai_inference.app.settings import Settings
+from packages.inference_protocol.contract import DOCUMENT, validate
+from packages.inference_protocol.hashing import request_hash
+from tests.helpers import configured, http_process, make_request
+
+BASE = "/internal/inference/v1"
+
+
+class FixtureTests(unittest.TestCase):
+ def test_authenticated_health_ready_version_follow_contract(self):
+ with configured() as token:
+ settings = Settings.from_env()
+ app = create_app(settings)
+ self.assertEqual(app.openapi(), DOCUMENT)
+ with TestClient(app) as client:
+ for route, schema in (
+ ("health", "Health"),
+ ("ready", "Health"),
+ ("version", "Version"),
+ ):
+ response = client.get(
+ f"{BASE}/{route}", headers={"Authorization": f"Bearer {token}"}
+ )
+ self.assertEqual(response.status_code, 200)
+ validate(schema, response.json())
+ identity = client.get(
+ f"{BASE}/version", headers={"Authorization": f"Bearer {token}"}
+ ).json()
+ self.assertTrue(identity["is_simulated"])
+ self.assertEqual(identity["purpose"], "development")
+ self.assertEqual(identity["detector_device"], "mock")
+ lock = (
+ files("apps.ai_inference").joinpath("fixtures/runtime-lock.json").read_bytes()
+ )
+ self.assertEqual(identity["runtime_lock_sha256"], hashlib.sha256(lock).hexdigest())
+
+ def test_all_contract_routes_require_token(self):
+ with configured():
+ with TestClient(create_app()) as client:
+ for path, operation in DOCUMENT["paths"].items():
+ for method in operation:
+ for headers in ({}, {"Authorization": "Bearer incorrect"}):
+ response = client.request(method, BASE + path, headers=headers, json={})
+ self.assertEqual(response.status_code, 401, response.text)
+ validate("Error", response.json())
+ self.assertNotIn("incorrect", response.text)
+ self.assertFalse(response.json()["error"]["retryable"])
+ self.assertEqual(client.post(f"{BASE}/fixture", json={}).status_code, 404)
+ self.assertEqual(client.get("/docs").status_code, 404)
+
+ def test_missing_lifespan_is_not_ready(self):
+ with configured() as token:
+ # Without context manager, TestClient does not enter ASGI lifespan.
+ client = TestClient(create_app())
+ try:
+ response = client.get(f"{BASE}/ready", headers={"Authorization": f"Bearer {token}"})
+ self.assertEqual(response.status_code, 503)
+ validate("Error", response.json())
+ finally:
+ client.close()
+
+ def test_quality_and_runs_echo_identity_without_false_safe_result(self):
+ with configured() as token:
+ settings = Settings.from_env()
+ payload = make_request(settings)
+ with TestClient(create_app(settings)) as client:
+ for stage, expected in (("quality", "facts_ready"), ("runs", "needs_review")):
+ response = client.post(
+ f"{BASE}/{stage}",
+ json=payload,
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ self.assertEqual(response.status_code, 200, response.text)
+ result = response.json()
+ validate("InferenceResult", result)
+ self.assertEqual(result["outcome"], expected)
+ for key in ("run_id", "attempt_id", "fencing_token", "request_hash"):
+ self.assertEqual(result[key], payload[key])
+ self.assertEqual(result["detections"], [])
+ self.assertEqual(
+ result["quality"][0]["image_id"], payload["image_refs"][0]["image_id"]
+ )
+
+ def test_retake_and_model_error_branches(self):
+ for scenario, status in (("needs_retake", 200), ("error", 500)):
+ with self.subTest(scenario=scenario), configured(scenario) as token:
+ settings = Settings.from_env()
+ with TestClient(create_app(settings)) as client:
+ for stage in ("quality", "runs"):
+ response = client.post(
+ f"{BASE}/{stage}",
+ json=make_request(settings),
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ self.assertEqual(response.status_code, status)
+ validate("InferenceResult" if status == 200 else "Error", response.json())
+ if status == 200:
+ self.assertEqual(response.json()["outcome"], "needs_retake")
+ self.assertEqual(response.json()["quality"][0]["reasons"], ["blur"])
+ else:
+ self.assertEqual(response.json()["error"]["code"], "MODEL_ERROR")
+
+ def test_schema_errors_do_not_echo_input(self):
+ with configured() as token:
+ settings = Settings.from_env()
+ valid = make_request(settings)
+ invalid = [
+ ({}, None),
+ ({**valid, "unknown": "sensitive-value"}, None),
+ ({**valid, "fencing_token": True}, None),
+ ({**valid, "run_id": "not-a-uuid"}, None),
+ ({**valid, "deadline_at": "not-a-date"}, None),
+ ]
+ with TestClient(create_app(settings)) as client:
+ for payload, _ in invalid:
+ response = client.post(
+ f"{BASE}/runs", json=payload, headers={"Authorization": f"Bearer {token}"}
+ )
+ self.assertEqual(response.status_code, 422)
+ validate("Error", response.json())
+ self.assertNotIn("sensitive-value", response.text)
+ response = client.post(
+ f"{BASE}/runs",
+ content="{broken",
+ headers={
+ "Authorization": f"Bearer {token}",
+ "Content-Type": "application/json",
+ },
+ )
+ self.assertEqual(response.status_code, 422)
+ validate("Error", response.json())
+
+ def test_semantic_validation(self):
+ with configured() as token:
+ settings = Settings.from_env()
+ mutations = [
+ ("tenant_id", str(uuid4()), "FORBIDDEN"),
+ ("model_bundle_id", str(uuid4()), "MODEL_VERSION_UNAVAILABLE"),
+ ("dictionary_version_id", str(uuid4()), "MODEL_VERSION_UNAVAILABLE"),
+ ("device_profile", "cuda", "MODEL_VERSION_UNAVAILABLE"),
+ ("model_checksum", "0" * 64, "HASH_MISMATCH"),
+ ("dictionary_sha256", "0" * 64, "HASH_MISMATCH"),
+ ("request_hash", "0" * 64, "HASH_MISMATCH"),
+ ]
+ with TestClient(create_app(settings)) as client:
+ for key, value, expected in mutations:
+ payload = make_request(settings)
+ payload[key] = value
+ response = client.post(
+ f"{BASE}/runs", json=payload, headers={"Authorization": f"Bearer {token}"}
+ )
+ self.assertEqual(response.json()["error"]["code"], expected)
+ validate("Error", response.json())
+ for field, value, expected in (
+ ("object_key", "https://example.invalid/remote", "UNAUTHORIZED_REF"),
+ ("parent_image_id", str(uuid4()), "VALIDATION_ERROR"),
+ ("role", "detail", "VALIDATION_ERROR"),
+ ("mime_type", "image/jpeg", "VALIDATION_ERROR"),
+ ):
+ payload = make_request(settings)
+ payload["image_refs"][0][field] = value
+ payload["request_hash"] = request_hash(payload)
+ response = client.post(
+ f"{BASE}/runs", json=payload, headers={"Authorization": f"Bearer {token}"}
+ )
+ self.assertEqual(response.json()["error"]["code"], expected)
+
+ def test_timeout_releases_capacity(self):
+ with configured(delay=100) as token:
+ settings = Settings.from_env()
+ with TestClient(create_app(settings)) as client:
+ for seconds in (-1, 0.03):
+ response = client.post(
+ f"{BASE}/runs",
+ json=make_request(settings, seconds=seconds),
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ self.assertEqual(response.status_code, 504)
+ self.assertTrue(response.json()["error"]["retryable"])
+ response = client.post(
+ f"{BASE}/runs",
+ json=make_request(settings),
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ self.assertEqual(response.status_code, 200)
+ status = client.get(f"{BASE}/health", headers={"Authorization": f"Bearer {token}"})
+ self.assertIsNone(status.json()["active_attempt_id"])
+
+ def test_single_inflight_and_duplicate_attempt(self):
+ async def check():
+ with configured(delay=400) as token:
+ settings = Settings.from_env()
+ app = create_app(settings)
+ async with app.router.lifespan_context(app):
+ async with httpx.AsyncClient(
+ transport=httpx.ASGITransport(app),
+ base_url="http://fixture",
+ headers={"Authorization": f"Bearer {token}"},
+ ) as client:
+ payload = make_request(settings)
+ first = asyncio.create_task(client.post(f"{BASE}/runs", json=payload))
+ try:
+ for _ in range(100):
+ health = await client.get(f"{BASE}/health")
+ if health.json()["active_attempt_id"] == payload["attempt_id"]:
+ break
+ await asyncio.sleep(0.002)
+ else:
+ self.fail("First attempt did not enter the gate")
+ duplicate = await client.post(f"{BASE}/quality", json=payload)
+ self.assertEqual(duplicate.status_code, 409)
+ self.assertEqual(duplicate.json()["error"]["code"], "RUN_IN_PROGRESS")
+ other = copy.deepcopy(payload)
+ other["attempt_id"] = str(uuid4())
+ busy = await client.post(f"{BASE}/runs", json=other)
+ self.assertEqual(busy.status_code, 429)
+ self.assertEqual(busy.json()["error"]["code"], "AI_BUSY")
+ self.assertEqual((await client.get(f"{BASE}/ready")).status_code, 200)
+ self.assertEqual((await first).status_code, 200)
+ finally:
+ if not first.done():
+ first.cancel()
+ await asyncio.gather(first, return_exceptions=True)
+
+ asyncio.run(check())
+
+ def test_invalid_configuration_is_rejected(self):
+ for name, value in (
+ ("APP_ENV", "production"),
+ ("APP_ENV", ""),
+ ("APP_ENV", "staging"),
+ ("AI_MODE", "real"),
+ ("AI_MODE", "fixture"),
+ ("AI_MODE", ""),
+ ("AI_ALLOWED_TENANTS", "*"),
+ ("AI_ALLOWED_TENANTS", ""),
+ ("AI_MAX_INFLIGHT", "2"),
+ ("AI_FIXTURE_DELAY_MS", "NaN"),
+ ("AI_FIXTURE_DELAY_MS", "-1"),
+ ("AI_FIXTURE_DELAY_MS", "10001"),
+ ("AI_FIXTURE_SCENARIO", "unknown"),
+ ("AI_TOKEN_FILE", ""),
+ ):
+ with self.subTest(name=name, value=value), configured():
+ with patch.dict(os.environ, {name: value}):
+ with self.assertRaises(RuntimeError):
+ create_app()
+ with configured():
+ Path(os.environ["AI_TOKEN_FILE"]).write_text("too-short", encoding="ascii")
+ with self.assertRaises(RuntimeError):
+ create_app()
+
+ def test_runtime_drift_is_rejected(self):
+ with configured(), patch("apps.ai_inference.app.settings.version", return_value="0.0"):
+ with self.assertRaisesRegex(RuntimeError, "dependency mismatch"):
+ create_app()
+
+ def test_rfc3339_lowercase_timestamp_and_timeout_scenario(self):
+ for scenario in ("no_targets", "timeout"):
+ with self.subTest(scenario=scenario), configured(scenario) as token:
+ settings = Settings.from_env()
+ payload = make_request(settings, seconds=0.2 if scenario == "timeout" else 10)
+ payload["deadline_at"] = payload["deadline_at"].replace("+00:00", "z").lower()
+ with TestClient(create_app(settings)) as client:
+ response = client.post(
+ f"{BASE}/runs",
+ json=payload,
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ self.assertEqual(response.status_code, 504 if scenario == "timeout" else 200)
+
+ def test_standalone_ai_http_process(self):
+ with configured() as token:
+ settings = Settings.from_env()
+ headers = {"Authorization": f"Bearer {token}"}
+ with http_process(
+ "apps.ai_inference.run", "AI_INFERENCE_PORT", f"{BASE}/ready", headers=headers
+ ) as client:
+ result = client.post(f"{BASE}/runs", json=make_request(settings), headers=headers)
+ self.assertEqual(result.status_code, 200)
+ validate("InferenceResult", result.json())
+
+ def test_production_cli_fails(self):
+ with configured(), patch.dict(os.environ, {"APP_ENV": "production"}):
+ result = subprocess.run(
+ [sys.executable, "-B", "-m", "apps.ai_inference.run"],
+ capture_output=True,
+ text=True,
+ timeout=15,
+ )
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn("production is disabled", result.stderr)
+
+ def test_ai_import_does_not_load_business_or_model_dependencies(self):
+ code = (
+ "import sys; import apps.ai_inference.app.main; "
+ "names=('celery','redis','sqlalchemy','torch','paddle','onnxruntime',"
+ "'packages.domain','packages.application','packages.persistence','packages.rules'); "
+ "assert not any(n in sys.modules for n in names)"
+ )
+ result = subprocess.run(
+ [sys.executable, "-B", "-c", code], capture_output=True, text=True, timeout=15
+ )
+ self.assertEqual(result.returncode, 0, result.stderr)
diff --git a/tests/business/__init__.py b/tests/business/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/tests/business/__init__.py
@@ -0,0 +1 @@
+
diff --git a/tests/business/test_bootstrap.py b/tests/business/test_bootstrap.py
new file mode 100644
index 0000000..f020546
--- /dev/null
+++ b/tests/business/test_bootstrap.py
@@ -0,0 +1,77 @@
+import os
+import subprocess
+import sys
+import unittest
+from unittest.mock import patch
+
+from celery import Celery
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+from apps.api.app.main import create_app
+from apps.worker.app.main import create_celery_app, fixture_echo
+from tests.helpers import configured, http_process
+
+
+class BootstrapTests(unittest.TestCase):
+ def test_actual_api_and_development_readiness(self):
+ with configured():
+ app = create_app()
+ self.assertIsInstance(app, FastAPI)
+ with TestClient(app) as client:
+ self.assertEqual(client.get("/health").json()["status"], "alive")
+ result = client.get("/ready")
+ self.assertEqual(result.status_code, 200)
+ self.assertEqual(result.json()["environment"], "test")
+ self.assertIn("process only", result.json()["scope"])
+
+ def test_actual_celery_and_registered_smoke_task(self):
+ with configured():
+ app = create_celery_app()
+ self.assertIsInstance(app, Celery)
+ self.assertIn("labsafe.fixture.echo", app.tasks)
+ self.assertTrue(app.conf.task_acks_late)
+ self.assertEqual(app.conf.worker_prefetch_multiplier, 1)
+ self.assertEqual(
+ fixture_echo({"task": "smoke"}), {"fixture": True, "payload": {"task": "smoke"}}
+ )
+
+ def test_invalid_environments_are_rejected(self):
+ for environment in ("production", "staging", ""):
+ with self.subTest(environment=environment), configured():
+ with patch.dict(os.environ, {"APP_ENV": environment}):
+ for factory in (create_app, create_celery_app):
+ with self.assertRaises(RuntimeError):
+ factory()
+ with configured():
+ os.environ.pop("APP_ENV")
+ with self.assertRaises(RuntimeError):
+ create_app()
+
+ def test_missing_frameworks_do_not_fall_back(self):
+ for module in ("apps.api.app.main", "apps.worker.app.main", "apps.ai_inference.app.main"):
+ with self.subTest(module=module):
+ code = f"import sys; sys.path.insert(0, {os.getcwd()!r}); import {module}"
+ result = subprocess.run(
+ [sys.executable, "-I", "-S", "-B", "-c", code],
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn("ModuleNotFoundError", result.stderr)
+
+ def test_api_starts_as_an_independent_http_process(self):
+ with configured(), http_process("apps.api.run", "API_PORT", "/health") as client:
+ self.assertEqual(client.get("/ready").status_code, 200)
+
+ def test_worker_configuration_command(self):
+ with configured():
+ result = subprocess.run(
+ [sys.executable, "-B", "-m", "apps.worker.run", "--check"],
+ capture_output=True,
+ text=True,
+ timeout=15,
+ )
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertIn("broker connectivity not tested", result.stdout)
diff --git a/tests/helpers.py b/tests/helpers.py
new file mode 100644
index 0000000..f6a5a18
--- /dev/null
+++ b/tests/helpers.py
@@ -0,0 +1,119 @@
+"""Synthetic data and temporary secrets only; no external services."""
+
+import base64
+import hashlib
+import os
+import secrets
+import socket
+import subprocess
+import sys
+import tempfile
+import time
+from contextlib import contextmanager
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from unittest.mock import patch
+from uuid import uuid4
+
+import httpx
+
+from packages.inference_protocol.hashing import request_hash
+
+TENANT = "11111111-1111-4111-8111-111111111111"
+
+
+@contextmanager
+def configured(scenario="no_targets", delay=0):
+ with tempfile.TemporaryDirectory(prefix="labsafe-test-") as directory:
+ token = secrets.token_urlsafe(32)
+ token_file = Path(directory) / "token"
+ token_file.write_text(token, encoding="ascii")
+ values = {
+ "APP_ENV": "test",
+ "AI_MODE": "mock",
+ "AI_TOKEN_FILE": str(token_file),
+ "AI_ALLOWED_TENANTS": TENANT,
+ "AI_MAX_INFLIGHT": "1",
+ "AI_FIXTURE_SCENARIO": scenario,
+ "AI_FIXTURE_DELAY_MS": str(delay),
+ "SERVICE_COMMIT": "synthetic-test",
+ "PYTHONDONTWRITEBYTECODE": "1",
+ }
+ with patch.dict(os.environ, values):
+ yield token
+
+
+def make_request(settings, *, seconds=10):
+ image_id, lab_id, location_id = (str(uuid4()) for _ in range(3))
+ # A synthetic 1x1 PNG, not a laboratory image or a quality-evaluation sample.
+ png = base64.b64decode(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jWZkAAAAASUVORK5CYII="
+ )
+ image_hash = hashlib.sha256(png).hexdigest()
+ payload = {
+ "run_id": str(uuid4()),
+ "attempt_id": str(uuid4()),
+ "fencing_token": 1,
+ "tenant_id": TENANT,
+ "laboratory_id": lab_id,
+ "item_id": str(uuid4()),
+ "submission_revision": 1,
+ "model_bundle_id": settings.identity["model_bundle_id"],
+ "model_checksum": settings.model_checksum,
+ "dictionary_version_id": settings.identity["dictionary_version_id"],
+ "dictionary_sha256": settings.dictionary_sha256,
+ "pipeline_version": "vision-v1",
+ "device_profile": "cpu",
+ "deadline_at": (datetime.now(timezone.utc) + timedelta(seconds=seconds)).isoformat(),
+ "image_refs": [
+ {
+ "image_id": image_id,
+ "object_key": f"tenant/{TENANT}/lab/{lab_id}/analysis/{image_id}/{image_hash}.png",
+ "sha256": image_hash,
+ "mime_type": "image/png",
+ "role": "overview",
+ "location_id": location_id,
+ "parent_image_id": None,
+ }
+ ],
+ }
+ payload["request_hash"] = request_hash(payload)
+ return payload
+
+
+@contextmanager
+def http_process(module, port_variable, probe, *, headers=None):
+ with socket.socket() as sock:
+ sock.bind(("127.0.0.1", 0))
+ port = sock.getsockname()[1]
+ environment = {**os.environ, port_variable: str(port), "PYTHONDONTWRITEBYTECODE": "1"}
+ process = subprocess.Popen(
+ [sys.executable, "-B", "-m", module],
+ env=environment,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
+ )
+ try:
+ with httpx.Client(base_url=f"http://127.0.0.1:{port}", trust_env=False) as client:
+ deadline = time.monotonic() + 15
+ while time.monotonic() < deadline:
+ if process.poll() is not None:
+ raise AssertionError(f"Service exited: {process.communicate()[0]}")
+ try:
+ if client.get(probe, headers=headers, timeout=0.5).status_code == 200:
+ yield client
+ return
+ except httpx.TransportError:
+ pass
+ time.sleep(0.05)
+ raise AssertionError("HTTP process did not become ready")
+ finally:
+ if process.poll() is None:
+ process.terminate()
+ try:
+ process.communicate(timeout=5)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ process.communicate(timeout=5)
diff --git a/tests/protocol/__init__.py b/tests/protocol/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/tests/protocol/__init__.py
@@ -0,0 +1 @@
+
diff --git a/tests/protocol/test_contract.py b/tests/protocol/test_contract.py
new file mode 100644
index 0000000..e6a215f
--- /dev/null
+++ b/tests/protocol/test_contract.py
@@ -0,0 +1,39 @@
+import json
+import math
+import unittest
+from importlib.resources import files
+
+from packages.inference_protocol.contract import DOCUMENT, InferenceRequest, validate
+from packages.inference_protocol.hashing import canonical_json, request_hash
+
+
+class ContractTests(unittest.TestCase):
+ def test_canonical_serialization(self):
+ self.assertEqual(canonical_json({"b": 1, "a": [True]}), '{"a":[true],"b":1}')
+ self.assertEqual(canonical_json({"x": "中文"}), '{"x":"中文"}')
+ with self.assertRaises(ValueError):
+ canonical_json({"x": math.nan})
+
+ def test_retry_identity_excludes_attempt_fencing_and_deadline(self):
+ from packages.inference_protocol.hashing import REQUEST_HASH_FIELDS
+
+ payload = {key: key for key in REQUEST_HASH_FIELDS}
+ original = request_hash(payload)
+ payload.update(attempt_id="retry", fencing_token=9, deadline_at="later")
+ self.assertEqual(original, request_hash(payload))
+ payload["submission_revision"] = "new"
+ self.assertNotEqual(original, request_hash(payload))
+
+ def test_generated_package_is_available_as_a_resource(self):
+ content = files("packages.inference_protocol").joinpath("contract.json").read_text("utf-8")
+ self.assertEqual(json.loads(content), DOCUMENT)
+ self.assertEqual(DOCUMENT["servers"][0]["url"], "/internal/inference/v1")
+ self.assertEqual(
+ set(DOCUMENT["paths"]), {"/health", "/ready", "/version", "/quality", "/runs"}
+ )
+
+ def test_empty_request_is_not_a_dto(self):
+ with self.assertRaises(ValueError):
+ InferenceRequest.model_validate({})
+ with self.assertRaises(ValueError):
+ validate("InferenceResult", {"facts": []})
diff --git a/tools/design/README.md b/tools/design/README.md
index c3cf9d9..efba874 100644
--- a/tools/design/README.md
+++ b/tools/design/README.md
@@ -25,7 +25,7 @@ python -m venv .venv-design
.\.venv-design\Scripts\python tools/design/validate_specs.py --report
~~~
-建议Python3.11/3.12独立环境。CI先安装依赖,再运行--check和validate;不要先自动生成再检查,否则掩盖未提交生成物漂移。虚拟环境、__pycache__和运行缓存不得提交。所有fixtures均模拟,无真实实验室图像或个人信息。
+使用Python3.11独立工具环境;应用与AI也统一3.11,但不与设计工具共用依赖环境。CI先安装依赖,再运行--check和validate;不要先自动生成再检查,否则掩盖未提交生成物漂移。虚拟环境、__pycache__和运行缓存不得提交。所有fixtures均模拟,无真实实验室图像或个人信息。
## 验证范围
diff --git a/tools/design/build_specs.py b/tools/design/build_specs.py
index 1ee1468..f561f93 100644
--- a/tools/design/build_specs.py
+++ b/tools/design/build_specs.py
@@ -208,6 +208,11 @@ def visit(v):
OUT['contracts/public-api-v1.yaml']=document('LabSafe 公共 API',PATHS,S,'/api/v1','cookieAuth')
OUT['contracts/operation-catalog.json']={'version':'1.1.0','operations':OPS}
OUT['contracts/error-codes.json']={k:{'http_status':v[0],'retryable':v[1]} for k,v in ERRORS.items()}
+
+# Package the exact inference contract for runtime validation, without runtime YAML/paths.
+runtime_contract=copy.deepcopy(OUT['contracts/inference-v1.yaml'])
+runtime_contract['x-error-codes']=copy.deepcopy(OUT['contracts/error-codes.json'])
+OUT['packages/inference_protocol/contract.json']=runtime_contract
def serialize(path,value):
return yaml.safe_dump(value,allow_unicode=True,sort_keys=False,width=110) if path.endswith('.yaml') else json.dumps(value,ensure_ascii=False,indent=2)+'\n'
def main():