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():