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