From 726786c70c35538b294dd4b2e954684f4dce428d Mon Sep 17 00:00:00 2001 From: SayQuan1 <15246906705@163.com> Date: Sat, 10 Oct 2026 20:46:06 +0800 Subject: [PATCH 1/3] feat(i-ml01): add versioned resident CPU inference and frozen OCR candidates Freeze analysis object versions and route official COCO80/PP-OCRv6_small CPU inference through a pinned resident HTTP process. Rebuild OCR crops in an isolated Worker, register all evidence with lease fencing, and recompute bottle associations, source fields and frozen dictionary candidates. Update generated contracts, migration head 0004, CI runtime isolation and delivery records for batches 36-41. Keep results in needs_review and retain unknown/conflicting evidence; development dictionaries contain no safety classes. Record real MinIO version/IAM checks without assets or secrets. Validation: pytest business/domain/security/protocol (1795); CPU/protocol unittest (75); Worker pixels (9); fixture/HTTP and isolated MySQL regression; ruff check/format, generated specs/design checks and environment pip checks. Rollback: drain fixed-identity tasks, restore the matched prior application, contracts, environments and bundle/lock/pin. Retain migration additions and historical evidence; use backup/forward repair, never destructive production downgrades or old-run version backfill. Base: 13a854f (PR #12). --- .env.example | 18 + .github/workflows/ci.yml | 17 +- DEVELOPMENT.md | 127 ++- apps/ai_inference/analysis.py | 281 +++++ apps/ai_inference/analysis_cpu.py | 78 ++ apps/ai_inference/app/capacity.py | 44 + apps/ai_inference/app/fixture.py | 37 +- apps/ai_inference/app/main.py | 79 +- apps/ai_inference/app/settings.py | 31 +- apps/ai_inference/bundle.py | 210 ++++ apps/ai_inference/cpu.py | 9 +- apps/ai_inference/inputs.py | 31 + apps/ai_inference/pipeline_cpu.py | 147 ++- apps/ai_inference/run.py | 19 +- apps/ai_inference/runtime_cpu.py | 152 +++ apps/ai_inference/supervisor.py | 219 ++++ apps/worker/evidence_child.py | 39 + apps/worker/inference_evidence.py | 106 ++ apps/worker/inference_pipeline.py | 3 +- contracts/data-model.json | 23 +- contracts/database-design.sql | 5 +- contracts/development-cpu-bundle-v1.json | 299 ++++++ contracts/development-cpu-lock-v1.json | 126 +++ contracts/development-dictionary-v1.json | 85 ++ contracts/inference-v1.yaml | 467 ++++++++- contracts/public-api-v1.yaml | 56 + docs/00-document-control.md | 12 + .../03-ai-inference-process.md | 46 +- docs/02-architecture/06-data-persistence.md | 6 + docs/03-interfaces/01-api-contract.md | 26 + docs/04-ai-rules/01-data-and-rules.md | 2 + .../03-fact-extraction-contract.md | 18 +- docs/08-delivery/01-implementation-plan.md | 151 ++- docs/08-delivery/02-traceability-matrix.md | 12 + docs/08-delivery/03-design-audit.md | 16 +- .../36-i-ml01-analysis-cpu-smoke.json | 988 ++++++++++++++++++ .../36-i-ml01-controlled-analysis-input.md | 55 + .../37-i-ml01-worker-evidence-smoke.json | 147 +++ .../37-i-ml01-worker-ocr-evidence.md | 42 + .../08-delivery/38-i-ml01-cpu-http-smoke.json | 41 + .../38-i-ml01-resident-cpu-http.md | 54 + .../39-i-ml01-text-association-smoke.json | 47 + .../39-i-ml01-text-bottle-association.md | 50 + .../40-i-ml01-ocr-fields-smoke.json | 109 ++ docs/08-delivery/40-i-ml01-ocr-fields.md | 58 + .../41-i-ml01-candidates-smoke.json | 89 ++ .../41-i-ml01-chemical-candidates.md | 65 ++ docs/08-delivery/41-i-ml01-minio-smoke.json | 171 +++ docs/08-delivery/README.md | 11 + .../design-validation-results.json | 16 +- docs/README.md | 8 +- packages/application/identity.py | 2 +- packages/application/inference_evidence.py | 71 ++ packages/application/inference_execution.py | 297 +++++- packages/domain/inference_evidence.py | 58 + packages/domain/inference_execution.py | 6 + packages/image_evidence/analysis.py | 44 + packages/inference_protocol/association.py | 148 +++ packages/inference_protocol/chemical.py | 184 ++++ packages/inference_protocol/contract.json | 723 ++++++++++++- packages/inference_protocol/evidence.py | 198 ++++ packages/inference_protocol/fields.py | 141 +++ packages/persistence/inference_execution.py | 47 +- packages/persistence/inference_runs.py | 28 +- .../versions/0003_run_image_version.py | 24 + .../migrations/versions/0004_ocr_evidence.py | 37 + packages/persistence/review.py | 5 +- packages/persistence/schema.py | 25 +- packages/storage/s3.py | 75 +- pyproject.toml | 9 + requirements/py311-worker-evidence.txt | 6 + tests/ai/test_analysis.py | 258 +++++ tests/ai/test_cpu_http.py | 208 ++++ tests/ai/test_pipeline_cpu.py | 73 +- tests/ai/test_supervisor.py | 282 +++++ tests/ai/test_text_association_pixels.py | 67 ++ tests/business/test_analysis_signature.py | 30 + tests/business/test_chemical_candidates.py | 338 ++++++ tests/business/test_cpu_bundle.py | 200 ++++ tests/business/test_identity_application.py | 12 +- tests/business/test_inference_evidence.py | 288 +++++ .../test_inference_evidence_storage.py | 127 +++ tests/business/test_inference_execution.py | 74 +- tests/business/test_inference_http.py | 189 ++++ tests/business/test_ocr_fields.py | 255 +++++ .../business/test_text_association_closure.py | 100 ++ .../business/test_text_bottle_association.py | 186 ++++ tests/evidence_helpers.py | 249 +++++ tests/helpers.py | 1 + tests/persistence/conftest.py | 62 +- .../test_inference_execution_mysql.py | 205 +++- .../test_inference_submit_mysql.py | 38 + tests/persistence/test_unit.py | 22 +- tests/worker/__init__.py | 1 + tests/worker/test_evidence_pixels.py | 332 ++++++ tools/design/data_model.py | 4 +- tools/design/extra_specs.py | 31 +- tools/design/release_specs.py | 1 + tools/design/validate_specs.py | 2 +- tools/ml/analysis_fixture.py | 118 +++ tools/ml/create_cpu_bundle.py | 114 ++ tools/ml/evidence_fixture.py | 99 ++ tools/ml/http_cpu_fixture.py | 67 ++ tools/ml/minio_fixture.py | 213 ++++ tools/ml/smoke_analysis_cpu.py | 119 +++ tools/ml/smoke_cpu_http.py | 394 +++++++ tools/ml/smoke_minio_cpu.py | 352 +++++++ tools/ml/smoke_worker_evidence.py | 219 ++++ 108 files changed, 12081 insertions(+), 325 deletions(-) create mode 100644 apps/ai_inference/analysis.py create mode 100644 apps/ai_inference/analysis_cpu.py create mode 100644 apps/ai_inference/app/capacity.py create mode 100644 apps/ai_inference/bundle.py create mode 100644 apps/ai_inference/inputs.py create mode 100644 apps/ai_inference/runtime_cpu.py create mode 100644 apps/ai_inference/supervisor.py create mode 100644 apps/worker/evidence_child.py create mode 100644 apps/worker/inference_evidence.py create mode 100644 contracts/development-cpu-bundle-v1.json create mode 100644 contracts/development-cpu-lock-v1.json create mode 100644 contracts/development-dictionary-v1.json create mode 100644 docs/08-delivery/36-i-ml01-analysis-cpu-smoke.json create mode 100644 docs/08-delivery/36-i-ml01-controlled-analysis-input.md create mode 100644 docs/08-delivery/37-i-ml01-worker-evidence-smoke.json create mode 100644 docs/08-delivery/37-i-ml01-worker-ocr-evidence.md create mode 100644 docs/08-delivery/38-i-ml01-cpu-http-smoke.json create mode 100644 docs/08-delivery/38-i-ml01-resident-cpu-http.md create mode 100644 docs/08-delivery/39-i-ml01-text-association-smoke.json create mode 100644 docs/08-delivery/39-i-ml01-text-bottle-association.md create mode 100644 docs/08-delivery/40-i-ml01-ocr-fields-smoke.json create mode 100644 docs/08-delivery/40-i-ml01-ocr-fields.md create mode 100644 docs/08-delivery/41-i-ml01-candidates-smoke.json create mode 100644 docs/08-delivery/41-i-ml01-chemical-candidates.md create mode 100644 docs/08-delivery/41-i-ml01-minio-smoke.json create mode 100644 packages/application/inference_evidence.py create mode 100644 packages/domain/inference_evidence.py create mode 100644 packages/image_evidence/analysis.py create mode 100644 packages/inference_protocol/association.py create mode 100644 packages/inference_protocol/chemical.py create mode 100644 packages/inference_protocol/evidence.py create mode 100644 packages/inference_protocol/fields.py create mode 100644 packages/persistence/migrations/versions/0003_run_image_version.py create mode 100644 packages/persistence/migrations/versions/0004_ocr_evidence.py create mode 100644 requirements/py311-worker-evidence.txt create mode 100644 tests/ai/test_analysis.py create mode 100644 tests/ai/test_cpu_http.py create mode 100644 tests/ai/test_supervisor.py create mode 100644 tests/ai/test_text_association_pixels.py create mode 100644 tests/business/test_analysis_signature.py create mode 100644 tests/business/test_chemical_candidates.py create mode 100644 tests/business/test_cpu_bundle.py create mode 100644 tests/business/test_inference_evidence.py create mode 100644 tests/business/test_inference_evidence_storage.py create mode 100644 tests/business/test_inference_http.py create mode 100644 tests/business/test_ocr_fields.py create mode 100644 tests/business/test_text_association_closure.py create mode 100644 tests/business/test_text_bottle_association.py create mode 100644 tests/evidence_helpers.py create mode 100644 tests/worker/__init__.py create mode 100644 tests/worker/test_evidence_pixels.py create mode 100644 tools/ml/analysis_fixture.py create mode 100644 tools/ml/create_cpu_bundle.py create mode 100644 tools/ml/evidence_fixture.py create mode 100644 tools/ml/http_cpu_fixture.py create mode 100644 tools/ml/minio_fixture.py create mode 100644 tools/ml/smoke_analysis_cpu.py create mode 100644 tools/ml/smoke_cpu_http.py create mode 100644 tools/ml/smoke_minio_cpu.py create mode 100644 tools/ml/smoke_worker_evidence.py diff --git a/.env.example b/.env.example index c79b8cf..00fbe76 100644 --- a/.env.example +++ b/.env.example @@ -39,8 +39,26 @@ S3_WORKER_SECRET_KEY_FILE=.local-secrets/s3-worker-secret-key AI_MODE=mock AI_TOKEN_FILE=.local-secrets/ai-token AI_ALLOWED_TENANTS=11111111-1111-4111-8111-111111111111 +# AI_MODE=cpu enables the resident development CPU HTTP service after pinned load/smoke. +# Generate these local files with tools.ml.create_cpu_bundle in the isolated AI environment. +# Absolute paths and explicit artifact roots (Windows semicolon; Unix colon) are required. +AI_CPU_BUNDLE_FILE= +AI_CPU_BUNDLE_SHA256= +AI_ARTIFACT_ROOTS= +# Worker pin contains only Version metadata; never pass model paths/credentials to requests. +AI_EXPECTED_VERSION_FILE= +# AI reads analysis versions only. +# Fixed labsafe-private bucket/us-east-1; explicit endpoint allowlist, no AWS provider chain. +# Plain HTTP is restricted to loopback; deployed stores require verified HTTPS. +AI_S3_ENDPOINT=http://127.0.0.1:9000 +AI_S3_ALLOWED_ENDPOINTS=http://127.0.0.1:9000 +AI_S3_ACCESS_KEY_FILE=.local-secrets/s3-ai-read-access-key +AI_S3_SECRET_KEY_FILE=.local-secrets/s3-ai-read-secret-key +# Grant only s3:GetObjectVersion on allowed tenant/lab analysis PNGs. No PUT/List/Delete. AI_MAX_INFLIGHT=1 AI_FIXTURE_SCENARIO=no_targets AI_FIXTURE_DELAY_MS=0 +# Independent CPU image/S3 Worker process; ORM and ONNX environments stay separate. +WORKER_EVIDENCE_PYTHON=.venv-evidence/Scripts/python.exe SERVICE_COMMIT=local-development VITE_APP_ENV=dev diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6ded4e..2be034e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,9 +66,24 @@ jobs: cache-dependency-path: requirements/*.txt - run: python -m pip install -r requirements/py311-ai-real.txt - run: python -m pip check - - run: python -m unittest tests.ai.test_dfine_adapter tests.ai.test_cpu_runner tests.ai.test_cpu_pixels tests.ai.test_ocrv6_adapter tests.ai.test_quality_pixels tests.ai.test_pipeline_cpu tests.ai.test_crop_evidence -v + - run: python -m unittest tests.ai.test_dfine_adapter tests.ai.test_cpu_runner tests.ai.test_cpu_pixels tests.ai.test_ocrv6_adapter tests.ai.test_quality_pixels tests.ai.test_pipeline_cpu tests.ai.test_crop_evidence tests.ai.test_analysis tests.ai.test_supervisor tests.ai.test_text_association_pixels -v # Official weights remain local; real-model smoke is recorded separately. + worker-evidence: + 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/py311-worker-evidence.txt + - run: python -m pip check + - name: Evidence runtime must not contain models or business infrastructure + run: python -c "import importlib.util; assert all(importlib.util.find_spec(n) is None for n in ['sqlalchemy','celery','redis','onnxruntime','torch','paddle'])" + - run: python -B -m unittest tests.worker.test_evidence_pixels -v + design-contracts: runs-on: ubuntu-latest steps: diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index b4398d6..665482e 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -200,7 +200,7 @@ Redis URL 必须指向操作者已准备好的专用开发实例;上例是变 登录 `POST /api/v1/auth/login` 发送 JSON `{tenant_code,username,password}` 与 Origin;后续请求携带服务端 session cookie。`GET /api/v1/me` 获取 CSRF;创建用户/禁用/退出还需单个 X-CSRF-Token、Idempotency-Key、Origin 和 application/json。具体字段、响应和错误使用 [公共契约](contracts/public-api-v1.yaml),不要将密码或 cookie 粘贴到日志/PR。 -本阶段不信任 X-Forwarded-For;反代后的 IP 限流按直接 peer 计数,不声称已识别真实终端 IP。身份及基础小请求暂限 64 KiB,模板创建为 2 MiB,以容纳 100 项完整 Unicode/转义正文;这不是未来上传/import 限额。/ready 只核对 `0002_report_object_version` 与 Redis ping;模式结构仍必须用 CLI verify,存储、Worker、完整业务流程和真实模型均未纳入该门禁。生产/远端数据库仍被拒绝。 +本阶段不信任 X-Forwarded-For;反代后的 IP 限流按直接 peer 计数,不声称已识别真实终端 IP。身份及基础小请求暂限 64 KiB,模板创建为 2 MiB,以容纳 100 项完整 Unicode/转义正文;这不是未来上传/import 限额。/ready 只核对 `0004_ocr_evidence` 与 Redis ping;模式结构仍必须用 CLI verify,存储、Worker、完整业务流程和真实模型均未纳入该门禁。生产/远端数据库仍被拒绝。 ### 3.7 组织→模板→巡检草稿(I-02D) @@ -445,6 +445,127 @@ crop = rebuild_ocr_evidence(pixels, metadata) 当前AI环境已有所需数值依赖;共享依赖独立锁在requirements/py311-image-evidence.txt,未来Worker启用需匹配图像运行环境,且重建SHA必须实际相同。未在业务环境安装NumPy/OpenCV,不改变HTTP fixture依赖隔离。真实三图/9区域跨进程重建和旋转验证见[验收记录](docs/08-delivery/35-i-ml01-rebuildable-ocr-evidence.md)。 +### 3.20 受控analysis准确对象版本输入 + +第36批提供可调用 `apps.ai_inference.analysis_cpu.run_analysis_pipeline(options, payload, settings)`,复用联合CPU质量/检测/OCR实现,不新建HTTP路由。options为PipelineOptions;payload为生成契约InferenceRequest;settings用 `AnalysisSettings.from_env()`读取独立AI只读secret和显式allowlist(见.env.example)。在Python主模块的 `if __name__ == '__main__':` 内调用,APP_ENV必须dev/test,device_profile必须cpu。模型目录仍由options显式提供并核制品摘要;请求的bundle字段仅校验结构/请求hash,不代表已经批准或激活该bundle,开发报告不回写业务结果。 + +每个image_ref必须含Worker提交时冻结的object_version;只读规范analysis路径,准确VersionId GET,核回包版本/大小/MIME/SHA和静态无元数据RGB PNG。保留Worker的image_id及顺序,不再做EXIF、RGB转换或resize。analysis字节上限128MiB与本地原图CLI的12MiB分开;单边10000/40MP一致。下载前重验租户、请求hash与overview/detail图片关系;不读取请求URL、代理环境、AWS credential chain或最新对象版本。 + +quality-only总预算≤10秒,联合≤180秒,与options及请求deadline取较小值;预算包含spawn/下载/解码/加载/推理。超时回收,无部分报告。输出 `cpu-pipeline-analysis-v1` development报告;仍需人工复核,HTTP仍mock,未接真实Worker RPC、瓶子关联、化学/日期事实或对象写入。 + +专用dev/test库先upgrade至head,再verify。0003仅追加run_images.analysis_object_version,初始快照与0002不变;ready要求新head。历史run的新列保持NULL,不用asset当前版本回填;旧run不可执行,授权用户重新提交后生成新的冻结输入,保留旧记录。升级Worker/AI必须同步内部新契约,旧无版本请求拒绝。回滚保留新增列和历史,只有明确确认的可丢弃test库允许downgrade。 + +真实模型工程smoke用合成图和loopback对象存储模拟器,无真实凭据,运行: + +~~~powershell +$env:APP_ENV = 'test' +.\.venv-ai\Scripts\python.exe -B -m tools.ml.smoke_analysis_cpu ` + --model C:/Users/12847/Desktop/model.onnx ` + --det-dir D:/LenovoSoftstore/Obsidian-Storage/PP-OCRv6_small_det_onnx ` + --rec-dir D:/LenovoSoftstore/Obsidian-Storage/PP-OCRv6_small_rec_onnx ` + --font C:/Windows/Fonts/msyh.ttc ` + --output docs/08-delivery/36-i-ml01-analysis-cpu-smoke.json +~~~ + +范围/测试/部署限制见[第36批验收](docs/08-delivery/36-i-ml01-controlled-analysis-input.md)。真实MinIO/IAM/TLS仍须单独验收。 + +### 3.21 独立OCR文字证据与Worker(第37批) + +第37批已接text_regions/crops闭包、固定源版本重建、准确D对象版本及围栏原子登记。仅dev/test,常驻真实CPU HTTP和受控bundle身份待第38批;HTTP默认仍为mock。迁移head为0004_ocr_evidence,历史0001–0003与旧证据保留,回滚策略见[交付](docs/08-delivery/37-i-ml01-worker-ocr-evidence.md)。 + +单独创建Worker证据环境,不向AI或业务ORM环境安装彼此依赖: + +~~~powershell +.venv-business/Scripts/python.exe -m venv .venv-evidence +.venv-evidence/Scripts/python.exe -m pip install -r requirements/py311-worker-evidence.txt +.venv-evidence/Scripts/python.exe -m pip check +.venv-evidence/Scripts/python.exe -B -m unittest tests.worker.test_evidence_pixels -v +$env:WORKER_EVIDENCE_PYTHON=(Resolve-Path .venv-evidence/Scripts/python.exe).Path +~~~ + +独立进程只需要numpy/Pillow/OpenCV、S3 SDK及协议验证依赖,无ORM/Celery/Redis/ONNX/Paddle/CUDA。Worker使用现有S3_ENDPOINT/S3_PUBLIC_ENDPOINT/PUBLIC_ORIGIN及专用S3_WORKER_ACCESS_KEY_FILE/SECRET_KEY_FILE,不使用AI只读凭据或API存储凭据。使用有版本的labsafe-private桶;源只读准确analysis VersionId、目标限定D路径。20秒包含启动到结果,取消/超时强制kill并回收;IPC输入≤2MiB、artifact输出≤256KiB,错误不输出SDK原文/凭据。任一crop失败仅可能留下未引用孤儿,数据库不部分成功;孤儿清理和crop下载另批实现。 + +真实模型→Worker工程smoke(合成图、loopback存储,不代表真实IAM/TLS或正式bundle批准): + +~~~powershell +$env:APP_ENV='test' +.venv-ai/Scripts/python.exe -B -m tools.ml.smoke_worker_evidence --model C:/Users/12847/Desktop/model.onnx --det-dir D:/LenovoSoftstore/Obsidian-Storage/PP-OCRv6_small_det_onnx --rec-dir D:/LenovoSoftstore/Obsidian-Storage/PP-OCRv6_small_rec_onnx --font C:/Windows/Fonts/msyh.ttc --evidence-python .venv-evidence/Scripts/python.exe --output docs/08-delivery/37-i-ml01-worker-evidence-smoke.json +~~~ + +两图/6区域真实ONNX、三个摘要重建、D准确版本及重放、第二crop失败已验证。数据库围栏与有旧数据004迁移由隔离MySQL用例单独验证;不将这些成绩当作完整生产端到端。 + +### 3.22 常驻真实CPU HTTP(第38批) + +AI_MODE=cpu仅dev/test,沿用官方80类D-FINE和PP-OCRv6_small CPU。使用现有独立.venv-ai真实CPU依赖,.venv-business不安装ORT/OpenCV;.venv-evidence仍独立。先生成本地不可变身份(空输出目录,不覆盖已有bundle): + +~~~powershell +.venv-ai/Scripts/python.exe -B -m tools.ml.create_cpu_bundle --output .local-secrets/cpu-bundle-38 --model C:/Users/12847/Desktop/model.onnx --det-dir D:/LenovoSoftstore/Obsidian-Storage/PP-OCRv6_small_det_onnx --rec-dir D:/LenovoSoftstore/Obsidian-Storage/PP-OCRv6_small_rec_onnx --commit local-development +~~~ + +输出实际bundle_file/bundle_sha256/artifact_roots/expected_version_file。在AI进程设置AI_MODE=cpu、AI_CPU_BUNDLE_FILE为输出绝对路径、AI_CPU_BUNDLE_SHA256为实际输出、AI_ARTIFACT_ROOTS为输出roots按Windows分号连接(Unix冒号),SERVICE_COMMIT须与生成参数一致;继续设置AI_TOKEN_FILE、AI_ALLOWED_TENANTS、AI_S3_ENDPOINT/AI_S3_ALLOWED_ENDPOINTS及独立只读secret,AI_MAX_INFLIGHT=1。启动: + +~~~powershell +.venv-ai/Scripts/python.exe -B -m apps.ai_inference.run +~~~ + +health/ready/version仍需Bearer token;启动120秒内核验、加载和smoke,ready成功后才接请求。version取实际加载身份,未ready时503。每实例固定一个bundle,拒绝请求切模型;繁忙429/同attempt409。失效至少30秒后重载,连续3次加载失败锁not_ready。代码/依赖/资产/配置变化需新lock/bundle与重启,保留旧环境才能恢复旧身份。生成的development bundle不代表完整ModelManifest审批、公共activation或多版本路由实现。 + +Worker设置AI_EXPECTED_VERSION_FILE为输出expected-version.json绝对路径(只含身份,不含权重路径),AI_INFERENCE_URL来自受控配置,token须匹配;每次RPC核ready/version与pin。将真实bundle/checksum/dictionary绑定到测试任务的既有固定输入,不能让fixture旧任务请求真实服务或伪造activation;公共模型配置完整导入/激活仍待后续实现。保留默认关闭的Worker开关,启用仍按既有dev/test运行指南;Worker沿用第37批独立证据环境与专用可写D凭据,AI只有analysis准确版本读权限。 + +正式HTTP工程smoke在业务环境编排,AI模型和Worker证据分别在独立环境执行。只用合成图/loopback模拟存储,专用测试host通过文件控制graceful stop,不给生产服务添加控制接口: + +~~~powershell +.venv-business/Scripts/python.exe -B -m tools.ml.smoke_cpu_http --ai-python .venv-ai/Scripts/python.exe --evidence-python .venv-evidence/Scripts/python.exe --model C:/Users/12847/Desktop/model.onnx --det-dir D:/LenovoSoftstore/Obsidian-Storage/PP-OCRv6_small_det_onnx --rec-dir D:/LenovoSoftstore/Obsidian-Storage/PP-OCRv6_small_rec_onnx --font C:/Windows/Fonts/msyh.ttc --output docs/08-delivery/38-i-ml01-cpu-http-smoke.json +~~~ + +smoke生成临时bundle并在结束后清理,只保留成绩摘要;重新运行会生成新UUID/SHA,不能把成绩内旧pin当作持久配置。运行回滚、验证与未覆盖范围见[第38批](docs/08-delivery/38-i-ml01-resident-cpu-http.md)。真实HTTP仍needs_review,不替代字段/词典/日期/容器事实或生产批准。 + +### 3.23 文字与真实bottle关联(第39批) + +联合CPU加入text-bottle-quad80-v1:同图COCO bottle(class39)、文字中心/实际quad交面积≥80%、唯一最小瓶框;无候选/并列NULL。不伪造label/shelf/cabinet,OCR原文/置信度、line/crop ID及三个摘要保留。Worker重算全部关联,包括NULL,错误框或伪关联在读取/写入存储前拒绝;runs仍needs_review,无化学/日期字段。 + +本地联合报告升级v3,非quality-only成功时bottle_association能力为true,其余语义仍false;独立OCR入口仍无关联。代码/契约摘要变化后,按3.22在**新空目录**生成bundle/lock并更新成套AI/Worker pin,排空旧固定身份任务后切换,不手改旧SHA。不需要新迁移,仍0004;历史结果不回填。 + +~~~powershell +.venv-business/Scripts/python.exe -B -m pytest tests/business/test_text_bottle_association.py tests/business/test_text_association_closure.py -q +.venv-ai/Scripts/python.exe -B -m unittest tests.ai.test_text_association_pixels tests.ai.test_pipeline_cpu -q +.venv-evidence/Scripts/python.exe -B -m unittest tests.worker.test_evidence_pixels -q +.venv-business/Scripts/python.exe -B -m tools.ml.smoke_cpu_http --ai-python .venv-ai/Scripts/python.exe --evidence-python .venv-evidence/Scripts/python.exe --model C:/Users/12847/Desktop/model.onnx --det-dir D:/LenovoSoftstore/Obsidian-Storage/PP-OCRv6_small_det_onnx --rec-dir D:/LenovoSoftstore/Obsidian-Storage/PP-OCRv6_small_rec_onnx --font C:/Windows/Fonts/msyh.ttc --output docs/08-delivery/39-i-ml01-text-association-smoke.json +~~~ + +第39批真实权重smoke为合成标签图,实际0瓶/6条NULL;正向关联使用显式合成检测框在几何、实际像素、HTTP和MySQL分别验证,不作为现场模型成绩。第40批已接字段词法/完整来源,见下节。 + +### 3.24 OCR字段词法与多行证据(第40批) + +联合CPU报告v4加入ocr-fields-v1,仅真实唯一bottle关联进入字段,按全局紧邻行解析;source_lines保留1–2行全部line/crop/原文/置信度,字段confidence取min。Worker在存储读取前重算完整字段,任何省略/伪值/坏引用均整批失败。严格日期/CAS只生成待复核字段,runs仍needs_review;化学词典/实体/日期事实后续接入。 + +AI/Worker/内部契约必须协调升级,在新空目录重新生成bundle/lock与成套pin;旧有跨行字段不得补猜来源,历史JSON不重写,迁移仍0004。回滚恢复成套旧代码/契约/身份/环境,保留已执行结果。 + +~~~powershell +.venv-business/Scripts/python.exe -B -m pytest tests/business/test_ocr_fields.py -q +.venv-business/Scripts/python.exe -B -m unittest tests.ai.test_cpu_http -q +.venv-evidence/Scripts/python.exe -B -m unittest tests.worker.test_evidence_pixels -q +.venv-business/Scripts/python.exe -B -m tools.ml.smoke_cpu_http --ai-python .venv-ai/Scripts/python.exe --evidence-python .venv-evidence/Scripts/python.exe --model C:/Users/12847/Desktop/model.onnx --det-dir D:/LenovoSoftstore/Obsidian-Storage/PP-OCRv6_small_det_onnx --rec-dir D:/LenovoSoftstore/Obsidian-Storage/PP-OCRv6_small_rec_onnx --font C:/Windows/Fonts/msyh.ttc --output test-results/ocr-fields-smoke.json +~~~ + +smoke可选 `--input <本地素材路径>`,转为临时去元数据RGB分析图,真实图片和像素结果不入Git。默认合成标签中NAME/Ethanol配对的正向字段probe使用明确synthetic瓶框、真实ONNX OCR/裁剪,标记is_simulated=true;不计模型检测成绩。用户素材工程探针和全部局限见[第40批](docs/08-delivery/40-i-ml01-ocr-fields.md)。下一批第41批先冻结开发化学词典,再接CAS/别名候选与同瓶名称冲突聚合。 + +### 3.25 冻结开发词典、名称候选与真实MinIO(第41批) + +第41批加入chemical-candidates-v1:CAS精确匹配、名称/别名精确及有界Levenshtein候选,同瓶全部name字段参与共识/冲突判断,再取top5。无前缀文字只接受冻结名称/别名整行精确匹配。开发词典最多100条、原始UTF-8≤64KiB,必填来源,不含安全分类;默认空词典,测试条目不得作为真实化学或安全数据。resolved结果也仍needs_review,日期聚合/事实快照尚待实现。 + +开发profile升级为dfine-cpu-fp32-ocrv6smallcpu-chemical-v2,联合报告v5,bundle新增entity_min=0.9。生成器可选 `--dictionary <冻结JSON路径>`;输入与输出均校验词典结构/名称/CAS/容量,生成器在新目录写入受控词典快照。沿用3.22的启动配置,在新空目录重建bundle/lock/expected-version与AI/Worker成套pin,不手改旧SHA。内部结果携带实际加载bundle/dictionary原始JSON的extraction_context;Worker核固定摘要和身份,用固定阈值/词典重算全部字段与候选。快照中路径只用于摘要验证,Worker不打开这些路径。迁移仍0004,历史结果不回填。 + +真实MinIO工程smoke使用业务环境编排、独立AI/证据环境执行: + +~~~powershell +.venv-business/Scripts/python.exe -B -m tools.ml.smoke_minio_cpu --minio D:/Tool/minio/bin/minio.exe --ai-python .venv-ai/Scripts/python.exe --evidence-python .venv-evidence/Scripts/python.exe --model C:/Users/12847/Desktop/model.onnx --det-dir D:/LenovoSoftstore/Obsidian-Storage/PP-OCRv6_small_det_onnx --rec-dir D:/LenovoSoftstore/Obsidian-Storage/PP-OCRv6_small_rec_onnx --font C:/Windows/Fonts/msyh.ttc --input <本地素材路径> --output test-results/minio-cpu-smoke.json +~~~ + +工具使用独立临时数据目录、随机loopback端口/凭据和有版本的labsafe-private测试桶,退出清理自己创建的目录/进程,不改安装目录的data或持久配置。AI只读同tenant/lab的analysis;Worker读analysis/derivatives、写derivatives,并仅允许该derivatives前缀的ListBucket以识别HEAD缺失。已实测11项IAM拒绝、准确V1读取(latest故意替换为坏V2)、裁剪准确版本/SHA与复用。新素材四图及空词典成绩见[第41批](docs/08-delivery/41-i-ml01-chemical-candidates.md)。 + +可选 `--synthetic-dictionary` 仅生成明确synthetic测试名称/CAS映射,并在真实OCR正向probe中使用显式synthetic瓶框;is_simulated=true,不作为现场检测/实体准确率。默认空词典素材结果与正向probe分开记录。nginx、公开下载签名、TLS、持久部署/备份和生产权限门禁仍待验收。下一批第42批接同瓶日期聚合与完整DateFact证据,继续保留未知和人工复核。 + ## 4. 验证 根目录、业务环境执行:领域守卫也使用业务环境,不向 AI 环境引入业务授权或持久化依赖。 @@ -465,7 +586,7 @@ 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 -.\.venv-ai\Scripts\python.exe -m unittest tests.ai.test_dfine_adapter tests.ai.test_cpu_runner tests.ai.test_cpu_pixels tests.ai.test_ocrv6_adapter tests.ai.test_quality_pixels tests.ai.test_pipeline_cpu tests.ai.test_crop_evidence -v +.\.venv-ai\Scripts\python.exe -m unittest tests.ai.test_dfine_adapter tests.ai.test_cpu_runner tests.ai.test_cpu_pixels tests.ai.test_ocrv6_adapter tests.ai.test_quality_pixels tests.ai.test_pipeline_cpu tests.ai.test_crop_evidence tests.ai.test_analysis -v ~~~ 最后一条须在已安装真实CPU依赖的AI环境执行,覆盖真实NumPy/Pillow及子进程恢复;fixture环境缺这些依赖时的跳过不计为通过。真实权重合成图证据可用 `APP_ENV=test`、`python -m tools.ml.smoke_cpu --model C:\Users\12847\Desktop\model.onnx --output docs/08-delivery/32-cpu-inference-evidence.json` 重建,不需要CUDA,也不证明现场照片准确率。 @@ -489,6 +610,8 @@ CI 必须先 --check,不能先生成来掩盖漂移。设计校验不是应用 ## 5. 提交和下一阶段 +2026-10-09当前状态:第36–41批已本地实现与验证,位于wsq/i-ml01-analysis-input,未提交/Push/PR;第27–35批已通过PR #11/#12合并。下一批第42批日期聚合;以下早期批次的未提交和PR基线说明保留为历史,最新交付以docs/08-delivery/01-implementation-plan.md为准。真实MinIO固定版本/限定IAM已有第41批临时loopback证据,完整生产部署门禁仍未完成。 + 遵循 [Git 协作规范](CONTRIBUTING.md)。提交特性分支、创建 PR;不直接推送 main,不自行合并或代替独立审查。 不提交虚拟环境、.local-secrets、真实图片、权重或 .env。 diff --git a/apps/ai_inference/analysis.py b/apps/ai_inference/analysis.py new file mode 100644 index 0000000..5719277 --- /dev/null +++ b/apps/ai_inference/analysis.py @@ -0,0 +1,281 @@ +"""Read-only, fixed-version analysis PNG input; no SDK credential provider chain.""" + +import hashlib +import hmac +import http.client +import ipaddress +import math +import os +import re +import ssl +import time +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from urllib.parse import quote, urlsplit +from uuid import UUID + +from apps.ai_inference.adapters.dfine import AdapterError + +MAX_ANALYSIS_BYTES = 128 * 1024 * 1024 +BUCKET = "labsafe-private" +REGION = "us-east-1" + + +def _uuid(value): + try: + if str(UUID(value)) != value: + raise ValueError + except (ValueError, TypeError, AttributeError): + raise AdapterError("UNAUTHORIZED_REF", "Invalid analysis namespace") from None + + +def _endpoint(value): + try: + parsed = urlsplit(value) + if ( + not isinstance(value, str) + or any(c.isspace() for c in value) + or parsed.scheme not in {"https", "http"} + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.path + or parsed.query + or parsed.fragment + or any(c in value for c in ("?", "#", "\\")) + ): + raise ValueError + parsed.port + if parsed.scheme == "http": + # Plain HTTP is a local development transport only. + if ( + parsed.hostname != "localhost" + and not ipaddress.ip_address(parsed.hostname).is_loopback + ): + raise ValueError + return parsed + except (ValueError, TypeError, AttributeError): + raise AdapterError("VALIDATION_ERROR", "Invalid analysis endpoint configuration") from None + + +@dataclass(frozen=True) +class AnalysisSettings: + endpoint: str + allowed_endpoints: tuple[str, ...] + allowed_tenants: frozenset[str] + access_key: str = field(repr=False) + secret_key: str = field(repr=False) + + def validate(self): + _endpoint(self.endpoint) + if ( + not isinstance(self.allowed_endpoints, tuple) + or not self.allowed_endpoints + or self.endpoint not in self.allowed_endpoints + or not isinstance(self.allowed_tenants, frozenset) + or not self.allowed_tenants + ): + raise AdapterError( + "VALIDATION_ERROR", "Explicit analysis endpoint and tenant allowlists required" + ) + for endpoint in self.allowed_endpoints: + _endpoint(endpoint) + for tenant in self.allowed_tenants: + _uuid(tenant) + for secret in (self.access_key, self.secret_key): + if ( + not isinstance(secret, str) + or not 1 <= len(secret) <= 256 + or any(not 33 <= ord(c) <= 126 for c in secret) + ): + raise AdapterError("VALIDATION_ERROR", "Invalid analysis credentials") + + @classmethod + def from_env(cls): + try: + settings = cls( + os.environ["AI_S3_ENDPOINT"], + tuple(os.environ["AI_S3_ALLOWED_ENDPOINTS"].split(",")), + frozenset(os.environ["AI_ALLOWED_TENANTS"].split(",")), + Path(os.environ["AI_S3_ACCESS_KEY_FILE"]) + .read_text(encoding="ascii") + .rstrip("\r\n"), + Path(os.environ["AI_S3_SECRET_KEY_FILE"]) + .read_text(encoding="ascii") + .rstrip("\r\n"), + ) + settings.validate() + return settings + except (KeyError, OSError, UnicodeError): + raise AdapterError("VALIDATION_ERROR", "Analysis configuration unavailable") from None + + +def verify_reference(reference, tenant, laboratory, allowed_tenants): + if not isinstance(reference, dict): + raise AdapterError("UNAUTHORIZED_REF", "Invalid analysis reference") + for value in (tenant, laboratory, reference.get("image_id")): + _uuid(value) + if tenant not in allowed_tenants: + raise AdapterError("FORBIDDEN", "Tenant is not allowed") + sha = reference.get("sha256") + version = reference.get("object_version") + if not isinstance(sha, str) or re.fullmatch(r"[0-9a-f]{64}", sha) is None: + raise AdapterError("UNAUTHORIZED_REF", "Invalid analysis digest") + if ( + not isinstance(version, str) + or not 1 <= len(version) <= 200 + or version == "null" + or any(not 33 <= ord(c) <= 126 for c in version) + ): + raise AdapterError("UNAUTHORIZED_REF", "An exact analysis version is required") + key = f"tenant/{tenant}/lab/{laboratory}/analysis/{reference['image_id']}/{sha}.png" + if reference.get("object_key") != key: + raise AdapterError( + "UNAUTHORIZED_REF", "Only canonical analysis PNG references are accepted" + ) + if reference.get("mime_type") != "image/png": + raise AdapterError("VALIDATION_ERROR", "Analysis images must be normalized PNG") + + +def signed_get(settings, key, version, instant): + """S3 SigV4 with one fixed VersionId and only a GET operation.""" + parsed = _endpoint(settings.endpoint) + path = f"/{BUCKET}/" + quote(key, safe="/") + query = "versionId=" + quote(version, safe="") + stamp = instant.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + date = stamp[:8] + empty = hashlib.sha256(b"").hexdigest() + headers = {"host": parsed.netloc, "x-amz-content-sha256": empty, "x-amz-date": stamp} + names = ";".join(sorted(headers)) + canonical_headers = "".join(f"{name}:{headers[name]}\n" for name in sorted(headers)) + canonical = "\n".join(("GET", path, query, canonical_headers, names, empty)) + scope = f"{date}/{REGION}/s3/aws4_request" + to_sign = "\n".join( + ("AWS4-HMAC-SHA256", stamp, scope, hashlib.sha256(canonical.encode()).hexdigest()) + ) + key_bytes = ("AWS4" + settings.secret_key).encode("ascii") + for part in (date, REGION, "s3", "aws4_request"): + key_bytes = hmac.new(key_bytes, part.encode(), hashlib.sha256).digest() + signature = hmac.new(key_bytes, to_sign.encode(), hashlib.sha256).hexdigest() + headers["authorization"] = ( + f"AWS4-HMAC-SHA256 Credential={settings.access_key}/{scope}, " + f"SignedHeaders={names}, Signature={signature}" + ) + return path + "?" + query, headers + + +def _remaining(deadline): + if type(deadline) not in (float, int) or not math.isfinite(deadline): + raise AdapterError("VALIDATION_ERROR", "Invalid analysis deadline") + remaining = deadline - time.monotonic() + if remaining <= 0: + raise AdapterError("AI_TIMEOUT", "Analysis deadline exceeded") + return remaining + + +def decode_analysis(raw, expected_sha256): + from packages.image_evidence.analysis import decode_analysis as decode + from packages.image_evidence.perspective import CropError + + try: + return decode(raw, expected_sha256) + except CropError as error: + raise AdapterError(error.code, str(error)) from None + + +class AnalysisReader: + def __init__(self, settings): + settings.validate() + self.settings = settings + + def read(self, reference, tenant, laboratory, deadline): + verify_reference(reference, tenant, laboratory, self.settings.allowed_tenants) + parsed = _endpoint(self.settings.endpoint) + remaining = _remaining(deadline) + timeout = min(2, remaining) + deadline_limited = remaining <= 2 + if parsed.scheme == "https": + connection = http.client.HTTPSConnection( + parsed.hostname, parsed.port, timeout=timeout, context=ssl.create_default_context() + ) + else: + connection = http.client.HTTPConnection(parsed.hostname, parsed.port, timeout=timeout) + try: + path, headers = signed_get( + self.settings, + reference["object_key"], + reference["object_version"], + datetime.now(timezone.utc), + ) + # Direct configured connection: no proxy env, URL opener or redirect handler. + connection.connect() + transport = connection.sock + remaining = _remaining(deadline) + deadline_limited = remaining <= 5 + transport.settimeout(min(5, remaining)) + connection.request("GET", path, headers=headers) + with connection.getresponse() as response: + _remaining(deadline) + if response.status == 404: + raise AdapterError("OBJECT_NOT_FOUND", "Analysis object version not found") + if response.status != 200: + raise AdapterError("DEPENDENCY_UNAVAILABLE", "Analysis object unavailable") + for name in ("Content-Length", "Content-Type", "x-amz-version-id"): + if len(response.headers.get_all(name, [])) != 1: + raise AdapterError( + "SCHEMA_MISMATCH", "Analysis response headers are invalid" + ) + if response.getheader("x-amz-version-id") != reference["object_version"]: + raise AdapterError("HASH_MISMATCH", "Analysis object version differs") + if ( + response.getheader("Content-Type") != "image/png" + or response.getheader("Transfer-Encoding") is not None + or response.getheader("Content-Encoding") not in (None, "identity") + ): + raise AdapterError("SCHEMA_MISMATCH", "Analysis response encoding is invalid") + length = response.getheader("Content-Length") + if len(length) > 10 or re.fullmatch(r"[0-9]+", length) is None: + raise AdapterError("SCHEMA_MISMATCH", "Analysis content length is invalid") + length = int(length) + if not 1 <= length <= MAX_ANALYSIS_BYTES: + raise AdapterError("IMAGE_TOO_LARGE", "Analysis byte limit exceeded") + raw, digest = bytearray(), hashlib.sha256() + while len(raw) < length: + remaining = _remaining(deadline) + deadline_limited = remaining <= 5 + # HTTP/1.0 may detach connection.sock while the response still + # owns its file. Retain the transport to bound every body read. + transport.settimeout(min(5, remaining)) + chunk = response.read1(min(65536, length - len(raw))) + _remaining(deadline) + if not chunk: + raise AdapterError("IMAGE_INVALID", "Analysis response is truncated") + raw.extend(chunk) + digest.update(chunk) + if digest.hexdigest() != reference["sha256"]: + raise AdapterError("HASH_MISMATCH", "Analysis content hash differs") + return raw + except AdapterError: + raise + except TimeoutError: + code = ( + "AI_TIMEOUT" + if deadline_limited or time.monotonic() >= deadline + else "DEPENDENCY_UNAVAILABLE" + ) + raise AdapterError(code, "Analysis read timed out") from None + except (OSError, http.client.HTTPException): + raise AdapterError("DEPENDENCY_UNAVAILABLE", "Analysis read failed") from None + finally: + connection.close() + + def decode(self, reference, tenant, laboratory, deadline): + raw = self.read(reference, tenant, laboratory, deadline) + image = decode_analysis(raw, reference["sha256"]) + try: + _remaining(deadline) + except AdapterError: + image.close() + raise + return image, reference["sha256"] diff --git a/apps/ai_inference/analysis_cpu.py b/apps/ai_inference/analysis_cpu.py new file mode 100644 index 0000000..9795e56 --- /dev/null +++ b/apps/ai_inference/analysis_cpu.py @@ -0,0 +1,78 @@ +"""Development runner: fixed-version analysis GET into the existing CPU pipeline. + +The report is not an InferenceResult or a claim of an approved model bundle. +""" + +import copy +import os +import sys +import time +from dataclasses import replace +from datetime import datetime, timezone + +from apps.ai_inference.adapters.dfine import AdapterError +from apps.ai_inference.analysis import AnalysisReader +from apps.ai_inference.cpu import run_bounded_child +from apps.ai_inference.inputs import verify_image_inputs +from apps.ai_inference.pipeline_cpu import _compute + + +def _child(channel, options, payload, settings, deadline): + try: + reader = AnalysisReader(settings) + downloaded_ms = 0 + + def decode(reference): + nonlocal downloaded_ms + started = time.monotonic() + result = reader.decode( + reference, payload["tenant_id"], payload["laboratory_id"], deadline + ) + downloaded_ms += round((time.monotonic() - started) * 1000) + return result + + report = _compute( + options, + payload["image_refs"], + payload["run_id"], + decoder=decode, + image_ids=[ref["image_id"] for ref in payload["image_refs"]], + ) + report["schema_version"] = "cpu-pipeline-analysis-v1" + report["input_source"] = "fixed-version-analysis" + report["request_hash"] = payload["request_hash"] + report["timing_ms"]["download_and_decode_ms"] = downloaded_ms + for row, ref in zip(report["images"], payload["image_refs"]): + row["object_version"] = ref["object_version"] + channel.send(("result", report)) + except AdapterError as error: + channel.send(("error", error.code)) + except MemoryError: + channel.send(("error", "MODEL_OOM")) + except Exception: + channel.send(("error", "MODEL_ERROR")) + finally: + channel.close() + + +def run_analysis_pipeline(options, payload, settings): + """Validate before any network/model work; one deadline includes child startup.""" + if sys.version_info[:2] != (3, 11) or os.environ.get("APP_ENV") not in {"dev", "test"}: + raise AdapterError("VALIDATION_ERROR", "Development CPU runtime requires Python 3.11") + options.validate() + settings.validate() + payload = copy.deepcopy(payload) + verify_image_inputs(payload, settings.allowed_tenants) + if payload["device_profile"] != "cpu": + raise AdapterError("VALIDATION_ERROR", "CPU-only execution is required") + remaining = ( + datetime.fromisoformat(payload["deadline_at"].upper().replace("Z", "+00:00")) + - datetime.now(timezone.utc) + ).total_seconds() + budget = min(remaining, options.timeout_seconds, 10 if options.quality_only else 180) + if budget <= 0: + raise AdapterError("AI_TIMEOUT", "Request deadline has expired") + deadline = time.monotonic() + budget + return run_bounded_child( + budget, _child, replace(options, timeout_seconds=budget), payload, settings, deadline + ) diff --git a/apps/ai_inference/app/capacity.py b/apps/ai_inference/app/capacity.py new file mode 100644 index 0000000..f267241 --- /dev/null +++ b/apps/ai_inference/app/capacity.py @@ -0,0 +1,44 @@ +"""Bound request bytes before JSON parsing, including chunked transport.""" + +import asyncio +from uuid import uuid4 + +from fastapi.responses import JSONResponse + + +class RequestCapacity: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] != "http" or scope["method"] != "POST": + return await self.app(scope, receive, send) + size, events = 0, [] + try: + async with asyncio.timeout(10): + while True: + event = await receive() + if event["type"] == "http.disconnect": + return + size += len(event.get("body", b"")) + if size > 64 * 1024: + raise ValueError + events.append(event) + if not event.get("more_body", False): + break + except (TimeoutError, ValueError): + value = { + "request_id": str(uuid4()), + "error": { + "code": "VALIDATION_ERROR", + "message": "Inference request exceeds transport capacity", + "retryable": False, + "details": [], + }, + } + return await JSONResponse(value, status_code=422)(scope, receive, send) + + async def buffered(): + return events.pop(0) if events else await receive() + + await self.app(scope, buffered, send) diff --git a/apps/ai_inference/app/fixture.py b/apps/ai_inference/app/fixture.py index 4be6721..8e37d32 100644 --- a/apps/ai_inference/app/fixture.py +++ b/apps/ai_inference/app/fixture.py @@ -4,8 +4,9 @@ from datetime import datetime, timezone from typing import Any +from apps.ai_inference.adapters.dfine import AdapterError +from apps.ai_inference.inputs import verify_image_inputs from packages.inference_protocol.contract import DOCUMENT -from packages.inference_protocol.hashing import request_hash from .settings import Settings @@ -21,40 +22,21 @@ def __init__(self, code: str, message: str) -> None: 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") + raise ProtocolError("FORBIDDEN", "Tenant is not allowed on this 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" + "MODEL_VERSION_UNAVAILABLE", "Request does not match loaded identity" ) 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") + raise ProtocolError("HASH_MISMATCH", "Loaded artifact hash mismatch") + try: + verify_image_inputs(payload, settings.allowed_tenants) + except AdapterError as error: + raise ProtocolError(error.code, str(error)) from None async def compute(payload: dict[str, Any], settings: Settings, stage: str) -> dict[str, Any]: @@ -107,6 +89,7 @@ async def compute(payload: dict[str, Any], settings: Settings, stage: str) -> di for image in payload["image_refs"] ], "detections": [], + "text_regions": [], "crops": [], "ocr_fields": [], "entities": [], diff --git a/apps/ai_inference/app/main.py b/apps/ai_inference/app/main.py index 507a576..b87e893 100644 --- a/apps/ai_inference/app/main.py +++ b/apps/ai_inference/app/main.py @@ -1,4 +1,4 @@ -"""Authenticated, same-protocol development fixture; no production inference.""" +"""Authenticated development fixture or pinned resident CPU service.""" import asyncio import copy @@ -14,6 +14,7 @@ from packages.inference_protocol.contract import DOCUMENT, InferenceRequest, validate +from .capacity import RequestCapacity from .fixture import ProtocolError, compute, verify_request from .settings import Settings @@ -26,18 +27,31 @@ def create_app(settings: Settings | None = None) -> FastAPI: gate = asyncio.Lock() ready = False active_attempt: str | None = None + runtime = None + if settings.mode == "cpu": + from apps.ai_inference.supervisor import CPUSupervisor + + runtime = CPUSupervisor(settings.bundle, settings.analysis) + + def is_ready(): + return runtime.ready if runtime is not None else ready @asynccontextmanager async def lifespan(app: FastAPI): nonlocal ready - ready = True + if runtime is not None: + runtime.start() + else: + ready = True try: yield finally: ready = False + if runtime is not None: + await runtime.close() app = FastAPI( - title="LabSafe AI development fixture", + title="LabSafe AI development service", lifespan=lifespan, docs_url=None, redoc_url=None, @@ -46,6 +60,8 @@ async def lifespan(app: FastAPI): # Exact checked-in wire schema, packaged by the existing design generator. app.openapi = lambda: copy.deepcopy(DOCUMENT) bearer = HTTPBearer(auto_error=False) + app.state.supervisor = runtime + app.add_middleware(RequestCapacity) async def authenticate( credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)], @@ -89,7 +105,7 @@ def health_payload(status: str) -> dict[str, Any]: "Health", { "status": status, - "model_bundle_id": settings.identity["model_bundle_id"], + "model_bundle_id": settings.identity["model_bundle_id"] if is_ready() else None, "active_attempt_id": active_attempt, }, ) @@ -100,18 +116,20 @@ async def health() -> dict[str, Any]: @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") + if not is_ready(): + raise ProtocolError("MODEL_NOT_READY", "Model is not ready") return health_payload("ready") @router.get("/version", operation_id="aiVersion") async def version() -> dict[str, Any]: - return checked("Version", dict(settings.identity)) + if runtime is not None and not runtime.ready: + raise ProtocolError("MODEL_NOT_READY", "Model is not ready") + return checked("Version", dict(runtime.identity if runtime else settings.identity)) - async def infer(payload: InferenceRequest, stage: str) -> dict[str, Any]: + async def infer(payload: InferenceRequest, stage: str, request: Request) -> dict[str, Any]: nonlocal active_attempt - if not ready: - raise ProtocolError("MODEL_NOT_READY", "Fixture is not ready") + if not is_ready(): + raise ProtocolError("MODEL_NOT_READY", "Model is not ready") body = payload.root verify_request(body, settings) if gate.locked(): @@ -120,17 +138,48 @@ async def infer(payload: InferenceRequest, stage: str) -> dict[str, Any]: async with gate: active_attempt = body["attempt_id"] try: - return checked("InferenceResult", await compute(body, settings, stage)) + if runtime: + task = asyncio.create_task(runtime.infer(body, stage)) + stop_watching = asyncio.Event() + + async def disconnected(): + while not stop_watching.is_set() and not await request.is_disconnected(): + await asyncio.sleep(0.02) + + watcher = asyncio.create_task(disconnected()) + try: + done, _ = await asyncio.wait( + (task, watcher), return_when=asyncio.FIRST_COMPLETED + ) + if watcher in done and not task.done(): + task.cancel() + value = await task + finally: + stop_watching.set() + watcher.cancel() + try: + await watcher + except asyncio.CancelledError: + pass + if not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + else: + value = await compute(body, settings, stage) + return checked("InferenceResult", value) finally: active_attempt = None @router.post("/quality", operation_id="aiQuality") - async def quality(payload: InferenceRequest) -> dict[str, Any]: - return await infer(payload, "quality") + async def quality(payload: InferenceRequest, request: Request) -> dict[str, Any]: + return await infer(payload, "quality", request) @router.post("/runs", operation_id="aiRuns") - async def runs(payload: InferenceRequest) -> dict[str, Any]: - return await infer(payload, "runs") + async def runs(payload: InferenceRequest, request: Request) -> dict[str, Any]: + return await infer(payload, "runs", request) app.include_router(router) return app diff --git a/apps/ai_inference/app/settings.py b/apps/ai_inference/app/settings.py index 1b873fb..17cc6af 100644 --- a/apps/ai_inference/app/settings.py +++ b/apps/ai_inference/app/settings.py @@ -1,4 +1,4 @@ -"""Explicit, development-only AI startup settings and verified fixture identity.""" +"""Explicit development-only AI startup settings and immutable artifact pins.""" import hashlib import json @@ -22,6 +22,9 @@ class Settings: identity: dict[str, Any] model_checksum: str dictionary_sha256: str + mode: str = "mock" + bundle: Any = None + analysis: Any = field(default=None, repr=False) @classmethod def from_env(cls) -> "Settings": @@ -30,8 +33,9 @@ def from_env(cls) -> "Settings": 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") + mode = os.environ.get("AI_MODE") + if mode not in {"mock", "cpu"}: + raise RuntimeError("AI_MODE must be mock or cpu") 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") @@ -60,6 +64,25 @@ def from_env(cls) -> "Settings": commit = os.environ.get("SERVICE_COMMIT", "local-development") if not 1 <= len(commit) <= 40: raise RuntimeError("SERVICE_COMMIT must contain 1 to 40 characters") + if mode == "cpu": + from apps.ai_inference.analysis import AnalysisSettings + from apps.ai_inference.bundle import CPUBundle + + bundle = CPUBundle.from_env(commit) + analysis = AnalysisSettings.from_env() + return cls( + environment, + token, + allowed_tenants, + scenario, + delay_ms, + bundle.identity, + bundle.sha256, + bundle.identity["dictionary_sha256"], + mode, + bundle, + analysis, + ) 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() @@ -82,6 +105,8 @@ def from_env(cls) -> "Settings": "runtime_lock_sha256": hashlib.sha256(lock_bytes).hexdigest(), "detector_device": "mock", "ocr_device": "mock", + "model_checksum": hashlib.sha256(model_bytes).hexdigest(), + "dictionary_sha256": hashlib.sha256(dictionary_bytes).hexdigest(), } return cls( environment, diff --git a/apps/ai_inference/bundle.py b/apps/ai_inference/bundle.py new file mode 100644 index 0000000..755b255 --- /dev/null +++ b/apps/ai_inference/bundle.py @@ -0,0 +1,210 @@ +"""Pinned local development bundle; never selects model artifacts from requests.""" + +import hashlib +import json +import os +import platform +from dataclasses import dataclass +from importlib.metadata import version +from pathlib import Path + +from apps.ai_inference.adapters.dfine import AdapterError +from apps.ai_inference.adapters.quality import QualityThresholds +from apps.ai_inference.pipeline_cpu import PipelineOptions +from packages.image_evidence.perspective import crop_runtime +from packages.inference_protocol.chemical import dictionary as validate_dictionary +from packages.inference_protocol.contract import validate + +ROOT = Path(__file__).resolve().parents[2] +PACKAGES = ( + "fastapi", + "pydantic", + "uvicorn", + "PyYAML", + "jsonschema", + "rfc3339-validator", + "onnxruntime", + "numpy", + "Pillow", + "opencv-python-headless", + "pyclipper", +) + + +def digest(raw): + return hashlib.sha256(raw).hexdigest() + + +def json_digest(value): + return digest( + json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False).encode() + ) + + +def code_digest(): + # Include the service, input reader, algorithms and shared protocol/pixel implementations. + paths = sorted( + p + for directory in ( + ROOT / "apps/ai_inference", + ROOT / "packages/image_evidence", + ROOT / "packages/inference_protocol", + ) + for p in directory.rglob("*.py") + ) + return json_digest( + {p.relative_to(ROOT).as_posix(): digest(p.read_bytes()) for p in paths} + | { + "packages/inference_protocol/contract.json": digest( + (ROOT / "packages/inference_protocol/contract.json").read_bytes() + ) + } + ) + + +def runtime_lock(): + return { + "purpose": "development", + "scope": "cpu-direct-runtime-v1", + "python": platform.python_version(), + "platform": platform.platform(), + "packages": {name: version(name) for name in PACKAGES}, + "crop_runtime_sha256": json_digest(crop_runtime()), + "code_sha256": code_digest(), + } + + +def bounded_json(path, expected=None): + with Path(path).open("rb") as source: + raw = source.read(65537) + if len(raw) > 65536 or expected is not None and digest(raw) != expected: + raise ValueError("Invalid pinned development artifact") + value = json.loads(raw) + json.dumps(value, allow_nan=False) + return raw, value + + +@dataclass(frozen=True) +class CPUBundle: + path: str + sha256: str + roots: tuple[str, ...] + commit: str + raw: bytes + + @classmethod + def from_env(cls, commit): + try: + return cls.load( + os.environ["AI_CPU_BUNDLE_FILE"], + os.environ["AI_CPU_BUNDLE_SHA256"], + tuple(os.environ["AI_ARTIFACT_ROOTS"].split(os.pathsep)), + commit, + ) + except (KeyError, OSError, ValueError, TypeError): + raise RuntimeError("Invalid controlled CPU bundle configuration") from None + + @classmethod + def load(cls, path, sha256, roots, commit="local-development"): + if len(sha256) != 64 or not roots or any(not Path(p).is_absolute() for p in roots): + raise ValueError("Explicit artifact roots and bundle SHA required") + path = Path(path) + if not path.is_absolute(): + raise ValueError("Absolute bundle path required") + raw, value = bounded_json(path, sha256) + validate("DevelopmentCPUBundle", value) + result = cls( + str(path.resolve()), sha256, tuple(str(Path(p).resolve()) for p in roots), commit, raw + ) + result.paths() # No large model read in the HTTP supervisor. + return result + + @property + def manifest(self): + return json.loads(self.raw) + + def paths(self): + paths = {} + for name, ref in self.manifest["artifacts"].items(): + path = Path(ref["path"]) + resolved = path.resolve(strict=True) + if ( + not path.is_absolute() + or not resolved.is_file() + or not any(resolved.is_relative_to(Path(root)) for root in self.roots) + ): + raise ValueError("Artifact outside controlled roots") + paths[name] = resolved + for prefix in ("ocr_det", "ocr_rec"): + if ( + paths[prefix + "_model"].name != "inference.onnx" + or paths[prefix + "_config"].name != "inference.yml" + or paths[prefix + "_model"].parent != paths[prefix + "_config"].parent + ): + raise ValueError("OCR artifact paths must match the adapter") + return paths + + @property + def identity(self): + value = self.manifest + return { + "service_commit": self.commit, + "pipeline_version": value["pipeline_version"], + "model_bundle_id": value["bundle_id"], + "dictionary_version_id": value["dictionary_version_id"], + "device_profile": "cpu", + "purpose": "development", + "is_simulated": False, + "adapter_id": value["adapter_id"], + "runtime_profile": value["runtime_profile"], + "runtime_lock_sha256": value["artifacts"]["runtime_lock"]["sha256"], + "model_checksum": self.sha256, + "dictionary_sha256": value["artifacts"]["dictionary"]["sha256"], + "detector_device": "cpu", + "ocr_device": "cpu", + } + + def verify(self): + try: + bounded_json(self.path, self.sha256) + paths = self.paths() + for name, path in paths.items(): + hasher = hashlib.sha256() + with path.open("rb") as source: + while chunk := source.read(1024 * 1024): + hasher.update(chunk) + if hasher.hexdigest() != self.manifest["artifacts"][name]["sha256"]: + raise ValueError("Artifact digest changed") + _, dictionary = bounded_json(paths["dictionary"], self.identity["dictionary_sha256"]) + validate("DevelopmentDictionary", dictionary) + validate_dictionary(dictionary) + if dictionary["dictionary_version_id"] != self.manifest["dictionary_version_id"]: + raise ValueError("Dictionary identity mismatch") + _, lock = bounded_json(paths["runtime_lock"], self.identity["runtime_lock_sha256"]) + validate("DevelopmentCPULock", lock) + if lock != runtime_lock(): + raise ValueError("Runtime identity mismatch") + validate("Version", self.identity) + return self.identity + except (OSError, ValueError, TypeError, KeyError): + raise AdapterError( + "MODEL_VERSION_UNAVAILABLE", "CPU bundle verification failed" + ) from None + + def options(self): + paths, value = self.paths(), self.manifest + thresholds = value["thresholds"] + return PipelineOptions( + str(paths["detector_model"]), + str(paths["ocr_det_model"].parent), + str(paths["ocr_rec_model"].parent), + str(paths["detector_config"]), + str(paths["detector_preprocessor"]), + thresholds["detection_min"], + thresholds["ocr_min"], + QualityThresholds( + thresholds["blur_min"], thresholds["dark_min"], thresholds["glare_max"] + ), + threads=value["threads"], + entity_min=thresholds["entity_min"], + ) diff --git a/apps/ai_inference/cpu.py b/apps/ai_inference/cpu.py index 3c89627..c27a310 100644 --- a/apps/ai_inference/cpu.py +++ b/apps/ai_inference/cpu.py @@ -203,10 +203,15 @@ def run_cpu_workload(options, paths, run_id: str, target): if not isinstance(paths, (list, tuple)) or not 1 <= len(paths) <= 3: raise AdapterError("VALIDATION_ERROR", "Supply 1..3 local images") paths = tuple(str(Path(path).resolve()) for path in paths) + return run_bounded_child(options.timeout_seconds, target, options, paths, run_id) + + +def run_bounded_child(timeout_seconds, target, *arguments): + """Spawn and reclaim a CPU child with structured or local inputs.""" context = multiprocessing.get_context("spawn") receive, send = context.Pipe(duplex=False) - child = context.Process(target=target, args=(send, options, paths, run_id), daemon=True) - deadline = time.monotonic() + options.timeout_seconds + deadline = time.monotonic() + timeout_seconds + child = context.Process(target=target, args=(send, *arguments), daemon=True) try: child.start() send.close() diff --git a/apps/ai_inference/inputs.py b/apps/ai_inference/inputs.py new file mode 100644 index 0000000..1ded732 --- /dev/null +++ b/apps/ai_inference/inputs.py @@ -0,0 +1,31 @@ +"""Shared request hash, ordered image graph and canonical analysis references.""" + +from apps.ai_inference.adapters.dfine import AdapterError +from apps.ai_inference.analysis import verify_reference +from packages.inference_protocol.contract import validate +from packages.inference_protocol.hashing import request_hash + + +def verify_image_inputs(payload, allowed_tenants): + try: + validate("InferenceRequest", payload) + except ValueError: + raise AdapterError("VALIDATION_ERROR", "Invalid inference request") from None + if payload["tenant_id"] not in allowed_tenants: + raise AdapterError("FORBIDDEN", "Tenant is not allowed") + if payload["request_hash"] != request_hash(payload): + raise AdapterError("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 AdapterError("VALIDATION_ERROR", "First image must be the unique overview") + if len({image["image_id"] for image in refs}) != len(refs): + raise AdapterError("VALIDATION_ERROR", "Image IDs must be unique") + for index, image in enumerate(refs): + verify_reference(image, payload["tenant_id"], payload["laboratory_id"], allowed_tenants) + if image["location_id"] != overview["location_id"]: + raise AdapterError("VALIDATION_ERROR", "Images must share a location") + if index and ( + image["role"] != "detail" or image["parent_image_id"] != overview["image_id"] + ): + raise AdapterError("VALIDATION_ERROR", "Detail must refer to the overview") diff --git a/apps/ai_inference/pipeline_cpu.py b/apps/ai_inference/pipeline_cpu.py index 280d287..1e5ec20 100644 --- a/apps/ai_inference/pipeline_cpu.py +++ b/apps/ai_inference/pipeline_cpu.py @@ -12,8 +12,13 @@ from apps.ai_inference.adapters.quality import QualityThresholds, evaluate_quality, quality_runtime from apps.ai_inference.cpu import CpuOptions, _decode_local, run_cpu_workload from apps.ai_inference.ocr_cpu import OcrOptions, annotate_lines, ocr_runtime +from packages.inference_protocol.association import ALGORITHM_ID, associate_rows +from packages.inference_protocol.chemical import ALGORITHM_ID as CHEMICAL_ID +from packages.inference_protocol.chemical import extract_entities +from packages.inference_protocol.fields import ALGORITHM_ID as FIELDS_ID +from packages.inference_protocol.fields import extract_fields -PIPELINE_ID = "quality-coco80-ocrv6-cpu-v2" +PIPELINE_ID = "quality-coco80-ocrv6-cpu-v5" @dataclass(frozen=True) @@ -29,6 +34,7 @@ class PipelineOptions: quality_only: bool = False threads: int = 2 timeout_seconds: float = 180.0 + entity_min: float = 0.9 def validate(self): CpuOptions("", "", "", self.detection_min, self.threads, self.timeout_seconds).validate() @@ -36,6 +42,8 @@ def validate(self): if type(self.quality_only) is not bool or not isinstance(self.quality, QualityThresholds): raise AdapterError("VALIDATION_ERROR", "Invalid pipeline configuration") self.quality.validate() + if type(self.entity_min) not in (int, float) or not 0 <= self.entity_min <= 1: + raise AdapterError("VALIDATION_ERROR", "Invalid entity threshold") if not self.quality_only and any( not isinstance(p, str) or not p for p in (self.model_path, self.det_dir, self.rec_dir) ): @@ -48,21 +56,73 @@ def _digest(value): ).hexdigest() -def _compute(options, paths, run_id): - """Decode once, gate the whole batch, then load each session once.""" +@dataclass(frozen=True) +class LoadedModels: + detector: object + ocr: object + artifacts: dict + runtime: dict + loaded_ms: int + + +def load_models(options): from PIL import Image + runtime = {} + loaded_at = time.monotonic() + detector = OnnxDetector.from_path( + options.model_path, + providers=["CPUExecutionProvider"], + config_path=options.config_path, + preprocessor_path=options.preprocessor_path, + cpu_threads=options.threads, + ) + if detector.providers != ("CPUExecutionProvider",): + raise AdapterError("MODEL_VERSION_UNAVAILABLE", "CPU-only detector required") + with Image.new("RGB", (640, 640)) as smoke: + detector.detect(smoke, options.detection_min) + ocr = OnnxOCR.from_directories(options.det_dir, options.rec_dir, options.threads) + ocr.smoke() + loaded_ms = round((time.monotonic() - loaded_at) * 1000) + evidence = detector.evidence + artifacts = { + "detector": { + "model_sha256": evidence.model_sha256, + "config_sha256": evidence.config_sha256, + "preprocessor_sha256": evidence.preprocessor_sha256, + }, + "ocr": ARTIFACTS, + } + runtime["detector"] = { + "adapter_id": ADAPTER_ID, + "providers": list(detector.providers), + "intra_op_threads": options.threads, + "inter_op_threads": 1, + "execution_mode": "sequential", + "preprocess": "rgb-stretch640-bilinear-rescale255", + "postprocess": "qmax-sigmoid-threshold-geometry-no-nms", + } + runtime["ocr"] = ocr_runtime(options.threads) + return LoadedModels(detector, ocr, artifacts, runtime, loaded_ms) + + +def _compute(options, paths, run_id, *, decoder=None, image_ids=None, models=None, dictionary=None): + """Decode once, gate the whole batch, then load each session once.""" started = time.monotonic() decoded, images = [], [] - quality_ms = 0 + quality_ms = association_ms = 0 try: for ordinal, path in enumerate(paths): - image, digest = _decode_local(path) + image, digest = (decoder or _decode_local)(path) decoded.append(image) checked_at = time.monotonic() quality = evaluate_quality(image, options.quality) quality_ms += round((time.monotonic() - checked_at) * 1000) - image_id = str(uuid5(UUID(run_id), f"image:{ordinal}:{digest}")) + image_id = ( + image_ids[ordinal] + if image_ids is not None + else str(uuid5(UUID(run_id), f"image:{ordinal}:{digest}")) + ) images.append( { "image_id": image_id, @@ -88,40 +148,11 @@ def _compute(options, paths, run_id): artifacts, loaded_ms = {}, 0 models_executed = passed and not options.quality_only if models_executed: - loaded_at = time.monotonic() - detector = OnnxDetector.from_path( - options.model_path, - providers=["CPUExecutionProvider"], - config_path=options.config_path, - preprocessor_path=options.preprocessor_path, - cpu_threads=options.threads, - ) - if detector.providers != ("CPUExecutionProvider",): - raise AdapterError("MODEL_VERSION_UNAVAILABLE", "CPU-only detector required") - with Image.new("RGB", (640, 640)) as smoke: - detector.detect(smoke, options.detection_min) - ocr = OnnxOCR.from_directories(options.det_dir, options.rec_dir, options.threads) - ocr.smoke() - loaded_ms = round((time.monotonic() - loaded_at) * 1000) - evidence = detector.evidence - artifacts = { - "detector": { - "model_sha256": evidence.model_sha256, - "config_sha256": evidence.config_sha256, - "preprocessor_sha256": evidence.preprocessor_sha256, - }, - "ocr": ARTIFACTS, - } - runtime["detector"] = { - "adapter_id": ADAPTER_ID, - "providers": list(detector.providers), - "intra_op_threads": options.threads, - "inter_op_threads": 1, - "execution_mode": "sequential", - "preprocess": "rgb-stretch640-bilinear-rescale255", - "postprocess": "qmax-sigmoid-threshold-geometry-no-nms", - } - runtime["ocr"] = ocr_runtime(options.threads) + loaded = models if models is not None else load_models(options) + detector, ocr = loaded.detector, loaded.ocr + artifacts = loaded.artifacts + runtime.update(loaded.runtime) + loaded_ms = 0 if models is not None else loaded.loaded_ms detections_total, lines_total = 0, 0 for image, row in zip(decoded, images): inferred_at = time.monotonic() @@ -149,16 +180,40 @@ def _compute(options, paths, run_id): for index, d in enumerate(detections) ] annotate_lines(lines, run_id, row["image_id"], options.text_min) + associated_at = time.monotonic() + try: + associate_rows(lines, row["detections"]) + except ValueError: + raise AdapterError("MODEL_ERROR", "Invalid text association geometry") from None + association_ms += round((time.monotonic() - associated_at) * 1000) row.update(inference_executed=True, lines=lines, detection_diagnostics=diagnostics) + from packages.inference_protocol.evidence import text_evidence + + text_regions, crops = text_evidence([line for row in images for line in row["lines"]]) + fields_at = time.monotonic() + try: + ocr_fields = extract_fields(text_regions, dictionary) + entities = ( + extract_entities(ocr_fields, dictionary, options.text_min, options.entity_min) + if dictionary is not None + else [] + ) + except ValueError: + raise AdapterError("MODEL_ERROR", "Invalid OCR fields or capacity") from None + fields_ms = round((time.monotonic() - fields_at) * 1000) config = { "pipeline_id": PIPELINE_ID, "detection_min": options.detection_min, "text_min": options.text_min, "quality": asdict(options.quality), "quality_only": options.quality_only, + "text_association": ALGORITHM_ID, + "ocr_fields": FIELDS_ID, + "chemical_candidates": CHEMICAL_ID, + "entity_min": options.entity_min, } return { - "schema_version": "cpu-pipeline-local-v2", + "schema_version": "cpu-pipeline-local-v5", "pipeline_id": PIPELINE_ID, "run_id": run_id, "purpose": "development", @@ -176,16 +231,24 @@ def _compute(options, paths, run_id): "config_sha256": _digest(config), "business_capabilities": { "chemical_entities": False, + "chemical_candidates": models_executed and dictionary is not None, "container_relations": False, - "bottle_association": False, + "bottle_association": models_executed, + "ocr_fields": models_executed, "date_facts": False, }, "timing_ms": { "quality_ms": quality_ms, + "association_ms": association_ms, + "fields_ms": fields_ms, "load_and_smoke_ms": loaded_ms, "total_ms": round((time.monotonic() - started) * 1000), }, "images": images, + "text_regions": text_regions, + "crops": crops, + "ocr_fields": ocr_fields, + "entities": entities, } finally: for image in decoded: diff --git a/apps/ai_inference/run.py b/apps/ai_inference/run.py index 4b5888a..a9d15a5 100644 --- a/apps/ai_inference/run.py +++ b/apps/ai_inference/run.py @@ -1,4 +1,4 @@ -"""One HTTP process; the real computation child is an I-03 deliverable.""" +"""One HTTP supervisor with an optional resident, pinned development CPU child.""" import os @@ -6,13 +6,24 @@ def main() -> None: - uvicorn.run( - "apps.ai_inference.app.main:create_app", - factory=True, + from apps.ai_inference.app.main import create_app + + app = create_app() + + class Server(uvicorn.Server): + def handle_exit(self, sig, frame): + if app.state.supervisor: + app.state.supervisor.closing = True + super().handle_exit(sig, frame) + + config = uvicorn.Config( + app, host=os.getenv("AI_INFERENCE_HOST", "127.0.0.1"), port=int(os.getenv("AI_INFERENCE_PORT", "8001")), workers=1, + timeout_graceful_shutdown=220, ) + Server(config).run() if __name__ == "__main__": diff --git a/apps/ai_inference/runtime_cpu.py b/apps/ai_inference/runtime_cpu.py new file mode 100644 index 0000000..4483426 --- /dev/null +++ b/apps/ai_inference/runtime_cpu.py @@ -0,0 +1,152 @@ +"""One resident numerical process; JSON IPC only, no ORM or business credentials.""" + +import json +import os +import time +from dataclasses import replace + +from apps.ai_inference.adapters.dfine import AdapterError +from apps.ai_inference.analysis import AnalysisReader +from apps.ai_inference.pipeline_cpu import _compute, load_models +from packages.inference_protocol.chemical import bounded_json +from packages.inference_protocol.contract import validate +from packages.inference_protocol.evidence import validate_closure + +MAX_REQUEST = 64 * 1024 +MAX_RESULT = 2 * 1024 * 1024 + + +def send(channel, value): + raw = json.dumps(value, separators=(",", ":"), allow_nan=False).encode() + if len(raw) > MAX_RESULT: + raise AdapterError("MODEL_ERROR", "Inference response exceeds capacity") + channel.send_bytes(raw) + + +def wire_result(report, payload, identity, download_ms, extraction_context=None): + result = { + key: payload[key] + for key in ("run_id", "attempt_id", "fencing_token", "tenant_id", "request_hash") + } + result.update( + { + key: identity[key] + for key in ( + "model_bundle_id", + "model_checksum", + "dictionary_version_id", + "dictionary_sha256", + "pipeline_version", + ) + } + ) + result.update( + execution_identity=identity, + input_hashes=[ + {"image_id": row["image_id"], "sha256": row["input_sha256"]} for row in report["images"] + ], + outcome="facts_ready" if report["outcome"] == "quality_passed" else report["outcome"], + quality=[ + { + "image_id": row["image_id"], + "status": "pass" if row["quality"]["passed"] else "needs_retake", + **{ + key: row["quality"][key] + for key in ("reasons", "blur_score", "brightness", "glare_ratio") + }, + } + for row in report["images"] + ], + detections=[d for row in report["images"] for d in row["detections"]], + text_regions=report["text_regions"], + crops=report["crops"], + ocr_fields=report["ocr_fields"], + entities=report["entities"], + relations=[], + timing_ms={ + "download_ms": download_ms, + "quality_ms": report["timing_ms"]["quality_ms"], + "detection_ms": sum(row["detection_ms"] for row in report["images"]), + "ocr_ms": sum(row["ocr_ms"] for row in report["images"]), + "normalization_ms": report["timing_ms"].get("association_ms", 0) + + report["timing_ms"].get("fields_ms", 0), + "total_ms": report["timing_ms"]["total_ms"], + }, + ) + if extraction_context is not None: + result["extraction_context"] = extraction_context + validate("InferenceResult", result) + validate_closure(result, [ref["image_id"] for ref in payload["image_refs"]]) + return result + + +def child_main(channel, bundle, analysis): + try: + # Explicit readonly AnalysisSettings is the child's only storage capability. + for name in list(os.environ): + if name.startswith( + ("DB_", "DATABASE_", "REDIS_", "CELERY_", "S3_", "AWS_", "API_") + ) or name in { + "AI_TOKEN_FILE", + "AI_EXPECTED_VERSION_FILE", + "AI_S3_ACCESS_KEY_FILE", + "AI_S3_SECRET_KEY_FILE", + }: + os.environ.pop(name, None) + identity = bundle.verify() + extraction_context = { + "bundle_json": bundle.raw.decode("utf-8"), + "dictionary_json": bundle.paths()["dictionary"].read_bytes().decode("utf-8"), + } + dictionary = bounded_json(extraction_context["dictionary_json"]) + options = bundle.options() + options.validate() + models = load_models(options) + # Recheck bytes/runtime after loading; adapter sessions already use verified model bytes. + bundle.verify() + send(channel, {"kind": "loaded", "identity": identity}) + reader = AnalysisReader(analysis) + while True: + command = json.loads(channel.recv_bytes(MAX_REQUEST)) + payload, deadline = command["payload"], command["deadline"] + downloaded_ms = 0 + + def decode(ref): + nonlocal downloaded_ms + started = time.monotonic() + decoded = reader.decode( + ref, payload["tenant_id"], payload["laboratory_id"], deadline + ) + downloaded_ms += round((time.monotonic() - started) * 1000) + return decoded + + report = _compute( + replace(options, quality_only=command["stage"] == "quality"), + payload["image_refs"], + payload["run_id"], + decoder=decode, + image_ids=[r["image_id"] for r in payload["image_refs"]], + models=models, + dictionary=dictionary, + ) + if time.monotonic() >= deadline: + raise AdapterError("AI_TIMEOUT", "CPU deadline exceeded") + send( + channel, + { + "kind": "result", + "value": wire_result( + report, payload, identity, downloaded_ms, extraction_context + ), + }, + ) + except AdapterError as error: + send(channel, {"kind": "error", "code": error.code}) + except MemoryError: + send(channel, {"kind": "error", "code": "MODEL_OOM"}) + except (EOFError, BrokenPipeError, OSError): + pass + except Exception: + send(channel, {"kind": "error", "code": "MODEL_ERROR"}) + finally: + channel.close() diff --git a/apps/ai_inference/supervisor.py b/apps/ai_inference/supervisor.py new file mode 100644 index 0000000..7558562 --- /dev/null +++ b/apps/ai_inference/supervisor.py @@ -0,0 +1,219 @@ +"""Async HTTP supervisor of a resident spawn child with bounded JSON IPC.""" + +import asyncio +import json +import multiprocessing +import queue +import threading +import time +from datetime import datetime, timezone + +from apps.ai_inference.app.fixture import ProtocolError +from apps.ai_inference.runtime_cpu import MAX_REQUEST, MAX_RESULT, child_main +from packages.inference_protocol.contract import DOCUMENT, validate +from packages.inference_protocol.evidence import validate_closure + + +class CPUSupervisor: + startup_seconds = 120 + reload_seconds = 30 + shutdown_seconds = 220 + + def __init__(self, bundle, analysis, target=child_main): + self.bundle, self.analysis, self.target = bundle, analysis, target + self.process = self.channel = self.receiver = self.messages = None + self.identity = None + self.closing = self.busy = False + self.failures = 0 + self.next_load = 0 + self.maintenance = None + + @property + def ready(self): + return bool(not self.closing and self.identity and self.process and self.process.is_alive()) + + def start(self): + self.maintenance = asyncio.create_task(self._maintain()) + + def _receive(self, channel, messages): + try: + while True: + value = json.loads(channel.recv_bytes(MAX_RESULT)) + messages.put_nowait(value) + except (EOFError, OSError, ValueError, queue.Full): + try: + messages.put_nowait({"kind": "error", "code": "MODEL_ERROR"}) + except queue.Full: + pass + + async def _message(self, deadline): + while time.monotonic() < deadline: + try: + return self.messages.get_nowait() + except queue.Empty: + if not self.process or not self.process.is_alive(): + raise ProtocolError("MODEL_ERROR", "CPU process exited") + await asyncio.sleep(min(0.01, max(0, deadline - time.monotonic()))) + raise ProtocolError("AI_TIMEOUT", "CPU phase exceeded its deadline") + + async def _dispose(self): + self.identity = None + process, channel, receiver = self.process, self.channel, self.receiver + self.process = self.channel = self.receiver = self.messages = None + if process is not None: + if process.is_alive(): + process.kill() + await asyncio.to_thread(process.join, 2) + if process.is_alive(): + raise RuntimeError("CPU child failed to exit") + process.close() + if channel is not None: + channel.close() + if receiver is not None: + await asyncio.to_thread(receiver.join, 2) + if receiver.is_alive(): + raise RuntimeError("CPU IPC receiver failed to exit") + + async def _launch(self): + context = multiprocessing.get_context("spawn") + parent, child = context.Pipe() + self.messages = queue.Queue(maxsize=2) + self.channel = parent + self.process = context.Process(target=self.target, args=(child, self.bundle, self.analysis)) + self.process.daemon = True + deadline = time.monotonic() + self.startup_seconds + try: + self.process.start() + except BaseException: + self.process.close() + self.process = None + raise + finally: + child.close() + self.receiver = threading.Thread( + target=self._receive, args=(parent, self.messages), name="cpu-ipc-receiver", daemon=True + ) + self.receiver.start() + message = await self._message(deadline) + if message.get("kind") != "loaded" or message.get("identity") != self.bundle.identity: + raise ProtocolError("MODEL_VERSION_UNAVAILABLE", "Loaded CPU identity mismatch") + validate("Version", message["identity"]) + self.identity = message["identity"] + + async def _maintain(self): + try: + while not self.closing: + if not self.busy and self.identity and self.process and not self.process.is_alive(): + self.next_load = max(self.next_load, time.monotonic() + self.reload_seconds) + await self._dispose() + if ( + not self.busy + and not self.ready + and self.failures < 3 + and time.monotonic() >= self.next_load + ): + await self._dispose() + self.next_load = time.monotonic() + self.reload_seconds + try: + await self._launch() + except ( + ProtocolError, + ValueError, + OSError, + TypeError, + KeyError, + AttributeError, + ): + self.failures += 1 + await self._dispose() + self.next_load = max(self.next_load, time.monotonic() + self.reload_seconds) + else: + self.failures = 0 + await asyncio.sleep(0.05) + except asyncio.CancelledError: + raise + + async def infer(self, payload, stage): + if self.busy: + raise ProtocolError("AI_BUSY", "CPU instance already has an active request") + if not self.ready: + raise ProtocolError("MODEL_NOT_READY", "CPU model is not ready") + remaining = ( + datetime.fromisoformat(payload["deadline_at"].upper().replace("Z", "+00:00")) + - datetime.now(timezone.utc) + ).total_seconds() + budget = min(remaining, 10 if stage == "quality" else 180) + if budget <= 0: + raise ProtocolError("AI_TIMEOUT", "Request deadline expired") + deadline = time.monotonic() + budget + raw = json.dumps( + {"stage": stage, "payload": payload, "deadline": deadline}, + separators=(",", ":"), + allow_nan=False, + ).encode() + if len(raw) > MAX_REQUEST: + raise ProtocolError("VALIDATION_ERROR", "Inference request exceeds capacity") + self.busy = True + try: + await asyncio.wait_for( + asyncio.to_thread(self.channel.send_bytes, raw), + timeout=max(0, deadline - time.monotonic()), + ) + message = await self._message(deadline) + if message.get("kind") != "result": + code = message.get("code", "MODEL_ERROR") + if code not in DOCUMENT["x-error-codes"]: + code = "MODEL_ERROR" + raise ProtocolError(code, "CPU computation failed") + result = message["value"] + validate("InferenceResult", result) + validate_closure(result, [r["image_id"] for r in payload["image_refs"]]) + if ( + result.get("execution_identity") != self.identity + or any( + result[key] != payload[key] + for key in ( + "run_id", + "attempt_id", + "fencing_token", + "tenant_id", + "request_hash", + "model_bundle_id", + "model_checksum", + "dictionary_version_id", + "dictionary_sha256", + "pipeline_version", + ) + ) + or result["input_hashes"] + != [ + {"image_id": r["image_id"], "sha256": r["sha256"]} + for r in payload["image_refs"] + ] + ): + raise ValueError("Result identity changed") + if time.monotonic() >= deadline: + raise ProtocolError("AI_TIMEOUT", "Response deadline exceeded") + return result + except BaseException as error: + await self._dispose() + self.next_load = max(self.next_load, time.monotonic() + self.reload_seconds) + if isinstance(error, (asyncio.CancelledError, ProtocolError)): + raise + code = "AI_TIMEOUT" if isinstance(error, TimeoutError) else "MODEL_ERROR" + raise ProtocolError(code, "CPU computation failed") from None + finally: + self.busy = False + + async def close(self): + self.closing = True + end = time.monotonic() + self.shutdown_seconds + while self.busy and time.monotonic() < end: + await asyncio.sleep(0.02) + if self.maintenance: + self.maintenance.cancel() + try: + await self.maintenance + except asyncio.CancelledError: + pass + await self._dispose() diff --git a/apps/worker/evidence_child.py b/apps/worker/evidence_child.py new file mode 100644 index 0000000..80c01d6 --- /dev/null +++ b/apps/worker/evidence_child.py @@ -0,0 +1,39 @@ +"""Dedicated evidence runtime; no AI models, DB connection or broker handles.""" + +import json +import os +import sys +from dataclasses import asdict +from pathlib import Path + + +def main(): + from packages.application.inference_evidence import prepare_evidence + from packages.domain.security import ServiceError + from packages.image_evidence.perspective import CropError + from packages.storage.s3 import S3Settings, S3Storage + + storage = None + try: + raw = Path(sys.argv[1]).read_bytes() + if len(raw) > 2 * 1024 * 1024: + raise ValueError("Evidence input limit exceeded") + value = json.loads(raw) + storage = S3Storage(S3Settings.from_environment(os.environ["PUBLIC_ORIGIN"], worker=True)) + artifacts = prepare_evidence(storage, value["source"], value["result"]) + output = {"artifacts": [asdict(row) for row in artifacts]} + except (ServiceError, CropError) as error: + output = {"error": error.code} + except ValueError: + output = {"error": "SCHEMA_MISMATCH"} + except Exception: + output = {"error": "INTERNAL_ERROR"} + finally: + if storage is not None: + storage.close() + # Never include SDK errors, raw text or credentials in the result/error stream. + Path(sys.argv[2]).write_text(json.dumps(output), encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/apps/worker/inference_evidence.py b/apps/worker/inference_evidence.py new file mode 100644 index 0000000..aa1145e --- /dev/null +++ b/apps/worker/inference_evidence.py @@ -0,0 +1,106 @@ +"""Cancel and hard-bound the independent image/storage runtime to 20 seconds.""" + +import json +import os +import subprocess +import tempfile +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from packages.domain.inference_evidence import InferenceDerivative, validate_derivatives +from packages.domain.inference_execution import InferenceLeaseLost, request_payload, validate_result +from packages.domain.security import ServiceError + +EVIDENCE_SECONDS = 20 +ERRORS = { + "DEPENDENCY_UNAVAILABLE", + "STAGE_TIMEOUT", + "INTERNAL_ERROR", + "SCHEMA_MISMATCH", + "HASH_MISMATCH", + "OBJECT_NOT_FOUND", + "IMAGE_INVALID", + "IMAGE_TOO_LARGE", + "MODEL_ERROR", +} + + +class BoundedEvidencePrepare: + def __init__(self, executable=None): + self.executable = executable or os.environ.get("WORKER_EVIDENCE_PYTHON") + + def __call__(self, lease, result, cancelled): + validate_result(result, lease) + if not result["crops"]: + return () + if not self.executable or not Path(self.executable).is_file(): + raise ServiceError("DEPENDENCY_UNAVAILABLE", 503, "Evidence runtime is required") + source = request_payload(lease, datetime.now(timezone.utc) + timedelta(seconds=20)) + raw = json.dumps({"source": source, "result": result}, allow_nan=False).encode() + if len(raw) > 2 * 1024 * 1024: + raise ServiceError("SCHEMA_MISMATCH", 502, "Evidence input exceeds limit") + # No database/broker/AI credentials inherited by this dedicated runtime. + environment = { + key: value + for key, value in os.environ.items() + if not key.startswith(("DATABASE_", "CELERY_", "REDIS_", "AI_", "BROKER_")) + and key not in {"S3_ACCESS_KEY_FILE", "S3_SECRET_KEY_FILE"} + } + environment.update( + PYTHONDONTWRITEBYTECODE="1", OPENBLAS_NUM_THREADS="1", OMP_NUM_THREADS="1" + ) + child = None + started = time.monotonic() + with tempfile.TemporaryDirectory(prefix="labsafe-ocr-evidence-") as directory: + input_path, output_path = ( + Path(directory) / "input.json", + Path(directory) / "output.json", + ) + input_path.write_bytes(raw) + try: + if cancelled.is_set(): + raise InferenceLeaseLost() + child = subprocess.Popen( + [ + str(Path(self.executable).resolve()), + "-B", + "-m", + "apps.worker.evidence_child", + str(input_path), + str(output_path), + ], + cwd=Path(__file__).resolve().parents[2], + env=environment, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, + ) + while child.poll() is None: + if cancelled.wait(0.05): + raise InferenceLeaseLost() + if time.monotonic() - started >= EVIDENCE_SECONDS: + raise ServiceError("STAGE_TIMEOUT", 504, "Evidence preparation timed out") + if cancelled.is_set(): + raise InferenceLeaseLost() + if time.monotonic() - started >= EVIDENCE_SECONDS: + raise ServiceError("STAGE_TIMEOUT", 504, "Evidence preparation timed out") + if ( + child.returncode + or not output_path.is_file() + or output_path.stat().st_size > 256 * 1024 + ): + raise ServiceError("INTERNAL_ERROR", 500, "Evidence runtime failed") + output = json.loads(output_path.read_text(encoding="utf-8")) + if "error" in output: + code = output["error"] if output["error"] in ERRORS else "INTERNAL_ERROR" + raise ServiceError(code, 503, "Evidence preparation failed") + artifacts = tuple(InferenceDerivative(**row) for row in output["artifacts"]) + validate_derivatives(lease, result, artifacts) + return artifacts + finally: + if child is not None: + if child.poll() is None: + child.kill() + child.wait(timeout=2) diff --git a/apps/worker/inference_pipeline.py b/apps/worker/inference_pipeline.py index 1122339..c695cdf 100644 --- a/apps/worker/inference_pipeline.py +++ b/apps/worker/inference_pipeline.py @@ -5,11 +5,12 @@ def consume_inference(message): + from apps.worker.inference_evidence import BoundedEvidencePrepare from packages.application.inference_execution import InferenceExecution validate_dispatch(message) engine = database_engine() try: - return InferenceExecution(engine).execute(message) + return InferenceExecution(engine, prepare=BoundedEvidencePrepare()).execute(message) finally: engine.dispose() diff --git a/contracts/data-model.json b/contracts/data-model.json index 101291b..9fa3b5c 100644 --- a/contracts/data-model.json +++ b/contracts/data-model.json @@ -4453,6 +4453,13 @@ "default_sql": null, "description": "", "generated_sql": null + }, + "analysis_object_version": { + "type": "VARCHAR(200)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null } }, "primary_key": [ @@ -4596,7 +4603,21 @@ }, "detection_id": { "type": "CHAR(36)", - "nullable": false, + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "line_id": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "size_bytes": { + "type": "BIGINT UNSIGNED", + "nullable": true, "default_sql": null, "description": "", "generated_sql": null diff --git a/contracts/database-design.sql b/contracts/database-design.sql index 7a550c8..2b04e2a 100644 --- a/contracts/database-design.sql +++ b/contracts/database-design.sql @@ -566,6 +566,7 @@ CREATE TABLE `run_images` ( `role` ENUM('overview','detail') NOT NULL DEFAULT 'overview', `parent_image_id` CHAR(36) NULL, `analysis_sha256` CHAR(64) NOT NULL, + `analysis_object_version` VARCHAR(200) NULL, PRIMARY KEY (`id`), UNIQUE KEY `uq_run_images_0` (`tenant_id`, `id`), UNIQUE KEY `uq_run_images_1` (`tenant_id`, `run_id`, `image_id`), @@ -581,7 +582,9 @@ CREATE TABLE `image_derivatives` ( `run_id` CHAR(36) NOT NULL, `image_id` CHAR(36) NOT NULL, `crop_id` CHAR(36) NOT NULL, - `detection_id` CHAR(36) NOT NULL, + `detection_id` CHAR(36) NULL, + `line_id` CHAR(36) NULL, + `size_bytes` BIGINT UNSIGNED NULL, `kind` ENUM('crop') NOT NULL DEFAULT 'crop', `recipe` JSON NOT NULL, `object_key` VARCHAR(1024) NOT NULL, diff --git a/contracts/development-cpu-bundle-v1.json b/contracts/development-cpu-bundle-v1.json new file mode 100644 index 0000000..53e1b9e --- /dev/null +++ b/contracts/development-cpu-bundle-v1.json @@ -0,0 +1,299 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://labsafe.local/contracts/DevelopmentCPUBundle.json", + "$ref": "#/$defs/DevelopmentCPUBundle", + "$defs": { + "DevelopmentCPUBundle": { + "type": "object", + "properties": { + "purpose": { + "type": "string", + "enum": [ + "development" + ] + }, + "bundle_id": { + "type": "string", + "format": "uuid" + }, + "dictionary_version_id": { + "type": "string", + "format": "uuid" + }, + "pipeline_version": { + "type": "string", + "enum": [ + "vision-v1" + ] + }, + "device_profile": { + "type": "string", + "enum": [ + "cpu" + ] + }, + "adapter_id": { + "type": "string", + "enum": [ + "dfine-coco80-rgb-stretch-v1" + ] + }, + "runtime_profile": { + "type": "string", + "enum": [ + "dfine-cpu-fp32-ocrv6smallcpu-chemical-v2" + ] + }, + "artifacts": { + "type": "object", + "properties": { + "detector_model": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "path", + "sha256" + ], + "additionalProperties": false + }, + "detector_config": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "path", + "sha256" + ], + "additionalProperties": false + }, + "detector_preprocessor": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "path", + "sha256" + ], + "additionalProperties": false + }, + "ocr_det_model": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "path", + "sha256" + ], + "additionalProperties": false + }, + "ocr_det_config": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "path", + "sha256" + ], + "additionalProperties": false + }, + "ocr_rec_model": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "path", + "sha256" + ], + "additionalProperties": false + }, + "ocr_rec_config": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "path", + "sha256" + ], + "additionalProperties": false + }, + "dictionary": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "path", + "sha256" + ], + "additionalProperties": false + }, + "runtime_lock": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "path", + "sha256" + ], + "additionalProperties": false + } + }, + "required": [ + "detector_model", + "detector_config", + "detector_preprocessor", + "ocr_det_model", + "ocr_det_config", + "ocr_rec_model", + "ocr_rec_config", + "dictionary", + "runtime_lock" + ], + "additionalProperties": false + }, + "thresholds": { + "type": "object", + "properties": { + "detection_min": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "ocr_min": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "entity_min": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "blur_min": { + "type": "number", + "minimum": 0 + }, + "dark_min": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "glare_max": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": [ + "detection_min", + "ocr_min", + "entity_min", + "blur_min", + "dark_min", + "glare_max" + ], + "additionalProperties": false + }, + "threads": { + "type": "integer", + "minimum": 1, + "maximum": 16 + } + }, + "required": [ + "purpose", + "bundle_id", + "dictionary_version_id", + "pipeline_version", + "device_profile", + "adapter_id", + "runtime_profile", + "artifacts", + "thresholds", + "threads" + ], + "additionalProperties": false + } + } +} diff --git a/contracts/development-cpu-lock-v1.json b/contracts/development-cpu-lock-v1.json new file mode 100644 index 0000000..e49e408 --- /dev/null +++ b/contracts/development-cpu-lock-v1.json @@ -0,0 +1,126 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://labsafe.local/contracts/DevelopmentCPULock.json", + "$ref": "#/$defs/DevelopmentCPULock", + "$defs": { + "DevelopmentCPULock": { + "type": "object", + "properties": { + "purpose": { + "type": "string", + "enum": [ + "development" + ] + }, + "scope": { + "type": "string", + "enum": [ + "cpu-direct-runtime-v1" + ] + }, + "python": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "platform": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "packages": { + "type": "object", + "properties": { + "fastapi": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "pydantic": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "uvicorn": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "PyYAML": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "jsonschema": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "rfc3339-validator": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "onnxruntime": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "numpy": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "Pillow": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "opencv-python-headless": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "pyclipper": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "required": [ + "fastapi", + "pydantic", + "uvicorn", + "PyYAML", + "jsonschema", + "rfc3339-validator", + "onnxruntime", + "numpy", + "Pillow", + "opencv-python-headless", + "pyclipper" + ], + "additionalProperties": false + }, + "crop_runtime_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "code_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "purpose", + "scope", + "python", + "platform", + "packages", + "crop_runtime_sha256", + "code_sha256" + ], + "additionalProperties": false + } + } +} diff --git a/contracts/development-dictionary-v1.json b/contracts/development-dictionary-v1.json new file mode 100644 index 0000000..77b7b31 --- /dev/null +++ b/contracts/development-dictionary-v1.json @@ -0,0 +1,85 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://labsafe.local/contracts/DevelopmentDictionary.json", + "$ref": "#/$defs/DevelopmentDictionary", + "$defs": { + "DevelopmentDictionary": { + "type": "object", + "properties": { + "purpose": { + "type": "string", + "enum": [ + "development" + ] + }, + "dictionary_version_id": { + "type": "string", + "format": "uuid" + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/$defs/DevelopmentDictionaryEntry" + }, + "minItems": 0, + "maxItems": 100 + } + }, + "required": [ + "purpose", + "dictionary_version_id", + "entries" + ], + "additionalProperties": false + }, + "DevelopmentDictionaryEntry": { + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "format": "uuid" + }, + "canonical_name": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "aliases": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "minItems": 0, + "maxItems": 10 + }, + "cas": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 12 + }, + { + "type": "null" + } + ] + }, + "source": { + "type": "string", + "minLength": 1, + "maxLength": 1000 + } + }, + "required": [ + "entity_id", + "canonical_name", + "aliases", + "cas", + "source" + ], + "additionalProperties": false + } + } +} diff --git a/contracts/inference-v1.yaml b/contracts/inference-v1.yaml index 223be0c..a5eb513 100644 --- a/contracts/inference-v1.yaml +++ b/contracts/inference-v1.yaml @@ -493,6 +493,15 @@ components: type: string minLength: 1 maxLength: 1024 + object_version: + type: string + minLength: 1 + maxLength: 200 + pattern: ^[!-~]+$ + not: + anyOf: + - const: 'null' + - pattern: '[^!-~]' sha256: &id002 type: string pattern: ^[a-f0-9]{64}$ @@ -515,6 +524,7 @@ components: required: - image_id - object_key + - object_version - sha256 - mime_type - role @@ -735,12 +745,82 @@ components: - bbox - confidence additionalProperties: false + CropEvidence: + type: object + properties: + png_sha256: *id002 + png_size_bytes: + type: integer + minimum: 1 + maximum: 16777216 + source_rgb_sha256: *id002 + recognition_rotation_ccw: + type: integer + enum: + - 0 + - 90 + recognition_width: + type: integer + minimum: 1 + maximum: 2048 + recognition_height: + type: integer + minimum: 1 + maximum: 2048 + recognition_rgb_sha256: *id002 + required: + - png_sha256 + - png_size_bytes + - source_rgb_sha256 + - recognition_rotation_ccw + - recognition_width + - recognition_height + - recognition_rgb_sha256 + additionalProperties: false + TextRegion: + type: object + properties: + line_id: *id001 + image_id: *id001 + crop_id: *id001 + detection_id: + anyOf: + - *id001 + - type: 'null' + description: 'text-bottle-quad80-v1: same-image real COCO bottle (class 39), center inside and >=80% quad + intersection area, unique smallest bbox; otherwise null. Worker recomputes. No synthetic label/container.' + raw_text: + type: string + minLength: 0 + maxLength: 2000 + confidence: *id003 + quad: + type: array + items: + $ref: '#/components/schemas/Point' + minItems: 4 + maxItems: 4 + required: + - line_id + - image_id + - crop_id + - detection_id + - raw_text + - confidence + - quad + additionalProperties: false CropRecipe: type: object properties: crop_id: *id001 image_id: *id001 - detection_id: *id001 + line_id: *id001 + detection_id: + anyOf: + - *id001 + - type: 'null' + description: Same nullable bottle association as the paired TextRegion; it identifies the bottle, not + an invented label detection. quad: type: array items: @@ -759,14 +839,34 @@ components: type: string enum: - perspective-rgb-v1 + evidence: + $ref: '#/components/schemas/CropEvidence' required: - crop_id - image_id + - line_id - detection_id - quad - output_width - output_height - transform_version + - evidence + additionalProperties: false + OCRFieldSource: + type: object + properties: + line_id: *id001 + crop_id: *id001 + raw_text: + type: string + minLength: 0 + maxLength: 2000 + confidence: *id003 + required: + - line_id + - crop_id + - raw_text + - confidence additionalProperties: false OCRField: type: object @@ -774,6 +874,12 @@ components: image_id: *id001 detection_id: *id001 crop_id: *id001 + source_lines: + type: array + items: + $ref: '#/components/schemas/OCRFieldSource' + minItems: 1 + maxItems: 2 field: type: string enum: @@ -799,11 +905,15 @@ components: - image_id - detection_id - crop_id + - source_lines - field - raw_text - normalized_text - confidence additionalProperties: false + description: 'ocr-fields-v1: actual unique bottle, all 1-2 consecutive source lines with original text/confidence/crop. + crop_id is first source only; raw_text joins sources with LF and confidence is min. Worker recomputes full + ordered fields; no chemical/date facts.' Candidate: type: object properties: @@ -953,6 +1063,12 @@ components: $ref: '#/components/schemas/Detection' minItems: 0 maxItems: 100 + text_regions: + type: array + items: + $ref: '#/components/schemas/TextRegion' + minItems: 0 + maxItems: 100 crops: type: array items: @@ -979,6 +1095,10 @@ components: maxItems: 200 timing_ms: $ref: '#/components/schemas/Timing' + execution_identity: + $ref: '#/components/schemas/Version' + extraction_context: + $ref: '#/components/schemas/ExtractionContext' required: - run_id - attempt_id @@ -994,6 +1114,7 @@ components: - outcome - quality - detections + - text_regions - crops - ocr_fields - entities @@ -1040,6 +1161,8 @@ components: enum: - cpu - cuda + model_checksum: *id002 + dictionary_sha256: *id002 purpose: type: string enum: @@ -1059,6 +1182,7 @@ components: - dfine-cpu-fp32-ocrv6smallcpu-v1 - dfine-cuda-fp32-ocrv6smallcpu-v1 - fixture-v1 + - dfine-cpu-fp32-ocrv6smallcpu-chemical-v2 runtime_lock_sha256: type: string pattern: ^[a-f0-9]{64}$ @@ -1079,6 +1203,8 @@ components: - model_bundle_id - dictionary_version_id - device_profile + - model_checksum + - dictionary_sha256 - purpose - is_simulated - adapter_id @@ -1087,3 +1213,342 @@ components: - detector_device - ocr_device additionalProperties: false + ExtractionContext: + type: object + properties: + bundle_json: + type: string + minLength: 1 + maxLength: 65536 + dictionary_json: + type: string + minLength: 1 + maxLength: 65536 + required: + - bundle_json + - dictionary_json + additionalProperties: false + DevelopmentCPUBundle: + type: object + properties: + purpose: + type: string + enum: + - development + bundle_id: *id001 + dictionary_version_id: *id001 + pipeline_version: + type: string + enum: + - vision-v1 + device_profile: + type: string + enum: + - cpu + adapter_id: + type: string + enum: + - dfine-coco80-rgb-stretch-v1 + runtime_profile: + type: string + enum: + - dfine-cpu-fp32-ocrv6smallcpu-chemical-v2 + artifacts: + type: object + properties: + detector_model: + type: object + properties: + path: + type: string + minLength: 1 + maxLength: 2048 + sha256: *id002 + required: + - path + - sha256 + additionalProperties: false + detector_config: + type: object + properties: + path: + type: string + minLength: 1 + maxLength: 2048 + sha256: *id002 + required: + - path + - sha256 + additionalProperties: false + detector_preprocessor: + type: object + properties: + path: + type: string + minLength: 1 + maxLength: 2048 + sha256: *id002 + required: + - path + - sha256 + additionalProperties: false + ocr_det_model: + type: object + properties: + path: + type: string + minLength: 1 + maxLength: 2048 + sha256: *id002 + required: + - path + - sha256 + additionalProperties: false + ocr_det_config: + type: object + properties: + path: + type: string + minLength: 1 + maxLength: 2048 + sha256: *id002 + required: + - path + - sha256 + additionalProperties: false + ocr_rec_model: + type: object + properties: + path: + type: string + minLength: 1 + maxLength: 2048 + sha256: *id002 + required: + - path + - sha256 + additionalProperties: false + ocr_rec_config: + type: object + properties: + path: + type: string + minLength: 1 + maxLength: 2048 + sha256: *id002 + required: + - path + - sha256 + additionalProperties: false + dictionary: + type: object + properties: + path: + type: string + minLength: 1 + maxLength: 2048 + sha256: *id002 + required: + - path + - sha256 + additionalProperties: false + runtime_lock: + type: object + properties: + path: + type: string + minLength: 1 + maxLength: 2048 + sha256: *id002 + required: + - path + - sha256 + additionalProperties: false + required: + - detector_model + - detector_config + - detector_preprocessor + - ocr_det_model + - ocr_det_config + - ocr_rec_model + - ocr_rec_config + - dictionary + - runtime_lock + additionalProperties: false + thresholds: + type: object + properties: + detection_min: *id003 + ocr_min: *id003 + entity_min: *id003 + blur_min: + type: number + minimum: 0 + dark_min: *id003 + glare_max: *id003 + required: + - detection_min + - ocr_min + - entity_min + - blur_min + - dark_min + - glare_max + additionalProperties: false + threads: + type: integer + minimum: 1 + maximum: 16 + required: + - purpose + - bundle_id + - dictionary_version_id + - pipeline_version + - device_profile + - adapter_id + - runtime_profile + - artifacts + - thresholds + - threads + additionalProperties: false + DevelopmentCPULock: + type: object + properties: + purpose: + type: string + enum: + - development + scope: + type: string + enum: + - cpu-direct-runtime-v1 + python: + type: string + minLength: 1 + maxLength: 40 + platform: + type: string + minLength: 1 + maxLength: 200 + packages: + type: object + properties: + fastapi: + type: string + minLength: 1 + maxLength: 64 + pydantic: + type: string + minLength: 1 + maxLength: 64 + uvicorn: + type: string + minLength: 1 + maxLength: 64 + PyYAML: + type: string + minLength: 1 + maxLength: 64 + jsonschema: + type: string + minLength: 1 + maxLength: 64 + rfc3339-validator: + type: string + minLength: 1 + maxLength: 64 + onnxruntime: + type: string + minLength: 1 + maxLength: 64 + numpy: + type: string + minLength: 1 + maxLength: 64 + Pillow: + type: string + minLength: 1 + maxLength: 64 + opencv-python-headless: + type: string + minLength: 1 + maxLength: 64 + pyclipper: + type: string + minLength: 1 + maxLength: 64 + required: + - fastapi + - pydantic + - uvicorn + - PyYAML + - jsonschema + - rfc3339-validator + - onnxruntime + - numpy + - Pillow + - opencv-python-headless + - pyclipper + additionalProperties: false + crop_runtime_sha256: *id002 + code_sha256: *id002 + required: + - purpose + - scope + - python + - platform + - packages + - crop_runtime_sha256 + - code_sha256 + additionalProperties: false + DevelopmentDictionaryEntry: + type: object + properties: + entity_id: *id001 + canonical_name: + type: string + minLength: 1 + maxLength: 200 + aliases: + type: array + items: + type: string + minLength: 1 + maxLength: 200 + minItems: 0 + maxItems: 10 + cas: + anyOf: + - type: string + minLength: 1 + maxLength: 12 + - type: 'null' + source: + type: string + minLength: 1 + maxLength: 1000 + required: + - entity_id + - canonical_name + - aliases + - cas + - source + additionalProperties: false + DevelopmentDictionary: + type: object + properties: + purpose: + type: string + enum: + - development + dictionary_version_id: *id001 + entries: + type: array + items: + $ref: '#/components/schemas/DevelopmentDictionaryEntry' + minItems: 0 + maxItems: 100 + required: + - purpose + - dictionary_version_id + - entries + additionalProperties: false diff --git a/contracts/public-api-v1.yaml b/contracts/public-api-v1.yaml index db52569..5e323b2 100644 --- a/contracts/public-api-v1.yaml +++ b/contracts/public-api-v1.yaml @@ -9574,6 +9574,12 @@ components: - RULESET_INVALID - LEASE_LOST - type: 'null' + text_regions: + type: array + items: + $ref: '#/components/schemas/TextRegion' + minItems: 0 + maxItems: 100 is_simulated: *id002 required: - id @@ -9592,6 +9598,7 @@ components: - result_hash - job_id - error_code + - text_regions - is_simulated additionalProperties: false Notification: @@ -12082,6 +12089,55 @@ components: - data - request_id additionalProperties: false + Point: + type: object + properties: + x: &id011 + type: number + minimum: 0 + maximum: 1 + y: *id011 + required: + - x + - y + additionalProperties: false + TextRegion: + type: object + properties: + line_id: &id012 + type: string + format: uuid + image_id: *id012 + crop_id: *id012 + detection_id: + anyOf: + - *id012 + - type: 'null' + description: 'text-bottle-quad80-v1: same-image real COCO bottle (class 39), center inside and >=80% quad + intersection area, unique smallest bbox; otherwise null. Worker recomputes. No synthetic label/container.' + raw_text: + type: string + minLength: 0 + maxLength: 2000 + confidence: + type: number + minimum: 0 + maximum: 1 + quad: + type: array + items: + $ref: '#/components/schemas/Point' + minItems: 4 + maxItems: 4 + required: + - line_id + - image_id + - crop_id + - detection_id + - raw_text + - confidence + - quad + additionalProperties: false Calibration: type: object properties: diff --git a/docs/00-document-control.md b/docs/00-document-control.md index 681f28b..5c81d0b 100644 --- a/docs/00-document-control.md +++ b/docs/00-document-control.md @@ -24,4 +24,16 @@ ## 3. 排版和维护 +2026-10-09 [第41批名称候选](08-delivery/41-i-ml01-chemical-candidates.md)扩展有界DevelopmentDictionary,新增实体ID/名称/别名/CAS/来源与语义拒绝;新增extraction_context原始bundle/dictionary快照,由既定Worker固定SHA核身份及阈值后重算。development CPU profile升级chemical-v2、报告v5,不扩完整ModelManifest或公共activation的批准范围。内部消费者/代码锁协调升级,无迁移/历史回填。实际MinIO版本/IAM和新素材工程结果另记,不代表生产部署或现场准确率。 + +2026-10-07 [受控analysis输入续批](08-delivery/36-i-ml01-controlled-analysis-input.md)新增ImageRef.object_version必填及run_images冻结版本迁移0003。内部契约仍属未部署1.1.0候选,缺版本旧请求不兼容;Worker/AI同步升级,禁止旧run补猜当前版本。公共提交DTO、事实语义、消息schema_version不变,真实HTTP和生产批准仍未完成。 + 主题目录两位数字排序;README 只做导航;正文一级标题为文档、二级为职责、三级为规则。业务变更同步更新主题文档、契约、测试用例、追踪矩阵与审计。所有现行链接必须指向存在的文件。 + +2026-10-07 [第37批OCR文字证据](08-delivery/37-i-ml01-worker-ocr-evidence.md)新增正式text_regions及crop的line_id/重建摘要/nullable detection_id,公共InferenceRun新增只读text_regions,数据库新增0004_ocr_evidence。旧无新字段的内部结果拒绝,需协调升级Worker/AI;历史结果/derivative不重写。候选协议状态及production限制保持,真实CPU常驻HTTP待第38批。 + +2026-10-07 [第38批常驻CPU HTTP](08-delivery/38-i-ml01-resident-cpu-http.md)本地接通实际ready/version与development身份;Version新增必填model_checksum/dictionary_sha256,InferenceResult可选execution_identity,真实CPU必带且Worker核受控pin。新增development bundle/直接运行锁/空业务词典三个Schema,区别于完整ModelManifest及生产批准;内部消费者协调升级,历史结果保留,迁移仍0004。公共is_simulated取已验证执行身份,旧结果默认true,语义事实/公共activation/现场与生产验收未完成。 + +2026-10-09 [第39批文字/bottle关联](08-delivery/39-i-ml01-text-bottle-association.md)明确独立文字/裁剪指向真实同图COCO bottle,使用text-bottle-quad80-v1实际四边形面积/唯一最小框,未知保持NULL;AI/Worker重算语义门禁。字段形状及vision-v1不变,联合本地报告v3/代码锁重新绑定,无迁移、无历史回填;旧AI/Worker需协调升级。字段词法/化学词典/日期事实和现场批准仍待后续,几何关联不等于安全事实。 + +2026-10-09 [第40批OCR字段](08-delivery/40-i-ml01-ocr-fields.md)新增OCRField必填source_lines(1–2行全部line/crop/原文/逐行置信度),首行crop_id保留。AI/Worker共享ocr-fields-v1并核全量有序输出,联合报告v4/实际代码锁重新绑定;内部消费者协调升级,旧字段不猜补来源,历史结果只读。无新迁移,无实体/日期事实。用户素材经过默认质量门禁/官方CPU模型,实际零瓶检测,保持未知,不视作现场评测通过。 diff --git a/docs/02-architecture/03-ai-inference-process.md b/docs/02-architecture/03-ai-inference-process.md index b47dd05..187ab92 100644 --- a/docs/02-architecture/03-ai-inference-process.md +++ b/docs/02-architecture/03-ai-inference-process.md @@ -12,13 +12,13 @@ AI 只能读授权 analysis/ 对象和只读模型目录,无 MySQL/Redis/业 模型和数据路线以[固定模型方案](../04-ai-rules/02-model-data-plan.md)及本批验收为准:AI运行时 Python3.11、官方 D-FINE COCO 80 类 ONNX,业务同为 Python3.11 但保留独立依赖环境;RGB直接resize640并除255,官方输出为 300 queries、qmax/无NMS,协议保留 0–79 `class_id` 和对应 `type`。建议生产检测 CUDA FP32;OCR保持 PP-OCRv6_small ONNX 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进程入口;I-ML-01 [CPU 本地检测续批](../08-delivery/32-i-ml01-cpu-detection.md) 已实现一次性spawn、真实模型加载/smoke、1–3图检测和超时回收。当前仅使用CPU,不安装CUDA;HTTP仍为AI_MODE=mock。本地coco80-local-v1报告尚未接受控analysis读取、quality、Worker RPC或事实落库;下面的常驻supervisor、完整流水线与版本激活仍待I-03/I-ML接通。运行和验收入口见[开发指南](../../DEVELOPMENT.md)。 +I-01A交付同协议开发fixture和HTTP进程入口;I-ML-01已接真实CPU检测、PP-OCRv6_small、全批质量门禁与可重建文字证据。[第36批受控analysis输入](../08-delivery/36-i-ml01-controlled-analysis-input.md)把准确对象版本读取接入同一有界CPU流水线。当前仅CPU,不安装CUDA;第38批AI_MODE=cpu已接常驻supervisor、正式HTTP/Worker pin门禁和实际ready/version,保留mock。业务事实、完整公共模型导入/activation、多版本路由与现场批准仍待接通。运行和验收入口见[开发指南](../../DEVELOPMENT.md)。 ## 2. 生命周期 -[联合CPU续批](../08-delivery/34-i-ml01-quality-cpu-pipeline.md)已实现本地真实质量门禁和同一子进程的检测/OCR顺序执行。全部原图先检查质量,任一失败返回整批needs_retake、跳过模型;quality-only与联合入口使用同一函数。仍未接受控analysis/HTTP,且不符合完整InferenceResult;下述常驻服务生命周期及RPC阶段预算未由本地180秒总期限替代。 +[联合CPU续批](../08-delivery/34-i-ml01-quality-cpu-pipeline.md)已实现真实质量门禁和同一子进程的检测/OCR顺序执行。全部输入先检查质量,任一失败返回整批needs_retake、跳过模型;quality-only与联合入口使用同一函数。第36批受控analysis入口内部预算取请求deadline、配置预算及quality 10秒/runs 180秒的最小值,含spawn、下载、解码、模型加载和推理;超时kill/join,无部分结果。第38批常驻HTTP/ready已本地实现,模型加载移到120秒启动阶段;请求10/180秒包含IPC/固定版本下载/解码/计算,不重复加载模型。 -2026-10-07 [PP-OCRv6_small CPU续批](../08-delivery/33-i-ml01-ocrv6-cpu.md) 已接独立本地文字检测/识别,原图文字区域不依赖COCO标签类别。当前ocrv6-local-v1行报告保留原文/置信度/quad、无瓶子归属或化学字段;不是完整InferenceResult。后续业务接线须明确OCR文字区域如何纳入证据闭包,不能借用任意COCO类别填充label/detection/crop引用。以下完整流水线中的label关联仍待落实。 +2026-10-07 [PP-OCRv6_small CPU续批](../08-delivery/33-i-ml01-ocrv6-cpu.md)交付独立本地文字检测/识别,后续第35–38批已补独立裁剪证据与正式HTTP。当前独立OCR CLI为ocrv6-local-v2,无D-FINE输入或瓶子归属;联合CPU第39批为v3,以真实bottle关联独立文字,不借用其他COCO类别填充label。业务字段/语义事实仍待后续,原文不等于安全事实。 ### 静态路由与多版本 @@ -43,7 +43,9 @@ activation命令通过该路由访问/ready和/version,实际tuple一致才允 - 没有 rule_version;AI 不做规则评估。请求版本必须等于实际加载版本,禁止自动最新、请求级下载、静默 CPU fallback。 - request_hash 对 tenant/lab/item/run/submission_revision、版本/device 和有序输入清单计算,不含 attempt/token/deadline。自动重试保持业务内容不变。 - MAX_INFLIGHT=1(含质量请求)。其他请求 429 AI_BUSY;相同活动 attempt 409 RUN_IN_PROGRESS。重启后可重复计算;不承诺 exactly-once 推理。 -- key 固定 tenant/{tenant}/lab/{lab}/analysis/{image}/{sha}.png,由 Worker 从 DB 构造;禁止 URL、..、反斜线或越前缀。下载校验 hash;失败不能转为空事实成功。 +- key 固定 tenant/{tenant}/lab/{lab}/analysis/{image}/{sha}.png,由 Worker 从 DB 构造;禁止 URL、..、反斜线或越前缀。ImageRef必填object_version(1–200可见ASCII字符,拒绝null版本),来自run_images提交时冻结版本;版本进入request_hash。GET固定VersionId,回包版本、Content-Length、PNG MIME、实算SHA全部核验,失败不能转为空事实成功或读最新版本。 +- 受控analysis读取只接受显式AI_S3_ENDPOINT/AI_S3_ALLOWED_ENDPOINTS和租户allowlist;固定labsafe-private/us-east-1,独立只读secret文件,不读取AWS provider chain、代理环境或请求URL,不跟重定向。HTTPS使用系统信任链;HTTP仅loopback开发测试。仅读analysis准确版本,无PUT/List/Delete。 +- analysis允许≤128MiB、≤40M像素、单边≤10000;必须静态RGB PNG且无元数据(加载前后均核验)。Worker已做EXIF、RGB和去元数据,AI不再旋转、转换或resize。原图CLI的12MiB上限独立保留。请求图片ID/overview-detail父子关系/位置/顺序在下载前校验,报告保留Worker图片ID。 ## 4. 预算 @@ -67,9 +69,9 @@ HTTP connect=2秒、池等待=1秒、读写取剩余总预算;不能仅靠 soc | quality | 每图计算质量;任一图不通过即停止 | needs_retake,整个图片清单重提 | | runs 质量复验 | 同不可变输入重验,应和 quality 相同 | 差异 SCHEMA_MISMATCH | | 检测 | RGB直接resize640/除255;D-FINE qmax输出原图像素框,裁边后按W/H归一化;全请求最多100结果 | 0≤x1`,默认继续生成空词典;本批真实素材 smoke 使用空词典。合成候选数据只在测试/明确标记的正向 probe 中使用。 + +## 2. 候选与所有名称字段参与的聚合 + +新增共享纯 Python `chemical-candidates-v1`。名称 NFKC+casefold+空白压缩,合法 CAS token 只作 CAS 精确检索,未命中为 unknown,不用相似 CAS 猜实体。其他名称优先 canonical/alias 精确匹配,否则按 `1-Levenshtein/maxlen` 生成正分候选。同条目的各名称取最高分,排序按 score 降序/entity_id 升序;一批最多2,000,000个距离矩阵单元,超限整批 MODEL_ERROR,不截断词典/字段。无前缀 OCR 只接受冻结 canonical/alias 整行精确匹配,不对任意背景文字模糊猜名称。 + +每个 `(image_id,bottle_id)` 的所有 name 字段都参与聚合。每个字段唯一最佳、OCR confidence≥ocr_min、候选score≥entity_min且全部最佳ID相同才 resolved;低置信、并列、缺值或名称冲突保持 candidate/unknown。候选合并按 entity_id 去重,每个字段都参与 min 分数,缺少该候选计0;完整冲突/唯一最佳判断先于 top5,不能把第6个冲突实体删去后宣布 resolved。最终 match_method 记录该实体所有实际命中中的最弱方法(CAS→alias→fuzzy);不把匹配分数当安全概率。 + +仅有实际 name 字段才生成 EntityCandidates 分组;没有字段不制造实体。已解析候选也仍 `needs_review`,本批不生成 DateFact、FactRevision 或规则输入的确定 storage_class。 + +## 3. 正式身份、重算与完整保存 + +开发 CPU profile 升级为 `dfine-cpu-fp32-ocrv6smallcpu-chemical-v2`,bundle 新增 `entity_min=0.9`;联合本地报告为 `cpu-pipeline-local-v5`/`quality-coco80-ocrv6-cpu-v5`。官方模型、COCO80、PP-OCRv6_small、默认 detection_min=0.4、CPU-only 选择保持。 + +内部结果新增 `extraction_context`:实际已加载 bundle/dictionary 的原始 JSON 字节文本。新 profile 必带;分别重新计算 SHA 与 Worker 冻结的 model_checksum/dictionary_sha256 比较,再核 bundle ID/词典ID/pipeline/device/profile及词典引用 SHA。这让 Worker 用被固定 bundle SHA 覆盖的阈值和被固定词典 SHA 覆盖的条目重新计算全部 OCRField/EntityCandidates,拒绝改词典、换阈值、伪候选、遗漏或冒充 facts_ready。内部快照只是有界验证数据,不打开其中的模型路径或请求路径。 + +AI常驻进程、supervisor、HTTP客户端、独立证据进程和事务提交均沿用共享闭包。新增快照/候选完整保存到已有 inference_runs.result JSON,与全部 derivatives 同事务;不扩公共投影、不新增迁移,仍 `0004_ocr_evidence`。旧 profile 的无候选 fixture 可继续校验;非空 entities 无受控快照拒绝。AI/Worker/契约/profile/bundle/lock必须协调升级,历史结果不回填,不改写旧对象。 + +## 4. 本机测试 + +| 验证 | 结果 | +|---|---| +| 新候选/快照黄金用例 | 39项通过:CAS/别名/模糊、冲突/并列/缺值、top5前冲突、低阈值/非有限数、距离/字节容量、坏词典、伪候选/快照 | +| 业务/领域/安全/协议 | 最终1795项通过,包含上行;套件重叠不相加为唯一数 | +| CPU/像素/supervisor/协议 | 75项通过,无跳过;非空词典正向接入、仍needs_review和预算错误验证 | +| fixture/spawn/正式ASGI | 26项通过;新profile实际IPC/HTTP及伪候选整批拒绝使用明确synthetic computation | +| 独立Worker真实像素 | 9项通过;候选快照核验、实际两crop重建,伪候选在读存储前拒绝 | +| 隔离MySQL8.0.33 | 50项通过,含21项unit;候选/快照/全部来源围栏保存,旧租约/版本变动拒绝;非全量持久化重跑 | + +MySQL结构核对为0004/43表115FK,无错误;业务/MySQL有既有 Starlette/anyio 弃用警告,无失败。文档同步后ruff check/format(245文件)、build_specs --check、validate_specs --report(含148 readiness)、git diff --check均通过;三环境pip check通过,importlib检查禁止依赖均未出现,未安装CUDA/Paddle/Torch。最新设计机器报告只代表设计检查,不代替上述应用/存储测试。远程CI未执行。实际回归命令沿用[第40批](40-i-ml01-ocr-fields.md)和[开发指南](../../DEVELOPMENT.md),新增候选定向入口为 `pytest tests/business/test_chemical_candidates.py tests/business/test_cpu_bundle.py -q`。 + +最终收尾检查于2026-10-10(北京时间)完成,CPU pipeline/supervisor定向16项、独立Worker像素9项再次通过,无跳过;与上述套件重叠,不累加为唯一测试数。 + +## 5. 新素材和真实MinIO + +用户已安装 `D:/Tool/minio/bin/minio.exe` 和 `mc.exe`,版本 `RELEASE.2025-07-23T15-54-02Z`。本批使用新建独立临时数据目录、随机 loopback API/console端口及随机测试凭据启动真实 MinIO,未修改 `D:/Tool/minio/data` 或安装配置。仅临时 bucket `labsafe-private` 开启 versioning;测试结束回收 CPU/MinIO进程并清理自己创建的目录,Windows 长路径清理使用经核验的完整路径。 + +为AI建立当前 tenant/lab analysis 的 GetObject/GetObjectVersion 权限;Worker只允许同范围 analysis/derivatives读取和 derivatives写入。Worker对derivatives的限定ListBucket权限仅用于可靠区分HEAD缺失,不允许全bucket或父目录列举。共11项实际AccessDenied:跨租户读取、父目录列表、analysis写入、删除、AI写/读derivatives及Worker删除已存在的准确版本均被拒绝。没有给AI写/删除权限或Worker全bucket权限。 + +真实签名GET中先写分析图V1,再向同key写坏的latest V2;官方模型及独立Worker仍从准确V1读取,核所有裁剪版本/SHA并复用。机器记录见[四图/空词典smoke](41-i-ml01-minio-smoke.json)。四图逐图运行,没有把无关素材冒充同一次巡检的overview/detail。 + +| 用户素材顺序 | 默认质量 | 检测/瓶子 | OCR文字/crop | 唯一关联文字 | 字段/候选 | +|---|---|---|---|---|---| +| 1:试剂瓶货架 | pass | 3/3 | 6/6 | 1 | 0/0 | +| 2:分类柜 | pass | 1/1 | 8/8 | 0 | 0/0 | +| 3:单瓶近照 | pass | 1/0(COCO cup) | 19/19 | 0 | 0/0 | +| 4:实验台 | pass | 0/0 | 1/1 | 0 | 0/0 | + +共34条真实文字/crop,均完整上传/准确版本复用。第三图没有把cup改名bottle,分类柜文字不作为容器或化学安全事实。质量通过只说明既定质量算法通过,不能证明标签足够清晰;四图无完整字段,全部保持needs_review。 + +另有真实OCR合成标签和明确synthetic瓶框的独立正向probe:空词典产生2个字段/1个unknown分组;[测试专用非空词典smoke](41-i-ml01-candidates-smoke.json)用真实冻结SHA/官方OCR/实际MinIO核验2个名称字段共识,结果resolved但仍needs_review。synthetic名称/CAS映射不属于真实化学数据,瓶框标记is_simulated=true,不计现场模型检测或实体准确率。无真实图片/原文/凭据/临时制品提交Git。 + +## 6. 回滚与下一批 + +只允许dev/test,production开关仍禁止。升级先排空旧CPU/固定身份任务,在新空目录重新生成bundle/lock/pin;回滚恢复成套旧代码、契约、环境和身份,不重写历史结果或删除对象,无迁移downgrade。新词典未获数据/安全审核不得成为规则事实来源。 + +下一批第42批:同瓶日期字段聚合、冲突/低置信/未知保留及全部行/crop证据,先冻结DateFact契约与Worker重算;仍needs_review,完整事实快照/规则接线、化学安全数据、现场标注/校准、公共模型activation和部署审批分别后续。实际MinIO loopback固定版本/IAM已验,不等于nginx、公开下载签名、TLS、持久部署/备份与生产权限验收。 diff --git a/docs/08-delivery/41-i-ml01-minio-smoke.json b/docs/08-delivery/41-i-ml01-minio-smoke.json new file mode 100644 index 0000000..1a235d5 --- /dev/null +++ b/docs/08-delivery/41-i-ml01-minio-smoke.json @@ -0,0 +1,171 @@ +{ + "status": "PASS", + "executed_at_utc": "2026-10-09T15:40:46.109387+00:00", + "storage_kind": "real-isolated-minio", + "server_version": "minio.exe version RELEASE.2025-07-23T15-54-02Z (commit-id=7ced9663e6a791fef9dc6be798ff24cda9c730ac)", + "inference_kind": "real-official-onnx-cpu-http", + "identity": { + "service_commit": "local-development", + "pipeline_version": "vision-v1", + "model_bundle_id": "2762e1a4-eeae-44da-b645-1e75a518c5b6", + "dictionary_version_id": "0e81fe49-9dbe-4fca-9bdf-c03888eda50b", + "device_profile": "cpu", + "purpose": "development", + "is_simulated": false, + "adapter_id": "dfine-coco80-rgb-stretch-v1", + "runtime_profile": "dfine-cpu-fp32-ocrv6smallcpu-chemical-v2", + "runtime_lock_sha256": "6debcd5b7851397ca1924b1ee4103e71caea2729d560a88618bfc4eccb72ef64", + "model_checksum": "d0de9c15a8716ebc79629a98ddbbc68a4905f02fc27d5222ad02cf338928f8eb", + "dictionary_sha256": "55ca4509732b5e771e8c6de4c95f0dc1ba0591f3acb79ce1b11198d3aa543132", + "detector_device": "cpu", + "ocr_device": "cpu" + }, + "dictionary_kind": "empty-development", + "images": [ + { + "photo_index": 1, + "source_sha256": "6cd697a4bf94c58a2f23a6c64b73852e4efd74c715f406f05455a5731d6b0f60", + "quality": { + "image_id": "42e2091d-263d-48be-879b-8d583e05286c", + "status": "pass", + "reasons": [], + "blur_score": 972.6889667588671, + "brightness": 0.4532727080021198, + "glare_ratio": 6.306306306306306e-05 + }, + "outcome": "needs_review", + "detections": 3, + "bottles": 3, + "text_regions": 6, + "associated_text_regions": 1, + "detection_classes": { + "bottle": 3 + }, + "fields": 0, + "entity_groups": 0, + "resolution_counts": { + "resolved": 0, + "candidate": 0, + "unknown": 0 + }, + "uploaded_crops": 6 + }, + { + "photo_index": 2, + "source_sha256": "589b28e5ba1cfa546bdf6f78f9dddd04eebc274d95ba54a6b16fcf78d5894dea", + "quality": { + "image_id": "7edff2bf-746f-466c-bceb-b353bda1ba33", + "status": "pass", + "reasons": [], + "blur_score": 309.47272641062483, + "brightness": 0.5592155747311061, + "glare_ratio": 0.0002142748786407767 + }, + "outcome": "needs_review", + "detections": 1, + "bottles": 1, + "text_regions": 8, + "associated_text_regions": 0, + "detection_classes": { + "bottle": 1 + }, + "fields": 0, + "entity_groups": 0, + "resolution_counts": { + "resolved": 0, + "candidate": 0, + "unknown": 0 + }, + "uploaded_crops": 8 + }, + { + "photo_index": 3, + "source_sha256": "0754bade5cb8232de2e2793f747d43c01036ea1628682e41ef9535b6e2c1db4f", + "quality": { + "image_id": "c75745f7-ef32-41c6-9d38-b987f48fdd42", + "status": "pass", + "reasons": [], + "blur_score": 186.7326544807147, + "brightness": 0.5159833072836881, + "glare_ratio": 9.663899739583333e-05 + }, + "outcome": "needs_review", + "detections": 1, + "bottles": 0, + "text_regions": 19, + "associated_text_regions": 0, + "detection_classes": { + "cup": 1 + }, + "fields": 0, + "entity_groups": 0, + "resolution_counts": { + "resolved": 0, + "candidate": 0, + "unknown": 0 + }, + "uploaded_crops": 19 + }, + { + "photo_index": 4, + "source_sha256": "178c3d8d58a89988632ac46a4081045b89e97d0611cbdcb4aff041a684f89a96", + "quality": { + "image_id": "6cddfd08-be6a-4b3c-9793-23a564b3ee4e", + "status": "pass", + "reasons": [], + "blur_score": 920.121898903142, + "brightness": 0.5413711548942177, + "glare_ratio": 0.07434334181348724 + }, + "outcome": "needs_review", + "detections": 0, + "bottles": 0, + "text_regions": 1, + "associated_text_regions": 0, + "detection_classes": {}, + "fields": 0, + "entity_groups": 0, + "resolution_counts": { + "resolved": 0, + "candidate": 0, + "unknown": 0 + }, + "uploaded_crops": 1 + } + ], + "positive_probe": { + "detection_kind": "explicit-synthetic-bottle", + "ocr_kind": "real-official-onnx", + "is_simulated": true, + "fields": 2, + "entity_groups": 1, + "resolution": "unknown", + "uploaded_crops": 3 + }, + "iam_denials": [ + "ai:get_object:denied", + "ai:list_objects_v2:denied", + "ai:put_object:denied", + "ai:delete_object:denied", + "worker:get_object:denied", + "worker:list_objects_v2:denied", + "worker:put_object:denied", + "worker:delete_object:denied", + "ai:put_object:denied", + "ai:get_object:denied", + "worker:delete_object:denied" + ], + "original_version_used_after_latest_overwrite": true, + "derivative_version_sha_and_reuse": true, + "resident_pid_reused": true, + "shutdown": { + "child_reaped": true, + "receiver_reaped": true + }, + "limitations": [ + "No annotated accuracy evaluation or threshold calibration", + "No production chemistry dictionary/safety classification", + "No database commit in smoke; MySQL fence tested separately", + "Loopback HTTP only; no nginx/public presigned URL/TLS or production deployment" + ] +} diff --git a/docs/08-delivery/README.md b/docs/08-delivery/README.md index 45316e2..85f470a 100644 --- a/docs/08-delivery/README.md +++ b/docs/08-delivery/README.md @@ -35,5 +35,16 @@ 33. [I-ML-01 PP-OCRv6_small ONNX CPU 续批](33-i-ml01-ocrv6-cpu.md)。 34. [I-ML-01 真实质量门禁与联合 CPU 流水线](34-i-ml01-quality-cpu-pipeline.md)。 35. [I-ML-01 可重建 OCR 裁剪证据](35-i-ml01-rebuildable-ocr-evidence.md)。 +36. [I-ML-01 受控analysis输入与对象版本冻结](36-i-ml01-controlled-analysis-input.md)。 + +37. [I-ML-01 独立OCR文字证据与Worker原子登记](37-i-ml01-worker-ocr-evidence.md)。 + +38. [I-ML-01 常驻真实CPU HTTP与受控身份门禁](38-i-ml01-resident-cpu-http.md)。 + +39. [I-ML-01 独立文字与真实bottle确定性关联](39-i-ml01-text-bottle-association.md)。 +40. [I-ML-01 OCR字段词法、严格格式与完整多行证据](40-i-ml01-ocr-fields.md)。 +41. [I-ML-01 冻结开发词典、名称候选与实际MinIO验证](41-i-ml01-chemical-candidates.md)。 + +当前进度与下一批以[实施计划](01-implementation-plan.md)为准:31–35批已合并,第36–41批本地完成与验证,尚未提交;下一批第42批接日期聚合与完整DateFact证据闭包。 本页仅导航;现行设计状态见 [文档控制](../00-document-control.md),不把候选设计视为已经批准。 diff --git a/docs/08-delivery/design-validation-results.json b/docs/08-delivery/design-validation-results.json index f2fe140..6307181 100644 --- a/docs/08-delivery/design-validation-results.json +++ b/docs/08-delivery/design-validation-results.json @@ -1,16 +1,16 @@ { "status": "PASS", - "executed_at_utc": "2026-10-07T10:23:09.509474+00:00", + "executed_at_utc": "2026-10-09T16:00:40.374307+00:00", "python_version": "3.11.4", "counts": { "openapi_specs": 2, - "resolved_refs": 1270, + "resolved_refs": 1283, "operations": 99, - "positive_schema_instances": 170, - "negative_schema_instances": 340, - "json_schemas": 8, - "positive_message_instances": 28, - "negative_message_instances": 28, + "positive_schema_instances": 180, + "negative_schema_instances": 360, + "json_schemas": 11, + "positive_message_instances": 31, + "negative_message_instances": 31, "ai_negative_cases": 5, "queue_negative_cases": 3, "tables": 43, @@ -20,7 +20,7 @@ "truth_table_cases": 9, "rule_semantic_negative_cases": 1, "rule_scope_cases": 2, - "doc_links": 355, + "doc_links": 437, "public_operations": 94, "traceable_test_ids": 42, "synthetic_release_gate_checks": 43, diff --git a/docs/README.md b/docs/README.md index 85f5fc8..0f928f8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,11 +1,13 @@ # LabSafe 文档入口 -状态:设计版本 1.1.0;实现进度更新于 2026-10-03。设计结论见 [最新审计](08-delivery/03-design-audit.md),不要把结构检查或局部实现通过当作全系统可上线。 +状态:设计版本 1.1.0;实现进度更新于 2026-10-09。设计结论见 [最新审计](08-delivery/03-design-audit.md),不要把结构检查或局部实现通过当作全系统可上线。 + +当前续批:[第41批冻结开发词典、名称候选与实际MinIO验证](08-delivery/41-i-ml01-chemical-candidates.md)已本地完成;第36–41批未提交/Push/PR。PR #10(截至26批)/#11(27–30批)/#12(31–35批)已合并。下一批第42批接同瓶日期聚合与完整DateFact证据,生产验收仍待完成。 ## 阅读顺序 -实现进度:I-01A/B/C、I-02A/B/C/D/E/F1/F2 已合并(后者为 PR #9);当前在 I-03A1/A2 调度与围栏、I-02F3 general 图像处理、I-03A3 图像任务查询/管理员重放上接通 I-02F4 ready 图像受控下载,并已接通 I-02G1 `submitInspectionItem` 的 run/task/outbox 原子入队;代码本地完成、真实存储部署验收与推理 worker 仍待关闭,完整 I-02/I-03 仍未完成。审批状态、远程 CI 与本机测试分别记录。 -运行入口见 [开发指南](../DEVELOPMENT.md),本轮证据见 [I-02G1 提交入队验收](08-delivery/22-i02g1-submit-inference-enqueue.md),前序证据见 [I-02F4 图片下载验收](08-delivery/21-i02f4-image-download.md) 与 [I-03A3 图像任务重放](08-delivery/20-i03a3-image-job-replay.md),完整阶段状态见下方实施计划。 +实现进度:持久任务、图像处理/下载、推理/规则执行、人工复核、整改及CSV报告已分批交付;真实CPU主线已本地接通常驻HTTP、文字关联/字段和开发词典候选,实际MinIO固定版本及限定IAM已有工程证据。完整I-02/I-03/I-ML-01仍进行中,现场模型、公开签名/TLS/持久部署和独立批准仍待验收。审批状态、远程CI与本机测试分别记录。 +运行入口见 [开发指南](../DEVELOPMENT.md),完整阶段状态和各批证据见[实施计划](08-delivery/01-implementation-plan.md)。 1. [文档控制](00-document-control.md):规范边界和变更要求。 2. [产品](01-requirements/01-product-requirements.md)与[范围](01-requirements/01-scope-and-acceptance.md)。 diff --git a/packages/application/identity.py b/packages/application/identity.py index 76ed1f1..b8be063 100644 --- a/packages/application/identity.py +++ b/packages/application/identity.py @@ -88,7 +88,7 @@ def readiness(self): # This is identity readiness, not the future object/AI/worker readiness gate. with self.engine.connect() as connection: revision = connection.scalar(text("SELECT version_num FROM alembic_version")) - if revision != "0002_report_object_version": + if revision != "0004_ocr_evidence": raise ServiceError("DEPENDENCY_UNAVAILABLE", 503, "Database revision is not ready") self.limits.ready() diff --git a/packages/application/inference_evidence.py b/packages/application/inference_evidence.py new file mode 100644 index 0000000..0756b2f --- /dev/null +++ b/packages/application/inference_evidence.py @@ -0,0 +1,71 @@ +"""Transaction-free, sequential source verification, reconstruction and upload.""" + +from packages.domain.inference_evidence import InferenceDerivative, derivative_key +from packages.image_evidence.analysis import decode_analysis +from packages.image_evidence.perspective import rebuild_ocr_evidence +from packages.inference_protocol.contract import validate +from packages.inference_protocol.evidence import crop_metadata, validate_closure +from packages.inference_protocol.hashing import request_hash + + +def prepare_evidence(storage, source, result): + """Runs only in bounded evidence runtime; returns no partial artifact collection.""" + import cv2 + import numpy as np + + cv2.setNumThreads(1) + validate("InferenceRequest", source) + validate("InferenceResult", result) + if source["request_hash"] != request_hash(source): + raise ValueError("Evidence request hash mismatch") + for key in ( + "run_id", + "tenant_id", + "attempt_id", + "fencing_token", + "request_hash", + "model_bundle_id", + "model_checksum", + "dictionary_version_id", + "dictionary_sha256", + "pipeline_version", + ): + if source[key] != result[key]: + raise ValueError("Evidence result identity mismatch") + if result["input_hashes"] != [ + {"image_id": row["image_id"], "sha256": row["sha256"]} for row in source["image_refs"] + ]: + raise ValueError("Evidence source hash mismatch") + validate_closure(result, [row["image_id"] for row in source["image_refs"]]) + artifacts = [] + for reference in source["image_refs"]: + crops = [crop for crop in result["crops"] if crop["image_id"] == reference["image_id"]] + if not crops: + continue + raw = storage.read_analysis(source["tenant_id"], source["laboratory_id"], reference) + with decode_analysis(raw, reference["sha256"]) as image: + rgb = np.asarray(image) + for crop in crops: + evidence = rebuild_ocr_evidence(rgb, crop_metadata(crop)) + key = derivative_key( + source["tenant_id"], + source["laboratory_id"], + source["run_id"], + crop["crop_id"], + evidence.sha256, + ) + version = storage.put_derivative(key, evidence.png) + artifacts.append( + InferenceDerivative( + **{ + key: crop[key] + for key in ("image_id", "crop_id", "line_id", "detection_id") + }, + object_key=key, + object_version=version, + sha256=evidence.sha256, + size_bytes=len(evidence.png), + ) + ) + del rgb, raw + return tuple(artifacts) diff --git a/packages/application/inference_execution.py b/packages/application/inference_execution.py index f6900eb..7439eae 100644 --- a/packages/application/inference_execution.py +++ b/packages/application/inference_execution.py @@ -1,11 +1,16 @@ """Worker-side inference orchestration with bounded, injectable AI RPC.""" +import http.client import json import os -import urllib.error -import urllib.request +import socket +import ssl +import time from datetime import datetime, timedelta, timezone -from threading import Event, Thread +from importlib.resources import files +from pathlib import Path +from threading import Event, Thread, Timer +from urllib.parse import urlsplit from uuid import uuid4 from packages.application.dispatch import transaction @@ -15,7 +20,9 @@ request_payload, validate_result, ) +from packages.domain.security import ServiceError from packages.inference_protocol.contract import validate +from packages.inference_protocol.evidence import validate_closure from packages.persistence import inference_execution from packages.shared.environment import development_environment @@ -29,36 +36,248 @@ def __init__(self, code): class InferenceHTTPClient: """Small stdlib client; tests may inject an object with quality/runs methods.""" - def __init__(self, endpoint=None, token=None, timeout=200): + def __init__(self, endpoint=None, token=None, timeout=200, expected_version=None): self.endpoint = ( endpoint or os.environ.get("AI_INFERENCE_URL", "http://127.0.0.1:8001") ).rstrip("/") self.token = token if token is not None else _read_token() self.timeout = timeout + self.connection = None + self.response_socket = None + self.cancelled = Event() + self.expected = expected_version + if self.expected is None and os.getenv("AI_EXPECTED_VERSION_FILE"): + try: + with Path(os.environ["AI_EXPECTED_VERSION_FILE"]).open("rb") as source: + raw = source.read(65537) + if len(raw) > 65536: + raise ValueError + self.expected = json.loads(raw) + validate("Version", self.expected) + except (OSError, ValueError): + raise RuntimeError("Invalid AI expected identity file") from None + if self.expected is not None: + validate("Version", self.expected) + self.expected = json.loads(json.dumps(self.expected, allow_nan=False)) + parsed = urlsplit(self.endpoint) + if ( + parsed.scheme not in {"http", "https"} + or not parsed.hostname + or parsed.path + or parsed.query + or parsed.fragment + or parsed.username + or parsed.password + or any(c.isspace() for c in self.endpoint) + or "\\" in self.endpoint + ): + raise RuntimeError("Invalid controlled AI endpoint") + # Plaintext is only for loopback development. No proxy/redirect/provider chain. + import ipaddress - def _post(self, path, payload): - request = urllib.request.Request( - f"{self.endpoint}/internal/inference/v1/{path}", - data=json.dumps(payload, separators=(",", ":")).encode(), - headers={"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"}, - method="POST", + if parsed.scheme == "http" and parsed.hostname != "localhost": + try: + if not ipaddress.ip_address(parsed.hostname).is_loopback: + raise ValueError + except ValueError: + raise RuntimeError("AI HTTP endpoint must be loopback") from None + self.parsed = parsed + + def cancel(self): + self.cancelled.set() + self._abort_socket() + + def _abort_socket(self): + connection = self.connection + if connection is not None: + try: + sock = self.response_socket or connection.sock + if sock: + sock.shutdown(socket.SHUT_RDWR) + connection.close() + except OSError: + pass + + def _remaining(self, deadline): + if self.cancelled.is_set(): + raise InferenceLeaseLost() + remaining = deadline - time.monotonic() + if remaining <= 0: + raise AIRequestError("AI_TIMEOUT") + return remaining + + def _exchange(self, path, deadline, payload=None): + parsed = self.parsed + timeout = self._remaining(deadline) + connection = ( + http.client.HTTPSConnection( + parsed.hostname, parsed.port, timeout=timeout, context=ssl.create_default_context() + ) + if parsed.scheme == "https" + else http.client.HTTPConnection(parsed.hostname, parsed.port, timeout=timeout) ) + self.connection = connection + timer = Timer(timeout, self._abort_socket) + timer.daemon = True + timer.start() try: - with urllib.request.urlopen(request, timeout=self.timeout) as response: - value = json.loads(response.read()) - except urllib.error.HTTPError as error: - try: - body = json.loads(error.read()) - code = body.get("error", {}).get("code", "DEPENDENCY_UNAVAILABLE") - except Exception: - code = "DEPENDENCY_UNAVAILABLE" - raise AIRequestError(code) from None - except (urllib.error.URLError, TimeoutError, OSError): + connection.timeout = min(2, timeout) + connection.connect() + connection.sock.settimeout(self._remaining(deadline)) + raw = ( + None + if payload is None + else json.dumps(payload, separators=(",", ":"), allow_nan=False).encode() + ) + if raw is not None and len(raw) > 64 * 1024: + raise AIRequestError("VALIDATION_ERROR") + connection.request( + "GET" if raw is None else "POST", + f"/internal/inference/v1/{path}", + body=raw, + headers={ + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json", + }, + ) + if connection.sock: + connection.sock.settimeout(self._remaining(deadline)) + response = connection.getresponse() + if response.fp: + self.response_socket = response.fp.raw._sock + chunks, size = [], 0 + while True: + remaining = self._remaining(deadline) + # HTTPResponse may own the socket after Connection: close. + if response.fp: + response.fp.raw._sock.settimeout(remaining) + chunk = response.read1(min(65536, 2 * 1024 * 1024 + 1 - size)) + if not chunk: + break + size += len(chunk) + if size > 2 * 1024 * 1024: + raise AIRequestError("SCHEMA_MISMATCH") + chunks.append(chunk) + self._remaining(deadline) + value = json.loads(b"".join(chunks)) + if response.status != 200: + try: + validate("Error", value) + code = value["error"]["code"] + except (ValueError, KeyError, TypeError): + code = "DEPENDENCY_UNAVAILABLE" + raise AIRequestError(code) + return value + except (TimeoutError, socket.timeout): + raise AIRequestError("AI_TIMEOUT") from None + except (OSError, http.client.HTTPException): + if self.cancelled.is_set(): + raise InferenceLeaseLost() from None + if time.monotonic() >= deadline: + raise AIRequestError("AI_TIMEOUT") from None raise AIRequestError("DEPENDENCY_UNAVAILABLE") from None + except (ValueError, TypeError): + raise AIRequestError("SCHEMA_MISMATCH") from None + finally: + timer.cancel() + timer.join(timeout=2) + connection.close() + self.connection = None + self.response_socket = None + + def _preflight(self, payload, deadline): + ready = self._exchange("ready", deadline) + actual = self._exchange("version", deadline) + try: + validate("Health", ready) + validate("Version", actual) + if actual["purpose"] != "development" or actual["device_profile"] != "cpu": + raise ValueError + if ready["status"] != "ready" or ready["model_bundle_id"] != actual["model_bundle_id"]: + raise ValueError + expected = self.expected + if expected is None: + # Only the checked-in fixture may run without an explicit CPU identity pin. + import hashlib + + fixture = files("apps.ai_inference").joinpath("fixtures") + model = fixture.joinpath("model.json").read_bytes() + dictionary = fixture.joinpath("dictionary.json").read_bytes() + expected = { + "purpose": "development", + "is_simulated": True, + "adapter_id": "fixture-v1", + "runtime_profile": "fixture-v1", + "detector_device": "mock", + "ocr_device": "mock", + "model_bundle_id": json.loads(model)["model_bundle_id"], + "dictionary_version_id": json.loads(dictionary)["dictionary_version_id"], + "model_checksum": hashlib.sha256(model).hexdigest(), + "dictionary_sha256": hashlib.sha256(dictionary).hexdigest(), + "runtime_lock_sha256": hashlib.sha256( + fixture.joinpath("runtime-lock.json").read_bytes() + ).hexdigest(), + "pipeline_version": "vision-v1", + "device_profile": "cpu", + } + if any(actual[key] != value for key, value in expected.items()): + raise ValueError + if any( + actual[key] != payload[key] + for key in ( + "model_bundle_id", + "model_checksum", + "dictionary_version_id", + "dictionary_sha256", + "pipeline_version", + "device_profile", + ) + ): + raise ValueError + except (ValueError, KeyError, TypeError): + raise AIRequestError("MODEL_VERSION_UNAVAILABLE") from None + self._remaining(deadline) + return actual + + def _post(self, path, payload): + remaining = ( + datetime.fromisoformat(payload["deadline_at"].replace("Z", "+00:00")) + - datetime.now(timezone.utc) + ).total_seconds() + deadline = time.monotonic() + min(remaining, self.timeout, 15 if path == "quality" else 200) + actual = self._preflight(payload, deadline) + value = self._exchange(path, deadline, payload) try: validate("InferenceResult", value) + validate_closure(value, [r["image_id"] for r in payload["image_refs"]]) + if ( + value.get("execution_identity", actual if actual["is_simulated"] else None) + != actual + ): + raise ValueError + if any( + value[key] != payload[key] + for key in ( + "run_id", + "attempt_id", + "fencing_token", + "tenant_id", + "request_hash", + "model_bundle_id", + "model_checksum", + "dictionary_version_id", + "dictionary_sha256", + "pipeline_version", + ) + ) or value["input_hashes"] != [ + {"image_id": row["image_id"], "sha256": row["sha256"]} + for row in payload["image_refs"] + ]: + raise ValueError except ValueError: raise AIRequestError("SCHEMA_MISMATCH") from None + value["execution_identity"] = actual + self._remaining(deadline) return value def quality(self, payload): @@ -118,11 +337,12 @@ def __exit__(self, exc_type, exc, traceback): class InferenceExecution: - def __init__(self, engine, client=None): + def __init__(self, engine, client=None, prepare=None): development_environment() self.engine = engine # Sweepers only recover leases and must not require an AI token file. self.client = client + self.prepare = prepare def claim(self, message): with transaction(self.engine) as connection: @@ -136,9 +356,9 @@ def fail(self, lease, code): with transaction(self.engine) as connection: return inference_execution.fail(connection, lease, code) - def commit(self, lease, result): + def commit(self, lease, result, derivatives=()): with transaction(self.engine) as connection: - return inference_execution.commit_result(connection, lease, result) + return inference_execution.commit_result(connection, lease, result, derivatives) def recover_expired(self, limit=100): with transaction(self.engine) as connection: @@ -155,8 +375,14 @@ def execute(self, message): return False cancelled = Event() try: - with InferenceHeartbeat(self, lease, cancelled.set): - client = self.client or InferenceHTTPClient() + client = self.client or InferenceHTTPClient() + + def cancel(): + cancelled.set() + if hasattr(client, "cancel"): + client.cancel() + + with InferenceHeartbeat(self, lease, cancel): quality_payload = request_payload( lease, datetime.now(timezone.utc) + timedelta(seconds=15) ) @@ -164,7 +390,14 @@ def execute(self, message): validate_result(quality, lease) if any( quality[name] - for name in ("detections", "crops", "ocr_fields", "entities", "relations") + for name in ( + "detections", + "text_regions", + "crops", + "ocr_fields", + "entities", + "relations", + ) ): raise AIRequestError("SCHEMA_MISMATCH") if quality["outcome"] == "needs_retake": @@ -193,7 +426,14 @@ def execute(self, message): item["status"] == "needs_retake" for item in result["quality"] ): raise AIRequestError("SCHEMA_MISMATCH") - self.commit(lease, result) + derivatives = () + if result["crops"]: + if self.prepare is None: + raise AIRequestError("DEPENDENCY_UNAVAILABLE") + derivatives = self.prepare(lease, result, cancelled) + if cancelled.is_set(): + raise InferenceLeaseLost() + self.commit(lease, result, derivatives) except InferenceLeaseLost: raise except AIRequestError as error: @@ -202,6 +442,9 @@ def execute(self, message): except InferenceInvalidResult: self.fail(lease, "SCHEMA_MISMATCH") raise + except ServiceError as error: + self.fail(lease, error.code) + raise except Exception: self.fail(lease, "INTERNAL_ERROR") raise diff --git a/packages/domain/inference_evidence.py b/packages/domain/inference_evidence.py new file mode 100644 index 0000000..a2b342a --- /dev/null +++ b/packages/domain/inference_evidence.py @@ -0,0 +1,58 @@ +"""Immutable Worker artifacts and canonical derivative namespace.""" + +from dataclasses import dataclass + +from packages.domain.inference_execution import InferenceInvalidResult +from packages.domain.uploads import digest, identifier + + +def derivative_key(tenant, laboratory, run, crop, sha256): + for value in (tenant, laboratory, run, crop): + identifier(value) + digest(sha256) + return f"tenant/{tenant}/lab/{laboratory}/derivatives/{run}/{crop}/{sha256}.png" + + +@dataclass(frozen=True) +class InferenceDerivative: + image_id: str + crop_id: str + line_id: str + detection_id: str | None + object_key: str + object_version: str + sha256: str + size_bytes: int + + +def validate_derivatives(lease, result, derivatives): + if not isinstance(derivatives, tuple) or len(derivatives) != len(result["crops"]): + raise InferenceInvalidResult() + artifacts = {row.crop_id: row for row in derivatives if isinstance(row, InferenceDerivative)} + if len(artifacts) != len(derivatives): + raise InferenceInvalidResult() + for crop in result["crops"]: + artifact = artifacts.get(crop["crop_id"]) + if artifact is None or any( + getattr(artifact, key) != crop[key] + for key in ("image_id", "crop_id", "line_id", "detection_id") + ): + raise InferenceInvalidResult() + if ( + artifact.sha256 != crop["evidence"]["png_sha256"] + or type(artifact.size_bytes) is not int + or artifact.size_bytes != crop["evidence"]["png_size_bytes"] + or artifact.object_key + != derivative_key( + lease.tenant_id, + lease.input.laboratory_id, + lease.input.run_id, + crop["crop_id"], + artifact.sha256, + ) + or not isinstance(artifact.object_version, str) + or not 1 <= len(artifact.object_version) <= 200 + or artifact.object_version == "null" + or any(not 33 <= ord(c) <= 126 for c in artifact.object_version) + ): + raise InferenceInvalidResult() diff --git a/packages/domain/inference_execution.py b/packages/domain/inference_execution.py index e0c49cb..8b39837 100644 --- a/packages/domain/inference_execution.py +++ b/packages/domain/inference_execution.py @@ -4,6 +4,7 @@ from datetime import datetime from packages.inference_protocol.contract import validate +from packages.inference_protocol.evidence import validate_closure from packages.inference_protocol.hashing import request_hash @@ -49,6 +50,7 @@ def __init__(self): class InferenceImage: image_id: str object_key: str + object_version: str sha256: str mime_type: str role: str @@ -149,3 +151,7 @@ def validate_result(result: dict, lease: InferenceLease) -> None: ] if hashes != expected_hashes: raise InferenceInvalidResult() + try: + validate_closure(result, [image.image_id for image in lease.input.images]) + except ValueError as exc: + raise InferenceInvalidResult() from exc diff --git a/packages/image_evidence/analysis.py b/packages/image_evidence/analysis.py new file mode 100644 index 0000000..1a7fb73 --- /dev/null +++ b/packages/image_evidence/analysis.py @@ -0,0 +1,44 @@ +"""Decode only already normalized analysis PNGs, shared by AI and evidence Worker.""" + +import hashlib +import io +import warnings + +from packages.image_evidence.perspective import CropError + +MAX_ANALYSIS_BYTES = 128 * 1024 * 1024 + + +def decode_analysis(raw, expected_sha256): + from PIL import Image, UnidentifiedImageError + + if not 1 <= len(raw) <= MAX_ANALYSIS_BYTES: + raise CropError("IMAGE_TOO_LARGE", "Analysis byte limit exceeded") + if hashlib.sha256(raw).hexdigest() != expected_sha256: + raise CropError("HASH_MISMATCH", "Analysis content hash differs") + if not raw.startswith(b"\x89PNG\r\n\x1a\n"): + raise CropError("IMAGE_INVALID", "Analysis must be PNG") + try: + with warnings.catch_warnings(): + warnings.simplefilter("error", Image.DecompressionBombWarning) + with Image.open(io.BytesIO(raw)) as source: + if ( + source.format != "PNG" + or source.mode != "RGB" + or getattr(source, "n_frames", 1) != 1 + ): + raise CropError("IMAGE_INVALID", "Analysis must be a static RGB PNG") + if max(source.size) > 10000 or source.width * source.height > 40_000_000: + raise CropError("IMAGE_TOO_LARGE", "Analysis pixel limit exceeded") + if source.info: + raise CropError("IMAGE_INVALID", "Analysis metadata must already be stripped") + source.load() + if source.info: + raise CropError("IMAGE_INVALID", "Analysis metadata must already be stripped") + return source.copy() + except CropError: + raise + except (Image.DecompressionBombError, Image.DecompressionBombWarning): + raise CropError("IMAGE_TOO_LARGE", "Analysis pixel limit exceeded") from None + except (UnidentifiedImageError, OSError, ValueError, SyntaxError): + raise CropError("IMAGE_INVALID", "Analysis PNG is invalid") from None diff --git a/packages/inference_protocol/association.py b/packages/inference_protocol/association.py new file mode 100644 index 0000000..dd5f75f --- /dev/null +++ b/packages/inference_protocol/association.py @@ -0,0 +1,148 @@ +"""Deterministic text-quad to COCO bottle geometry, without numerical dependencies.""" + +import math + +ALGORITHM_ID = "text-bottle-quad80-v1" +BOTTLE_CLASS_ID = 39 +MAX_REGIONS = 100 +MAX_DETECTIONS = 100 + + +def _number(value): + return type(value) in (int, float) and math.isfinite(value) and 0 <= value <= 1 + + +def box(value): + if ( + not isinstance(value, (list, tuple)) + or len(value) != 4 + or not all(_number(v) for v in value) + ): + raise ValueError("Invalid normalized detection box") + x1, y1, x2, y2 = value + if x1 >= x2 or y1 >= y2: + raise ValueError("Detection box must have positive area") + return x1, y1, x2, y2 + + +def polygon(quad): + if not isinstance(quad, (list, tuple)) or len(quad) != 4: + raise ValueError("Text needs four normalized points") + points = [] + for point in quad: + if ( + not isinstance(point, dict) + or set(point) != {"x", "y"} + or not all(_number(point[k]) for k in ("x", "y")) + ): + raise ValueError("Invalid normalized text point") + points.append((point["x"], point["y"])) + crosses = [] + for i in range(4): + a, b, c = points[i], points[(i + 1) % 4], points[(i + 2) % 4] + crosses.append((b[0] - a[0]) * (c[1] - b[1]) - (b[1] - a[1]) * (c[0] - b[0])) + if not all(v > 0 for v in crosses): + raise ValueError("Text quad must be convex and clockwise in image coordinates") + return points + + +def area(points): + # Fan triangles avoid cancellation from large absolute image coordinates. + if len(points) < 3: + return 0.0 + origin = points[0] + return ( + abs( + math.fsum( + (points[i][0] - origin[0]) * (points[i + 1][1] - origin[1]) + - (points[i][1] - origin[1]) * (points[i + 1][0] - origin[0]) + for i in range(1, len(points) - 1) + ) + ) + / 2 + ) + + +def _clip(points, axis, edge, keep_greater): + if not points: + return [] + result = [] + previous = points[-1] + inside_previous = previous[axis] >= edge if keep_greater else previous[axis] <= edge + for current in points: + inside = current[axis] >= edge if keep_greater else current[axis] <= edge + if inside != inside_previous: + ratio = (edge - previous[axis]) / (current[axis] - previous[axis]) + other = 1 - axis + point = [0.0, 0.0] + point[axis] = edge + point[other] = previous[other] + ratio * (current[other] - previous[other]) + result.append(tuple(point)) + if inside: + result.append(current) + previous, inside_previous = current, inside + return result + + +def intersection_area(points, bounds): + clipped = points + for axis, edge, greater in ( + (0, bounds[0], True), + (0, bounds[2], False), + (1, bounds[1], True), + (1, bounds[3], False), + ): + clipped = _clip(clipped, axis, edge, greater) + return area(clipped) + + +def select_bottle(quad, image_id, detections): + """Center inside, >=80% actual quad area, unique smallest bottle area; else None. + + The center is the arithmetic mean of four serialized vertices, not a bounding-box + center. No score/ID tie break, cross-image matching, rounding or epsilon widening. + """ + if len(detections) > MAX_DETECTIONS: + raise ValueError("Association detection capacity exceeded") + points = polygon(quad) + text_area = area(points) + if text_area <= 0: + raise ValueError("Text quad must have positive area") + cx, cy = (math.fsum(p[axis] for p in points) / 4 for axis in (0, 1)) + candidates = [] + seen = set() + for detection in detections: + bounds = box(detection["bbox"]) + key = detection["detection_id"] + if key in seen: + raise ValueError("Duplicate detection identity") + seen.add(key) + if detection["image_id"] != image_id or detection["type"] != "bottle": + continue + if detection["class_id"] != BOTTLE_CLASS_ID: + raise ValueError("Bottle class mismatch") + x1, y1, x2, y2 = bounds + if ( + x1 <= cx <= x2 + and y1 <= cy <= y2 + and (intersection_area(points, bounds) >= 0.8 * text_area) + ): + candidates.append(((x2 - x1) * (y2 - y1), key)) + if not candidates: + return None + smallest = min(size for size, _ in candidates) + winners = [key for size, key in candidates if size == smallest] + return winners[0] if len(winners) == 1 else None + + +def associate_rows(rows, detections): + """Mutate only nullable association; keep line/crop IDs, pixels and OCR unchanged.""" + if len(rows) > MAX_REGIONS or len(detections) > MAX_DETECTIONS: + raise ValueError("Association capacity exceeded") + # Compute all before mutating, so malformed geometry cannot leave partial associations. + associations = [ + select_bottle(row["crop_evidence"]["recipe"]["quad"], row["image_id"], detections) + for row in rows + ] + for row, association in zip(rows, associations): + row["parent_detection_id"] = association diff --git a/packages/inference_protocol/chemical.py b/packages/inference_protocol/chemical.py new file mode 100644 index 0000000..ac83395 --- /dev/null +++ b/packages/inference_protocol/chemical.py @@ -0,0 +1,184 @@ +"""Frozen development dictionary and deterministic candidates, without safety classes.""" + +import hashlib +import json +from collections import defaultdict + +from packages.inference_protocol.contract import validate +from packages.inference_protocol.fields import normalize, valid_cas + +ALGORITHM_ID = "chemical-candidates-v1" +RUNTIME_PROFILE = "dfine-cpu-fp32-ocrv6smallcpu-chemical-v2" +MAX_BYTES = 65536 +MAX_DISTANCE_CELLS = 2_000_000 + + +def key(text): + return normalize(text).casefold() + + +def dictionary(value): + validate("DevelopmentDictionary", value) + seen = set() + for entry in value["entries"]: + names = [key(entry["canonical_name"]), *map(key, entry["aliases"])] + if ( + entry["entity_id"] in seen + or any(not name or len(name) > 500 for name in names) + or len(set(names)) != len(names) + or not entry["source"].strip() + or entry["cas"] is not None + and not valid_cas(entry["cas"]) + ): + raise ValueError("Invalid development dictionary") + seen.add(entry["entity_id"]) + return value + + +def bounded_json(raw): + if not isinstance(raw, str) or len(raw.encode("utf-8")) > MAX_BYTES: + raise ValueError("Extraction snapshot exceeds capacity") + + def unique(pairs): + result = {} + for name, value in pairs: + if name in result: + raise ValueError("Duplicate snapshot key") + result[name] = value + return result + + value = json.loads(raw, object_pairs_hook=unique) + json.dumps(value, allow_nan=False) + return value + + +def context_for_result(result): + context = result.get("extraction_context") + profile = result.get("execution_identity", {}).get("runtime_profile") + if context is None: + if profile == RUNTIME_PROFILE: + raise ValueError("Frozen extraction context required") + return None + bundle_raw, dictionary_raw = context["bundle_json"], context["dictionary_json"] + if ( + hashlib.sha256(bundle_raw.encode()).hexdigest() != result["model_checksum"] + or hashlib.sha256(dictionary_raw.encode()).hexdigest() != result["dictionary_sha256"] + ): + raise ValueError("Extraction snapshot digest mismatch") + bundle = bounded_json(bundle_raw) + validate("DevelopmentCPUBundle", bundle) + value = dictionary(bounded_json(dictionary_raw)) + if ( + bundle["bundle_id"] != result["model_bundle_id"] + or bundle["pipeline_version"] != result["pipeline_version"] + or bundle["dictionary_version_id"] != result["dictionary_version_id"] + or value["dictionary_version_id"] != result["dictionary_version_id"] + or bundle["artifacts"]["dictionary"]["sha256"] != result["dictionary_sha256"] + or bundle["runtime_profile"] != RUNTIME_PROFILE + or profile != RUNTIME_PROFILE + or result["execution_identity"]["purpose"] != "development" + or result["execution_identity"]["device_profile"] != "cpu" + ): + raise ValueError("Extraction identity mismatch") + return value, bundle["thresholds"] + + +def exact_name(text, value): + needle = key(text) + return any( + needle in [key(entry["canonical_name"]), *map(key, entry["aliases"])] + for entry in value["entries"] + ) + + +def distance(left, right, budget): + budget[0] += len(left) * len(right) + if budget[0] > MAX_DISTANCE_CELLS: + raise ValueError("Dictionary distance budget exceeded") + row = list(range(len(right) + 1)) + for i, a in enumerate(left, 1): + following = [i] + for j, b in enumerate(right, 1): + following.append(min(following[-1] + 1, row[j] + 1, row[j - 1] + (a != b))) + row = following + return row[-1] + + +def proposals(text, value, budget): + if not text: + return {} + needle = key(text) + entries = value["entries"] + if valid_cas(needle): + return {e["entity_id"]: (1.0, "cas_exact") for e in entries if e["cas"] == needle} + names = {e["entity_id"]: [key(e["canonical_name"]), *map(key, e["aliases"])] for e in entries} + exact = { + identifier: (1.0, "alias_exact") + for identifier, aliases in names.items() + if needle in aliases + } + if exact: + return exact + result = {} + for identifier, aliases in names.items(): + score = max( + 1 - distance(needle, alias, budget) / max(len(needle), len(alias)) for alias in aliases + ) + if score > 0: + result[identifier] = score, "fuzzy" + return result + + +def extract_entities(fields, value, ocr_min=0.6, entity_min=0.9): + """All name fields vote before top5; missing votes count zero, never disappear.""" + dictionary(value) + if len(fields) > 500 or any( + type(t) not in (int, float) or not 0 <= t <= 1 for t in (ocr_min, entity_min) + ): + raise ValueError("Invalid candidate extraction capacity or thresholds") + groups = defaultdict(list) + for field in fields: + if field["field"] == "name": + groups[field["image_id"], field["detection_id"]].append(field) + by_id = {entry["entity_id"]: entry for entry in value["entries"]} + budget, results = [0], [] + rank = {"cas_exact": 0, "alias_exact": 1, "fuzzy": 2} + for (image, bottle), sources in groups.items(): + votes = [proposals(f["normalized_text"], value, budget) for f in sources] + winners = [] + for field, vote in zip(sources, votes): + best = max((score for score, _ in vote.values()), default=0) + winning = [identifier for identifier, (score, _) in vote.items() if score == best] + winners.append( + winning[0] + if len(winning) == 1 and field["confidence"] >= ocr_min and best >= entity_min + else None + ) + identifiers = set().union(*votes) + candidates = [] + for identifier in identifiers: + score = min(vote.get(identifier, (0, "fuzzy"))[0] for vote in votes) + method = max( + (vote[identifier][1] for vote in votes if identifier in vote), key=rank.get + ) + candidates.append( + { + "entity_id": identifier, + "canonical_name": by_id[identifier]["canonical_name"], + "confidence": score, + "match_method": method, + } + ) + candidates.sort(key=lambda c: (-c["confidence"], c["entity_id"])) + resolved = bool(winners) and None not in winners and len(set(winners)) == 1 + results.append( + { + "image_id": image, + "detection_id": bottle, + "candidates": candidates[:5], + "resolution": "resolved" if resolved else "candidate" if candidates else "unknown", + } + ) + if len(results) > 100: + raise ValueError("Entity capacity exceeded") + return results diff --git a/packages/inference_protocol/contract.json b/packages/inference_protocol/contract.json index 67f3f59..f98d816 100644 --- a/packages/inference_protocol/contract.json +++ b/packages/inference_protocol/contract.json @@ -787,6 +787,22 @@ "minLength": 1, "maxLength": 1024 }, + "object_version": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "pattern": "^[!-~]+$", + "not": { + "anyOf": [ + { + "const": "null" + }, + { + "pattern": "[^!-~]" + } + ] + } + }, "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" @@ -825,6 +841,7 @@ "required": [ "image_id", "object_key", + "object_version", "sha256", "mime_type", "role", @@ -1145,6 +1162,112 @@ ], "additionalProperties": false }, + "CropEvidence": { + "type": "object", + "properties": { + "png_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "png_size_bytes": { + "type": "integer", + "minimum": 1, + "maximum": 16777216 + }, + "source_rgb_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "recognition_rotation_ccw": { + "type": "integer", + "enum": [ + 0, + 90 + ] + }, + "recognition_width": { + "type": "integer", + "minimum": 1, + "maximum": 2048 + }, + "recognition_height": { + "type": "integer", + "minimum": 1, + "maximum": 2048 + }, + "recognition_rgb_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "png_sha256", + "png_size_bytes", + "source_rgb_sha256", + "recognition_rotation_ccw", + "recognition_width", + "recognition_height", + "recognition_rgb_sha256" + ], + "additionalProperties": false + }, + "TextRegion": { + "type": "object", + "properties": { + "line_id": { + "type": "string", + "format": "uuid" + }, + "image_id": { + "type": "string", + "format": "uuid" + }, + "crop_id": { + "type": "string", + "format": "uuid" + }, + "detection_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "description": "text-bottle-quad80-v1: same-image real COCO bottle (class 39), center inside and >=80% quad intersection area, unique smallest bbox; otherwise null. Worker recomputes. No synthetic label/container." + }, + "raw_text": { + "type": "string", + "minLength": 0, + "maxLength": 2000 + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "quad": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Point" + }, + "minItems": 4, + "maxItems": 4 + } + }, + "required": [ + "line_id", + "image_id", + "crop_id", + "detection_id", + "raw_text", + "confidence", + "quad" + ], + "additionalProperties": false + }, "CropRecipe": { "type": "object", "properties": { @@ -1156,10 +1279,22 @@ "type": "string", "format": "uuid" }, - "detection_id": { + "line_id": { "type": "string", "format": "uuid" }, + "detection_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "description": "Same nullable bottle association as the paired TextRegion; it identifies the bottle, not an invented label detection." + }, "quad": { "type": "array", "items": { @@ -1183,16 +1318,51 @@ "enum": [ "perspective-rgb-v1" ] + }, + "evidence": { + "$ref": "#/components/schemas/CropEvidence" } }, "required": [ "crop_id", "image_id", + "line_id", "detection_id", "quad", "output_width", "output_height", - "transform_version" + "transform_version", + "evidence" + ], + "additionalProperties": false + }, + "OCRFieldSource": { + "type": "object", + "properties": { + "line_id": { + "type": "string", + "format": "uuid" + }, + "crop_id": { + "type": "string", + "format": "uuid" + }, + "raw_text": { + "type": "string", + "minLength": 0, + "maxLength": 2000 + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": [ + "line_id", + "crop_id", + "raw_text", + "confidence" ], "additionalProperties": false }, @@ -1211,6 +1381,14 @@ "type": "string", "format": "uuid" }, + "source_lines": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OCRFieldSource" + }, + "minItems": 1, + "maxItems": 2 + }, "field": { "type": "string", "enum": [ @@ -1250,12 +1428,14 @@ "image_id", "detection_id", "crop_id", + "source_lines", "field", "raw_text", "normalized_text", "confidence" ], - "additionalProperties": false + "additionalProperties": false, + "description": "ocr-fields-v1: actual unique bottle, all 1-2 consecutive source lines with original text/confidence/crop. crop_id is first source only; raw_text joins sources with LF and confidence is min. Worker recomputes full ordered fields; no chemical/date facts." }, "Candidate": { "type": "object", @@ -1502,6 +1682,14 @@ "minItems": 0, "maxItems": 100 }, + "text_regions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TextRegion" + }, + "minItems": 0, + "maxItems": 100 + }, "crops": { "type": "array", "items": { @@ -1536,6 +1724,12 @@ }, "timing_ms": { "$ref": "#/components/schemas/Timing" + }, + "execution_identity": { + "$ref": "#/components/schemas/Version" + }, + "extraction_context": { + "$ref": "#/components/schemas/ExtractionContext" } }, "required": [ @@ -1553,6 +1747,7 @@ "outcome", "quality", "detections", + "text_regions", "crops", "ocr_fields", "entities", @@ -1631,6 +1826,14 @@ "cuda" ] }, + "model_checksum": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "dictionary_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, "purpose": { "type": "string", "enum": [ @@ -1654,7 +1857,8 @@ "enum": [ "dfine-cpu-fp32-ocrv6smallcpu-v1", "dfine-cuda-fp32-ocrv6smallcpu-v1", - "fixture-v1" + "fixture-v1", + "dfine-cpu-fp32-ocrv6smallcpu-chemical-v2" ] }, "runtime_lock_sha256": { @@ -1683,6 +1887,8 @@ "model_bundle_id", "dictionary_version_id", "device_profile", + "model_checksum", + "dictionary_sha256", "purpose", "is_simulated", "adapter_id", @@ -1692,6 +1898,515 @@ "ocr_device" ], "additionalProperties": false + }, + "ExtractionContext": { + "type": "object", + "properties": { + "bundle_json": { + "type": "string", + "minLength": 1, + "maxLength": 65536 + }, + "dictionary_json": { + "type": "string", + "minLength": 1, + "maxLength": 65536 + } + }, + "required": [ + "bundle_json", + "dictionary_json" + ], + "additionalProperties": false + }, + "DevelopmentCPUBundle": { + "type": "object", + "properties": { + "purpose": { + "type": "string", + "enum": [ + "development" + ] + }, + "bundle_id": { + "type": "string", + "format": "uuid" + }, + "dictionary_version_id": { + "type": "string", + "format": "uuid" + }, + "pipeline_version": { + "type": "string", + "enum": [ + "vision-v1" + ] + }, + "device_profile": { + "type": "string", + "enum": [ + "cpu" + ] + }, + "adapter_id": { + "type": "string", + "enum": [ + "dfine-coco80-rgb-stretch-v1" + ] + }, + "runtime_profile": { + "type": "string", + "enum": [ + "dfine-cpu-fp32-ocrv6smallcpu-chemical-v2" + ] + }, + "artifacts": { + "type": "object", + "properties": { + "detector_model": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "path", + "sha256" + ], + "additionalProperties": false + }, + "detector_config": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "path", + "sha256" + ], + "additionalProperties": false + }, + "detector_preprocessor": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "path", + "sha256" + ], + "additionalProperties": false + }, + "ocr_det_model": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "path", + "sha256" + ], + "additionalProperties": false + }, + "ocr_det_config": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "path", + "sha256" + ], + "additionalProperties": false + }, + "ocr_rec_model": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "path", + "sha256" + ], + "additionalProperties": false + }, + "ocr_rec_config": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "path", + "sha256" + ], + "additionalProperties": false + }, + "dictionary": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "path", + "sha256" + ], + "additionalProperties": false + }, + "runtime_lock": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "path", + "sha256" + ], + "additionalProperties": false + } + }, + "required": [ + "detector_model", + "detector_config", + "detector_preprocessor", + "ocr_det_model", + "ocr_det_config", + "ocr_rec_model", + "ocr_rec_config", + "dictionary", + "runtime_lock" + ], + "additionalProperties": false + }, + "thresholds": { + "type": "object", + "properties": { + "detection_min": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "ocr_min": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "entity_min": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "blur_min": { + "type": "number", + "minimum": 0 + }, + "dark_min": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "glare_max": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": [ + "detection_min", + "ocr_min", + "entity_min", + "blur_min", + "dark_min", + "glare_max" + ], + "additionalProperties": false + }, + "threads": { + "type": "integer", + "minimum": 1, + "maximum": 16 + } + }, + "required": [ + "purpose", + "bundle_id", + "dictionary_version_id", + "pipeline_version", + "device_profile", + "adapter_id", + "runtime_profile", + "artifacts", + "thresholds", + "threads" + ], + "additionalProperties": false + }, + "DevelopmentCPULock": { + "type": "object", + "properties": { + "purpose": { + "type": "string", + "enum": [ + "development" + ] + }, + "scope": { + "type": "string", + "enum": [ + "cpu-direct-runtime-v1" + ] + }, + "python": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "platform": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "packages": { + "type": "object", + "properties": { + "fastapi": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "pydantic": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "uvicorn": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "PyYAML": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "jsonschema": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "rfc3339-validator": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "onnxruntime": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "numpy": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "Pillow": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "opencv-python-headless": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "pyclipper": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "required": [ + "fastapi", + "pydantic", + "uvicorn", + "PyYAML", + "jsonschema", + "rfc3339-validator", + "onnxruntime", + "numpy", + "Pillow", + "opencv-python-headless", + "pyclipper" + ], + "additionalProperties": false + }, + "crop_runtime_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "code_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "purpose", + "scope", + "python", + "platform", + "packages", + "crop_runtime_sha256", + "code_sha256" + ], + "additionalProperties": false + }, + "DevelopmentDictionaryEntry": { + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "format": "uuid" + }, + "canonical_name": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "aliases": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "minItems": 0, + "maxItems": 10 + }, + "cas": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 12 + }, + { + "type": "null" + } + ] + }, + "source": { + "type": "string", + "minLength": 1, + "maxLength": 1000 + } + }, + "required": [ + "entity_id", + "canonical_name", + "aliases", + "cas", + "source" + ], + "additionalProperties": false + }, + "DevelopmentDictionary": { + "type": "object", + "properties": { + "purpose": { + "type": "string", + "enum": [ + "development" + ] + }, + "dictionary_version_id": { + "type": "string", + "format": "uuid" + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DevelopmentDictionaryEntry" + }, + "minItems": 0, + "maxItems": 100 + } + }, + "required": [ + "purpose", + "dictionary_version_id", + "entries" + ], + "additionalProperties": false } } }, diff --git a/packages/inference_protocol/evidence.py b/packages/inference_protocol/evidence.py new file mode 100644 index 0000000..720faf1 --- /dev/null +++ b/packages/inference_protocol/evidence.py @@ -0,0 +1,198 @@ +"""Result reference closure shared by AI and Worker; no ORM or numerical imports.""" + +import json +from uuid import UUID, uuid5 + +from packages.image_evidence.perspective import CropTransform +from packages.inference_protocol.association import box, select_bottle +from packages.inference_protocol.chemical import context_for_result, extract_entities +from packages.inference_protocol.contract import DOCUMENT +from packages.inference_protocol.fields import extract_fields + +RECIPE_KEYS = ("quad", "output_width", "output_height", "transform_version") +EVIDENCE_KEYS = ( + "png_sha256", + "png_size_bytes", + "source_rgb_sha256", + "recognition_rotation_ccw", + "recognition_width", + "recognition_height", + "recognition_rgb_sha256", +) +MAX_CROP_BYTES = 16 * 1024 * 1024 + + +def region_ids(run_id, image_id, index): + namespace = UUID(run_id) + return ( + str(uuid5(namespace, f"ocr:{image_id}:{index}")), + str(uuid5(namespace, f"ocr-crop:{image_id}:{index}")), + ) + + +def crop_metadata(crop): + return {"recipe": {key: crop[key] for key in RECIPE_KEYS}, **crop["evidence"]} + + +def validate_closure(result, image_ids): + """Schema is checked by caller; all nonempty references must resolve in this result.""" + + def reject(): + raise ValueError("Invalid inference evidence closure") + + images = set(image_ids) + json.dumps(result, allow_nan=False) + identity = result.get("execution_identity") + if identity is not None and any( + identity[key] != result[key] + for key in ( + "model_bundle_id", + "model_checksum", + "dictionary_version_id", + "dictionary_sha256", + "pipeline_version", + ) + ): + reject() + if len(images) != len(image_ids) or not images: + reject() + quality_ids = [row["image_id"] for row in result["quality"]] + if len(set(quality_ids)) != len(quality_ids) or set(quality_ids) != images: + reject() + + def indexed(rows, key): + values = {row[key]: row for row in rows} + if len(values) != len(rows) or any(row["image_id"] not in images for row in rows): + reject() + return values + + detections = indexed(result["detections"], "detection_id") + lines = indexed(result["text_regions"], "line_id") + crops = indexed(result["crops"], "crop_id") + if len(lines) != len(crops): + reject() + + def detection_reference(detection_id, image_id): + if detection_id is not None and ( + detection_id not in detections or detections[detection_id]["image_id"] != image_id + ): + reject() + + def bottle_reference(detection_id, image_id): + detection_reference(detection_id, image_id) + if detection_id is not None: + row = detections[detection_id] + if row["type"] != "bottle" or row["class_id"] != 39: + reject() + + detection_indices = {} + for row in result["detections"]: + box(row["bbox"]) + image = row["image_id"] + index = detection_indices.get(image, 0) + detection_indices[image] = index + 1 + if ( + row["type"] + != DOCUMENT["components"]["schemas"]["Detection"]["properties"]["type"]["enum"][ + row["class_id"] + ] + ): + reject() + if row["detection_id"] != str( + uuid5(UUID(result["run_id"]), f"{image}:{row['type']}:{index}") + ): + reject() + detection_reference(row["parent_detection_id"], row["image_id"]) + visited, parent = {row["detection_id"]}, row["parent_detection_id"] + while parent is not None: + if parent in visited: + reject() + visited.add(parent) + parent = detections[parent]["parent_detection_id"] + counters = {} + for line in result["text_regions"]: + image = line["image_id"] + index = counters.get(image, 0) + counters[image] = index + 1 + if (line["line_id"], line["crop_id"]) != region_ids(result["run_id"], image, index): + reject() + bottle_reference(line["detection_id"], image) + if line["detection_id"] != select_bottle(line["quad"], image, result["detections"]): + reject() + crop = crops.get(line["crop_id"]) + if crop is None or any( + crop[key] != line[key] + for key in ("line_id", "image_id", "crop_id", "detection_id", "quad") + ): + reject() + recipe = CropTransform.from_dict({key: crop[key] for key in RECIPE_KEYS}) + evidence = crop["evidence"] + rotated = recipe.output_height / recipe.output_width >= 1.5 + width, height = ( + (recipe.output_height, recipe.output_width) + if rotated + else (recipe.output_width, recipe.output_height) + ) + if ( + evidence["recognition_rotation_ccw"] != (90 if rotated else 0) + or evidence["recognition_width"] != width + or evidence["recognition_height"] != height + ): + reject() + # Recompute the complete ordered output, including low-confidence/conflicting + # fields and both sources. This also rejects omissions and fabricated values. + context = context_for_result(result) + dictionary = context[0] if context is not None else None + if result["ocr_fields"] != extract_fields(result["text_regions"], dictionary): + reject() + if context is not None: + thresholds = context[1] + if result["entities"] != extract_entities( + result["ocr_fields"], dictionary, thresholds["ocr_min"], thresholds["entity_min"] + ): + reject() + if result["outcome"] == "facts_ready" and any( + result[k] for k in ("detections", "text_regions", "ocr_fields", "entities", "relations") + ): + reject() + elif result["entities"]: + reject() + for entity in result["entities"]: + detection_reference(entity["detection_id"], entity["image_id"]) + for relation in result["relations"]: + for key in ("source_detection_id", "target_detection_id"): + detection_reference(relation[key], relation["image_id"]) + if result["outcome"] == "needs_retake" and any( + result[key] + for key in ("detections", "text_regions", "crops", "ocr_fields", "entities", "relations") + ): + reject() + + +def text_evidence(rows): + """Serialize existing real OCR rows without inventing field or bottle facts.""" + lines, crops = [], [] + for row in rows: + evidence = row["crop_evidence"] + recipe = evidence["recipe"] + association = row["parent_detection_id"] + lines.append( + { + "line_id": row["line_id"], + "image_id": row["image_id"], + "crop_id": evidence["crop_id"], + "detection_id": association, + "raw_text": row["text"], + "confidence": row["confidence"], + "quad": recipe["quad"], + } + ) + crops.append( + { + **{key: evidence[key] for key in ("crop_id", "image_id", "line_id")}, + "detection_id": association, + **recipe, + "evidence": {key: evidence[key] for key in EVIDENCE_KEYS}, + } + ) + return lines, crops diff --git a/packages/inference_protocol/fields.py b/packages/inference_protocol/fields.py new file mode 100644 index 0000000..72cf43b --- /dev/null +++ b/packages/inference_protocol/fields.py @@ -0,0 +1,141 @@ +"""Deterministic OCR lexing, shared by CPU and Worker; no chemical inference.""" + +import re +import unicodedata +from datetime import date + +ALGORITHM_ID = "ocr-fields-v1" +PREFIXES = { + "name": ("化学品名称", "名称", "品名", "NAME", "CAS"), + "expiry": ("有效期", "失效", "EXP"), + "production": ("生产日期", "生产", "MFG"), + "opened": ("开封日期", "开封"), + "concentration": ("浓度", "含量", "CONCENTRATION"), + "hazard_mark": ("危险标识", "危险性", "HAZARD"), +} +ORDERED = sorted( + ((word, kind) for kind, words in PREFIXES.items() for word in words), + key=lambda pair: -len(pair[0]), +) +DATE_WORDS = re.compile( + "|".join( + (r"(? 100 or any(len(line["raw_text"]) > 2000 for line in lines): + raise ValueError("OCR input capacity exceeded") + fields, index = [], 0 + while index < len(lines): + first = lines[index] + text = normalize(first["raw_text"]) + index += 1 + if first["detection_id"] is None or not text: + continue + sources = [first] + found = prefix(text) + if len(DATE_WORDS.findall(text)) >= 2: + kind, value = "date_unknown", None + elif found: + kind, word, value = found + if not value and index < len(lines): + second = lines[index] + following = normalize(second["raw_text"]) + if ( + following + and second["image_id"] == first["image_id"] + and second["detection_id"] == first["detection_id"] + and prefix(following) is None + and len(DATE_WORDS.findall(following)) < 2 + ): + sources.append(second) + value = following + index += 1 + if kind in ("expiry", "production", "opened"): + value = strict_date(value) + elif word == "CAS": + value = value if valid_cas(value) else None + else: + value = value or None + elif valid_cas(text): + kind, value = "name", text + elif dictionary is not None and _exact_name(text, dictionary): + kind, value = "name", text + elif DATE_HINT.search(text): + kind, value = "date_unknown", None + else: + continue + raw = "\n".join(line["raw_text"] for line in sources) + if len(raw) > 2000 or (value is not None and len(value) > 500): + raise ValueError("OCR field capacity exceeded") + fields.append( + { + "image_id": first["image_id"], + "detection_id": first["detection_id"], + "crop_id": first["crop_id"], + "source_lines": [ + {key: line[key] for key in ("line_id", "crop_id", "raw_text", "confidence")} + for line in sources + ], + "field": kind, + "raw_text": raw, + "normalized_text": value, + "confidence": min(line["confidence"] for line in sources), + } + ) + if len(fields) > 500: + raise ValueError("OCR field count exceeded") + return fields + + +def _exact_name(text, dictionary): + from packages.inference_protocol.chemical import exact_name + + return exact_name(text, dictionary) diff --git a/packages/persistence/inference_execution.py b/packages/persistence/inference_execution.py index d0ac0e0..c4916f5 100644 --- a/packages/persistence/inference_execution.py +++ b/packages/persistence/inference_execution.py @@ -9,6 +9,7 @@ from sqlalchemy import text +from packages.domain.inference_evidence import validate_derivatives from packages.domain.inference_execution import ( RETRYABLE_ERRORS, InferenceImage, @@ -105,6 +106,7 @@ def _load(connection, tenant, task_id): connection.execute( text( "SELECT ri.image_id,ri.role,ri.parent_image_id,ri.analysis_sha256," + "ri.analysis_object_version AS frozen_object_version," "ai.analysis_sha256 AS asset_analysis_sha256,ai.analysis_key," "ai.analysis_object_version,ai.mime_type,ai.status AS image_status,i.location_id " "FROM run_images ri JOIN asset_images ai ON ai.tenant_id=ri.tenant_id " @@ -124,6 +126,8 @@ def _load(connection, tenant, task_id): row["analysis_key"] is None or row["analysis_sha256"] != row["asset_analysis_sha256"] or row["analysis_object_version"] in (None, "", "null") + or row["frozen_object_version"] in (None, "", "null") + or row["frozen_object_version"] != row["analysis_object_version"] or row["analysis_sha256"] is None or row["mime_type"] != "image/png" or row["image_status"] != "ready" @@ -134,6 +138,7 @@ def _load(connection, tenant, task_id): InferenceImage( image_id=row["image_id"], object_key=row["analysis_key"], + object_version=row["frozen_object_version"], sha256=row["analysis_sha256"], mime_type=row["mime_type"], role=row["role"], @@ -396,12 +401,50 @@ def fail(connection, lease, code): return True -def commit_result(connection, lease, result): +def commit_result(connection, lease, result, derivatives=()): validate_result(result, lease) + validate_derivatives(lease, result, derivatives) task, current = _load(connection, lease.tenant_id, lease.task_id) now = db_now(connection) if not _owned(task, lease, now) or current != lease.input: raise InferenceLeaseLost() + active_attempt = connection.scalar( + text( + "SELECT id FROM task_attempts WHERE tenant_id=:tenant AND id=:id " + "AND task_id=:task AND status='running' AND lease_owner=:owner " + "AND fencing_token=:token AND replay_generation=:generation AND attempt=:attempt " + "FOR UPDATE" + ), + { + "tenant": lease.tenant_id, + "id": lease.attempt_id, + "task": lease.task_id, + "owner": lease.owner, + "token": lease.token, + "generation": lease.generation, + "attempt": lease.attempt, + }, + ) + if active_attempt is None: + raise InferenceLeaseLost() + crops = {row["crop_id"]: row for row in result["crops"]} + for artifact in derivatives: + connection.execute( + text( + "INSERT INTO image_derivatives " + "(id,tenant_id,run_id,image_id,crop_id,line_id,detection_id,kind,recipe," + "object_key,object_version,sha256,size_bytes) VALUES " + "(:id,:tenant,:run,:image_id,:crop_id,:line_id,:detection_id,'crop',:recipe," + ":object_key,:object_version,:sha256,:size_bytes)" + ), + { + "id": str(uuid4()), + "tenant": lease.tenant_id, + "run": lease.input.run_id, + **artifact.__dict__, + "recipe": canonical_json(crops[artifact.crop_id]), + }, + ) outcome = result["outcome"] if outcome == "needs_retake": run_status, run_stage, item_status = "needs_retake", "done", "needs_retake" @@ -539,6 +582,8 @@ def commit_result(connection, lease, result): "run": lease.input.run_id, }, ) + if not _owned(task, lease, db_now(connection)): + raise InferenceLeaseLost() _attempt(connection, task, lease.attempt_id, "succeeded", None, now) connection.execute( text( diff --git a/packages/persistence/inference_runs.py b/packages/persistence/inference_runs.py index 16c5fc8..f5ea898 100644 --- a/packages/persistence/inference_runs.py +++ b/packages/persistence/inference_runs.py @@ -7,6 +7,7 @@ """ import hashlib +import json from uuid import uuid4 from sqlalchemy import text @@ -105,7 +106,8 @@ def _images(connection, tenant, item_id, selections): rows = ( connection.execute( text( - "SELECT id,tenant_id,laboratory_id,inspection_item_id,status,analysis_sha256 " + "SELECT id,tenant_id,laboratory_id,inspection_item_id,status,analysis_sha256," + "analysis_object_version " "FROM asset_images WHERE tenant_id=:tenant AND inspection_item_id=:item " "ORDER BY id FOR UPDATE" ), @@ -129,7 +131,13 @@ def _images(connection, tenant, item_id, selections): owner_id=item_id, location_id=None, status=row["status"], - available=row["analysis_sha256"] is not None, + available=( + row["analysis_sha256"] is not None + and isinstance(row["analysis_object_version"], str) + and 1 <= len(row["analysis_object_version"]) <= 200 + and row["analysis_object_version"] != "null" + and all(33 <= ord(c) <= 126 for c in row["analysis_object_version"]) + ), ) ) return tuple(loaded), tuple(by_id[s.image_id] for s in selections) @@ -271,7 +279,7 @@ def _projection(connection, tenant, run_id): text( "SELECT r.id,r.created_at,r.updated_at,r.version,r.item_id,r.status,r.stage," "r.submission_revision,r.model_bundle_id,r.dictionary_version_id,r.rule_bundle_id," - "r.pipeline_version,r.result_hash,r.error_code,t.id AS job_id " + "r.pipeline_version,r.result_hash,r.result,r.error_code,t.id AS job_id " "FROM inference_runs r JOIN task_runs t ON t.tenant_id=r.tenant_id " "AND t.resource_id=r.id AND t.task_type='inference_pipeline' " "WHERE r.tenant_id=:tenant AND r.id=:id" @@ -295,10 +303,13 @@ def _projection(connection, tenant, run_id): .all() ) value = dict(row) + raw_result = value.pop("result") + result = json.loads(raw_result) if isinstance(raw_result, str) else (raw_result or {}) + value["text_regions"] = result.get("text_regions", []) value["created_at"] = timestamp(value["created_at"]) value["updated_at"] = timestamp(value["updated_at"]) value["input_image_ids"] = list(images) - value["is_simulated"] = True + value["is_simulated"] = result.get("execution_identity", {}).get("is_simulated", True) return value @@ -387,8 +398,10 @@ def submit(connection, actor, item_id, expected_version, selections, request_id) "image_id": selection.image_id, "role": selection.role, "parent_image_id": selection.parent_image_id, + "analysis_sha256": image["analysis_sha256"], + "analysis_object_version": image["analysis_object_version"], } - for selection in selections + for selection, image in zip(selections, image_rows) ] reference_date = now.date() config = { @@ -429,8 +442,8 @@ def submit(connection, actor, item_id, expected_version, selections, request_id) connection.execute( text( "INSERT INTO run_images (id,tenant_id,item_id,run_id,image_id,ordinal,role," - "parent_image_id,analysis_sha256) " - "VALUES (:id,:tenant,:item,:run,:image,:ordinal,:role,:parent,:sha)" + "parent_image_id,analysis_sha256,analysis_object_version) " + "VALUES (:id,:tenant,:item,:run,:image,:ordinal,:role,:parent,:sha,:object_version)" ), { "id": str(uuid4()), @@ -442,6 +455,7 @@ def submit(connection, actor, item_id, expected_version, selections, request_id) "role": selection.role, "parent": selection.parent_image_id, "sha": image["analysis_sha256"], + "object_version": image["analysis_object_version"], }, ) payload = { diff --git a/packages/persistence/migrations/versions/0003_run_image_version.py b/packages/persistence/migrations/versions/0003_run_image_version.py new file mode 100644 index 0000000..24d6df5 --- /dev/null +++ b/packages/persistence/migrations/versions/0003_run_image_version.py @@ -0,0 +1,24 @@ +"""Freeze analysis object versions for new runs; do not guess historical versions.""" + +import os + +from alembic import op + +revision = "0003_run_image_version" +down_revision = "0002_report_object_version" +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute("ALTER TABLE run_images ADD COLUMN analysis_object_version VARCHAR(200) NULL") + + +def downgrade(): + schema = op.get_bind().engine.url.database + if ( + os.environ.get("APP_ENV") != "test" + or os.environ.get("LABSAFE_ALLOW_DESTRUCTIVE_DOWNGRADE") != schema + ): + raise RuntimeError("Destructive downgrade requires explicitly confirmed test schema") + op.execute("ALTER TABLE run_images DROP COLUMN analysis_object_version") diff --git a/packages/persistence/migrations/versions/0004_ocr_evidence.py b/packages/persistence/migrations/versions/0004_ocr_evidence.py new file mode 100644 index 0000000..cd3d0ab --- /dev/null +++ b/packages/persistence/migrations/versions/0004_ocr_evidence.py @@ -0,0 +1,37 @@ +"""Allow independent OCR evidence while preserving historical derivative rows.""" + +import os + +from alembic import op + +revision = "0004_ocr_evidence" +down_revision = "0003_run_image_version" +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute( + "ALTER TABLE image_derivatives MODIFY COLUMN detection_id CHAR(36) NULL, " + "ADD COLUMN line_id CHAR(36) NULL AFTER detection_id, " + "ADD COLUMN size_bytes BIGINT UNSIGNED NULL AFTER line_id" + ) + + +def downgrade(): + schema = op.get_bind().engine.url.database + if ( + os.environ.get("APP_ENV") != "test" + or os.environ.get("LABSAFE_ALLOW_DESTRUCTIVE_DOWNGRADE") != schema + ): + raise RuntimeError("Destructive downgrade requires explicitly confirmed test schema") + if ( + op.get_bind() + .exec_driver_sql("SELECT COUNT(*) FROM image_derivatives WHERE detection_id IS NULL") + .scalar() + ): + raise RuntimeError("Independent OCR evidence requires forward repair; downgrade refused") + op.execute( + "ALTER TABLE image_derivatives DROP COLUMN line_id, DROP COLUMN size_bytes, " + "MODIFY COLUMN detection_id CHAR(36) NOT NULL" + ) diff --git a/packages/persistence/review.py b/packages/persistence/review.py index 632eba1..910ba27 100644 --- a/packages/persistence/review.py +++ b/packages/persistence/review.py @@ -425,6 +425,7 @@ def _validate_fact_snapshot(connection, tenant, item_id, run, body): for value in detections if isinstance(value, dict) and isinstance(value.get("detection_id"), str) } + detection_images = {value.get("detection_id"): value.get("image_id") for value in detections} image_ids = { value for value in connection.execute( @@ -461,7 +462,9 @@ def evidence_refs(value): raise ServiceError( "STATE_CONFLICT", 409, "Evidence image is not in the current run" ) - if detection_id is not None and detection_id not in detection_ids: + if detection_id is not None and ( + detection_id not in detection_ids or detection_images[detection_id] != image_id + ): raise ServiceError( "STATE_CONFLICT", 409, "Evidence detection is not in the current run" ) diff --git a/packages/persistence/schema.py b/packages/persistence/schema.py index c11bdb4..12a7def 100644 --- a/packages/persistence/schema.py +++ b/packages/persistence/schema.py @@ -23,6 +23,29 @@ def model(): "description": "", "generated_sql": None, } + value["tables"]["run_images"]["columns"]["analysis_object_version"] = { + "type": "VARCHAR(200)", + "nullable": True, + "default_sql": None, + "description": "", + "generated_sql": None, + } + columns = value["tables"]["image_derivatives"]["columns"] + columns["detection_id"]["nullable"] = True + # Preserve generated column ordering, as well as types, without editing old snapshots. + ordered = {} + for name, column in columns.items(): + ordered[name] = column + if name == "detection_id": + for added, sql_type in (("line_id", "CHAR(36)"), ("size_bytes", "BIGINT UNSIGNED")): + ordered[added] = { + "type": sql_type, + "nullable": True, + "default_sql": None, + "description": "", + "generated_sql": None, + } + value["tables"]["image_derivatives"]["columns"] = ordered return value @@ -238,7 +261,7 @@ def rows(query): ) compare(f"{table}.{name}.enforced", checks[table][name]["ENFORCED"], "YES") head = connection.scalar(text("SELECT version_num FROM alembic_version")) - compare("migration_head", head, "0002_report_object_version") + compare("migration_head", head, "0004_ocr_evidence") compare("session.time_zone", connection.scalar(text("SELECT @@session.time_zone")), "+00:00") actual_modes = set(connection.scalar(text("SELECT @@session.sql_mode")).split(",")) required_modes = set(STRICT_SQL_MODE.split(",")) diff --git a/packages/storage/s3.py b/packages/storage/s3.py index 5003421..84918ea 100644 --- a/packages/storage/s3.py +++ b/packages/storage/s3.py @@ -29,7 +29,14 @@ report_key, ) from packages.domain.security import ServiceError -from packages.domain.uploads import GRANT_SECONDS, MAX_BYTES, MIME_TYPES, digest, staging_key +from packages.domain.uploads import ( + GRANT_SECONDS, + MAX_BYTES, + MIME_TYPES, + digest, + identifier, + staging_key, +) BUCKET = "labsafe-private" MAX_ANALYSIS_BYTES = ( @@ -518,6 +525,72 @@ def put_analysis(self, key, payload): except (BotoCoreError, ClientError, OSError) as error: raise self._failure(error) from None + def read_analysis(self, tenant, laboratory, reference): + """Read the frozen canonical version, bound bytes and verify actual SHA.""" + for value in (tenant, laboratory, reference["image_id"]): + identifier(value) + digest(reference["sha256"]) + expected = ( + f"tenant/{tenant}/lab/{laboratory}/analysis/{reference['image_id']}/" + f"{reference['sha256']}.png" + ) + version = self.result_version({"VersionId": reference["object_version"]}) + if reference["object_key"] != expected or reference["mime_type"] != "image/png": + raise ServiceError("SCHEMA_MISMATCH", 502, "Invalid frozen analysis reference") + stream = None + try: + result = self.internal.get_object(Bucket=BUCKET, Key=expected, VersionId=version) + stream = result["Body"] + size = result.get("ContentLength") + if ( + result.get("VersionId") != version + or type(size) is not int + or not 1 <= size <= MAX_ANALYSIS_BYTES + or result.get("ContentType") != "image/png" + or result.get("DeleteMarker") is True + or result.get("ContentEncoding") + ): + raise ServiceError("IMAGE_INVALID", 422, "Frozen analysis metadata differs") + chunks, checksum, total = [], hashlib.sha256(), 0 + while chunk := stream.read(min(64 * 1024, size - total + 1)): + total += len(chunk) + if total > size: + raise ServiceError("IMAGE_INVALID", 422, "Analysis size differs") + checksum.update(chunk) + chunks.append(chunk) + if total != size: + raise ServiceError("IMAGE_INVALID", 422, "Analysis stream was truncated") + if not hmac.compare_digest(checksum.hexdigest(), reference["sha256"]): + raise ServiceError("HASH_MISMATCH", 422, "Analysis hash differs") + return b"".join(chunks) + except (BotoCoreError, ClientError, OSError) as error: + raise self._failure(error) from None + finally: + if stream is not None: + stream.close() + + def put_derivative(self, key, payload): + """Canonical D object, deterministic bytes, explicit non-null VersionId.""" + from packages.domain.inference_evidence import derivative_key + from packages.inference_protocol.evidence import MAX_CROP_BYTES + + if not isinstance(payload, bytes) or not 1 <= len(payload) <= MAX_CROP_BYTES: + raise ServiceError("INTERNAL_ERROR", 500, "Crop encoding exceeds limit") + parts = key.split("/") + sha = hashlib.sha256(payload).hexdigest() + if len(parts) != 8 or key != derivative_key(parts[1], parts[3], parts[5], parts[6], sha): + raise ServiceError("SCHEMA_MISMATCH", 502, "Invalid derivative namespace") + existing = self.reuse_version(key, sha, "image/png", MAX_CROP_BYTES) + if existing is not None: + return existing + try: + result = self.internal.put_object( + Bucket=BUCKET, Key=key, Body=payload, ContentType="image/png" + ) + return self.result_version(result) + except (BotoCoreError, ClientError, OSError) as error: + raise self._failure(error) from None + def put_report(self, source, payload): """Upload deterministic CSV bytes; register only an explicit VersionId.""" if not isinstance(payload, bytes) or not 1 <= len(payload) <= MAX_REPORT_BYTES: diff --git a/pyproject.toml b/pyproject.toml index c802c78..30870d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,15 @@ image-evidence = [ "Pillow==11.3.0", "opencv-python-headless==4.11.0.86", ] +worker-evidence = [ + "numpy==2.2.2", + "Pillow==11.3.0", + "opencv-python-headless==4.11.0.86", + "boto3==1.43.106", + "pydantic==2.10.5", + "jsonschema==4.23.0", + "rfc3339-validator==0.1.4", +] api = [ "fastapi==0.115.6", "pydantic==2.10.5", diff --git a/requirements/py311-worker-evidence.txt b/requirements/py311-worker-evidence.txt new file mode 100644 index 0000000..90d6686 --- /dev/null +++ b/requirements/py311-worker-evidence.txt @@ -0,0 +1,6 @@ +# Independent bounded Worker image/S3 process; no ORM, broker or model runtime. +-r py311-image-evidence.txt +boto3==1.43.106 +pydantic==2.10.5 +jsonschema==4.23.0 +rfc3339-validator==0.1.4 diff --git a/tests/ai/test_analysis.py b/tests/ai/test_analysis.py new file mode 100644 index 0000000..f5f3903 --- /dev/null +++ b/tests/ai/test_analysis.py @@ -0,0 +1,258 @@ +"""Read boundaries without SDK/business packages; numeric PNG cases in CPU environment.""" + +import copy +import hashlib +import importlib.util +import io +import multiprocessing +import os +import time +import unittest +from dataclasses import replace +from unittest.mock import patch + +from apps.ai_inference.adapters.dfine import AdapterError +from apps.ai_inference.analysis import MAX_ANALYSIS_BYTES, AnalysisReader, decode_analysis +from apps.ai_inference.analysis_cpu import run_analysis_pipeline +from apps.ai_inference.inputs import verify_image_inputs +from apps.ai_inference.pipeline_cpu import PipelineOptions +from packages.inference_protocol.contract import validate +from packages.inference_protocol.hashing import request_hash +from tools.ml.analysis_fixture import LAB, TENANT, object_server, payload_for + +REAL = all(importlib.util.find_spec(name) for name in ("PIL", "numpy", "cv2")) + + +class AnalysisReadTests(unittest.TestCase): + def setUp(self): + self.raw = b"synthetic bytes: read tests do not decode pixels" + self.payload = payload_for([self.raw]) + self.ref = self.payload["image_refs"][0] + + def read(self, settings, deadline=None): + return AnalysisReader(settings).read( + self.ref, TENANT, LAB, deadline or time.monotonic() + 3 + ) + + def test_fixed_get_version_signature_and_no_environment_proxy(self): + with object_server(self.payload, [self.raw]) as (settings, seen): + with patch.dict( + os.environ, HTTP_PROXY="http://invalid:1", HTTPS_PROXY="http://invalid:1" + ): + self.assertEqual(self.read(settings), self.raw) + self.assertEqual(len(seen), 1) + path, headers = seen[0] + self.assertTrue(path.endswith("?versionId=synthetic%2Fv0%2Bexact%3D")) + self.assertIn("AWS4-HMAC-SHA256", headers["authorization"]) + self.assertNotIn(settings.secret_key, repr(settings)) + + def test_invalid_refs_and_endpoint_configuration_never_connect(self): + with object_server(self.payload, [self.raw]) as (settings, seen): + for key, value in ( + ("object_key", "https://attacker/image.png"), + ("object_key", self.ref["object_key"].replace("/analysis/", "/original/")), + ("object_version", "null"), + ("object_version", ""), + ("object_version", "line\nfeed"), + ("sha256", "A" * 64), + ("image_id", self.ref["image_id"].upper()), + ): + changed = {**self.ref, key: value} + # Generated UUID may contain no letters; use a known noncanonical UUID. + if key == "image_id": + changed[key] = "AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA" + with self.assertRaises(AdapterError): + AnalysisReader(settings).read(changed, TENANT, LAB, time.monotonic() + 1) + with self.assertRaises(AdapterError): + AnalysisReader(replace(settings, allowed_tenants=frozenset({LAB}))).read( + self.ref, TENANT, LAB, time.monotonic() + 1 + ) + for endpoint in ( + "http://192.168.1.1", + "http://evil.test", + settings.endpoint + "/", + "https://u:p@host", + "https://host?x=1", + ): + with self.assertRaises(AdapterError): + AnalysisReader(replace(settings, endpoint=endpoint)) + self.assertEqual(seen, []) + + def test_response_failures_no_redirect_or_partial_result(self): + cases = [ + ({"status": 302}, "DEPENDENCY_UNAVAILABLE"), + ({"status": 404}, "OBJECT_NOT_FOUND"), + ({"headers": {"x-amz-version-id": "replaced"}}, "HASH_MISMATCH"), + ({"headers": {"x-amz-version-id": None}}, "SCHEMA_MISMATCH"), + ({"headers": {"Content-Type": "image/jpeg"}}, "SCHEMA_MISMATCH"), + ({"headers": {"Content-Length": ["42", "42"]}}, "SCHEMA_MISMATCH"), + ({"headers": {"Content-Length": "-1"}}, "SCHEMA_MISMATCH"), + ({"headers": {"Content-Length": str(MAX_ANALYSIS_BYTES + 1)}}, "IMAGE_TOO_LARGE"), + ({"headers": {"Content-Encoding": "gzip"}}, "SCHEMA_MISMATCH"), + ({"headers": {"Transfer-Encoding": "chunked"}}, "SCHEMA_MISMATCH"), + ({"truncated": True}, "IMAGE_INVALID"), + ] + for arguments, code in cases: + with ( + self.subTest(arguments=arguments), + object_server(self.payload, [self.raw], **arguments) as (settings, seen), + ): + with self.assertRaises(AdapterError) as error: + self.read(settings) + self.assertEqual(error.exception.code, code) + self.assertEqual(len(seen), 1) + with object_server(self.payload, [b"different content"]) as (settings, _): + with self.assertRaises(AdapterError) as error: + self.read(settings) + self.assertEqual(error.exception.code, "HASH_MISMATCH") + + def test_read_deadline_and_spawn_reclaimed_then_next_request(self): + before = {child.pid for child in multiprocessing.active_children()} + with object_server(self.payload, [self.raw], delay=2) as (settings, _): + with self.assertRaises(AdapterError) as error: + self.read(settings, time.monotonic() + 0.05) + self.assertEqual(error.exception.code, "AI_TIMEOUT") + with patch.dict(os.environ, APP_ENV="test"), self.assertRaises(AdapterError) as error: + run_analysis_pipeline( + PipelineOptions(quality_only=True, timeout_seconds=0.1), self.payload, settings + ) + self.assertEqual(error.exception.code, "AI_TIMEOUT") + self.assertEqual({child.pid for child in multiprocessing.active_children()}, before) + with object_server(self.payload, [self.raw]) as (settings, _): + self.assertEqual(self.read(settings), self.raw) + + def test_version_schema_hash_graph_and_host_validation(self): + for version in (None, "", "null", "x" * 201, "a\tb", "v\n"): + changed = copy.deepcopy(self.payload) + changed["image_refs"][0]["object_version"] = version + changed["request_hash"] = request_hash(changed) + with self.assertRaises(ValueError): + validate("InferenceRequest", changed) + with self.assertRaises(AdapterError): + verify_image_inputs(changed, frozenset({TENANT})) + changed = copy.deepcopy(self.payload) + del changed["image_refs"][0]["object_version"] + with self.assertRaises(ValueError): + validate("InferenceRequest", changed) + changed = copy.deepcopy(self.payload) + changed["image_refs"][0]["object_version"] = "another" + self.assertNotEqual(request_hash(changed), self.payload["request_hash"]) + with self.assertRaises(AdapterError) as error: + verify_image_inputs(changed, frozenset({TENANT})) + self.assertEqual(error.exception.code, "HASH_MISMATCH") + changed = copy.deepcopy(self.payload) + changed["attempt_id"] = LAB + changed["deadline_at"] = "2026-01-01T00:00:00Z" + self.assertEqual(request_hash(changed), self.payload["request_hash"]) + for mutation in ( + lambda p: p["image_refs"].append(copy.deepcopy(p["image_refs"][0])), + lambda p: p["image_refs"][0].update(role="detail", parent_image_id=LAB), + ): + changed = copy.deepcopy(self.payload) + mutation(changed) + changed["request_hash"] = request_hash(changed) + with self.assertRaises(AdapterError): + verify_image_inputs(changed, frozenset({TENANT})) + with object_server(self.payload, [self.raw]) as (settings, seen): + with patch.dict(os.environ, APP_ENV="production"), self.assertRaises(AdapterError): + run_analysis_pipeline(PipelineOptions(quality_only=True), self.payload, settings) + with patch.dict(os.environ, APP_ENV="test"), self.assertRaises(AdapterError) as error: + run_analysis_pipeline(PipelineOptions(quality_only=True), changed, settings) + self.assertEqual(seen, []) + + def test_dripping_http10_body_cannot_extend_total_deadline(self): + with object_server(self.payload, [self.raw], chunk_delay=0.04) as (settings, _): + started = time.monotonic() + with self.assertRaises(AdapterError) as error: + self.read(settings, started + 0.2) + self.assertEqual(error.exception.code, "AI_TIMEOUT") + self.assertLess(time.monotonic() - started, 0.5) + + +@unittest.skipUnless(REAL, "PNG/quality tests require the real CPU environment") +class AnalysisPixelTests(unittest.TestCase): + def png(self, mode="RGB", size=(64, 32), **options): + from PIL import Image + + stream = io.BytesIO() + with Image.new(mode, size) as image: + image.save(stream, format="PNG", **options) + return stream.getvalue() + + def decode(self, raw): + return decode_analysis(raw, hashlib.sha256(raw).hexdigest()) + + def test_normalized_pixels_no_rotation_resize_and_reject_originals(self): + from PIL import Image + from PIL.PngImagePlugin import PngInfo + + with self.decode(self.png()) as image: + self.assertEqual(image.size, (64, 32)) + self.assertEqual(image.mode, "RGB") + self.assertEqual(image.info, {}) + metadata = PngInfo() + metadata.add_text("comment", "synthetic") + late = PngInfo() + late.add(b"tEXt", b"comment\0synthetic", after_idat=True) + exif = Image.Exif() + exif[274] = 6 + animated = io.BytesIO() + with Image.new("RGB", (64, 32)) as first, Image.new("RGB", (64, 32), "white") as second: + first.save(animated, format="PNG", save_all=True, append_images=[second]) + for raw in ( + self.png("L"), + self.png("RGBA"), + self.png(pnginfo=metadata), + self.png(pnginfo=late), + self.png(exif=exif), + animated.getvalue(), + b"JPEG", + ): + with self.assertRaises(AdapterError) as error: + self.decode(raw) + self.assertEqual(error.exception.code, "IMAGE_INVALID") + with self.assertRaises(AdapterError) as error: + self.decode(self.png(size=(10001, 1))) + self.assertEqual(error.exception.code, "IMAGE_TOO_LARGE") + + def test_analysis_over_original_12mib_limit_is_supported(self): + import numpy as np + from PIL import Image + + stream = io.BytesIO() + with Image.fromarray( + np.random.default_rng(36).integers(0, 256, (2200, 2200, 3), dtype=np.uint8) + ) as image: + image.save(stream, format="PNG") + raw = stream.getvalue() + self.assertGreater(len(raw), 12 * 1024 * 1024) + with self.decode(raw) as image: + self.assertEqual(image.size, (2200, 2200)) + + def test_remote_pipeline_keeps_worker_ids_and_whole_batch_gate(self): + raw = self.png() + payload = payload_for([raw, raw]) + with ( + object_server(payload, [raw, raw]) as (settings, seen), + patch.dict(os.environ, APP_ENV="test"), + ): + report = run_analysis_pipeline( + PipelineOptions("missing", "missing", "missing"), payload, settings + ) + self.assertEqual(len(seen), 2) + self.assertEqual(report["outcome"], "needs_retake") + self.assertFalse(report["models_executed"]) + self.assertEqual(report["schema_version"], "cpu-pipeline-analysis-v1") + self.assertEqual( + [row["image_id"] for row in report["images"]], + [ref["image_id"] for ref in payload["image_refs"]], + ) + self.assertEqual( + [row["object_version"] for row in report["images"]], + [ref["object_version"] for ref in payload["image_refs"]], + ) + self.assertEqual(report["artifacts"], {}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/ai/test_cpu_http.py b/tests/ai/test_cpu_http.py new file mode 100644 index 0000000..6ae36fd --- /dev/null +++ b/tests/ai/test_cpu_http.py @@ -0,0 +1,208 @@ +"""Same HTTP routes and gate with actual spawn IPC, synthetic computation.""" + +import asyncio +import threading +import time +import unittest +from dataclasses import replace +from types import SimpleNamespace +from unittest.mock import patch + +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 apps.ai_inference.supervisor import CPUSupervisor +from tests.ai.test_supervisor import IDENTITY, fake_child, until +from tests.helpers import configured, make_request + +BASE = "/internal/inference/v1" + + +class CPUHTTPTests(unittest.TestCase): + def test_hash_pinned_candidates_and_forged_score_rejection(self): + for scenario in ("chemical", "wrong_chemical"): + with configured() as token: + settings, app = self.build(scenario) + headers = {"Authorization": f"Bearer {token}"} + with TestClient(app) as client: + self.await_ready(client, headers) + response = client.post( + BASE + "/runs", json=make_request(settings), headers=headers + ) + self.assertEqual( + response.status_code, 200 if scenario == "chemical" else 500, response.text + ) + if scenario == "chemical": + result = response.json() + self.assertEqual(result["entities"][0]["resolution"], "resolved") + self.assertEqual(result["outcome"], "needs_review") + self.assertIn("extraction_context", result) + else: + self.assertEqual(response.json()["error"]["code"], "MODEL_ERROR") + self.assertFalse(app.state.supervisor.ready) + + def test_formal_http_multiline_fields_and_partial_source_rejection(self): + for scenario in ("fields", "wrong_fields"): + with configured() as token: + settings, app = self.build(scenario) + headers = {"Authorization": f"Bearer {token}"} + with TestClient(app) as client: + self.await_ready(client, headers) + response = client.post( + BASE + "/runs", json=make_request(settings), headers=headers + ) + if scenario == "fields": + self.assertEqual(response.status_code, 200, response.text) + result = response.json() + field = result["ocr_fields"][0] + self.assertEqual(field["normalized_text"], "Ethanol") + self.assertEqual(field["confidence"], 0.3) + self.assertEqual(len(field["source_lines"]), 2) + self.assertEqual(result["outcome"], "needs_review") + self.assertEqual(result["entities"], []) + else: + self.assertEqual(response.status_code, 500, response.text) + self.assertEqual(response.json()["error"]["code"], "MODEL_ERROR") + self.assertFalse(app.state.supervisor.ready) + + def test_formal_http_link_and_bad_geometry_response(self): + for scenario in ("association", "wrong_association"): + with configured() as token: + settings, app = self.build(scenario) + headers = {"Authorization": f"Bearer {token}"} + with TestClient(app) as client: + self.await_ready(client, headers) + response = client.post( + BASE + "/runs", json=make_request(settings), headers=headers + ) + if scenario == "association": + self.assertEqual(response.status_code, 200, response.text) + result = response.json() + self.assertEqual( + result["text_regions"][0]["detection_id"], + result["detections"][0]["detection_id"], + ) + self.assertEqual( + result["crops"][0]["detection_id"], + result["text_regions"][0]["detection_id"], + ) + self.assertEqual(result["outcome"], "needs_review") + else: + self.assertEqual(response.status_code, 500, response.text) + self.assertEqual(response.json()["error"]["code"], "MODEL_ERROR") + self.assertFalse(app.state.supervisor.ready) + + def build(self, scenario="ok"): + identity = IDENTITY + if "chemical" in scenario: + from tests.ai.test_supervisor import payload, synthetic_result + from tests.evidence_helpers import add_chemical_context, synthetic_chemical_entries + + identity = add_chemical_context( + synthetic_result(payload(), IDENTITY), synthetic_chemical_entries() + )["execution_identity"] + settings = replace( + Settings.from_env(), + mode="cpu", + identity=identity, + model_checksum=identity["model_checksum"], + dictionary_sha256=identity["dictionary_sha256"], + bundle=SimpleNamespace(identity=identity), + analysis=scenario, + ) + with patch( + "apps.ai_inference.supervisor.CPUSupervisor", + side_effect=lambda b, a: CPUSupervisor(b, a, target=fake_child), + ): + app = create_app(settings) + return settings, app + + def await_ready(self, client, headers): + end = time.monotonic() + 6 + while time.monotonic() < end: + if client.get(BASE + "/ready", headers=headers).status_code == 200: + return + time.sleep(0.01) + self.fail("Synthetic CPU HTTP not ready") + + def test_actual_version_health_result_and_wrong_pin(self): + with configured() as token: + settings, app = self.build() + headers = {"Authorization": f"Bearer {token}"} + with TestClient(app) as client: + self.assertEqual(client.get(BASE + "/health", headers=headers).status_code, 200) + self.await_ready(client, headers) + self.assertEqual(client.get(BASE + "/version", headers=headers).json(), IDENTITY) + self.assertEqual(client.get(BASE + "/version").status_code, 401) + source = make_request(settings) + result = client.post(BASE + "/runs", json=source, headers=headers) + self.assertEqual(result.status_code, 200, result.text) + self.assertFalse(result.json()["execution_identity"]["is_simulated"]) + source["model_checksum"] = "f" * 64 + self.assertEqual( + client.post(BASE + "/runs", json=source, headers=headers).status_code, 422 + ) + oversized = client.post(BASE + "/runs", content=b" " * 65537, headers=headers) + self.assertEqual(oversized.status_code, 422) + self.assertIsNone(app.state.supervisor.process) + self.assertIsNone(app.state.supervisor.receiver) + + def test_busy_same_attempt_conflict_health_and_timeout_dispose(self): + with configured() as token: + settings, app = self.build("timeout") + headers = {"Authorization": f"Bearer {token}"} + with TestClient(app) as client: + self.await_ready(client, headers) + source = make_request(settings, seconds=1) + results = [] + thread = threading.Thread( + target=lambda: results.append( + client.post(BASE + "/runs", json=source, headers=headers) + ) + ) + thread.start() + end = time.monotonic() + 1 + while not app.state.supervisor.busy and time.monotonic() < end: + time.sleep(0.01) + self.assertEqual(client.get(BASE + "/ready", headers=headers).status_code, 200) + conflict = client.post(BASE + "/runs", json=source, headers=headers) + self.assertEqual(conflict.status_code, 409) + from uuid import uuid4 + + other = dict(source, attempt_id=str(uuid4())) + busy = client.post(BASE + "/runs", json=other, headers=headers) + self.assertEqual(busy.status_code, 429) + thread.join(timeout=4) + self.assertFalse(thread.is_alive()) + self.assertEqual(results[0].json()["error"]["code"], "AI_TIMEOUT") + self.assertEqual(client.get(BASE + "/version", headers=headers).status_code, 503) + + def test_asgi_cancel_reaps_child(self): + async def exercise(settings, app, token): + async with app.router.lifespan_context(app): + await until(lambda: app.state.supervisor.ready) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test" + ) as client: + task = asyncio.create_task( + client.post( + BASE + "/runs", + json=make_request(settings), + headers={"Authorization": f"Bearer {token}"}, + ) + ) + await until(lambda: app.state.supervisor.busy) + task.cancel() + with self.assertRaises(asyncio.CancelledError): + await task + self.assertIsNone(app.state.supervisor.process) + + with configured() as token: + settings, app = self.build("timeout") + asyncio.run(exercise(settings, app, token)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/ai/test_pipeline_cpu.py b/tests/ai/test_pipeline_cpu.py index cd9a4a1..ed74573 100644 --- a/tests/ai/test_pipeline_cpu.py +++ b/tests/ai/test_pipeline_cpu.py @@ -164,7 +164,23 @@ def recognize(self, image): "confidence": 0.3, "detection_confidence": 0.8, "quad": [[0, 0], [1, 0], [1, 1], [0, 1]], - "crop_evidence": {}, + "crop_evidence": { + "recipe": { + "quad": [ + {"x": x, "y": y} for x, y in ((0, 0), (1, 0), (1, 1), (0, 1)) + ], + "output_width": 20, + "output_height": 10, + "transform_version": "perspective-rgb-v1", + }, + "png_sha256": "d" * 64, + "png_size_bytes": 100, + "source_rgb_sha256": "e" * 64, + "recognition_rotation_ccw": 0, + "recognition_width": 20, + "recognition_height": 10, + "recognition_rgb_sha256": "f" * 64, + }, } for _ in range(line_count) ] @@ -189,11 +205,16 @@ def test_sessions_loaded_once_decode_once_shared_pixels_ids_and_cleanup(self): self.assertEqual(det.inputs, ocr.inputs) self.assertEqual(report["outcome"], "needs_review") self.assertTrue(report["models_executed"]) - self.assertFalse(any(report["business_capabilities"].values())) + self.assertTrue(report["business_capabilities"]["bottle_association"]) + self.assertFalse(report["business_capabilities"]["chemical_entities"]) + self.assertFalse(report["business_capabilities"]["container_relations"]) for image, row in zip(det.inputs, report["images"]): self.assertTrue(row["inference_executed"]) self.assertTrue(row["lines"][0]["uncertain"]) - self.assertIsNone(row["lines"][0]["parent_detection_id"]) + self.assertEqual( + row["lines"][0]["parent_detection_id"], + row["detections"][0]["detection_id"], + ) self.assertIsNone(row["detections"][0]["parent_detection_id"]) with self.assertRaises(ValueError): image.getpixel((0, 0)) @@ -219,6 +240,52 @@ def test_both_batch_capacities_fail_without_truncation_and_100_is_allowed(self): _compute(self.options, self.paths[:2], self.run_id) self.assertEqual(error.exception.code, "MODEL_ERROR") + def test_joint_pipeline_extracts_both_sources_and_maps_overflow_to_model_error(self): + det, ocr = self._models(line_count=2) + recognize = ocr.recognize + + def lines(image): + result = recognize(image) + result[0].update(text="NAME", confidence=0.9) + result[1].update(text="Ethanol", confidence=0.3) + return result + + ocr.recognize = lines + with ( + patch("apps.ai_inference.pipeline_cpu.OnnxDetector.from_path", return_value=det), + patch("apps.ai_inference.pipeline_cpu.OnnxOCR.from_directories", return_value=ocr), + ): + report = _compute(self.options, self.paths[:1], self.run_id) + field = report["ocr_fields"][0] + self.assertEqual(field["raw_text"], "NAME\nEthanol") + self.assertEqual(field["confidence"], 0.3) + self.assertEqual(len(field["source_lines"]), 2) + self.assertEqual(report["outcome"], "needs_review") + self.assertFalse(report["business_capabilities"]["date_facts"]) + from tests.evidence_helpers import synthetic_chemical_entries + + data = { + "purpose": "development", + "dictionary_version_id": str(uuid4()), + "entries": synthetic_chemical_entries(), + } + + def known_lines(image): + result = recognize(image) + result[0].update(text="NAME", confidence=0.9) + result[1].update(text="Alpha", confidence=0.9) + return result + + ocr.recognize = known_lines + report = _compute(self.options, self.paths[:1], self.run_id, dictionary=data) + self.assertEqual(report["entities"][0]["resolution"], "resolved") + self.assertEqual(report["outcome"], "needs_review") + self.assertTrue(report["business_capabilities"]["chemical_candidates"]) + with patch("apps.ai_inference.pipeline_cpu.extract_fields", side_effect=ValueError): + with self.assertRaises(AdapterError) as error: + _compute(self.options, self.paths[:1], self.run_id) + self.assertEqual(error.exception.code, "MODEL_ERROR") + def test_later_model_failure_closes_all_inputs_and_never_returns_partial_success(self): det, ocr = self._models(fail_second=True) with ( diff --git a/tests/ai/test_supervisor.py b/tests/ai/test_supervisor.py new file mode 100644 index 0000000..33185eb --- /dev/null +++ b/tests/ai/test_supervisor.py @@ -0,0 +1,282 @@ +"""Real spawn/IPC fault tests; synthetic child, no claim of numerical accuracy.""" + +import asyncio +import json +import multiprocessing +import os +import threading +import time +import unittest +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace + +from apps.ai_inference.app.fixture import ProtocolError +from apps.ai_inference.runtime_cpu import send +from apps.ai_inference.supervisor import CPUSupervisor + +ID = "11111111-1111-4111-8111-111111111111" +IDENTITY = { + "service_commit": "test", + "pipeline_version": "vision-v1", + "model_bundle_id": ID, + "dictionary_version_id": ID, + "device_profile": "cpu", + "purpose": "development", + "is_simulated": False, + "adapter_id": "dfine-coco80-rgb-stretch-v1", + "runtime_profile": "dfine-cpu-fp32-ocrv6smallcpu-v1", + "runtime_lock_sha256": "a" * 64, + "model_checksum": "b" * 64, + "dictionary_sha256": "c" * 64, + "detector_device": "cpu", + "ocr_device": "cpu", +} + + +def payload(seconds=5): + return { + "run_id": ID, + "attempt_id": ID, + "fencing_token": 1, + "tenant_id": ID, + "request_hash": "d" * 64, + **{ + k: IDENTITY[k] + for k in ( + "model_bundle_id", + "model_checksum", + "dictionary_version_id", + "dictionary_sha256", + "pipeline_version", + ) + }, + "deadline_at": (datetime.now(timezone.utc) + timedelta(seconds=seconds)).isoformat(), + "image_refs": [{"image_id": ID, "sha256": "e" * 64}], + } + + +def synthetic_result(source, identity): + value = { + **{ + k: source[k] + for k in ( + "run_id", + "attempt_id", + "fencing_token", + "tenant_id", + "request_hash", + "model_bundle_id", + "model_checksum", + "dictionary_version_id", + "dictionary_sha256", + "pipeline_version", + ) + }, + "execution_identity": identity, + "input_hashes": [{"image_id": ID, "sha256": "e" * 64}], + "outcome": "needs_review", + "quality": [ + { + "image_id": ID, + "status": "pass", + "reasons": [], + "blur_score": 100, + "brightness": 0.5, + "glare_ratio": 0, + } + ], + **{ + k: [] + for k in ("detections", "text_regions", "crops", "ocr_fields", "entities", "relations") + }, + "timing_ms": { + k: 0 + for k in ( + "download_ms", + "quality_ms", + "detection_ms", + "ocr_ms", + "normalization_ms", + "total_ms", + ) + }, + } + value["input_hashes"] = [ + {"image_id": row["image_id"], "sha256": row["sha256"]} for row in source["image_refs"] + ] + value["quality"] = [ + dict(value["quality"][0], image_id=row["image_id"]) for row in source["image_refs"] + ] + return value + + +def fake_child(channel, bundle, scenario): + try: + if scenario == "startup_timeout": + time.sleep(20) + if scenario == "startup_fail": + send(channel, {"kind": "error", "code": "MODEL_ERROR"}) + return + identity = dict(bundle.identity) + if scenario == "wrong_identity": + identity["runtime_lock_sha256"] = "f" * 64 + send(channel, {"kind": "loaded", "identity": identity}) + while True: + command = json.loads(channel.recv_bytes(65536)) + if scenario == "crash": + os._exit(2) + if scenario == "timeout": + time.sleep(20) + if scenario == "partial": + channel.send_bytes(b'{"kind":"result","value":') + time.sleep(20) + if scenario == "oversized": + channel.send_bytes(b"x" * (2 * 1024 * 1024 + 1)) + time.sleep(20) + value = synthetic_result(command["payload"], identity) + if scenario in { + "association", + "wrong_association", + "fields", + "wrong_fields", + "chemical", + "wrong_chemical", + }: + from tests.evidence_helpers import add_bottles, add_regions + + source = command["payload"] + lease = SimpleNamespace( + input=SimpleNamespace( + run_id=source["run_id"], + images=(SimpleNamespace(image_id=source["image_refs"][0]["image_id"]),), + ) + ) + add_regions( + lease, value, 2 if "fields" in scenario or "chemical" in scenario else 1 + ) + add_bottles(lease, value, 1) + if scenario == "wrong_association": + value["detections"][0]["bbox"] = [0, 0, 0.7, 1] + if "fields" in scenario: + from packages.inference_protocol.fields import extract_fields + + value["text_regions"][0].update(raw_text="NAME", confidence=0.9) + value["text_regions"][1].update(raw_text="Ethanol", confidence=0.3) + value["ocr_fields"] = extract_fields(value["text_regions"]) + if scenario == "wrong_fields": + value["ocr_fields"][0]["source_lines"].pop() + if "chemical" in scenario: + from tests.evidence_helpers import ( + add_chemical_context, + synthetic_chemical_entries, + ) + + value["text_regions"][0]["raw_text"] = "名称:Alpha" + value["text_regions"][1]["raw_text"] = "名称:甲" + add_chemical_context(value, synthetic_chemical_entries()) + if scenario == "wrong_chemical": + value["entities"][0]["candidates"][0]["confidence"] = 0.1 + if scenario == "stale": + value["fencing_token"] += 1 + send(channel, {"kind": "result", "value": value}) + except (EOFError, OSError): + pass + finally: + channel.close() + + +async def until(predicate, seconds=6): + end = time.monotonic() + seconds + while time.monotonic() < end: + if predicate(): + return + await asyncio.sleep(0.01) + raise AssertionError("Supervisor state did not converge") + + +class SupervisorTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.baseline = {p.pid for p in multiprocessing.active_children()} + self.runtimes = [] + + def runtime(self, scenario="ok"): + runtime = CPUSupervisor(SimpleNamespace(identity=IDENTITY), scenario, target=fake_child) + runtime.startup_seconds = 3 + runtime.reload_seconds = 0.1 + self.runtimes.append(runtime) + runtime.start() + return runtime + + async def asyncTearDown(self): + for runtime in self.runtimes: + await runtime.close() + self.assertEqual({p.pid for p in multiprocessing.active_children()}, self.baseline) + self.assertFalse(any(t.name == "cpu-ipc-receiver" for t in threading.enumerate())) + + async def test_loaded_identity_resident_pid_and_shutdown(self): + runtime = self.runtime() + self.assertFalse(runtime.ready) + await until(lambda: runtime.ready) + pid = runtime.process.pid + for _ in range(2): + result = await runtime.infer(payload(), "runs") + self.assertEqual(result["execution_identity"], IDENTITY) + self.assertEqual(runtime.process.pid, pid) + runtime.closing = True + self.assertFalse(runtime.ready) + with self.assertRaises(ProtocolError): + await runtime.infer(payload(), "runs") + + async def test_three_load_failures_lock_not_ready(self): + for scenario in ("startup_fail", "wrong_identity", "startup_timeout"): + runtime = self.runtime(scenario) + if scenario == "startup_timeout": + runtime.startup_seconds = 0.1 + await until(lambda: runtime.failures == 3, seconds=12) + self.assertFalse(runtime.ready) + self.assertIsNone(runtime.process) + await asyncio.sleep(0.15) + self.assertEqual(runtime.failures, 3) + + async def test_crash_timeout_partial_oversized_and_stale_dispose(self): + for scenario in ("crash", "timeout", "partial", "oversized", "stale"): + runtime = self.runtime(scenario) + await until(lambda: runtime.ready) + pid = runtime.process.pid + with self.assertRaises(ProtocolError): + await runtime.infer(payload(0.15), "runs") + self.assertFalse(runtime.ready) + runtime.analysis = "ok" + await until(lambda: runtime.ready) + self.assertNotEqual(pid, runtime.process.pid) + await runtime.infer(payload(), "runs") + await runtime.close() + + async def test_cancel_and_close_during_loading_reap_process(self): + runtime = self.runtime("timeout") + await until(lambda: runtime.ready) + task = asyncio.create_task(runtime.infer(payload(), "runs")) + await until(lambda: runtime.busy) + task.cancel() + with self.assertRaises(asyncio.CancelledError): + await task + self.assertIsNone(runtime.process) + loading = self.runtime("startup_timeout") + await until(lambda: loading.process is not None) + await loading.close() + self.assertIsNone(loading.process) + + async def test_idle_child_exit_reloads_and_expired_request_keeps_process(self): + runtime = self.runtime() + await until(lambda: runtime.ready) + pid = runtime.process.pid + with self.assertRaises(ProtocolError) as error: + await runtime.infer(payload(-1), "quality") + self.assertEqual(error.exception.code, "AI_TIMEOUT") + self.assertEqual(runtime.process.pid, pid) + runtime.process.kill() + await until(lambda: runtime.ready and runtime.process.pid != pid) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/ai/test_text_association_pixels.py b/tests/ai/test_text_association_pixels.py new file mode 100644 index 0000000..c5a93ec --- /dev/null +++ b/tests/ai/test_text_association_pixels.py @@ -0,0 +1,67 @@ +"""Independent OpenCV polygon checks and real crop bytes; no model accuracy claim.""" + +import importlib.util +import math +import unittest + +from packages.inference_protocol.association import area, intersection_area, polygon, select_bottle + +HAS_PIXELS = all(importlib.util.find_spec(name) for name in ("numpy", "cv2", "PIL")) + + +@unittest.skipUnless(HAS_PIXELS, "Requires isolated real pixel runtime") +class AssociationPixelsTests(unittest.TestCase): + def test_actual_quad_intersections_match_independent_opencv(self): + import cv2 + import numpy as np + + for angle in (0, 13, 30, 45, 60, 87): + radians = math.radians(angle) + corners = [] + for x, y in ((-0.3, -0.1), (0.3, -0.1), (0.3, 0.1), (-0.3, 0.1)): + corners.append( + ( + 0.5 + x * math.cos(radians) - y * math.sin(radians), + 0.5 + x * math.sin(radians) + y * math.cos(radians), + ) + ) + points = polygon([{"x": x, "y": y} for x, y in corners]) + quad = np.asarray(points, np.float32) + self.assertAlmostEqual(area(points), cv2.contourArea(quad), places=7) + for bounds in ( + (0, 0, 1, 1), + (0, 0, 0.5, 1), + (0.35, 0.3, 0.7, 0.7), + (0.8, 0.8, 1, 1), + (0, 0, 0.75, 1), + ): + x1, y1, x2, y2 = bounds + box = np.array(((x1, y1), (x2, y1), (x2, y2), (x1, y2)), np.float32) + expected, _ = cv2.intersectConvexConvex(quad, box) + self.assertAlmostEqual(intersection_area(points, bounds), expected, places=7) + + def test_tilted_crop_digests_and_ids_unaffected_by_association(self): + import numpy as np + + from packages.image_evidence.perspective import CropTransform, rebuild_crop + + y, x = np.indices((80, 120)) + rgb = np.stack((x * 2, y * 3, x + y), axis=-1).astype(np.uint8) + recipe = CropTransform(((0.2, 0.3), (0.8, 0.2), (0.85, 0.6), (0.25, 0.7)), 80, 35) + before = rebuild_crop(rgb, recipe) + detection = { + "image_id": "same", + "detection_id": "bottle", + "class_id": 39, + "type": "bottle", + "bbox": [0.1, 0.1, 0.9, 0.8], + } + self.assertEqual(select_bottle(recipe.as_dict()["quad"], "same", [detection]), "bottle") + after = rebuild_crop(rgb, recipe) + self.assertEqual(before.sha256, after.sha256) + self.assertEqual(before.png, after.png) + self.assertTrue(np.array_equal(before.pixels, after.pixels)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/business/test_analysis_signature.py b/tests/business/test_analysis_signature.py new file mode 100644 index 0000000..1da3895 --- /dev/null +++ b/tests/business/test_analysis_signature.py @@ -0,0 +1,30 @@ +"""Independent botocore oracle; the SDK stays out of the AI environment.""" + +from datetime import datetime, timezone +from unittest.mock import patch + +from botocore.auth import S3SigV4Auth +from botocore.awsrequest import AWSRequest +from botocore.credentials import Credentials + +from apps.ai_inference.analysis import AnalysisSettings, signed_get + + +def test_sigv4_matches_sdk_with_exact_encoded_version(): + settings = AnalysisSettings( + "https://store.test:9443", + ("https://store.test:9443",), + frozenset(), + "synthetic", + "test-only", + ) + instant = datetime(2026, 10, 7, 1, 2, 3, tzinfo=timezone.utc) + path, headers = signed_get(settings, "tenant/synthetic/analysis/a.png", "v/+=%", instant) + request = AWSRequest(method="GET", url=settings.endpoint + path) + with patch("botocore.auth.get_current_datetime", return_value=instant): + S3SigV4Auth( + Credentials(settings.access_key, settings.secret_key), "s3", "us-east-1" + ).add_auth(request) + assert request.headers["Authorization"] == headers["authorization"] + assert request.headers["X-Amz-Date"] == headers["x-amz-date"] + assert request.headers["X-Amz-Content-SHA256"] == headers["x-amz-content-sha256"] diff --git a/tests/business/test_chemical_candidates.py b/tests/business/test_chemical_candidates.py new file mode 100644 index 0000000..c39e1b3 --- /dev/null +++ b/tests/business/test_chemical_candidates.py @@ -0,0 +1,338 @@ +"""Frozen dictionary, consensus and hash-bound Worker replay with synthetic entities.""" + +import copy +import hashlib +import json +from uuid import uuid4 + +import pytest + +from packages.domain.inference_execution import InferenceInvalidResult, validate_result +from packages.inference_protocol.chemical import ( + RUNTIME_PROFILE, + bounded_json, + dictionary, + extract_entities, +) +from packages.inference_protocol.fields import extract_fields +from tests.ai.test_supervisor import IDENTITY +from tests.evidence_helpers import add_bottles, add_regions, lease, result_for + + +def entries(): + return { + "purpose": "development", + "dictionary_version_id": str(uuid4()), + "entries": [ + { + "entity_id": "11111111-1111-4111-8111-111111111111", + "canonical_name": "Alpha", + "aliases": ["甲", "shared"], + "cas": "64-17-5", + "source": "synthetic test-only mapping; no chemical safety data", + }, + { + "entity_id": "22222222-2222-4222-8222-222222222222", + "canonical_name": "Beta", + "aliases": ["乙", "shared"], + "cas": None, + "source": "synthetic test-only mapping; no chemical safety data", + }, + ], + } + + +def fields(*texts): + return extract_fields( + [ + { + "image_id": "image", + "detection_id": "bottle", + "line_id": f"line{i}", + "crop_id": f"crop{i}", + "raw_text": "NAME:" + text, + "confidence": 0.9, + } + for i, text in enumerate(texts) + ] + ) + + +@pytest.mark.parametrize( + "text,method", + [ + ("64-17-5", "cas_exact"), + ("ALPHA", "alias_exact"), + ("甲", "alias_exact"), + ("Alph", "fuzzy"), + ], +) +def test_cas_alias_fuzzy_order_and_normalization(text, method): + result = extract_entities(fields(text), entries())[0] + assert result["candidates"][0]["canonical_name"] == "Alpha" + assert result["candidates"][0]["match_method"] == method + assert result["resolution"] == ("candidate" if method == "fuzzy" else "resolved") + + +@pytest.mark.parametrize( + "texts,resolution", + [ + (("Alpha", "甲"), "resolved"), + (("Alpha", "Beta"), "candidate"), + (("Alpha", ""), "candidate"), + (("shared",), "candidate"), + (("12-34-1",), "unknown"), + ], +) +def test_all_fields_vote_conflicts_ties_and_missing_never_disappear(texts, resolution): + result = extract_entities(fields(*texts), entries())[0] + assert result["resolution"] == resolution + if texts in [("Alpha", "Beta"), ("Alpha", "")]: + assert all(c["confidence"] == 0 for c in result["candidates"]) + + +def test_threshold_edges_min_scores_and_no_cross_image_consensus(): + source = fields("Alpha", "Alph") + source[1]["confidence"] = 0.6 + assert extract_entities(source, entries(), entity_min=0.8)[0]["resolution"] == "resolved" + assert ( + extract_entities(source, entries(), entity_min=0.80000001)[0]["resolution"] == "candidate" + ) + source[1]["confidence"] = 0.5999999 + assert extract_entities(source, entries(), entity_min=0.8)[0]["resolution"] == "candidate" + source[1]["image_id"] = "different" + assert len(extract_entities(source, entries())) == 2 + + +def test_empty_dictionary_unknown_and_unprefixed_exact_only(): + value = entries() + value["entries"] = [] + assert extract_entities(fields("Alpha"), value)[0]["resolution"] == "unknown" + line = fields("Alpha")[0] + line = { + **line["source_lines"][0], + "image_id": "image", + "detection_id": "bottle", + "raw_text": "ALPHA", + } + assert extract_fields([line], entries())[0]["normalized_text"] == "ALPHA" + line["raw_text"] = "Alph" + assert extract_fields([line], entries()) == [] + + +@pytest.mark.parametrize( + "case", + ["duplicate_id", "empty", "aliases", "bad_cas", "source", "101", "too_long", "storage_class"], +) +def test_dictionary_bad_content_rejected(case): + value = entries() + first = value["entries"][0] + if case == "duplicate_id": + value["entries"][1]["entity_id"] = first["entity_id"] + elif case == "empty": + first["canonical_name"] = " " + elif case == "aliases": + first["aliases"] = ["ALPHA"] + elif case == "bad_cas": + first["cas"] = "64-17-6" + elif case == "source": + first["source"] = " " + elif case == "101": + value["entries"] *= 51 + elif case == "too_long": + first["aliases"] = ["x" * 201] + else: + first["storage_class"] = "flammable" + with pytest.raises(ValueError): + dictionary(value) + + +def test_cas_ambiguity_preserved_and_no_fuzzy_guess_for_valid_unknown_cas(): + value = entries() + value["entries"][1]["cas"] = "64-17-5" + result = extract_entities(fields("64-17-5"), value)[0] + assert result["resolution"] == "candidate" and len(result["candidates"]) == 2 + assert extract_entities(fields("7732-18-5"), value)[0]["resolution"] == "unknown" + + +def test_top5_does_not_hide_sixth_winner_conflict(): + value = entries() + value["entries"] = [ + { + "entity_id": f"00000000-0000-4000-8000-{i:012d}", + "canonical_name": f"Name{i}", + "aliases": ["many"] if i < 5 else [], + "cas": None, + "source": "synthetic", + } + for i in range(6) + ] + result = extract_entities(fields("many", "Name5"), value)[0] + assert result["resolution"] == "candidate" and len(result["candidates"]) == 5 + assert all(c["confidence"] == 0 for c in result["candidates"]) + + +def test_distance_budget_and_snapshot_size_duplicate_keys(): + value = entries() + value["entries"] = [ + { + "entity_id": str(uuid4()), + "canonical_name": "b" * 200, + "aliases": [], + "cas": None, + "source": "synthetic", + } + for _ in range(100) + ] + with pytest.raises(ValueError, match="budget"): + extract_entities(fields("a" * 200), value) + for raw in ('{"purpose":1,"purpose":2}', " " * 65537, '"' + "甲" * 22000 + '"'): + with pytest.raises(ValueError): + bounded_json(raw) + + +@pytest.mark.parametrize( + "ocr,entity", [(float("nan"), 0.9), (0.6, float("inf")), (-0.1, 0.9), (0.6, 1.1)] +) +def test_nonfinite_or_out_of_range_thresholds_fail(ocr, entity): + with pytest.raises(ValueError): + extract_entities(fields("Alpha"), entries(), ocr, entity) + + +def chemical_case(): + value = lease() + result = add_bottles(value, add_regions(value, result_for(value, "needs_review"))) + data = entries() + result["dictionary_version_id"] = value.input.dictionary_version_id + data["dictionary_version_id"] = value.input.dictionary_version_id + raw = json.dumps(data, ensure_ascii=False) + result["dictionary_sha256"] = hashlib.sha256(raw.encode()).hexdigest() + bundle = { + "purpose": "development", + "bundle_id": value.input.model_bundle_id, + "dictionary_version_id": value.input.dictionary_version_id, + "pipeline_version": "vision-v1", + "device_profile": "cpu", + "adapter_id": "dfine-coco80-rgb-stretch-v1", + "runtime_profile": RUNTIME_PROFILE, + "artifacts": { + name: { + "path": "C:/synthetic/" + name, + "sha256": result["dictionary_sha256"] if name == "dictionary" else "a" * 64, + } + for name in ( + "detector_model", + "detector_config", + "detector_preprocessor", + "ocr_det_model", + "ocr_det_config", + "ocr_rec_model", + "ocr_rec_config", + "dictionary", + "runtime_lock", + ) + }, + "thresholds": { + "detection_min": 0.4, + "ocr_min": 0.6, + "entity_min": 0.9, + "blur_min": 80, + "dark_min": 0.12, + "glare_max": 0.3, + }, + "threads": 2, + } + bundle_raw = json.dumps(bundle) + result["model_checksum"] = hashlib.sha256(bundle_raw.encode()).hexdigest() + result["extraction_context"] = {"bundle_json": bundle_raw, "dictionary_json": raw} + result["execution_identity"] = dict( + IDENTITY, + runtime_profile=RUNTIME_PROFILE, + **{ + k: result[k] + for k in ( + "model_bundle_id", + "model_checksum", + "dictionary_version_id", + "dictionary_sha256", + "pipeline_version", + ) + }, + ) + result["text_regions"][0]["raw_text"] = "名称:Alpha" + result["text_regions"][1]["raw_text"] = "名称:甲" + result["ocr_fields"] = extract_fields(result["text_regions"], data) + result["entities"] = extract_entities(result["ocr_fields"], data) + from dataclasses import replace + + value = replace( + value, + input=replace( + value.input, + model_checksum=result["model_checksum"], + dictionary_sha256=result["dictionary_sha256"], + ), + ) + result["request_hash"] = value.input.request_hash + return value, result + + +def test_worker_recomputes_candidates_using_original_pinned_bytes(): + value, result = chemical_case() + validate_result(result, value) + assert result["entities"][0]["resolution"] == "resolved" + assert result["outcome"] == "needs_review" + + +@pytest.mark.parametrize( + "case", + [ + "missing_context", + "dictionary", + "bundle", + "threshold", + "version", + "candidate", + "confidence", + "method", + "resolution", + "omit", + "duplicate", + "facts_ready", + ], +) +def test_worker_rejects_wrong_snapshot_or_candidates(case): + value, result = chemical_case() + if case == "missing_context": + result.pop("extraction_context") + elif case in ("dictionary", "bundle", "threshold", "version"): + context = result["extraction_context"] + which = "dictionary_json" if case in ("dictionary", "version") else "bundle_json" + obj = json.loads(context[which]) + if case == "dictionary": + obj["entries"] = [] + elif case == "threshold": + obj["thresholds"]["entity_min"] = 0 + elif case == "version": + obj["dictionary_version_id"] = str(uuid4()) + else: + obj["bundle_id"] = str(uuid4()) + context[which] = json.dumps(obj) + elif case == "omit": + result["entities"] = [] + elif case == "duplicate": + result["entities"].append(copy.deepcopy(result["entities"][0])) + elif case == "facts_ready": + result["outcome"] = "facts_ready" + elif case == "resolution": + result["entities"][0]["resolution"] = "unknown" + else: + candidate = result["entities"][0]["candidates"][0] + name, wrong = { + "candidate": ("canonical_name", "forged"), + "confidence": ("confidence", 0.1), + "method": ("match_method", "fuzzy"), + }[case] + candidate[name] = wrong + with pytest.raises(InferenceInvalidResult): + validate_result(result, value) diff --git a/tests/business/test_cpu_bundle.py b/tests/business/test_cpu_bundle.py new file mode 100644 index 0000000..f2098a4 --- /dev/null +++ b/tests/business/test_cpu_bundle.py @@ -0,0 +1,200 @@ +"""Controlled metadata failures before any model/network activity.""" + +import copy +from pathlib import Path +from unittest.mock import patch +from uuid import uuid4 + +import pytest + +from apps.ai_inference.adapters.dfine import AdapterError +from apps.ai_inference.bundle import CPUBundle, bounded_json, digest +from tools.ml.create_cpu_bundle import write + + +@pytest.fixture +def local_bundle(tmp_path): + dictionary_id = str(uuid4()) + artifacts = {} + for name in ( + "detector_model", + "detector_config", + "detector_preprocessor", + "ocr_det_model", + "ocr_det_config", + "ocr_rec_model", + "ocr_rec_config", + "dictionary", + "runtime_lock", + ): + directory = tmp_path / name.removesuffix("_model").removesuffix("_config") + directory.mkdir(exist_ok=True) + filename = ( + "inference.onnx" + if name.startswith("ocr_") and name.endswith("_model") + else ("inference.yml" if name.startswith("ocr_") else "artifact.json") + ) + path = directory / filename + path.write_bytes(name.encode()) + artifacts[name] = {"path": str(path), "sha256": digest(path.read_bytes())} + value = { + "purpose": "development", + "bundle_id": str(uuid4()), + "dictionary_version_id": dictionary_id, + "pipeline_version": "vision-v1", + "device_profile": "cpu", + "adapter_id": "dfine-coco80-rgb-stretch-v1", + "runtime_profile": "dfine-cpu-fp32-ocrv6smallcpu-chemical-v2", + "artifacts": artifacts, + "thresholds": { + "detection_min": 0.4, + "ocr_min": 0.6, + "entity_min": 0.9, + "blur_min": 80, + "dark_min": 0.12, + "glare_max": 0.3, + }, + "threads": 2, + } + lock = { + "purpose": "development", + "scope": "cpu-direct-runtime-v1", + "python": "3.11.4", + "platform": "test", + "packages": { + k: "1" + for k in ( + "fastapi", + "pydantic", + "uvicorn", + "PyYAML", + "jsonschema", + "rfc3339-validator", + "onnxruntime", + "numpy", + "Pillow", + "opencv-python-headless", + "pyclipper", + ) + }, + "crop_runtime_sha256": "a" * 64, + "code_sha256": "b" * 64, + } + write( + Path(artifacts["dictionary"]["path"]), + {"purpose": "development", "dictionary_version_id": dictionary_id, "entries": []}, + ) + write(Path(artifacts["runtime_lock"]["path"]), lock) + for artifact in artifacts.values(): + artifact["sha256"] = digest(Path(artifact["path"]).read_bytes()) + return tmp_path, value, lock + + +def load(local_bundle, changes=None, roots=None): + root, value, _ = local_bundle + value = copy.deepcopy(value) + if changes: + changes(value) + path = root / "bundle.json" + write(path, value) + return CPUBundle.load(str(path), digest(path.read_bytes()), roots or (str(root),)) + + +def test_actual_identity_options_and_snapshot(local_bundle): + bundle = load(local_bundle) + with patch("apps.ai_inference.bundle.runtime_lock", return_value=local_bundle[2]): + assert bundle.verify() == bundle.identity + assert bundle.identity["is_simulated"] is False + assert bundle.options().threads == 2 + changed = bundle.manifest + changed["threads"] = 1 + assert bundle.manifest["threads"] == 2 + + +@pytest.mark.parametrize( + "field,value", + [ + ("purpose", "production"), + ("device_profile", "cuda"), + ("adapter_id", "fixture-v1"), + ("threads", 0), + ], +) +def test_wrong_profile_rejected(local_bundle, field, value): + with pytest.raises(ValueError): + load(local_bundle, lambda v: v.update({field: value})) + + +def test_escape_root_and_wrong_ocr_filename_rejected(local_bundle): + root = local_bundle[0] + with pytest.raises(ValueError): + load(local_bundle, roots=(str(root / "dictionary"),)) + path = root / "wrong.onnx" + path.write_bytes(b"wrong") + with pytest.raises(ValueError): + load(local_bundle, lambda v: v["artifacts"]["ocr_det_model"].update(path=str(path))) + + +@pytest.mark.parametrize("name", ["detector_model", "dictionary", "runtime_lock", "ocr_rec_config"]) +def test_changed_artifact_or_lock_prevents_load(local_bundle, name): + bundle = load(local_bundle) + Path(bundle.manifest["artifacts"][name]["path"]).write_bytes(b"changed") + with patch("apps.ai_inference.bundle.runtime_lock", return_value=local_bundle[2]): + with pytest.raises(AdapterError) as error: + bundle.verify() + assert error.value.code == "MODEL_VERSION_UNAVAILABLE" + + +def test_runtime_drift_or_manifest_drift_rejected(local_bundle): + bundle = load(local_bundle) + with patch("apps.ai_inference.bundle.runtime_lock", return_value={}): + with pytest.raises(AdapterError): + bundle.verify() + Path(bundle.path).write_bytes(b"changed") + with pytest.raises(AdapterError): + bundle.verify() + + +def test_pinned_digest_and_small_json_cap(local_bundle): + bundle = load(local_bundle) + with pytest.raises(ValueError): + CPUBundle.load(bundle.path, "0" * 64, bundle.roots) + Path(bundle.path).write_bytes(b" " * 65537) + with pytest.raises(ValueError): + bounded_json(bundle.path) + + +def test_dictionary_wrong_id_even_with_correct_file_digest(local_bundle): + root, value, lock = local_bundle + artifact = value["artifacts"]["dictionary"] + write( + Path(artifact["path"]), + {"purpose": "development", "dictionary_version_id": str(uuid4()), "entries": []}, + ) + artifact["sha256"] = digest(Path(artifact["path"]).read_bytes()) + bundle = load((root, value, lock)) + with patch("apps.ai_inference.bundle.runtime_lock", return_value=lock): + with pytest.raises(AdapterError): + bundle.verify() + + +def test_semantically_invalid_dictionary_rejected_with_correct_digest(local_bundle): + root, value, lock = local_bundle + from tests.evidence_helpers import synthetic_chemical_entries + + entries = synthetic_chemical_entries() + entries[0]["cas"] = "64-17-6" + artifact = value["artifacts"]["dictionary"] + write( + Path(artifact["path"]), + { + "purpose": "development", + "dictionary_version_id": value["dictionary_version_id"], + "entries": entries, + }, + ) + artifact["sha256"] = digest(Path(artifact["path"]).read_bytes()) + bundle = load((root, value, lock)) + with patch("apps.ai_inference.bundle.runtime_lock", return_value=lock): + with pytest.raises(AdapterError): + bundle.verify() diff --git a/tests/business/test_identity_application.py b/tests/business/test_identity_application.py index 7e38389..58f5b8d 100644 --- a/tests/business/test_identity_application.py +++ b/tests/business/test_identity_application.py @@ -38,7 +38,15 @@ def test_non_deadlock_database_errors_are_not_retried(errno): @pytest.mark.parametrize( - "revision", ["0002_report_object_version", "0001_initial", "unknown", None] + "revision", + [ + "0004_ocr_evidence", + "0003_run_image_version", + "0002_report_object_version", + "0001_initial", + "unknown", + None, + ], ) def test_readiness_checks_revision_and_releases_connection_before_redis(revision): connected = False @@ -61,7 +69,7 @@ def ready(): engine.connect.side_effect = connect limits.ready.side_effect = ready app = IdentityApplication(engine, Mock(), limits, "test") - if revision == "0002_report_object_version": + if revision == "0004_ocr_evidence": app.readiness() limits.ready.assert_called_once_with() else: diff --git a/tests/business/test_inference_evidence.py b/tests/business/test_inference_evidence.py new file mode 100644 index 0000000..f28a667 --- /dev/null +++ b/tests/business/test_inference_evidence.py @@ -0,0 +1,288 @@ +"""Wire closure, Worker failures, scope and artifact completeness without numeric deps.""" + +import copy +import json +from dataclasses import replace +from threading import Event +from unittest.mock import Mock +from uuid import UUID, uuid4, uuid5 + +import pytest + +from apps.worker.inference_evidence import BoundedEvidencePrepare +from packages.application.inference_execution import InferenceExecution, InferenceHeartbeat +from packages.domain.inference_evidence import validate_derivatives +from packages.domain.inference_execution import ( + InferenceInvalidResult, + InferenceLeaseLost, + validate_result, +) +from packages.domain.security import ServiceError +from tests.business.test_inference_execution import lease, result_for +from tests.evidence_helpers import add_regions, artifacts_for + + +def test_independent_regions_have_no_detection_or_fact_and_replay_stably(): + value = lease() + result = add_regions(value, result_for(value, "needs_review")) + validate_result(result, value) + assert not result["detections"] and not result["ocr_fields"] and not result["entities"] + assert result == add_regions(value, result_for(value, "needs_review")) + validate_derivatives(value, result, artifacts_for(value, result)) + + +@pytest.mark.parametrize( + "case", + [ + "duplicate_line", + "duplicate_crop", + "dangling_crop", + "dangling_line", + "dangling_detection", + "foreign_image", + "foreign_run", + "foreign_tenant", + "quad", + "rotation", + "dimension", + "quality_missing", + "duplicate_quality", + "retake_with_regions", + "field_crop", + "entity", + "relation", + "101", + "legacy_missing_regions", + "legacy_missing_evidence", + ], +) +def test_bad_reference_closure_and_capacity_rejected(case): + value = lease() + result = add_regions(value, result_for(value, "needs_review")) + lines, crops = result["text_regions"], result["crops"] + if case == "duplicate_line": + lines[1] = copy.deepcopy(lines[0]) + elif case == "duplicate_crop": + crops[1] = copy.deepcopy(crops[0]) + elif case == "dangling_crop": + lines[0]["crop_id"] = str(uuid4()) + elif case == "dangling_line": + crops[0]["line_id"] = str(uuid4()) + elif case == "dangling_detection": + lines[0]["detection_id"] = crops[0]["detection_id"] = str(uuid4()) + elif case == "foreign_image": + lines[0]["image_id"] = crops[0]["image_id"] = str(uuid4()) + elif case == "foreign_run": + other = replace(value, input=replace(value.input, run_id=str(uuid4()))) + imported = add_regions(other, result_for(other, "needs_review")) + lines[:], crops[:] = imported["text_regions"], imported["crops"] + elif case == "foreign_tenant": + result["tenant_id"] = str(uuid4()) + elif case == "quad": + crops[0]["quad"] = copy.deepcopy(crops[0]["quad"]) + crops[0]["quad"][0]["x"] = 0.1 + elif case == "rotation": + crops[0]["evidence"]["recognition_rotation_ccw"] = 90 + elif case == "dimension": + crops[0]["evidence"]["recognition_width"] += 1 + elif case == "quality_missing": + result["quality"] = [] + elif case == "duplicate_quality": + result["quality"].append(result["quality"][0]) + elif case == "retake_with_regions": + result["outcome"] = "needs_retake" + elif case == "field_crop": + result["ocr_fields"] = [ + { + "image_id": value.input.images[0].image_id, + "detection_id": str(uuid4()), + "crop_id": str(uuid4()), + "field": "name", + "raw_text": "ethanol", + "normalized_text": None, + "confidence": 0.9, + } + ] + elif case == "entity": + result["entities"] = [ + { + "image_id": value.input.images[0].image_id, + "detection_id": str(uuid4()), + "candidates": [], + "resolution": "unknown", + } + ] + elif case == "relation": + result["relations"] = [ + { + "image_id": value.input.images[0].image_id, + "source_detection_id": str(uuid4()), + "target_detection_id": str(uuid4()), + "relation": "unknown", + "confidence": 0, + } + ] + elif case == "101": + result = add_regions(value, result_for(value, "needs_review"), 101) + elif case == "legacy_missing_regions": + del result["text_regions"] + elif case == "legacy_missing_evidence": + del crops[0]["evidence"] + with pytest.raises(InferenceInvalidResult): + validate_result(result, value) + + +def test_exact_capacity_100_accepted(): + value = lease() + validate_result(add_regions(value, result_for(value, "needs_review"), 100), value) + + +@pytest.mark.parametrize( + "mode", ["ok", "cross_image", "foreign_detection_run", "duplicate", "cycle", "nan"] +) +def test_optional_real_detection_reference_is_same_image_and_current_run(mode): + value = lease() + first = value.input.images[0] + other = replace(first, image_id=str(uuid4()), role="detail", parent_image_id=first.image_id) + value = replace(value, input=replace(value.input, images=(first, other))) + result = add_regions(value, result_for(value, "needs_review")) + result["input_hashes"].append({"image_id": other.image_id, "sha256": other.sha256}) + result["quality"].append({**result["quality"][0], "image_id": other.image_id}) + image = other.image_id if mode == "cross_image" else first.image_id + namespace = UUID(str(uuid4())) if mode == "foreign_detection_run" else UUID(value.input.run_id) + detection = str(uuid5(namespace, f"{image}:bottle:0")) + result["detections"] = [ + { + "image_id": image, + "detection_id": detection, + "parent_detection_id": None, + "class_id": 39, + "type": "bottle", + "bbox": [0, 0, 1, 1], + "confidence": 0.9, + } + ] + for row in result["text_regions"] + result["crops"]: + row["detection_id"] = detection + if mode == "duplicate": + result["detections"].append(result["detections"][0]) + if mode == "cycle": + result["detections"][0]["parent_detection_id"] = detection + if mode == "nan": + result["text_regions"][0]["confidence"] = float("nan") + if mode == "ok": + validate_result(result, value) + else: + with pytest.raises(InferenceInvalidResult): + validate_result(result, value) + + +@pytest.mark.parametrize( + "field", + [ + "image_id", + "crop_id", + "line_id", + "detection_id", + "object_key", + "object_version", + "sha256", + "size_bytes", + ], +) +def test_artifact_scope_and_metadata_rejected(field): + value = lease() + result = add_regions(value, result_for(value, "needs_review")) + artifacts = artifacts_for(value, result) + bad = "null" if field == "object_version" else (99 if field == "size_bytes" else str(uuid4())) + with pytest.raises(InferenceInvalidResult): + validate_derivatives(value, result, (replace(artifacts[0], **{field: bad}), artifacts[1])) + + +def test_partial_and_duplicate_artifact_set_rejected(): + value = lease() + result = add_regions(value, result_for(value, "needs_review")) + artifacts = artifacts_for(value, result) + for bad in ((), artifacts[:1], artifacts + artifacts[:1], (artifacts[0], artifacts[0])): + with pytest.raises(InferenceInvalidResult): + validate_derivatives(value, result, bad) + + +@pytest.mark.parametrize("mode", ["ok", "failure", "lost", "no_runtime"]) +def test_orchestration_prepares_inside_heartbeat_and_never_commits_partial(monkeypatch, mode): + monkeypatch.setenv("APP_ENV", "test") + monkeypatch.setattr(InferenceHeartbeat, "interval", 0.01) + value = lease() + result = add_regions(value, result_for(value, "needs_review")) + client = Mock() + client.quality.return_value = result_for(value, "facts_ready") + client.runs.return_value = result + artifacts = artifacts_for(value, result) + execution = InferenceExecution(Mock(), client) + execution.claim = Mock(return_value=value) + execution.commit = Mock() + execution.fail = Mock() + execution.heartbeat = Mock(return_value=mode != "lost") + + def prepare(lease, result, cancelled): + assert lease == value + if mode == "failure": + raise ServiceError("HASH_MISMATCH", 422, "Synthetic second crop failure") + if mode == "lost": + assert cancelled.wait(2) + return artifacts + return artifacts + + execution.prepare = None if mode == "no_runtime" else prepare + if mode == "ok": + assert execution.execute({}) + execution.commit.assert_called_once_with(value, result, artifacts) + else: + with pytest.raises((ServiceError, InferenceLeaseLost, RuntimeError)): + execution.execute({}) + execution.commit.assert_not_called() + if mode == "lost": + execution.fail.assert_not_called() + else: + execution.fail.assert_called_once() + + +@pytest.mark.parametrize("mode", ["timeout", "cancel", "success", "exit", "invalid"]) +def test_bounded_process_is_killed_joined_and_result_validated(tmp_path, monkeypatch, mode): + value = lease() + result = add_regions(value, result_for(value, "needs_review")) + executable = tmp_path / "python.exe" + executable.write_bytes(b"synthetic executable") + cancel, child = Event(), Mock() + child.poll.return_value = None if mode in {"timeout", "cancel"} else 0 + child.returncode = 1 if mode == "exit" else 0 + monkeypatch.setenv("DATABASE_URL_FILE", "must-not-inherit") + monkeypatch.setenv("AI_S3_SECRET_KEY_FILE", "must-not-inherit") + + def start(args, **kwargs): + assert "DATABASE_URL_FILE" not in kwargs["env"] + assert "AI_S3_SECRET_KEY_FILE" not in kwargs["env"] + assert json.loads(open(args[-2], encoding="utf-8").read())["result"] == result + if mode == "cancel": + cancel.set() + artifacts = artifacts_for(value, result) + output = {"artifacts": [row.__dict__ for row in artifacts]} + if mode == "invalid": + output["artifacts"] = output["artifacts"][:1] + from pathlib import Path + + Path(args[-1]).write_text(json.dumps(output), encoding="utf-8") + return child + + monkeypatch.setattr("apps.worker.inference_evidence.subprocess.Popen", start) + monkeypatch.setattr( + "apps.worker.inference_evidence.EVIDENCE_SECONDS", 0.01 if mode == "timeout" else 20 + ) + prepare = BoundedEvidencePrepare(str(executable)) + if mode == "success": + assert prepare(value, result, cancel) == artifacts_for(value, result) + else: + with pytest.raises((ServiceError, InferenceLeaseLost, InferenceInvalidResult)): + prepare(value, result, cancel) + child.wait.assert_called_once_with(timeout=2) + assert child.kill.called == (mode in {"timeout", "cancel"}) diff --git a/tests/business/test_inference_evidence_storage.py b/tests/business/test_inference_evidence_storage.py new file mode 100644 index 0000000..1add7be --- /dev/null +++ b/tests/business/test_inference_evidence_storage.py @@ -0,0 +1,127 @@ +"""Negative SDK responses and fixed-version integrity; no live S3 required.""" + +import hashlib +import io +from uuid import uuid4 + +import pytest +from botocore.response import StreamingBody +from botocore.stub import Stubber + +from packages.domain.inference_evidence import derivative_key +from packages.domain.security import ServiceError +from packages.storage.s3 import BUCKET, S3Storage +from tests.business.test_s3_storage import settings + + +@pytest.mark.parametrize( + "case", ["ok", "version", "mime", "size", "hash", "oversize", "encoding", "missing"] +) +def test_analysis_get_exact_version_stream_sha_and_closed(case): + tenant, lab, image = (str(uuid4()) for _ in range(3)) + raw = b"synthetic SDK payload, decoding tested separately" + sha = hashlib.sha256(raw).hexdigest() + key = f"tenant/{tenant}/lab/{lab}/analysis/{image}/{sha}.png" + reference = { + "image_id": image, + "sha256": sha, + "mime_type": "image/png", + "object_key": key, + "object_version": "exact/v1+=", + } + body = StreamingBody(io.BytesIO(raw), len(raw)) + reply = { + "Body": body, + "ContentLength": len(raw), + "ContentType": "image/png", + "VersionId": "exact/v1+=", + } + if case == "version": + reply["VersionId"] = "different" + elif case == "mime": + reply["ContentType"] = "image/jpeg" + elif case == "size": + reply["ContentLength"] += 1 + elif case == "hash": + body = StreamingBody(io.BytesIO(b"x" * len(raw)), len(raw)) + reply["Body"] = body + elif case == "oversize": + reply["ContentLength"] = 128 * 1024 * 1024 + 1 + elif case == "encoding": + reply["ContentEncoding"] = "gzip" + storage = S3Storage(settings()) + try: + with Stubber(storage.internal) as stub: + params = {"Bucket": BUCKET, "Key": key, "VersionId": reference["object_version"]} + if case == "missing": + stub.add_client_error( + "get_object", + service_error_code="NoSuchVersion", + http_status_code=404, + expected_params=params, + ) + else: + stub.add_response("get_object", reply, params) + if case == "ok": + assert storage.read_analysis(tenant, lab, reference) == raw + else: + with pytest.raises(ServiceError): + storage.read_analysis(tenant, lab, reference) + stub.assert_no_pending_responses() + if case != "missing": + assert body._raw_stream.closed + finally: + storage.close() + + +@pytest.mark.parametrize("version", [None, "null", "", "has space", "v" * 201]) +def test_upload_without_valid_exact_version_never_returns_artifact(version): + tenant, lab, run, crop = (str(uuid4()) for _ in range(4)) + raw = b"synthetic PNG upload" + key = derivative_key(tenant, lab, run, crop, hashlib.sha256(raw).hexdigest()) + storage = S3Storage(settings()) + try: + with Stubber(storage.internal) as stub: + stub.add_client_error( + "head_object", + service_error_code="NoSuchKey", + http_status_code=404, + expected_params={"Bucket": BUCKET, "Key": key}, + ) + stub.add_response( + "put_object", + {} if version is None else {"VersionId": version}, + {"Bucket": BUCKET, "Key": key, "Body": raw, "ContentType": "image/png"}, + ) + with pytest.raises(ServiceError): + storage.put_derivative(key, raw) + stub.assert_no_pending_responses() + finally: + storage.close() + + +def test_conflicting_existing_crop_is_not_overwritten(): + tenant, lab, run, crop = (str(uuid4()) for _ in range(4)) + raw = b"claimed crop" + key = derivative_key(tenant, lab, run, crop, hashlib.sha256(raw).hexdigest()) + storage = S3Storage(settings()) + try: + with Stubber(storage.internal) as stub: + metadata = { + "ContentLength": len(raw), + "ContentType": "image/png", + "VersionId": "old-v1", + } + stub.add_response("head_object", metadata, {"Bucket": BUCKET, "Key": key}) + body = StreamingBody(io.BytesIO(b"x" * len(raw)), len(raw)) + stub.add_response( + "get_object", + {**metadata, "Body": body}, + {"Bucket": BUCKET, "Key": key, "VersionId": "old-v1"}, + ) + with pytest.raises(ServiceError): + storage.put_derivative(key, raw) + stub.assert_no_pending_responses() + assert body._raw_stream.closed + finally: + storage.close() diff --git a/tests/business/test_inference_execution.py b/tests/business/test_inference_execution.py index 36497d0..fe94964 100644 --- a/tests/business/test_inference_execution.py +++ b/tests/business/test_inference_execution.py @@ -4,83 +4,11 @@ import pytest from packages.domain.inference_execution import ( - InferenceImage, - InferenceInput, InferenceInvalidResult, - InferenceLease, request_payload, validate_result, ) - - -def lease(): - tenant, lab, item, run, image = (str(uuid4()) for _ in range(5)) - value = InferenceInput( - tenant_id=tenant, - laboratory_id=lab, - item_id=item, - run_id=run, - submission_revision=2, - model_bundle_id=str(uuid4()), - model_checksum="a" * 64, - dictionary_version_id=str(uuid4()), - dictionary_sha256="b" * 64, - pipeline_version="vision-v1", - device_profile="cpu", - images=( - InferenceImage( - image_id=image, - object_key=f"tenant/{tenant}/lab/{lab}/analysis/{image}/{'c' * 64}.png", - sha256="c" * 64, - mime_type="image/png", - role="overview", - location_id=str(uuid4()), - parent_image_id=None, - ), - ), - ) - return InferenceLease(tenant, str(uuid4()), str(uuid4()), str(uuid4()), 3, 0, 1, value) - - -def result_for(value, outcome="needs_retake"): - image = value.input.images[0] - return { - "run_id": value.input.run_id, - "attempt_id": value.attempt_id, - "fencing_token": value.token, - "tenant_id": value.input.tenant_id, - "request_hash": value.input.request_hash, - "input_hashes": [{"image_id": image.image_id, "sha256": image.sha256}], - "model_bundle_id": value.input.model_bundle_id, - "model_checksum": value.input.model_checksum, - "dictionary_version_id": value.input.dictionary_version_id, - "dictionary_sha256": value.input.dictionary_sha256, - "pipeline_version": "vision-v1", - "outcome": outcome, - "quality": [ - { - "image_id": image.image_id, - "status": "needs_retake" if outcome == "needs_retake" else "pass", - "reasons": ["blur"] if outcome == "needs_retake" else [], - "blur_score": 0 if outcome == "needs_retake" else 100, - "brightness": 0.5, - "glare_ratio": 0, - } - ], - "detections": [], - "crops": [], - "ocr_fields": [], - "entities": [], - "relations": [], - "timing_ms": { - "download_ms": 0, - "quality_ms": 0, - "detection_ms": 0, - "ocr_ms": 0, - "normalization_ms": 0, - "total_ms": 0, - }, - } +from tests.evidence_helpers import lease, result_for def test_request_payload_contains_fixed_input_and_fence(): diff --git a/tests/business/test_inference_http.py b/tests/business/test_inference_http.py new file mode 100644 index 0000000..de130d1 --- /dev/null +++ b/tests/business/test_inference_http.py @@ -0,0 +1,189 @@ +"""Worker uses real socket RPC; synthetic service covers bounded transport and pins.""" + +import asyncio +import json +import threading +import time +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import pytest + +from apps.ai_inference.app.fixture import compute +from apps.ai_inference.app.settings import Settings +from packages.application.inference_execution import AIRequestError, InferenceHTTPClient +from packages.domain.inference_execution import InferenceLeaseLost +from tests.helpers import configured, make_request + + +@contextmanager +def service(mode="ok", *, actual=None): + with configured() as token: + settings = Settings.from_env() + identity = actual or settings.identity + source = make_request(settings) + calls = [] + entered = threading.Event() + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *_args): + pass + + def handle_wire(self): + path = self.path.rsplit("/", 1)[-1] + calls.append(path) + if path == "ready": + value = { + "status": "ready", + "model_bundle_id": identity["model_bundle_id"], + "active_attempt_id": None, + } + elif path == "version": + value = identity + else: + payload = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + value = asyncio.run(compute(payload, settings, path)) + value["execution_identity"] = identity + entered.set() + if mode == "echo": + value["fencing_token"] += 1 + if mode == "result_identity": + value["execution_identity"] = dict(identity, service_commit="drifted") + if mode == "slow_body": + time.sleep(0.5) + raw = json.dumps(value).encode() + if mode == "slow_headers" and path in {"quality", "runs"}: + try: + self.wfile.write(b"HTTP/1.1 200 OK\r\nX-Slow: ") + for _ in range(30): + self.wfile.write(b"a") + self.wfile.flush() + time.sleep(0.03) + except OSError: + pass + return + if mode == "oversized" and path == "runs": + raw = b"x" * (2 * 1024 * 1024 + 1) + if mode == "malformed" and path == "runs": + raw = b"{" + self.send_response(302 if mode == "redirect" else 200) + self.send_header("Content-Length", str(len(raw))) + self.send_header("Content-Type", "application/json") + self.end_headers() + try: + self.wfile.write(raw) + except OSError: + pass + + do_GET = handle_wire + do_POST = handle_wire + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + server.daemon_threads = True + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + client = InferenceHTTPClient(f"http://127.0.0.1:{server.server_port}", token) + yield client, source, calls, entered + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + +def test_fixture_preflight_and_actual_simulated_identity(): + with service() as (client, source, calls, _): + value = client.quality(source) + assert value["execution_identity"]["is_simulated"] is True + assert calls == ["ready", "version", "quality"] + + +@pytest.mark.parametrize( + "mode,code", + [ + ("echo", "SCHEMA_MISMATCH"), + ("result_identity", "SCHEMA_MISMATCH"), + ("oversized", "SCHEMA_MISMATCH"), + ("malformed", "SCHEMA_MISMATCH"), + ("redirect", "DEPENDENCY_UNAVAILABLE"), + ], +) +def test_bad_results_and_redirects_fail_closed(mode, code): + with service(mode) as (client, source, _, _): + with pytest.raises(AIRequestError) as error: + client.runs(source) + assert error.value.code == code + + +def test_changed_pin_rejects_before_post(): + with service() as (client, source, calls, _): + source["dictionary_sha256"] = "f" * 64 + with pytest.raises(AIRequestError) as error: + client.quality(source) + assert error.value.code == "MODEL_VERSION_UNAVAILABLE" + assert calls == ["ready", "version"] + + +def test_cpu_requires_explicit_full_expected_identity(): + with configured(): + actual = dict( + Settings.from_env().identity, + is_simulated=False, + adapter_id="dfine-coco80-rgb-stretch-v1", + runtime_profile="dfine-cpu-fp32-ocrv6smallcpu-v1", + detector_device="cpu", + ocr_device="cpu", + ) + with service(actual=actual) as (client, source, calls, _): + with pytest.raises(AIRequestError) as error: + client.runs(source) + assert error.value.code == "MODEL_VERSION_UNAVAILABLE" + assert "runs" not in calls + client.expected = actual + assert client.runs(source)["execution_identity"] == actual + with pytest.raises(ValueError): + InferenceHTTPClient(token="synthetic", expected_version={}) + + +@pytest.mark.parametrize("mode", ["slow_body", "slow_headers"]) +def test_deadline_bounds_entire_http_even_trickling_headers(mode): + with service(mode) as (client, source, _, _): + client.timeout = 0.15 + started = time.monotonic() + with pytest.raises(AIRequestError) as error: + client.runs(source) + assert error.value.code == "AI_TIMEOUT" + assert time.monotonic() - started < 0.5 + + +def test_lease_cancel_shuts_down_active_rpc_socket(): + with service("slow_body") as (client, source, _, entered): + errors = [] + + def call(): + try: + client.runs(source) + except Exception as error: + errors.append(error) + + thread = threading.Thread(target=call) + thread.start() + assert entered.wait(2) + client.cancel() + thread.join(timeout=1) + assert not thread.is_alive() + assert isinstance(errors[0], InferenceLeaseLost) + + +@pytest.mark.parametrize( + "endpoint", + [ + "http://example.com", + "http://127.0.0.1/payload", + "https://user:secret@example.com", + "file:///tmp/token", + ], +) +def test_uncontrolled_endpoint_rejected(endpoint): + with pytest.raises(RuntimeError): + InferenceHTTPClient(endpoint, "synthetic") diff --git a/tests/business/test_ocr_fields.py b/tests/business/test_ocr_fields.py new file mode 100644 index 0000000..276a20f --- /dev/null +++ b/tests/business/test_ocr_fields.py @@ -0,0 +1,255 @@ +"""Golden lexical cases and independently recomputed, complete field evidence.""" + +import copy + +import pytest + +from packages.domain.inference_execution import InferenceInvalidResult, validate_result +from packages.inference_protocol.fields import extract_fields, strict_date, valid_cas +from tests.evidence_helpers import add_bottles, add_regions, lease, result_for + + +def rows(*texts): + return [ + { + "image_id": "image", + "detection_id": "bottle", + "line_id": f"line-{i}", + "crop_id": f"crop-{i}", + "raw_text": text, + "confidence": 0.9 if i == 0 else 0.3, + } + for i, text in enumerate(texts) + ] + + +@pytest.mark.parametrize( + "text,kind,value", + [ + (" NAME: Ethanol ", "name", "Ethanol"), + ("化学品名称乙醇", "name", "乙醇"), + ("品名: 乙醇\t ETHANOL", "name", "乙醇 ETHANOL"), + ("名称:甲", "name", "甲"), + ("name乙醇", "name", "乙醇"), + ("CAS:64-17-5", "name", "64-17-5"), + ("cas 64-17-6", "name", None), + ("64-17-5", "name", "64-17-5"), + ("生产日期:2026-03-01", "production", "2026-03-01"), + ("生产2026年03月01日", "production", "2026-03-01"), + ("mfg:2024/02/29", "production", "2024-02-29"), + ("有效期2027/03/01", "expiry", "2027-03-01"), + ("失效:2027-03-01", "expiry", "2027-03-01"), + ("EXP 2027-03-01", "expiry", "2027-03-01"), + ("开封日期2026年03月01日", "opened", "2026-03-01"), + ("开封:2026-03-01", "opened", "2026-03-01"), + ("浓度:95%", "concentration", "95%"), + ("含量 5 mol/L", "concentration", "5 mol/L"), + ("concentration: 2%", "concentration", "2%"), + ("危险标识:易燃", "hazard_mark", "易燃"), + ("危险性 腐蚀", "hazard_mark", "腐蚀"), + ("hazard: flammable", "hazard_mark", "flammable"), + ("生产日期:2026-03-01 有效期:2027-03-01", "date_unknown", None), + ("EXP MFG", "date_unknown", None), + ("2027-03-01", "date_unknown", None), + ("打印日期 2027-03", "date_unknown", None), + ("10/11/2027", "date_unknown", None), + ("EXP", "expiry", None), + ("CAS", "name", None), + ], +) +def test_single_line_golden_preserves_original(text, kind, value): + sources = rows(text) + field = extract_fields(sources)[0] + assert (field["field"], field["normalized_text"]) == (kind, value) + assert field["raw_text"] == text + assert field["source_lines"] == [ + {key: sources[0][key] for key in ("line_id", "crop_id", "raw_text", "confidence")} + ] + + +@pytest.mark.parametrize("text", ["Ethanol", "乙醇", "NAMEthanol", "EXPIRED", "64-17-6", "", " "]) +def test_no_unfrozen_dictionary_or_english_prefix_guess(text): + assert extract_fields(rows(text)) == [] + + +@pytest.mark.parametrize( + "text", + [ + "2027-03", + "2027-02-30", + "2026-02-29", + "10/11/2027", + "2027-3-01", + "2027-03-1", + "2027-03-01 extra", + "0000-01-01", + "2027/03-01", + ], +) +def test_entire_strict_date_value(text): + assert strict_date(text) is None + assert extract_fields(rows("EXP:" + text))[0]["normalized_text"] is None + + +@pytest.mark.parametrize( + "text,valid", + [ + ("64-17-5", True), + ("7732-18-5", True), + ("50-00-0", True), + ("64-17-6", False), + ("6-17-5", False), + ("12345678-12-3", False), + ("64-1-5", False), + ("64-17-55", False), + ("64-17-5 extra", False), + ], +) +def test_cas_complete_format_and_checksum(text, valid): + assert valid_cas(text) is valid + + +def test_pair_sources_low_confidence_and_conflicts_all_preserved(): + source = rows("NAME", " Ethanol ", "有效期", "2027/03/01", "有效期:2027-04-01") + fields = extract_fields(source) + assert [(f["field"], f["normalized_text"]) for f in fields] == [ + ("name", "Ethanol"), + ("expiry", "2027-03-01"), + ("expiry", "2027-04-01"), + ] + assert fields[0]["raw_text"] == "NAME\n Ethanol " + assert fields[0]["confidence"] == 0.3 + assert [s["crop_id"] for s in fields[0]["source_lines"]] == ["crop-0", "crop-1"] + assert fields[0]["crop_id"] == "crop-0" + assert source == rows("NAME", " Ethanol ", "有效期", "2027/03/01", "有效期:2027-04-01") + + +@pytest.mark.parametrize("block", ["unknown", "other_bottle", "other_image", "blank", "prefix"]) +def test_global_adjacency_never_filters_intervening_rows(block): + source = rows("有效期", "noise", "2027-03-01") + if block == "unknown": + source[1]["detection_id"] = None + elif block == "other_bottle": + source[1]["detection_id"] = "other" + elif block == "other_image": + source[1]["image_id"] = "other" + elif block == "blank": + source[1]["raw_text"] = " \t " + else: + source[1]["raw_text"] = "生产日期:2026-03-01" + fields = extract_fields(source) + assert fields[0]["field"] == "expiry" and fields[0]["normalized_text"] is None + assert len(fields[0]["source_lines"]) == 1 + assert fields[-1]["field"] == "date_unknown" + if block == "prefix": + assert fields[1]["field"] == "production" + + +def test_inline_value_never_appends_following_line(): + fields = extract_fields(rows("NAME:甲", "乙", "EXP:2027-03-01", "2027-04-01")) + assert fields[0]["normalized_text"] == "甲" + assert len(fields[0]["source_lines"]) == 1 + assert [f["field"] for f in fields] == ["name", "expiry", "date_unknown"] + + +def test_capacity_boundaries_unicode_and_unknown_lines(): + assert extract_fields(rows("名称:" + "甲" * 500))[0]["normalized_text"] == "甲" * 500 + source = rows("NAME" + " " * 1495, "乙" * 500) + assert len(extract_fields(source)[0]["raw_text"]) == 2000 + assert len(extract_fields(rows(*(["名称:甲"] * 100)))) == 100 + source = rows("名称:甲") + source[0]["detection_id"] = None + assert extract_fields(source) == [] + + +def test_english_keyword_inside_word_is_not_a_date_keyword(): + field = extract_fields(rows("EXP:2027-03-01 PREMFG"))[0] + assert field["field"] == "expiry" and field["normalized_text"] is None + + +def test_ambiguous_date_line_cannot_be_consumed_as_a_name_value(): + fields = extract_fields(rows("NAME", "printed MFG 2026-03-01 EXP 2027-03-01")) + assert [(f["field"], f["normalized_text"]) for f in fields] == [ + ("name", None), + ("date_unknown", None), + ] + + +@pytest.mark.parametrize( + "source", + [ + rows("x" * 2001), + rows("NAME:" + "甲" * 501), + rows("NAME" + " " * 1500, "甲" * 500), + rows(*(["名称:甲"] * 101)), + ], +) +def test_capacity_rejects_without_truncation(source): + with pytest.raises(ValueError): + extract_fields(source) + + +def field_case(): + value = lease() + result = add_bottles(value, add_regions(value, result_for(value, "needs_review"))) + result["text_regions"][0].update(raw_text="NAME", confidence=0.9) + result["text_regions"][1].update(raw_text="Ethanol", confidence=0.3) + result["ocr_fields"] = extract_fields(result["text_regions"]) + return value, result + + +def test_worker_accepts_complete_multiline_low_confidence(): + value, result = field_case() + validate_result(result, value) + assert result["outcome"] == "needs_review" and not result["entities"] + + +@pytest.mark.parametrize( + "mutation", + [ + "omit", + "duplicate", + "single_crop", + "wrong_crop", + "wrong_line", + "source_raw", + "source_confidence", + "raw", + "confidence", + "normalized", + "kind", + "bottle", + "first_crop", + "reverse", + ], +) +def test_worker_rejects_forged_missing_or_partial_field_sources(mutation): + value, result = field_case() + field = result["ocr_fields"][0] + if mutation == "omit": + result["ocr_fields"] = [] + elif mutation == "duplicate": + result["ocr_fields"].append(copy.deepcopy(field)) + elif mutation == "single_crop": + field["source_lines"].pop() + elif mutation in ("wrong_crop", "wrong_line"): + key = "crop_id" if mutation == "wrong_crop" else "line_id" + field["source_lines"][1][key] = field["source_lines"][0][key] + elif mutation in ("source_raw", "source_confidence"): + field["source_lines"][1]["raw_text" if mutation == "source_raw" else "confidence"] = ( + "forged" if mutation == "source_raw" else 1 + ) + elif mutation == "reverse": + field["source_lines"].reverse() + else: + key, wrong = { + "raw": ("raw_text", "NAME\nforged"), + "confidence": ("confidence", 1), + "normalized": ("normalized_text", "forged"), + "kind": ("field", "expiry"), + "bottle": ("detection_id", value.input.images[0].image_id), + "first_crop": ("crop_id", result["crops"][1]["crop_id"]), + }[mutation] + field[key] = wrong + with pytest.raises(InferenceInvalidResult): + validate_result(result, value) diff --git a/tests/business/test_text_association_closure.py b/tests/business/test_text_association_closure.py new file mode 100644 index 0000000..bd34355 --- /dev/null +++ b/tests/business/test_text_association_closure.py @@ -0,0 +1,100 @@ +"""Worker recomputes bottle association, including unknown, before network/commit.""" + +import copy +from uuid import UUID, uuid5 + +import pytest + +from packages.domain.inference_evidence import validate_derivatives +from packages.domain.inference_execution import InferenceInvalidResult, validate_result +from tests.evidence_helpers import add_regions, artifacts_for, lease, result_for + + +def associated_case(): + value = lease() + result = add_regions(value, result_for(value, "needs_review"), 1) + image = value.input.images[0].image_id + detection_id = str(uuid5(UUID(value.input.run_id), f"{image}:bottle:0")) + result["detections"] = [ + { + "detection_id": detection_id, + "image_id": image, + "parent_detection_id": None, + "class_id": 39, + "type": "bottle", + "bbox": [0, 0, 1, 1], + "confidence": 0.1, + } + ] + for row in result["text_regions"] + result["crops"]: + row["detection_id"] = detection_id + return value, result + + +def test_valid_link_preserves_raw_ids_and_derivative_binding(): + value, result = associated_case() + validate_result(result, value) + artifacts = artifacts_for(value, result) + validate_derivatives(value, result, artifacts) + assert artifacts[0].detection_id == result["detections"][0]["detection_id"] + assert result["text_regions"][0]["raw_text"] == "乙醇 ETHANOL" + + +@pytest.mark.parametrize( + "case", + [ + "non_bottle", + "wrong_class", + "insufficient_overlap", + "missing_link", + "line_crop_mismatch", + "tied_bottles", + "zero_area", + "wrong_quad", + ], +) +def test_worker_rejects_forged_or_inconsistent_association(case): + value, result = associated_case() + detection = result["detections"][0] + if case == "non_bottle": + detection.update( + class_id=0, + type="person", + detection_id=str(uuid5(UUID(value.input.run_id), f"{detection['image_id']}:person:0")), + ) + for row in result["text_regions"] + result["crops"]: + row["detection_id"] = detection["detection_id"] + elif case == "wrong_class": + detection["class_id"] = 40 + elif case == "insufficient_overlap": + detection["bbox"] = [0, 0, 0.79, 1] + elif case == "missing_link": + for row in result["text_regions"] + result["crops"]: + row["detection_id"] = None + elif case == "line_crop_mismatch": + result["crops"][0]["detection_id"] = None + elif case == "tied_bottles": + other = copy.deepcopy(detection) + other["detection_id"] = str( + uuid5(UUID(value.input.run_id), f"{detection['image_id']}:bottle:1") + ) + result["detections"].append(other) + elif case == "zero_area": + detection["bbox"] = [0, 0, 0, 1] + else: + for row in result["text_regions"] + result["crops"]: + row["quad"] = [{"x": 0, "y": 0}] * 4 + with pytest.raises(InferenceInvalidResult): + validate_result(result, value) + + +def test_ambiguous_bottles_keep_null_with_complete_evidence(): + value, result = associated_case() + other = copy.deepcopy(result["detections"][0]) + other["detection_id"] = str(uuid5(UUID(value.input.run_id), f"{other['image_id']}:bottle:1")) + result["detections"].append(other) + for row in result["text_regions"] + result["crops"]: + row["detection_id"] = None + validate_result(result, value) + assert result["outcome"] == "needs_review" + assert not result["ocr_fields"] and not result["entities"] diff --git a/tests/business/test_text_bottle_association.py b/tests/business/test_text_bottle_association.py new file mode 100644 index 0000000..020418f --- /dev/null +++ b/tests/business/test_text_bottle_association.py @@ -0,0 +1,186 @@ +"""Golden geometry for text-bottle-quad80-v1; no model or ORM dependencies.""" + +import copy +import math +from uuid import uuid4 + +import pytest + +from packages.inference_protocol.association import associate_rows, select_bottle + + +def point_quad(x1=0.2, y1=0.2, x2=0.8, y2=0.4): + return [ + {"x": x1, "y": y1}, + {"x": x2, "y": y1}, + {"x": x2, "y": y2}, + {"x": x1, "y": y2}, + ] + + +def detection(image, key, bbox, *, class_id=39, kind="bottle"): + return { + "image_id": image, + "detection_id": key, + "class_id": class_id, + "type": kind, + "bbox": bbox, + "confidence": 0.9, + "parent_detection_id": None, + } + + +def test_unique_smallest_same_image_bottle_is_selected(): + image, first, second = str(uuid4()), str(uuid4()), str(uuid4()) + detections = [ + detection(image, first, [0, 0, 1, 1]), + detection(image, second, [0.1, 0.1, 0.9, 0.9]), + ] + assert select_bottle(point_quad(), image, detections) == second + + +def test_no_bottle_cross_image_wrong_class_or_insufficient_overlap_is_unknown(): + image, other = str(uuid4()), str(uuid4()) + quad = point_quad() + assert select_bottle(quad, image, [detection(other, str(uuid4()), [0, 0, 1, 1])]) is None + assert ( + select_bottle( + quad, image, [detection(image, str(uuid4()), [0, 0, 1, 1], class_id=40, kind="chair")] + ) + is None + ) + assert ( + select_bottle( + point_quad(0.2, 0.2, 0.8, 0.4), + image, + [detection(image, str(uuid4()), [0, 0.2, 0.5, 0.4])], + ) + is None + ) + + +def test_equal_smallest_bottles_do_not_use_id_tiebreak(): + image = str(uuid4()) + rows = [detection(image, key, [0, 0, 1, 1]) for key in (str(uuid4()), str(uuid4()))] + assert select_bottle(point_quad(), image, rows) is None + + +def test_text_vertices_on_box_boundary_and_exact_eighty_percent_are_accepted(): + image, key = str(uuid4()), str(uuid4()) + assert select_bottle(point_quad(), image, [detection(image, key, [0.2, 0.2, 0.8, 0.4])]) == key + quad = point_quad(0, 0, 1, 1) + assert select_bottle(quad, image, [detection(image, key, [0, 0, 0.8, 1])]) == key + assert ( + select_bottle(quad, image, [detection(image, key, [0, 0, math.nextafter(0.8, 0), 1])]) + is None + ) + + +def test_tilted_quad_uses_polygon_area_instead_of_enclosing_rectangle(): + image, key = str(uuid4()), str(uuid4()) + diamond = [{"x": x, "y": y} for x, y in ((0.5, 0), (1, 0.5), (0.5, 1), (0, 0.5))] + # Actual diamond intersection is 7/8; bbox approximation would be 3/4. + assert select_bottle(diamond, image, [detection(image, key, [0, 0, 0.75, 1])]) == key + + +def test_skew_quad_uses_actual_area_and_vertex_center(): + image, key = str(uuid4()), str(uuid4()) + trapezoid = [{"x": x, "y": y} for x, y in ((0, 0), (1, 0), (0.2, 1), (0, 1))] + # Clip off the top tenth: area ratio > 80%, vertex mean y=0.5 is inside. + assert select_bottle(trapezoid, image, [detection(image, key, [0, 0.1, 1, 1])]) == key + + +def test_input_order_and_confidence_do_not_break_equal_area_ties(): + image = str(uuid4()) + rows = [ + detection(image, str(uuid4()), [0, 0, 1, 1]), + detection(image, str(uuid4()), [0, 0, 1, 1]), + ] + rows[0]["confidence"] = 0.99 + rows[1]["confidence"] = 0.01 + for order in (rows, list(reversed(rows))): + assert select_bottle(point_quad(), image, order) is None + + +@pytest.mark.parametrize( + "bounds", [[0, 0, 0, 1], [1, 0, 0, 1], [0, 0, float("nan"), 1], [0, 0, True, 1]] +) +def test_invalid_detection_geometry_rejected(bounds): + with pytest.raises(ValueError): + select_bottle(point_quad(), "image", [detection("image", "key", bounds)]) + + +@pytest.mark.parametrize("case", ["concave", "crossed", "counterclockwise", "nonfinite", "outside"]) +def test_invalid_quad_geometry_rejected(case): + quad = point_quad() + if case == "concave": + quad[2] = {"x": 0.3, "y": 0.25} + elif case == "crossed": + quad[1], quad[2] = quad[2], quad[1] + elif case == "counterclockwise": + quad.reverse() + elif case == "nonfinite": + quad[0]["x"] = float("inf") + else: + quad[0]["x"] = -0.1 + with pytest.raises(ValueError): + select_bottle(quad, "image", []) + + +@pytest.mark.parametrize("quad", [[], point_quad(0.8, 0.2, 0.2, 0.4), [{"x": 0, "y": 0}] * 4]) +def test_bad_quad_rejected_without_partial_mutation(quad): + image, key = str(uuid4()), str(uuid4()) + rows = [ + { + "image_id": image, + "crop_evidence": {"recipe": {"quad": quad}}, + "parent_detection_id": None, + } + ] + before = copy.deepcopy(rows) + with pytest.raises(ValueError): + associate_rows(rows, [detection(image, key, [0, 0, 1, 1])]) + assert rows == before + + +def test_associate_rows_all_or_nothing_and_stable(): + image, first, second = str(uuid4()), str(uuid4()), str(uuid4()) + rows = [ + { + "image_id": image, + "crop_evidence": {"recipe": {"quad": point_quad()}}, + "parent_detection_id": "stale", + }, + { + "image_id": image, + "crop_evidence": {"recipe": {"quad": point_quad(0.1, 0.5, 0.9, 0.7)}}, + "parent_detection_id": "stale", + }, + ] + detections = [detection(image, first, [0, 0, 1, 1]), detection(image, second, [0, 0, 1, 1])] + associate_rows(rows, detections) + assert rows[0]["parent_detection_id"] is None and rows[1]["parent_detection_id"] is None + detections.pop() + associate_rows(rows, detections) + assert rows[0]["parent_detection_id"] == first and rows[1]["parent_detection_id"] == first + before = copy.deepcopy(rows) + rows[1]["crop_evidence"]["recipe"]["quad"] = [] + with pytest.raises(ValueError): + associate_rows(rows, []) + assert rows[0] == before[0] + assert rows[1]["parent_detection_id"] == first + + +def test_limits_and_duplicate_detection_rejected(): + image = str(uuid4()) + with pytest.raises(ValueError): + select_bottle(point_quad(), image, [detection(image, "same", [0, 0, 1, 1])] * 2) + rows = [ + { + "image_id": image, + "crop_evidence": {"recipe": {"quad": point_quad()}}, + "parent_detection_id": None, + } + ] * 101 + with pytest.raises(ValueError): + associate_rows(rows, []) diff --git a/tests/evidence_helpers.py b/tests/evidence_helpers.py new file mode 100644 index 0000000..d44296e --- /dev/null +++ b/tests/evidence_helpers.py @@ -0,0 +1,249 @@ +"""Synthetic wire-only OCR evidence; numerical tests replace the claimed digests.""" + +import hashlib +import json +from dataclasses import replace +from uuid import UUID, uuid4, uuid5 + +from packages.domain.inference_evidence import InferenceDerivative, derivative_key +from packages.domain.inference_execution import InferenceImage, InferenceInput, InferenceLease +from packages.inference_protocol.evidence import region_ids + + +def add_regions(lease, result, count=2): + image_id = lease.input.images[0].image_id + for index in range(count): + line_id, crop_id = region_ids(lease.input.run_id, image_id, index) + quad = [{"x": x, "y": y} for x, y in ((0, 0), (1, 0), (1, 1), (0, 1))] + result["text_regions"].append( + { + "line_id": line_id, + "crop_id": crop_id, + "image_id": image_id, + "detection_id": None, + "raw_text": "乙醇 ETHANOL", + "confidence": 0.9, + "quad": quad, + } + ) + result["crops"].append( + { + "line_id": line_id, + "crop_id": crop_id, + "image_id": image_id, + "detection_id": None, + "quad": quad, + "output_width": 20, + "output_height": 10, + "transform_version": "perspective-rgb-v1", + "evidence": { + "png_sha256": "d" * 64, + "png_size_bytes": 100, + "source_rgb_sha256": "e" * 64, + "recognition_rotation_ccw": 0, + "recognition_width": 20, + "recognition_height": 10, + "recognition_rgb_sha256": "e" * 64, + }, + } + ) + return result + + +def artifacts_for(lease, result): + return tuple( + InferenceDerivative( + **{key: crop[key] for key in ("image_id", "crop_id", "line_id", "detection_id")}, + object_key=derivative_key( + lease.tenant_id, + lease.input.laboratory_id, + lease.input.run_id, + crop["crop_id"], + crop["evidence"]["png_sha256"], + ), + object_version="synthetic-crop/v1+exact=", + sha256=crop["evidence"]["png_sha256"], + size_bytes=crop["evidence"]["png_size_bytes"], + ) + for crop in result["crops"] + ) + + +def add_bottles(value, result, count=1): + """Synthetic full-image boxes for association/transaction tests, never model evidence.""" + image = value.input.images[0].image_id + result["detections"] = [ + { + "detection_id": str(uuid5(UUID(value.input.run_id), f"{image}:bottle:{index}")), + "image_id": image, + "parent_detection_id": None, + "class_id": 39, + "type": "bottle", + "bbox": [0, 0, 1, 1], + "confidence": 0.9, + } + for index in range(count) + ] + for row in result["text_regions"] + result["crops"]: + row["detection_id"] = result["detections"][0]["detection_id"] if count == 1 else None + return result + + +def add_chemical_context(result, entries=()): + """Explicit synthetic dictionary/bundle bytes, shared by spawn and MySQL tests.""" + from packages.inference_protocol.chemical import RUNTIME_PROFILE, extract_entities + from packages.inference_protocol.fields import extract_fields + from tests.ai.test_supervisor import IDENTITY + + data = { + "purpose": "development", + "dictionary_version_id": result["dictionary_version_id"], + "entries": list(entries), + } + raw = json.dumps(data, ensure_ascii=False) + result["dictionary_sha256"] = hashlib.sha256(raw.encode()).hexdigest() + bundle = { + "purpose": "development", + "bundle_id": result["model_bundle_id"], + "dictionary_version_id": result["dictionary_version_id"], + "pipeline_version": "vision-v1", + "device_profile": "cpu", + "adapter_id": "dfine-coco80-rgb-stretch-v1", + "runtime_profile": RUNTIME_PROFILE, + "artifacts": { + name: { + "path": "C:/synthetic/" + name, + "sha256": result["dictionary_sha256"] if name == "dictionary" else "a" * 64, + } + for name in ( + "detector_model", + "detector_config", + "detector_preprocessor", + "ocr_det_model", + "ocr_det_config", + "ocr_rec_model", + "ocr_rec_config", + "dictionary", + "runtime_lock", + ) + }, + "thresholds": { + "detection_min": 0.4, + "ocr_min": 0.6, + "entity_min": 0.9, + "blur_min": 80, + "dark_min": 0.12, + "glare_max": 0.3, + }, + "threads": 2, + } + bundle_raw = json.dumps(bundle) + result["model_checksum"] = hashlib.sha256(bundle_raw.encode()).hexdigest() + result["extraction_context"] = {"bundle_json": bundle_raw, "dictionary_json": raw} + result["execution_identity"] = dict( + IDENTITY, + runtime_profile=RUNTIME_PROFILE, + **{ + k: result[k] + for k in ( + "model_bundle_id", + "model_checksum", + "dictionary_version_id", + "dictionary_sha256", + "pipeline_version", + ) + }, + ) + result["ocr_fields"] = extract_fields(result["text_regions"], data) + result["entities"] = extract_entities(result["ocr_fields"], data) + return result + + +def synthetic_chemical_entries(): + return [ + { + "entity_id": "33333333-3333-4333-8333-333333333333", + "canonical_name": "Alpha", + "aliases": ["甲"], + "cas": "64-17-5", + "source": "synthetic test only; no safety data", + } + ] + + +def with_source_hash(lease, sha): + image = lease.input.images[0] + image = replace(image, sha256=sha, object_key=image.object_key.replace(image.sha256, sha)) + return replace(lease, input=replace(lease.input, images=(image,))) + + +def lease(): + tenant, lab, item, run, image = (str(uuid4()) for _ in range(5)) + value = InferenceInput( + tenant_id=tenant, + laboratory_id=lab, + item_id=item, + run_id=run, + submission_revision=2, + model_bundle_id=str(uuid4()), + model_checksum="a" * 64, + dictionary_version_id=str(uuid4()), + dictionary_sha256="b" * 64, + pipeline_version="vision-v1", + device_profile="cpu", + images=( + InferenceImage( + image_id=image, + object_key=f"tenant/{tenant}/lab/{lab}/analysis/{image}/{'c' * 64}.png", + object_version="analysis-v1", + sha256="c" * 64, + mime_type="image/png", + role="overview", + location_id=str(uuid4()), + parent_image_id=None, + ), + ), + ) + return InferenceLease(tenant, str(uuid4()), str(uuid4()), str(uuid4()), 3, 0, 1, value) + + +def result_for(value, outcome="needs_retake"): + image = value.input.images[0] + return { + "run_id": value.input.run_id, + "attempt_id": value.attempt_id, + "fencing_token": value.token, + "tenant_id": value.input.tenant_id, + "request_hash": value.input.request_hash, + "input_hashes": [{"image_id": image.image_id, "sha256": image.sha256}], + "model_bundle_id": value.input.model_bundle_id, + "model_checksum": value.input.model_checksum, + "dictionary_version_id": value.input.dictionary_version_id, + "dictionary_sha256": value.input.dictionary_sha256, + "pipeline_version": "vision-v1", + "outcome": outcome, + "quality": [ + { + "image_id": image.image_id, + "status": "needs_retake" if outcome == "needs_retake" else "pass", + "reasons": ["blur"] if outcome == "needs_retake" else [], + "blur_score": 0 if outcome == "needs_retake" else 100, + "brightness": 0.5, + "glare_ratio": 0, + } + ], + "detections": [], + "text_regions": [], + "crops": [], + "ocr_fields": [], + "entities": [], + "relations": [], + "timing_ms": { + "download_ms": 0, + "quality_ms": 0, + "detection_ms": 0, + "ocr_ms": 0, + "normalization_ms": 0, + "total_ms": 0, + }, + } diff --git a/tests/helpers.py b/tests/helpers.py index f6a5a18..676a5d6 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -69,6 +69,7 @@ def make_request(settings, *, seconds=10): { "image_id": image_id, "object_key": f"tenant/{TENANT}/lab/{lab_id}/analysis/{image_id}/{image_hash}.png", + "object_version": "synthetic-analysis-v1", "sha256": image_hash, "mime_type": "image/png", "role": "overview", diff --git a/tests/persistence/conftest.py b/tests/persistence/conftest.py index 20c2f1e..b091fd6 100644 --- a/tests/persistence/conftest.py +++ b/tests/persistence/conftest.py @@ -20,7 +20,7 @@ from packages.persistence.database import database_engine from packages.persistence.schema import verify_schema from packages.persistence.security import SessionService -from tests.persistence.factories import insert +from tests.persistence.factories import Graph, insert from tests.persistence.test_security_mysql import create_tenant @@ -61,7 +61,67 @@ def database(): .mappings() .one() ) + command.upgrade(config, "0002_report_object_version") + with engine.begin() as connection: + graph = Graph(connection) + frozen = dict( + connection.execute( + text("SELECT * FROM run_images WHERE run_id=:run"), {"run": graph.run} + ) + .mappings() + .one() + ) + command.upgrade(config, "0003_run_image_version") + # A historical detection-based crop survives the new nullable OCR fields unchanged. + with engine.begin() as connection: + graph.connection = connection + derivative_id = graph.add( + "image_derivatives", + run_id=graph.run, + image_id=graph.image, + crop_id=str(uuid4()), + detection_id=str(uuid4()), + ) + old_derivative = dict( + connection.execute( + text("SELECT * FROM image_derivatives WHERE id=:id"), {"id": derivative_id} + ) + .mappings() + .one() + ) command.upgrade(config, "head") + with engine.connect() as connection: + migrated = dict( + connection.execute(text("SELECT * FROM run_images WHERE id=:id"), {"id": frozen["id"]}) + .mappings() + .one() + ) + assert {key: migrated[key] for key in frozen} == frozen + assert migrated["analysis_object_version"] is None # Never infer an old run's version. + new_derivative = dict( + connection.execute( + text("SELECT * FROM image_derivatives WHERE id=:id"), {"id": derivative_id} + ) + .mappings() + .one() + ) + assert {key: new_derivative[key] for key in old_derivative} == old_derivative + assert new_derivative["line_id"] is new_derivative["size_bytes"] is None + with engine.begin() as connection: + graph.connection = connection + independent = graph.add( + "image_derivatives", + run_id=graph.run, + image_id=graph.image, + crop_id=str(uuid4()), + detection_id=None, + line_id=str(uuid4()), + size_bytes=100, + ) + with pytest.raises(RuntimeError, match="forward repair"): + command.downgrade(config, "0003_run_image_version") + with engine.begin() as connection: + connection.execute(text("DELETE FROM image_derivatives WHERE id=:id"), {"id": independent}) with engine.begin() as connection: upgraded = dict( connection.execute(text("SELECT * FROM report_exports WHERE id=:id"), {"id": export_id}) diff --git a/tests/persistence/test_inference_execution_mysql.py b/tests/persistence/test_inference_execution_mysql.py index bb2e5ec..7a3b36a 100644 --- a/tests/persistence/test_inference_execution_mysql.py +++ b/tests/persistence/test_inference_execution_mysql.py @@ -5,9 +5,17 @@ import pytest from fastapi.testclient import TestClient -from sqlalchemy import text +from sqlalchemy import event, text +from packages.domain.inference_execution import InferenceLeaseLost from packages.persistence import inference_execution, rule_execution +from tests.evidence_helpers import ( + add_bottles, + add_chemical_context, + add_regions, + artifacts_for, + synthetic_chemical_entries, +) from tests.persistence.factories import insert from tests.persistence.test_identity_mysql import ORIGIN, login from tests.persistence.test_item_queries_mysql import inspection @@ -26,7 +34,12 @@ def post(http, session, path, body): @pytest.mark.usefixtures("database") -def test_inference_claim_and_facts_commit_are_fenced(identity, database): +@pytest.mark.parametrize("outcome", ["facts_ready", "needs_review"]) +@pytest.mark.parametrize("simulated", [False, True]) +@pytest.mark.parametrize("association", ["none", "unique", "tie"]) +def test_inference_claim_and_facts_commit_are_fenced( + identity, database, outcome, simulated, association +): tenant, _, app = identity with TestClient(app, base_url=ORIGIN) as http: session = login(http, tenant).json() @@ -80,6 +93,23 @@ def test_inference_claim_and_facts_commit_are_fenced(identity, database): dictionary_version_id=dictionary, status="published", ) + if outcome == "needs_review": + synthetic = { + "model_bundle_id": model, + "dictionary_version_id": dictionary, + "ocr_fields": [], + "text_regions": [], + "pipeline_version": "vision-v1", + } + add_chemical_context(synthetic, synthetic_chemical_entries()) + connection.execute( + text("UPDATE dictionary_versions SET checksum=:sha WHERE id=:id"), + {"id": dictionary, "sha": synthetic["dictionary_sha256"]}, + ) + connection.execute( + text("UPDATE model_versions SET checksum=:sha WHERE id=:id"), + {"id": model, "sha": synthetic["model_checksum"]}, + ) rules = insert(connection, "rule_bundles", tenant_id=tenant["tenant_id"]) rule_set = insert(connection, "rule_sets", tenant_id=tenant["tenant_id"]) rule_version = insert( @@ -143,9 +173,32 @@ def test_inference_claim_and_facts_commit_are_fenced(identity, database): {"task": run["job_id"]}, ) ) + # Every rejected mutation is rolled back, so the original frozen run remains executable. + for table, value in (("run_images", None), ("asset_images", "analysis-v2")): + with database.connect() as connection: + transaction = connection.begin() + try: + connection.execute( + text( + f"UPDATE {table} SET analysis_object_version=:value WHERE " + + ("run_id=:id" if table == "run_images" else "id=:id") + ), + {"value": value, "id": run["id"] if table == "run_images" else image}, + ) + assert inference_execution.claim(connection, message, str(uuid4())) is None + assert ( + connection.scalar( + text("SELECT COUNT(*) FROM task_attempts WHERE task_id=:id"), + {"id": message["task_id"]}, + ) + == 0 + ) + finally: + transaction.rollback() with database.begin() as connection: lease = inference_execution.claim(connection, message, str(uuid4())) assert lease is not None + assert lease.input.images[0].object_version == "analysis-v1" result = { "run_id": lease.input.run_id, "attempt_id": lease.attempt_id, @@ -160,7 +213,7 @@ def test_inference_claim_and_facts_commit_are_fenced(identity, database): "dictionary_version_id": lease.input.dictionary_version_id, "dictionary_sha256": lease.input.dictionary_sha256, "pipeline_version": "vision-v1", - "outcome": "facts_ready", + "outcome": outcome, "quality": [ { "image_id": image, @@ -172,6 +225,7 @@ def test_inference_claim_and_facts_commit_are_fenced(identity, database): } ], "detections": [], + "text_regions": [], "crops": [], "ocr_fields": [], "entities": [], @@ -188,7 +242,130 @@ def test_inference_claim_and_facts_commit_are_fenced(identity, database): ) }, } - inference_execution.commit_result(connection, lease, result) + # Synthetic wire metadata tests persistence/projection, not real numerical execution. + from tests.ai.test_supervisor import IDENTITY + + result["execution_identity"] = dict( + IDENTITY, + is_simulated=simulated, + **{ + key: result[key] + for key in ( + "model_bundle_id", + "model_checksum", + "dictionary_version_id", + "dictionary_sha256", + "pipeline_version", + ) + }, + ) + add_regions(lease, result) + if association != "none": + add_bottles(lease, result, 1 if association == "unique" else 2) + if outcome == "needs_review": + result["text_regions"][0].update(raw_text="NAME", confidence=0.9) + result["text_regions"][1].update(raw_text="Alpha", confidence=0.3) + add_chemical_context(result, synthetic_chemical_entries()) + result["execution_identity"]["is_simulated"] = simulated + assert result["model_checksum"] == lease.input.model_checksum + assert result["dictionary_sha256"] == lease.input.dictionary_sha256 + artifacts = artifacts_for(lease, result) + for mutation in ( + "UPDATE task_runs SET lease_until=UTC_TIMESTAMP(3)-INTERVAL 1 SECOND WHERE id=:id", + "UPDATE inference_runs SET status='superseded' WHERE id=:id", + ): + savepoint = connection.begin_nested() + connection.execute( + text(mutation), + {"id": lease.task_id if "task_runs" in mutation else lease.input.run_id}, + ) + with pytest.raises(InferenceLeaseLost): + inference_execution.commit_result(connection, lease, result, artifacts) + savepoint.rollback() + savepoint = connection.begin_nested() + connection.execute( + text("UPDATE asset_images SET analysis_object_version='analysis-v2' WHERE id=:id"), + {"id": image}, + ) + with pytest.raises(InferenceLeaseLost): + inference_execution.commit_result(connection, lease, result, artifacts) + savepoint.rollback() + + # Every derivative and all success state must roll back if a later DB write fails. + def fail_after_crops(conn, cursor, statement, parameters, context, many): + if statement.startswith("UPDATE inference_runs SET status="): + raise RuntimeError("Injected result write fault") + + savepoint = connection.begin_nested() + event.listen(connection, "before_cursor_execute", fail_after_crops) + try: + with pytest.raises(RuntimeError, match="Injected"): + inference_execution.commit_result(connection, lease, result, artifacts) + finally: + event.remove(connection, "before_cursor_execute", fail_after_crops) + savepoint.rollback() + assert ( + connection.scalar( + text("SELECT COUNT(*) FROM image_derivatives WHERE run_id=:run"), + {"run": lease.input.run_id}, + ) + == 0 + ) + assert ( + connection.scalar( + text("SELECT state FROM task_runs WHERE id=:task"), {"task": lease.task_id} + ) + == "leased" + ) + for field, wrong in ( + ("owner", str(uuid4())), + ("token", lease.token + 1), + ("attempt_id", str(uuid4())), + ("generation", lease.generation + 1), + ("attempt", lease.attempt + 1), + ): + from dataclasses import replace + + stale = replace(lease, **{field: wrong}) + stale_result = {**result, "attempt_id": stale.attempt_id, "fencing_token": stale.token} + with pytest.raises(InferenceLeaseLost): + inference_execution.commit_result(connection, stale, stale_result, artifacts) + inference_execution.commit_result(connection, lease, result, artifacts) + stored = json.loads( + connection.scalar( + text("SELECT result FROM inference_runs WHERE id=:run"), + {"run": lease.input.run_id}, + ) + ) + assert stored["ocr_fields"] == result["ocr_fields"] + assert stored["entities"] == result["entities"] + if outcome == "needs_review" and association == "unique": + assert len(stored["ocr_fields"][0]["source_lines"]) == 2 + assert stored["ocr_fields"][0]["confidence"] == 0.3 + assert stored["entities"][0]["resolution"] == "candidate" + assert stored["extraction_context"] == result["extraction_context"] + registered = ( + connection.execute( + text( + "SELECT crop_id,line_id,detection_id,object_key,object_version," + "sha256,size_bytes,recipe " + "FROM image_derivatives WHERE tenant_id=:tenant AND run_id=:run" + ), + {"tenant": lease.tenant_id, "run": lease.input.run_id}, + ) + .mappings() + .all() + ) + assert len(registered) == 2 + expected_detection = ( + result["detections"][0]["detection_id"] if association == "unique" else None + ) + assert all( + row["detection_id"] == expected_detection and row["line_id"] for row in registered + ) + crops_by_id = {row["crop_id"]: row for row in result["crops"]} + assert all(json.loads(row["recipe"]) == crops_by_id[row["crop_id"]] for row in registered) + assert {row["object_version"] for row in registered} == {artifacts[0].object_version} state = connection.execute( text( "SELECT r.status AS run_status,r.stage,t.state,i.status AS item_status," @@ -199,11 +376,23 @@ def test_inference_claim_and_facts_commit_are_fenced(identity, database): ), {"run": lease.input.run_id}, ).one() - assert state.run_status == "processing" - assert state.stage == "rules" + assert state.run_status == ("processing" if outcome == "facts_ready" else "needs_review") + assert state.stage == ("rules" if outcome == "facts_ready" else "done") assert state.state == "succeeded" - assert state.item_status == "processing" - assert state.current_fact_revision_id is not None + assert state.item_status == ("processing" if outcome == "facts_ready" else "needs_review") + assert (state.current_fact_revision_id is not None) == (outcome == "facts_ready") + from packages.persistence.inference_runs import _projection + + assert ( + _projection(connection, lease.tenant_id, lease.input.run_id)["text_regions"] + == result["text_regions"] + ) + assert ( + _projection(connection, lease.tenant_id, lease.input.run_id)["is_simulated"] + == simulated + ) + if outcome == "needs_review": + return with database.connect() as connection: rule_message = json.loads( connection.scalar( diff --git a/tests/persistence/test_inference_submit_mysql.py b/tests/persistence/test_inference_submit_mysql.py index ee23c38..5a4c4c5 100644 --- a/tests/persistence/test_inference_submit_mysql.py +++ b/tests/persistence/test_inference_submit_mysql.py @@ -1,5 +1,6 @@ """Real MySQL acceptance for the atomic submitInspectionItem command.""" +import hashlib import json from pathlib import Path from uuid import uuid4 @@ -9,6 +10,7 @@ from jsonschema import Draft202012Validator, FormatChecker from sqlalchemy import text +from packages.shared.json_hash import canonical_json from tests.persistence.factories import insert from tests.persistence.test_identity_mysql import ORIGIN, login from tests.persistence.test_item_queries_mysql import inspection @@ -103,6 +105,42 @@ def test_submit_creates_pinned_run_task_and_outbox(identity, database): assert run["input_image_ids"] == [image_id] assert run["model_bundle_id"] == model and run["dictionary_version_id"] == dictionary with database.connect() as connection: + frozen = ( + connection.execute( + text( + "SELECT analysis_sha256,analysis_object_version " + "FROM run_images WHERE run_id=:id" + ), + {"id": run["id"]}, + ) + .mappings() + .one() + ) + assert frozen == {"analysis_sha256": "a" * 64, "analysis_object_version": "analysis-v1"} + inputs = ( + connection.execute( + text("SELECT * FROM inference_runs WHERE id=:id"), {"id": run["id"]} + ) + .mappings() + .one() + ) + config = { + key: inputs[key] + for key in ( + "model_bundle_id", + "dictionary_version_id", + "rule_bundle_id", + "pipeline_version", + "device_profile", + ) + } + config["reference_date"] = inputs["reference_date"].isoformat() + config["images"] = [ + {"image_id": image_id, "role": "overview", "parent_image_id": None, **frozen} + ] + assert ( + inputs["input_hash"] == hashlib.sha256(canonical_json(config).encode()).hexdigest() + ) item = ( connection.execute( text("SELECT status,current_run_id FROM inspection_items WHERE id=:id"), diff --git a/tests/persistence/test_unit.py b/tests/persistence/test_unit.py index 721f64b..3c7d5e7 100644 --- a/tests/persistence/test_unit.py +++ b/tests/persistence/test_unit.py @@ -26,12 +26,32 @@ def test_head_schema_matches_authoritative_design_and_preserves_initial_baseline head = model() for name in ("object_version", "size_bytes"): del head["tables"]["report_exports"]["columns"][name] + del head["tables"]["run_images"]["columns"]["analysis_object_version"] + columns = head["tables"]["image_derivatives"]["columns"] + columns["detection_id"]["nullable"] = False + del columns["line_id"], columns["size_bytes"] assert head == baseline snapshot = MODEL_PATH.with_suffix(".sql").read_text(encoding="utf-8").strip() head_ddl = (ROOT / "contracts/database-design.sql").read_text(encoding="utf-8").strip() additions = " `object_version` VARCHAR(200) NULL,\n `size_bytes` BIGINT UNSIGNED NULL,\n" assert head_ddl.count(additions) == 1 - assert snapshot == head_ddl.replace(additions, "") + version_column = " `analysis_object_version` VARCHAR(200) NULL,\n" + # One existing asset column and one new frozen run column. + assert head_ddl.count(version_column) == 2 + index = head_ddl.index("CREATE TABLE `run_images`") + without_version = head_ddl[:index] + head_ddl[index:].replace(version_column, "", 1) + derivative_start = without_version.index("CREATE TABLE `image_derivatives`") + derivative_end = without_version.index("CREATE TABLE", derivative_start + 1) + derivative = without_version[derivative_start:derivative_end] + original = derivative.replace( + "`detection_id` CHAR(36) NULL", "`detection_id` CHAR(36) NOT NULL" + ) + for name, sql_type in (("line_id", "CHAR(36)"), ("size_bytes", "BIGINT UNSIGNED")): + original = original.replace(f" `{name}` {sql_type} NULL,\n", "") + without_version = ( + without_version[:derivative_start] + original + without_version[derivative_end:] + ) + assert snapshot == without_version.replace(additions, "") @pytest.mark.parametrize("environment", ["production", "staging", "", "prod"]) diff --git a/tests/worker/__init__.py b/tests/worker/__init__.py new file mode 100644 index 0000000..de19fbe --- /dev/null +++ b/tests/worker/__init__.py @@ -0,0 +1 @@ +"""Independent Worker image/storage runtime acceptance.""" diff --git a/tests/worker/test_evidence_pixels.py b/tests/worker/test_evidence_pixels.py new file mode 100644 index 0000000..940ef2f --- /dev/null +++ b/tests/worker/test_evidence_pixels.py @@ -0,0 +1,332 @@ +"""Real pixels plus SDK Stubber, in the independent evidence environment.""" + +import copy +import hashlib +import io +import os +import subprocess +import sys +import tempfile +import unittest +from datetime import datetime, timedelta, timezone +from threading import Event, Timer +from unittest.mock import patch + +import numpy as np +from botocore.response import StreamingBody +from botocore.stub import Stubber + +from packages.application.inference_evidence import prepare_evidence +from packages.domain.inference_evidence import derivative_key, validate_derivatives +from packages.domain.inference_execution import request_payload +from packages.domain.security import ServiceError +from packages.image_evidence.perspective import ( + CropError, + CropTransform, + encode_rgb_png, + rebuild_crop, + recognition_rgb, + rgb_digest, +) +from packages.inference_protocol.fields import extract_fields +from packages.storage.s3 import BUCKET, S3Settings, S3Storage +from tests.evidence_helpers import ( + add_bottles, + add_chemical_context, + add_regions, + lease, + result_for, + synthetic_chemical_entries, + with_source_hash, +) + + +def pixel_case(): + y, x = np.indices((30, 40)) + rgb = np.stack((x * 5, y * 7, x + y), axis=-1).astype(np.uint8) + raw = encode_rgb_png(rgb) + value = with_source_hash(lease(), hashlib.sha256(raw).hexdigest()) + result = add_regions(value, result_for(value, "needs_review")) + for index, crop in enumerate(result["crops"]): + recipe = CropTransform(((0, 0), (1, 0), (1, 1), (0, 1)), 40 if index == 0 else 10, 30) + crop.update(recipe.as_dict()) + evidence = rebuild_crop(rgb, recipe) + rotation = 90 if index else 0 + oriented = recognition_rgb(evidence.pixels, rotation) + crop["evidence"] = { + "png_sha256": evidence.sha256, + "png_size_bytes": len(evidence.png), + "source_rgb_sha256": rgb_digest(rgb), + "recognition_rotation_ccw": rotation, + "recognition_width": oriented.shape[1], + "recognition_height": oriented.shape[0], + "recognition_rgb_sha256": rgb_digest(oriented), + } + source = request_payload(value, datetime.now(timezone.utc) + timedelta(seconds=20)) + return value, source, result, raw + + +class MemoryStorage: + def __init__(self, source, raw, fail_at=None): + self.source, self.raw, self.fail_at = source, raw, fail_at + self.reads, self.uploads = [], [] + + def read_analysis(self, tenant, laboratory, reference): + assert tenant == self.source["tenant_id"] and laboratory == self.source["laboratory_id"] + assert reference == self.source["image_refs"][0] + self.reads.append(reference) + return self.raw + + def put_derivative(self, key, raw): + if len(self.uploads) == self.fail_at: + raise ServiceError("DEPENDENCY_UNAVAILABLE", 503, "Injected storage failure") + self.uploads.append((key, raw)) + return f"exact-crop/v{len(self.uploads)}+=" + + +class EvidencePixelsTests(unittest.TestCase): + def test_pinned_candidates_rebuilt_with_real_pixels_and_forgery_before_storage(self): + value, source, result, raw = pixel_case() + add_bottles(value, result) + result["text_regions"][0]["raw_text"] = "NAME:Alpha" + result["text_regions"][1]["raw_text"] = "NAME:甲" + add_chemical_context(result, synthetic_chemical_entries()) + for key in ("model_checksum", "dictionary_sha256"): + source[key] = result[key] + from packages.inference_protocol.hashing import request_hash + + source["request_hash"] = request_hash(source) + result["request_hash"] = source["request_hash"] + store = MemoryStorage(source, raw) + artifacts = prepare_evidence(store, source, result) + self.assertEqual(len(artifacts), 2) + self.assertEqual(result["entities"][0]["resolution"], "resolved") + changed = copy.deepcopy(result) + changed["entities"][0]["candidates"][0]["confidence"] = 0.1 + store = MemoryStorage(source, raw) + with self.assertRaises(ValueError): + prepare_evidence(store, source, changed) + self.assertEqual(store.reads, []) + self.assertEqual(store.uploads, []) + + def test_multiline_field_both_pixel_crops_verified_and_forgery_before_storage(self): + value, source, result, raw = pixel_case() + add_bottles(value, result) + result["text_regions"][0].update(raw_text="NAME", confidence=0.9) + result["text_regions"][1].update(raw_text="Ethanol", confidence=0.3) + result["ocr_fields"] = extract_fields(result["text_regions"]) + store = MemoryStorage(source, raw) + artifacts = prepare_evidence(store, source, result) + validate_derivatives(value, result, artifacts) + self.assertEqual(len(artifacts), 2) + self.assertEqual( + {a.crop_id for a in artifacts}, + {s["crop_id"] for s in result["ocr_fields"][0]["source_lines"]}, + ) + self.assertEqual(len(store.reads), 1) + changed = copy.deepcopy(result) + changed["ocr_fields"][0]["source_lines"].pop() + store = MemoryStorage(source, raw) + with self.assertRaises(ValueError): + prepare_evidence(store, source, changed) + self.assertEqual(store.reads, []) + self.assertEqual(store.uploads, []) + + def test_real_evidence_child_timeout_and_cancellation_kill_and_join(self): + from apps.worker.inference_evidence import BoundedEvidencePrepare + from packages.domain.inference_execution import InferenceLeaseLost + from tools.ml.evidence_fixture import evidence_store + from tools.ml.smoke_worker_evidence import worker_environment + + value, source, result, raw = pixel_case() + original_start = subprocess.Popen + for mode in ("timeout", "cancel"): + children = [] + + def start(*args, **kwargs): + child = original_start(*args, **kwargs) + children.append(child) + return child + + with tempfile.TemporaryDirectory() as directory: + with evidence_store(source, [raw], delay_get=4) as (settings, uploads, _): + cancel = Event() + timer = Timer(0.15, cancel.set) if mode == "cancel" else None + environment = worker_environment(directory, settings.endpoint, sys.executable) + with ( + patch.dict(os.environ, environment), + patch("apps.worker.inference_evidence.subprocess.Popen", start), + patch("apps.worker.inference_evidence.EVIDENCE_SECONDS", 1.5), + ): + try: + if timer: + timer.start() + with self.assertRaises( + InferenceLeaseLost if timer else ServiceError + ) as error: + BoundedEvidencePrepare()(value, result, cancel) + if not timer: + self.assertEqual(error.exception.code, "STAGE_TIMEOUT") + finally: + if timer: + timer.cancel() + timer.join(timeout=2) + self.assertEqual(uploads, []) + self.assertEqual(len(children), 1) + self.assertIsNotNone(children[0].poll()) + + def test_two_crops_one_source_and_recognition_rotation_verified(self): + value, source, result, raw = pixel_case() + store = MemoryStorage(source, raw) + artifacts = prepare_evidence(store, source, result) + validate_derivatives(value, result, artifacts) + self.assertEqual(len(store.reads), 1) + self.assertEqual(len(artifacts), 2) + for artifact, (key, png) in zip(artifacts, store.uploads): + self.assertEqual(artifact.object_key, key) + self.assertEqual(artifact.sha256, hashlib.sha256(png).hexdigest()) + self.assertEqual(artifact.size_bytes, len(png)) + self.assertEqual(result["crops"][1]["evidence"]["recognition_rotation_ccw"], 90) + self.assertFalse( + set(sys.modules) & {"sqlalchemy", "onnxruntime", "celery", "redis", "paddle"} + ) + + def test_linked_and_ambiguous_text_preserve_actual_crop_bytes(self): + for count in (1, 2): + value, source, result, raw = pixel_case() + baseline = prepare_evidence(MemoryStorage(source, raw), source, result) + add_bottles(value, result, count) + store = MemoryStorage(source, raw) + artifacts = prepare_evidence(store, source, result) + validate_derivatives(value, result, artifacts) + self.assertEqual([r.sha256 for r in baseline], [r.sha256 for r in artifacts]) + self.assertEqual([r.crop_id for r in baseline], [r.crop_id for r in artifacts]) + for artifact in artifacts: + self.assertEqual( + artifact.detection_id, + result["detections"][0]["detection_id"] if count == 1 else None, + ) + + def test_wrong_link_is_rejected_before_storage_reads(self): + value, source, result, raw = pixel_case() + add_bottles(value, result, 1) + result["detections"][0]["bbox"] = [0, 0, 0.7, 1] + store = MemoryStorage(source, raw) + with self.assertRaises(ValueError): + prepare_evidence(store, source, result) + self.assertEqual(store.reads, []) + self.assertEqual(store.uploads, []) + + def test_second_crop_failure_returns_no_partial_result(self): + _, source, result, raw = pixel_case() + store = MemoryStorage(source, raw, fail_at=1) + with self.assertRaises(ServiceError): + prepare_evidence(store, source, result) + self.assertEqual(len(store.uploads), 1) # Unreferenced orphan, no DB handle available. + + def test_corrupt_png_source_recognition_geometry_and_size_rejected(self): + for field in ( + "source_rgb_sha256", + "png_sha256", + "png_size_bytes", + "recognition_rgb_sha256", + ): + _, source, result, raw = pixel_case() + crop = result["crops"][0] + crop["evidence"][field] = 1 if field == "png_size_bytes" else "a" * 64 + store = MemoryStorage(source, raw) + with self.subTest(field=field), self.assertRaises(CropError): + prepare_evidence(store, source, result) + self.assertEqual(store.uploads, []) + _, source, result, raw = pixel_case() + with self.assertRaises(CropError): + prepare_evidence(MemoryStorage(source, raw[:-1]), source, result) + changed = copy.deepcopy(result) + changed["crops"][0]["quad"] = [{"x": 0, "y": 0}] * 4 + changed["text_regions"][0]["quad"] = changed["crops"][0]["quad"] + with self.assertRaises(ValueError): + prepare_evidence(MemoryStorage(source, raw), source, changed) + + def test_real_sdk_pinned_get_put_and_verified_exact_version_reuse(self): + value, source, result, raw = pixel_case() + storage = S3Storage( + S3Settings( + internal_endpoint="http://127.0.0.1:9000", + public_endpoint="https://labsafe.test", + public_origin="https://labsafe.test", + access_key="synthetic", + secret_key="test-only", + ) + ) + try: + stub = Stubber(storage.internal) + body = StreamingBody(io.BytesIO(raw), len(raw)) + reference = source["image_refs"][0] + stub.add_response( + "get_object", + { + "Body": body, + "ContentLength": len(raw), + "ContentType": "image/png", + "VersionId": reference["object_version"], + }, + { + "Bucket": BUCKET, + "Key": reference["object_key"], + "VersionId": reference["object_version"], + }, + ) + for index, crop in enumerate(result["crops"]): + key = derivative_key( + value.tenant_id, + value.input.laboratory_id, + value.input.run_id, + crop["crop_id"], + crop["evidence"]["png_sha256"], + ) + png = MemoryStorage(source, raw) + prepare_evidence(png, source, result) + png = png.uploads[index][1] + stub.add_client_error( + "head_object", + service_error_code="NoSuchKey", + http_status_code=404, + expected_params={"Bucket": BUCKET, "Key": key}, + ) + stub.add_response( + "put_object", + {"VersionId": f"crop/v{index}+="}, + {"Bucket": BUCKET, "Key": key, "Body": png, "ContentType": "image/png"}, + ) + with stub: + artifacts = prepare_evidence(storage, source, result) + stub.assert_no_pending_responses() + self.assertTrue(body._raw_stream.closed) + first = artifacts[0] + png = MemoryStorage(source, raw) + prepare_evidence(png, source, result) + png = png.uploads[0][1] + with Stubber(storage.internal) as stub: + metadata = { + "ContentLength": len(png), + "ContentType": "image/png", + "VersionId": first.object_version, + } + stub.add_response( + "head_object", metadata, {"Bucket": BUCKET, "Key": first.object_key} + ) + stub.add_response( + "get_object", + {**metadata, "Body": StreamingBody(io.BytesIO(png), len(png))}, + {"Bucket": BUCKET, "Key": first.object_key, "VersionId": first.object_version}, + ) + self.assertEqual( + storage.put_derivative(first.object_key, png), first.object_version + ) + stub.assert_no_pending_responses() + finally: + storage.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/design/data_model.py b/tools/design/data_model.py index e9a8539..414f3f0 100644 --- a/tools/design/data_model.py +++ b/tools/design/data_model.py @@ -56,8 +56,8 @@ def build_database(): for tab in ['uploads','asset_images']: link(tab,'laboratory_id','laboratories');link(tab,'inspection_item_id','inspection_items',['laboratory_id']);link(tab,'remediation_task_id','remediation_tasks',['laboratory_id']) table('evidence_images',{'task_id':uid(),'evidence_id':uid(),'image_id':uid(),'ordinal':num()},unique=[['tenant_id','evidence_id','image_id'],['tenant_id','evidence_id','ordinal']],immutable=True);link('evidence_images','evidence_id','remediation_evidence',['task_id']);fk('evidence_images',['tenant_id','task_id','image_id'],'asset_images',['tenant_id','remediation_task_id','id']) - table('run_images',{'item_id':uid(),'run_id':uid(),'image_id':uid(),'ordinal':num(),'role':state('overview','detail'),'parent_image_id':uid(True),'analysis_sha256':c('CHAR(64)')},unique=[['tenant_id','run_id','image_id'],['tenant_id','run_id','ordinal']],immutable=True);link('run_images','run_id','inference_runs',['item_id']);fk('run_images',['tenant_id','item_id','image_id'],'asset_images',['tenant_id','inspection_item_id','id']);fk('run_images',['tenant_id','run_id','parent_image_id'],'run_images',['tenant_id','run_id','image_id']) - table('image_derivatives',{'run_id':uid(),'image_id':uid(),'crop_id':uid(),'detection_id':uid(),'kind':state('crop'),'recipe':j(),'object_key':s(1024),'object_version':s(200),'sha256':c('CHAR(64)')},unique=[['tenant_id','run_id','crop_id']],immutable=True);fk('image_derivatives',['tenant_id','run_id','image_id'],'run_images',['tenant_id','run_id','image_id']) + table('run_images',{'item_id':uid(),'run_id':uid(),'image_id':uid(),'ordinal':num(),'role':state('overview','detail'),'parent_image_id':uid(True),'analysis_sha256':c('CHAR(64)'),'analysis_object_version':s(200,True)},unique=[['tenant_id','run_id','image_id'],['tenant_id','run_id','ordinal']],immutable=True);link('run_images','run_id','inference_runs',['item_id']);fk('run_images',['tenant_id','item_id','image_id'],'asset_images',['tenant_id','inspection_item_id','id']);fk('run_images',['tenant_id','run_id','parent_image_id'],'run_images',['tenant_id','run_id','image_id']) + table('image_derivatives',{'run_id':uid(),'image_id':uid(),'crop_id':uid(),'detection_id':uid(True),'line_id':uid(True),'size_bytes':c('BIGINT UNSIGNED',True),'kind':state('crop'),'recipe':j(),'object_key':s(1024),'object_version':s(200),'sha256':c('CHAR(64)')},unique=[['tenant_id','run_id','crop_id']],immutable=True);fk('image_derivatives',['tenant_id','run_id','image_id'],'run_images',['tenant_id','run_id','image_id']) table('api_idempotency',{'actor_id':uid(),'method':s(10),'path_hash':c('CHAR(64)'),'key_hash':c('CHAR(64)'),'request_hash':c('CHAR(64)'),'state':state('pending','completed'),'response_status':c('SMALLINT UNSIGNED',True),'response':c('JSON',True),'lease_owner':uid(True),'lease_until':dt(True),'expires_at':dt()},unique=[['tenant_id','actor_id','method','path_hash','key_hash']],indexes=[['expires_at']]);link('api_idempotency','actor_id','users') table('outbox_events',{'event_type':s(80),'aggregate_type':s(80),'aggregate_id':uid(),'aggregate_version':num('1'),'payload':j(),'state':state('pending','leased','published'),'lease_owner':uid(True),'lease_until':dt(True),'attempts':num(),'available_at':dt(),'published_at':dt(True)},indexes=[['state','available_at','id'],['state','lease_until']]) table('event_inbox',{'event_id':uid(),'consumer':s(80),'processed_at':dt()},unique=[['tenant_id','event_id','consumer']]);link('event_inbox','event_id','outbox_events') diff --git a/tools/design/extra_specs.py b/tools/design/extra_specs.py index 988522a..87b1757 100644 --- a/tools/design/extra_specs.py +++ b/tools/design/extra_specs.py @@ -35,20 +35,43 @@ def extend(g): PATHS[path][method]['parameters'].append(parameter(name,'query',schema)) A={k:copy.deepcopy(S[k]) for k in ['Error','ErrorDetail']} def am(n,p,optional=()): A[n]=obj(p,optional);return ref(n) - am('ImageRef',{'image_id':ID,'object_key':text(1024),'sha256':SHA,'mime_type':enum('image/jpeg','image/png','image/webp'),'role':enum('overview','detail'),'location_id':ID,'parent_image_id':nullable(ID)}) + am('ImageRef',{'image_id':ID,'object_key':text(1024),'object_version':{'type':'string','minLength':1,'maxLength':200,'pattern':'^[!-~]+$','not':{'anyOf':[{'const':'null'},{'pattern':'[^!-~]'}]}},'sha256':SHA,'mime_type':enum('image/jpeg','image/png','image/webp'),'role':enum('overview','detail'),'location_id':ID,'parent_image_id':nullable(ID)}) am('InferenceRequest',{'run_id':ID,'attempt_id':ID,'fencing_token':integer(1),'tenant_id':ID,'laboratory_id':ID,'item_id':ID,'submission_revision':integer(1),'model_bundle_id':ID,'model_checksum':SHA,'dictionary_version_id':ID,'dictionary_sha256':SHA,'pipeline_version':enum('vision-v1'),'device_profile':enum('cpu','cuda'),'deadline_at':DT,'request_hash':SHA,'image_refs':arr(ref('ImageRef'),1,3)}) am('Point',{'x':PROB,'y':PROB}) am('Quality',{'image_id':ID,'status':enum('pass','needs_retake'),'reasons':arr(enum('blur','dark','glare','occluded','unreadable'),hi=5),'blur_score':{'type':'number','minimum':0},'brightness':PROB,'glare_ratio':PROB}) am('Detection',{'detection_id':ID,'image_id':ID,'parent_detection_id':nullable(ID),'class_id':{'type':'integer','minimum':0,'maximum':79},'type':enum(*DFINE_CLASSES),'bbox':arr(PROB,4,4),'confidence':PROB}) - am('CropRecipe',{'crop_id':ID,'image_id':ID,'detection_id':ID,'quad':arr(ref('Point'),4,4),'output_width':integer(1,2048),'output_height':integer(1,2048),'transform_version':enum('perspective-rgb-v1')}) - am('OCRField',{'image_id':ID,'detection_id':ID,'crop_id':ID,'field':enum('name','expiry','production','opened','date_unknown','concentration','hazard_mark'),'raw_text':text(2000,0),'normalized_text':nullable(text(500)),'confidence':PROB}) + am('CropEvidence',{'png_sha256':SHA,'png_size_bytes':integer(1,16*1024*1024),'source_rgb_sha256':SHA,'recognition_rotation_ccw':{'type':'integer','enum':[0,90]},'recognition_width':integer(1,2048),'recognition_height':integer(1,2048),'recognition_rgb_sha256':SHA}) + am('TextRegion',{'line_id':ID,'image_id':ID,'crop_id':ID,'detection_id':nullable(ID),'raw_text':text(2000,0),'confidence':PROB,'quad':arr(ref('Point'),4,4)}) + A['TextRegion']['properties']['detection_id']['description']='text-bottle-quad80-v1: same-image real COCO bottle (class 39), center inside and >=80% quad intersection area, unique smallest bbox; otherwise null. Worker recomputes. No synthetic label/container.' + am('CropRecipe',{'crop_id':ID,'image_id':ID,'line_id':ID,'detection_id':nullable(ID),'quad':arr(ref('Point'),4,4),'output_width':integer(1,2048),'output_height':integer(1,2048),'transform_version':enum('perspective-rgb-v1'),'evidence':ref('CropEvidence')}) + A['CropRecipe']['properties']['detection_id']['description']='Same nullable bottle association as the paired TextRegion; it identifies the bottle, not an invented label detection.' + am('OCRFieldSource',{'line_id':ID,'crop_id':ID,'raw_text':text(2000,0),'confidence':PROB}) + am('OCRField',{'image_id':ID,'detection_id':ID,'crop_id':ID,'source_lines':arr(ref('OCRFieldSource'),1,2),'field':enum('name','expiry','production','opened','date_unknown','concentration','hazard_mark'),'raw_text':text(2000,0),'normalized_text':nullable(text(500)),'confidence':PROB}) + A['OCRField']['description']='ocr-fields-v1: actual unique bottle, all 1-2 consecutive source lines with original text/confidence/crop. crop_id is first source only; raw_text joins sources with LF and confidence is min. Worker recomputes full ordered fields; no chemical/date facts.' am('Candidate',{'entity_id':ID,'canonical_name':text(),'confidence':PROB,'match_method':enum('cas_exact','alias_exact','fuzzy')}) am('EntityCandidates',{'image_id':ID,'detection_id':ID,'candidates':arr(ref('Candidate'),0,5),'resolution':enum('resolved','candidate','unknown')}) am('Relation',{'image_id':ID,'source_detection_id':ID,'target_detection_id':ID,'relation':enum('adjacent','not_adjacent','unknown'),'confidence':PROB}) am('Timing',{'download_ms':integer(),'quality_ms':integer(),'detection_ms':integer(),'ocr_ms':integer(),'normalization_ms':integer(),'total_ms':integer()}) - am('InferenceResult',{'run_id':ID,'attempt_id':ID,'fencing_token':integer(1),'tenant_id':ID,'request_hash':SHA,'input_hashes':arr(obj({'image_id':ID,'sha256':SHA}),1,3),'model_bundle_id':ID,'model_checksum':SHA,'dictionary_version_id':ID,'dictionary_sha256':SHA,'pipeline_version':enum('vision-v1'),'outcome':enum('facts_ready','needs_retake','needs_review'),'quality':arr(ref('Quality'),1,3),'detections':arr(ref('Detection'),0,100),'crops':arr(ref('CropRecipe'),0,100),'ocr_fields':arr(ref('OCRField'),0,500),'entities':arr(ref('EntityCandidates'),0,100),'relations':arr(ref('Relation'),0,200),'timing_ms':ref('Timing')}) + am('InferenceResult',{'run_id':ID,'attempt_id':ID,'fencing_token':integer(1),'tenant_id':ID,'request_hash':SHA,'input_hashes':arr(obj({'image_id':ID,'sha256':SHA}),1,3),'model_bundle_id':ID,'model_checksum':SHA,'dictionary_version_id':ID,'dictionary_sha256':SHA,'pipeline_version':enum('vision-v1'),'outcome':enum('facts_ready','needs_retake','needs_review'),'quality':arr(ref('Quality'),1,3),'detections':arr(ref('Detection'),0,100),'text_regions':arr(ref('TextRegion'),0,100),'crops':arr(ref('CropRecipe'),0,100),'ocr_fields':arr(ref('OCRField'),0,500),'entities':arr(ref('EntityCandidates'),0,100),'relations':arr(ref('Relation'),0,200),'timing_ms':ref('Timing')}) + S['Point']=copy.deepcopy(A['Point']) + S['TextRegion']=copy.deepcopy(A['TextRegion']) + S['InferenceRun']['properties']['text_regions']=arr(ref('TextRegion'),0,100) + S['InferenceRun']['required'].append('text_regions') am('Health',{'status':enum('alive','ready','not_ready'),'model_bundle_id':nullable(ID),'active_attempt_id':nullable(ID)}) am('Version',{'service_commit':text(40),'pipeline_version':enum('vision-v1'),'model_bundle_id':ID,'dictionary_version_id':ID,'device_profile':enum('cpu','cuda')}) + A['Version']['properties'].update({'model_checksum':SHA,'dictionary_sha256':SHA}) + A['Version']['required']+=['model_checksum','dictionary_sha256'] + A['InferenceResult']['properties']['execution_identity']=ref('Version') + am('ExtractionContext',{'bundle_json':text(65536),'dictionary_json':text(65536)}) + A['InferenceResult']['properties']['extraction_context']=ref('ExtractionContext') + cpu_files=['detector_model','detector_config','detector_preprocessor','ocr_det_model','ocr_det_config','ocr_rec_model','ocr_rec_config','dictionary','runtime_lock'] + cpu_packages=['fastapi','pydantic','uvicorn','PyYAML','jsonschema','rfc3339-validator','onnxruntime','numpy','Pillow','opencv-python-headless','pyclipper'] + am('DevelopmentCPUBundle',{'purpose':enum('development'),'bundle_id':ID,'dictionary_version_id':ID,'pipeline_version':enum('vision-v1'),'device_profile':enum('cpu'),'adapter_id':enum('dfine-coco80-rgb-stretch-v1'),'runtime_profile':enum('dfine-cpu-fp32-ocrv6smallcpu-chemical-v2'),'artifacts':obj({k:obj({'path':text(2048),'sha256':SHA}) for k in cpu_files}),'thresholds':obj({'detection_min':PROB,'ocr_min':PROB,'entity_min':PROB,'blur_min':{'type':'number','minimum':0},'dark_min':PROB,'glare_max':PROB}),'threads':integer(1,16)}) + am('DevelopmentCPULock',{'purpose':enum('development'),'scope':enum('cpu-direct-runtime-v1'),'python':text(40),'platform':text(200),'packages':obj({k:text(64) for k in cpu_packages}),'crop_runtime_sha256':SHA,'code_sha256':SHA}) + am('DevelopmentDictionaryEntry',{'entity_id':ID,'canonical_name':text(200),'aliases':arr(text(200),0,10),'cas':nullable(text(12)),'source':text(1000)}) + am('DevelopmentDictionary',{'purpose':enum('development'),'dictionary_version_id':ID,'entries':arr(ref('DevelopmentDictionaryEntry'),0,100)}) + for name,filename in [('DevelopmentCPUBundle','development-cpu-bundle-v1.json'),('DevelopmentCPULock','development-cpu-lock-v1.json'),('DevelopmentDictionary','development-dictionary-v1.json')]: + OUT['contracts/'+filename]=standalone(name,{name:A[name],**({'DevelopmentDictionaryEntry':A['DevelopmentDictionaryEntry']} if name=='DevelopmentDictionary' else {})}) AP={} for path,method,req,res in [('/quality','post','InferenceRequest','InferenceResult'),('/runs','post','InferenceRequest','InferenceResult'),('/health','get',None,'Health'),('/ready','get',None,'Health'),('/version','get',None,'Version')]: operation={'operationId':'ai'+path[1:].title(),'responses':{'200':{'description':'结构化事实或健康状态','content':{'application/json':{'schema':ref(res)}}},**{str(v):{'description':'统一错误','content':{'application/json':{'schema':ref('Error')}}} for v in [401,403,404,409,413,415,422,429,500,503,504]}}} diff --git a/tools/design/release_specs.py b/tools/design/release_specs.py index 5bef629..e5da6b7 100644 --- a/tools/design/release_specs.py +++ b/tools/design/release_specs.py @@ -6,6 +6,7 @@ def extend_release(g): version=OUT['contracts/inference-v1.yaml']['components']['schemas']['Version'] version['properties'].update({'purpose':enum('development','evaluation','production'),'is_simulated':BOOL,'adapter_id':enum('dfine-coco80-rgb-stretch-v1','fixture-v1'),'runtime_profile':enum('dfine-cpu-fp32-ocrv6smallcpu-v1','dfine-cuda-fp32-ocrv6smallcpu-v1','fixture-v1'),'runtime_lock_sha256':SHA,'detector_device':enum('cpu','cuda','mock'),'ocr_device':enum('cpu','mock')}) version['required']+=['purpose','is_simulated','adapter_id','runtime_profile','runtime_lock_sha256','detector_device','ocr_device'] + version['properties']['runtime_profile']['enum'].append('dfine-cpu-fp32-ocrv6smallcpu-chemical-v2') model('Calibration',{'status':enum('uncalibrated','calibrated','failed'),'validation_split_sha256':nullable(SHA),'report_sha256':nullable(SHA)}) model('InferenceRuntime',{'detector_device':enum('cpu','cuda','mock'),'ocr_device':enum('cpu','mock'),'detector_precision':enum('fp32','mock'),'preprocess_id':enum('dfine-rgb-stretch640-v1','fixture-v1'),'postprocess_id':enum('dfine-qmax-v1','fixture-v1'),'onnx_opset':{'type':'integer','enum':[16,0]},'runtime_lock_sha256':SHA}) additions={'purpose':enum('development','evaluation','production'),'adapter_id':enum('dfine-coco80-rgb-stretch-v1','fixture-v1'),'model_family':enum('dfine_n','mock_fixture'),'source_ref':text(200),'source_commit':{'type':'string','pattern':'^[a-f0-9]{40}$'},'runtime_profile':enum('dfine-cpu-fp32-ocrv6smallcpu-v1','dfine-cuda-fp32-ocrv6smallcpu-v1','fixture-v1'),'runtime':ref('InferenceRuntime'),'content_sha256':SHA,'calibration':ref('Calibration'),'evaluation_policy_id':nullable(text(80)),'evaluation_policy_sha256':nullable(SHA),'evaluation_report_sha256':nullable(SHA)} diff --git a/tools/design/validate_specs.py b/tools/design/validate_specs.py index 8b2d92f..87f75e1 100644 --- a/tools/design/validate_specs.py +++ b/tools/design/validate_specs.py @@ -75,7 +75,7 @@ def main(): bad=copy.deepcopy(instance);bad['_unexpected']=1;check(not v.is_valid(bad),'extra field accepted '+key);count('negative_schema_instances') if s.get('required'): bad=copy.deepcopy(instance);bad.pop(s['required'][0]);check(not v.is_valid(bad),'missing required accepted '+key);count('negative_schema_instances') - for name in ['task-message-v1.json','events-v1.json','rule-dsl-v1.json','model-manifest-v1.json','inference-routes-v1.json','acceptance-policy-v1.json','model-evaluation-v1.json','release-approvals-v1.json']: + for name in ['task-message-v1.json','events-v1.json','rule-dsl-v1.json','model-manifest-v1.json','inference-routes-v1.json','acceptance-policy-v1.json','model-evaluation-v1.json','release-approvals-v1.json','development-cpu-bundle-v1.json','development-cpu-lock-v1.json','development-dictionary-v1.json']: doc=load('contracts/'+name);Draft202012Validator.check_schema(doc);walk(doc,doc);count('json_schemas') v=validator(doc,doc) for branch in doc.get('oneOf',[doc]): diff --git a/tools/ml/analysis_fixture.py b/tools/ml/analysis_fixture.py new file mode 100644 index 0000000..0ed92a2 --- /dev/null +++ b/tools/ml/analysis_fixture.py @@ -0,0 +1,118 @@ +"""Loopback object-store simulator with synthetic credentials; engineering tests only.""" + +import hashlib +import threading +import time +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import quote +from uuid import uuid4 + +from apps.ai_inference.analysis import BUCKET, AnalysisSettings +from packages.inference_protocol.hashing import request_hash + +TENANT = "11111111-1111-4111-8111-111111111111" +LAB = "22222222-2222-4222-8222-222222222222" + + +def payload_for(raws, *, seconds=180): + overview, location = str(uuid4()), str(uuid4()) + refs = [] + for index, raw in enumerate(raws): + image = str(uuid4()) if index else overview + sha = hashlib.sha256(raw).hexdigest() + refs.append( + { + "image_id": image, + "sha256": sha, + "object_key": f"tenant/{TENANT}/lab/{LAB}/analysis/{image}/{sha}.png", + "object_version": f"synthetic/v{index}+exact=", + "mime_type": "image/png", + "role": "detail" if index else "overview", + "parent_image_id": overview if index else None, + "location_id": location, + } + ) + value = { + "run_id": str(uuid4()), + "attempt_id": str(uuid4()), + "fencing_token": 1, + "tenant_id": TENANT, + "laboratory_id": LAB, + "item_id": str(uuid4()), + "submission_revision": 1, + "model_bundle_id": str(uuid4()), + "model_checksum": "a" * 64, + "dictionary_version_id": str(uuid4()), + "dictionary_sha256": "b" * 64, + "pipeline_version": "vision-v1", + "device_profile": "cpu", + "deadline_at": (datetime.now(timezone.utc) + timedelta(seconds=seconds)).isoformat(), + "image_refs": refs, + } + value["request_hash"] = request_hash(value) + return value + + +@contextmanager +def object_server( + payload, raws, *, status=200, headers=None, delay=0, truncated=False, chunk_delay=0 +): + objects = { + f"/{BUCKET}/{ref['object_key']}?versionId={quote(ref['object_version'], safe='')}": ( + ref, + raw, + ) + for ref, raw in zip(payload["image_refs"], raws) + } + seen = [] + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *_args): + pass + + def do_GET(self): + seen.append((self.path, dict(self.headers))) + if self.path not in objects: + self.send_error(404) + return + ref, raw = objects[self.path] + time.sleep(delay) + self.send_response(status) + response_headers = { + "Content-Length": str(len(raw)), + "Content-Type": "image/png", + "x-amz-version-id": ref["object_version"], + **(headers or {}), + } + for name, value in response_headers.items(): + if value is not None: + for entry in value if isinstance(value, list) else [value]: + self.send_header(name, entry) + self.end_headers() + try: + content = raw[: len(raw) // 2] if truncated else raw + if chunk_delay: + for byte in content: + time.sleep(chunk_delay) + self.wfile.write(bytes([byte])) + self.wfile.flush() + else: + self.wfile.write(content) + except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError): + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + endpoint = f"http://127.0.0.1:{server.server_port}" + settings = AnalysisSettings( + endpoint, (endpoint,), frozenset({TENANT}), "synthetic", "test-only" + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield settings, seen + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) diff --git a/tools/ml/create_cpu_bundle.py b/tools/ml/create_cpu_bundle.py new file mode 100644 index 0000000..b078c8f --- /dev/null +++ b/tools/ml/create_cpu_bundle.py @@ -0,0 +1,114 @@ +"""Generate actual local CPU identity/lock; no downloading or production activation.""" + +import argparse +import json +from pathlib import Path +from uuid import uuid4 + +from apps.ai_inference.bundle import CPUBundle, digest, runtime_lock +from packages.inference_protocol.chemical import RUNTIME_PROFILE, bounded_json +from packages.inference_protocol.chemical import dictionary as validate_dictionary +from packages.inference_protocol.contract import validate + + +def write(path, value): + path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def generate(output, model, det_dir, rec_dir, *, commit="local-development", dictionary_file=None): + output = Path(output).resolve() + output.mkdir(parents=True, exist_ok=True) + if any(output.iterdir()): + raise ValueError("Use an empty local output directory; existing identities are immutable") + model, det_dir, rec_dir = (Path(p).resolve() for p in (model, det_dir, rec_dir)) + dictionary_id = str(uuid4()) + dictionary = {"purpose": "development", "dictionary_version_id": dictionary_id, "entries": []} + if dictionary_file is not None: + with Path(dictionary_file).open("rb") as source: + raw_dictionary = source.read(65537) + dictionary = validate_dictionary(bounded_json(raw_dictionary.decode("utf-8"))) + dictionary_id = dictionary["dictionary_version_id"] + lock = runtime_lock() + validate("DevelopmentDictionary", dictionary) + validate("DevelopmentCPULock", lock) + write(output / "dictionary.json", dictionary) + write(output / "runtime-lock.json", lock) + paths = { + "detector_model": model, + "detector_config": model.parent / "config.json", + "detector_preprocessor": model.parent / "preprocessor_config.json", + "ocr_det_model": det_dir / "inference.onnx", + "ocr_det_config": det_dir / "inference.yml", + "ocr_rec_model": rec_dir / "inference.onnx", + "ocr_rec_config": rec_dir / "inference.yml", + "dictionary": output / "dictionary.json", + "runtime_lock": output / "runtime-lock.json", + } + artifacts = {} + import hashlib + + for name, path in paths.items(): + hasher = hashlib.sha256() + with path.open("rb") as source: + while chunk := source.read(1024 * 1024): + hasher.update(chunk) + artifacts[name] = {"path": str(path), "sha256": hasher.hexdigest()} + value = { + "purpose": "development", + "bundle_id": str(uuid4()), + "dictionary_version_id": dictionary_id, + "pipeline_version": "vision-v1", + "device_profile": "cpu", + "adapter_id": "dfine-coco80-rgb-stretch-v1", + "runtime_profile": RUNTIME_PROFILE, + "artifacts": artifacts, + "thresholds": { + "detection_min": 0.4, + "ocr_min": 0.6, + "entity_min": 0.9, + "blur_min": 80, + "dark_min": 0.12, + "glare_max": 0.3, + }, + "threads": 2, + } + validate("DevelopmentCPUBundle", value) + path = output / "bundle.json" + write(path, value) + sha = digest(path.read_bytes()) + roots = tuple(sorted({str(p.parent) for p in paths.values()})) + bundle = CPUBundle.load(str(path), sha, roots, commit) + bundle.verify() + write(output / "expected-version.json", bundle.identity) + return bundle + + +def main(): + parser = argparse.ArgumentParser() + for name in ("output", "model", "det-dir", "rec-dir"): + parser.add_argument(f"--{name}", type=Path, required=True) + parser.add_argument("--commit", default="local-development") + parser.add_argument("--dictionary", type=Path) + args = parser.parse_args() + bundle = generate( + args.output, + args.model, + args.det_dir, + args.rec_dir, + commit=args.commit, + dictionary_file=args.dictionary, + ) + print( + json.dumps( + { + "bundle_file": bundle.path, + "bundle_sha256": bundle.sha256, + "artifact_roots": bundle.roots, + "expected_version_file": str(args.output.resolve() / "expected-version.json"), + } + ) + ) + + +if __name__ == "__main__": + main() diff --git a/tools/ml/evidence_fixture.py b/tools/ml/evidence_fixture.py new file mode 100644 index 0000000..036a03a --- /dev/null +++ b/tools/ml/evidence_fixture.py @@ -0,0 +1,99 @@ +"""Versioned loopback S3 simulator for engineering tests, not IAM/TLS acceptance.""" + +import hashlib +import threading +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, unquote, urlsplit + +from apps.ai_inference.analysis import AnalysisSettings + + +@contextmanager +def evidence_store(source, raws, *, fail_put_at=None, wrong_source_version=False, delay_get=0): + import time + + objects = { + ref["object_key"]: (ref["object_version"], raw) + for ref, raw in zip(source["image_refs"], raws) + } + uploads, seen = [], [] + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *_args): + pass + + def parts(self): + parsed = urlsplit(self.path) + return unquote(parsed.path.removeprefix("/labsafe-private/")), parse_qs(parsed.query) + + def error(self, status, code): + raw = f"{code}".encode() + self.send_response(status) + self.send_header("Content-Type", "application/xml") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + if self.command != "HEAD": + self.wfile.write(raw) + + def serve_object(self): + key, query = self.parts() + seen.append((self.command, key, query)) + if key not in objects: + self.error(404, "NoSuchKey") + return + version, raw = objects[key] + if self.command == "GET" and query.get("versionId") != [version]: + self.error(404, "NoSuchVersion") + return + if self.command == "GET": + time.sleep(delay_get) + self.send_response(200) + self.send_header("Content-Type", "image/png") + self.send_header("Content-Length", str(len(raw))) + self.send_header("x-amz-version-id", "wrong" if wrong_source_version else version) + self.end_headers() + if self.command == "GET": + try: + self.wfile.write(raw) + except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError): + pass + + do_GET = serve_object + do_HEAD = serve_object + + def do_PUT(self): + key, query = self.parts() + seen.append(("PUT", key, query)) + raw = self.rfile.read(int(self.headers["Content-Length"])) + if len(uploads) == fail_put_at: + self.error(403, "AccessDenied") + return + version = f"synthetic-crop/v{len(uploads)}+exact=" + objects[key] = (version, raw) + uploads.append( + { + "key": key, + "version": version, + "sha256": hashlib.sha256(raw).hexdigest(), + "size_bytes": len(raw), + } + ) + self.send_response(200) + self.send_header("x-amz-version-id", version) + self.send_header("Content-Length", "0") + self.end_headers() + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + endpoint = f"http://127.0.0.1:{server.server_port}" + settings = AnalysisSettings( + endpoint, (endpoint,), frozenset({source["tenant_id"]}), "synthetic", "test-only" + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield settings, uploads, seen + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) diff --git a/tools/ml/http_cpu_fixture.py b/tools/ml/http_cpu_fixture.py new file mode 100644 index 0000000..f98653c --- /dev/null +++ b/tools/ml/http_cpu_fixture.py @@ -0,0 +1,67 @@ +"""Test-only HTTP host with file-based graceful shutdown/fault injection.""" + +import asyncio +import json +import os +from pathlib import Path + +import uvicorn + +from apps.ai_inference.app.main import create_app + + +async def run(): + app = create_app() + server = uvicorn.Server( + uvicorn.Config( + app, + host="127.0.0.1", + port=int(os.environ["AI_INFERENCE_PORT"]), + log_level="error", + timeout_graceful_shutdown=220, + ) + ) + directory = Path(os.environ["SMOKE_CONTROL_DIRECTORY"]) + + async def control(): + try: + while not server.should_exit: + runtime = app.state.supervisor + if runtime.process is not None and runtime.process.pid: + (directory / "pid.json").write_text( + json.dumps({"pid": runtime.process.pid, "ready": runtime.ready}), + encoding="ascii", + ) + if (directory / "crash").exists(): + (directory / "crash").unlink() + if runtime.process: + runtime.process.kill() + if (directory / "stop").exists(): + runtime.closing = True + server.should_exit = True + await asyncio.sleep(0.05) + finally: + pass + + task = asyncio.create_task(control()) + try: + await server.serve() + finally: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + (directory / "closed.json").write_text( + json.dumps( + { + "child_reaped": app.state.supervisor.process is None, + "receiver_reaped": app.state.supervisor.receiver is None, + } + ), + encoding="ascii", + ) + + +if __name__ == "__main__": + asyncio.run(run()) diff --git a/tools/ml/minio_fixture.py b/tools/ml/minio_fixture.py new file mode 100644 index 0000000..386bec1 --- /dev/null +++ b/tools/ml/minio_fixture.py @@ -0,0 +1,213 @@ +"""Isolated real MinIO test server, random credentials, explicit scoped IAM policies.""" + +import json +import os +import secrets +import shutil +import socket +import subprocess +import tempfile +import time +from contextlib import contextmanager +from pathlib import Path + +import boto3 +from botocore.config import Config +from botocore.exceptions import ClientError + +from apps.ai_inference.analysis import AnalysisSettings +from packages.storage.s3 import BUCKET, S3Settings + + +def free_port(): + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +@contextmanager +def temporary_store(): + root = Path(tempfile.mkdtemp(prefix="labsafe-minio-test-")).resolve() + try: + yield root + finally: + # Versioned object namespaces exceed legacy Windows 260-char deletion paths. + # Verify our exact newly-created temp directory before recursive cleanup. + if root.parent != Path(tempfile.gettempdir()).resolve() or not root.name.startswith( + "labsafe-minio-test-" + ): + raise RuntimeError("Unsafe MinIO test cleanup path") + path = "\\\\?\\" + str(root) if os.name == "nt" else str(root) + shutil.rmtree(path) + + +@contextmanager +def minio_store(binary, tenant, laboratory): + """Uses ONLY a newly created temporary directory, never the installed data directory.""" + binary = Path(binary).resolve() + mc = binary.with_name("mc.exe" if os.name == "nt" else "mc") + if not binary.is_file() or not mc.is_file(): + raise ValueError("MinIO server and mc executable required") + with temporary_store() as root: + port, console = free_port(), free_port() + endpoint = f"http://127.0.0.1:{port}" + admin, password = "test" + secrets.token_hex(8), secrets.token_urlsafe(32) + env = {**os.environ, "MINIO_ROOT_USER": admin, "MINIO_ROOT_PASSWORD": password} + process = None + clients = [] + + def client(access, secret): + result = boto3.client( + "s3", + endpoint_url=endpoint, + aws_access_key_id=access, + aws_secret_access_key=secret, + region_name="us-east-1", + config=Config( + signature_version="s3v4", + s3={"addressing_style": "path"}, + connect_timeout=1, + read_timeout=3, + retries={"max_attempts": 0}, + ), + ) + clients.append(result) + return result + + def command(*args): + result = subprocess.run( + [str(mc), "--config-dir", str(root / "mc"), *args], + capture_output=True, + timeout=15, + creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, + ) + if result.returncode: + raise RuntimeError("Isolated MinIO administration command failed") + + with (root / "server.log").open("wb") as log: + try: + process = subprocess.Popen( + [ + str(binary), + "server", + str(root / "data"), + "--address", + f"127.0.0.1:{port}", + "--console-address", + f"127.0.0.1:{console}", + ], + env=env, + stdout=log, + stderr=subprocess.STDOUT, + creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, + ) + root_client = client(admin, password) + end = time.monotonic() + 20 + while time.monotonic() < end: + if process.poll() is not None: + raise RuntimeError("Isolated MinIO server failed to start") + try: + root_client.list_buckets() + break + except Exception: + time.sleep(0.1) + else: + raise RuntimeError("Isolated MinIO startup timed out") + root_client.create_bucket(Bucket=BUCKET) + root_client.put_bucket_versioning( + Bucket=BUCKET, VersioningConfiguration={"Status": "Enabled"} + ) + command("alias", "set", "test", endpoint, admin, password) + prefix = f"tenant/{tenant}/lab/{laboratory}" + analysis = f"arn:aws:s3:::{BUCKET}/{prefix}/analysis/*" + derived = f"arn:aws:s3:::{BUCKET}/{prefix}/derivatives/*" + users = {} + for role, statements in { + "ai": [ + { + "Effect": "Allow", + "Action": ["s3:GetObject", "s3:GetObjectVersion"], + "Resource": [analysis], + } + ], + "worker": [ + { + "Effect": "Allow", + "Action": ["s3:GetObject", "s3:GetObjectVersion"], + "Resource": [analysis, derived], + }, + {"Effect": "Allow", "Action": ["s3:PutObject"], "Resource": [derived]}, + { + "Effect": "Allow", + "Action": ["s3:ListBucket"], + "Resource": [f"arn:aws:s3:::{BUCKET}"], + "Condition": {"StringLike": {"s3:prefix": [prefix + "/derivatives/*"]}}, + }, + ], + }.items(): + access, secret = role + secrets.token_hex(8), secrets.token_urlsafe(32) + policy = root / f"{role}.json" + policy.write_text( + json.dumps({"Version": "2012-10-17", "Statement": statements}) + ) + command("admin", "user", "add", "test", access, secret) + command("admin", "policy", "create", "test", role, str(policy)) + command("admin", "policy", "attach", "test", role, "--user", access) + users[role] = (access, secret, client(access, secret)) + checks = [] + + def denied(role, method, **kwargs): + try: + response = getattr(users[role][2], method)(Bucket=BUCKET, **kwargs) + if "Body" in response: + response["Body"].close() + except ClientError as error: + if error.response["Error"]["Code"] != "AccessDenied": + raise + checks.append(f"{role}:{method}:denied") + else: + raise AssertionError("Scoped MinIO policy allowed forbidden operation") + + forbidden = "tenant/other/lab/other/analysis/test.png" + root_client.put_object(Bucket=BUCKET, Key=forbidden, Body=b"synthetic") + for role in users: + denied(role, "get_object", Key=forbidden) + denied(role, "list_objects_v2", Prefix=prefix) + denied( + role, + "put_object", + Key=prefix + "/analysis/forbidden.png", + Body=b"synthetic", + ) + denied(role, "delete_object", Key=prefix + "/derivatives/forbidden.png") + denied( + "ai", "put_object", Key=prefix + "/derivatives/forbidden.png", Body=b"synthetic" + ) + existing = prefix + "/derivatives/test.png" + version = root_client.put_object(Bucket=BUCKET, Key=existing, Body=b"synthetic")[ + "VersionId" + ] + denied("ai", "get_object", Key=existing, VersionId=version) + denied("worker", "delete_object", Key=existing, VersionId=version) + ai = AnalysisSettings( + endpoint, (endpoint,), frozenset({tenant}), users["ai"][0], users["ai"][1] + ) + worker = S3Settings( + internal_endpoint=endpoint, + public_endpoint="https://labsafe.test", + public_origin="https://labsafe.test", + access_key=users["worker"][0], + secret_key=users["worker"][1], + ) + yield root_client, ai, worker, checks + finally: + for connection in clients: + connection.close() + if process is not None: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=3) diff --git a/tools/ml/smoke_analysis_cpu.py b/tools/ml/smoke_analysis_cpu.py new file mode 100644 index 0000000..a968193 --- /dev/null +++ b/tools/ml/smoke_analysis_cpu.py @@ -0,0 +1,119 @@ +"""Real CPU models with simulated fixed-version S3 input, replay and crop checks.""" + +import argparse +import io +import json +import tempfile +from dataclasses import replace +from datetime import datetime, timezone +from pathlib import Path + +from apps.ai_inference.adapters.dfine import AdapterError +from apps.ai_inference.analysis_cpu import run_analysis_pipeline +from apps.ai_inference.pipeline_cpu import PipelineOptions, run_cpu_pipeline +from tools.ml.analysis_fixture import object_server, payload_for +from tools.ml.crop_checks import verify_crops + + +def main(): + from PIL import Image, ImageDraw, ImageFont + + parser = argparse.ArgumentParser() + parser.add_argument("--model", type=Path, required=True) + parser.add_argument("--det-dir", type=Path, required=True) + parser.add_argument("--rec-dir", type=Path, required=True) + parser.add_argument("--font", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + options = PipelineOptions(str(args.model), str(args.det_dir), str(args.rec_dir)) + font = ImageFont.truetype(str(args.font), 42) + expected = ["ETHANOL", "EXP 2027-12-31", "乙醇"] + raws = [] + for size in ((800, 400), (400, 800)): + with Image.new("RGB", size, (180, 180, 180)) as image: + draw = ImageDraw.Draw(image) + for index, label in enumerate(expected): + draw.text((40, 50 + 100 * index), label, font=font, fill="black") + stream = io.BytesIO() + image.save(stream, format="PNG") + raws.append(stream.getvalue()) + payload = payload_for(raws) + with object_server(payload, raws) as (settings, seen): + first = run_analysis_pipeline(options, payload, settings) + second = run_analysis_pipeline(options, payload, settings) + quality = run_analysis_pipeline(replace(options, quality_only=True), payload, settings) + if len(seen) != 6: + raise RuntimeError("Expected exactly six versioned GET requests") + if first["outcome"] != "needs_review" or not first["models_executed"]: + raise RuntimeError("Joint real CPU models did not run") + for left, right, ref in zip(first["images"], second["images"], payload["image_refs"]): + for key in ("image_id", "input_sha256", "object_version", "quality", "detections", "lines"): + if left[key] != right[key]: + raise RuntimeError("Remote CPU replay differs") + if left["image_id"] != ref["image_id"] or left["object_version"] != ref["object_version"]: + raise RuntimeError("Worker input identity was changed") + if [line["text"] for line in left["lines"]] != expected: + raise RuntimeError("Synthetic real OCR differs") + with tempfile.TemporaryDirectory(prefix="labsafe-analysis-smoke-") as directory: + paths = [Path(directory) / f"{index}.png" for index in range(len(raws))] + for path, raw in zip(paths, raws): + path.write_bytes(raw) + local = run_cpu_pipeline(replace(options, quality_only=True), paths, payload["run_id"]) + scores = [row["quality"] for row in first["images"]] + if scores != [row["quality"] for row in quality["images"]] or scores != [ + row["quality"] for row in local["images"] + ]: + raise RuntimeError("Local/analysis/quality-only scores differ") + crops = verify_crops(first, paths) + with object_server(payload, raws, headers={"x-amz-version-id": "wrong-version"}) as ( + settings, + seen, + ): + try: + run_analysis_pipeline(options, payload, settings) + except AdapterError as error: + if error.code != "HASH_MISMATCH": + raise + else: + raise RuntimeError("Wrong object version was accepted") + if len(seen) != 1: + raise RuntimeError("Wrong-version run continued downloading") + stream = io.BytesIO() + with Image.new("RGB", (800, 400)) as image: + image.save(stream, format="PNG") + mixed_raws = [raws[0], stream.getvalue()] + mixed_payload = payload_for(mixed_raws) + with object_server(mixed_payload, mixed_raws) as (settings, _): + mixed = run_analysis_pipeline(options, mixed_payload, settings) + if mixed["outcome"] != "needs_retake" or mixed["models_executed"] or mixed["artifacts"]: + raise RuntimeError("Retake batch loaded models") + evidence = { + "status": "PASS", + "executed_at_utc": datetime.now(timezone.utc).isoformat(), + "input_kind": "synthetic-rgb-pngs", + "storage_kind": "loopback-s3-simulator", + "inference_kind": "real-official-onnx-cpu", + "versioned_get_count": 6, + "replay_equal": True, + "worker_image_ids_preserved": True, + "quality_local_analysis_equal": True, + "wrong_version_rejected": True, + "crop_reconstruction": crops, + "joint_result": first, + "quality_only_result": quality, + "mixed_retake_result": mixed, + "limitations": [ + "No real object-store IAM/TLS acceptance", + "No real-photo accuracy evaluation", + "No HTTP/Worker RPC or ready model approval", + "No chemical/date facts or association", + ], + } + args.output.write_text( + json.dumps(evidence, ensure_ascii=False, indent=2, allow_nan=False) + "\n", encoding="utf-8" + ) + print("Versioned analysis GET + real CPU models + stable IDs/replay/crops: PASS") + + +if __name__ == "__main__": + main() diff --git a/tools/ml/smoke_cpu_http.py b/tools/ml/smoke_cpu_http.py new file mode 100644 index 0000000..cd42606 --- /dev/null +++ b/tools/ml/smoke_cpu_http.py @@ -0,0 +1,394 @@ +"""Real official CPU HTTP -> Worker evidence, with explicit simulated storage.""" + +import argparse +import copy +import hashlib +import io +import json +import os +import signal +import socket +import subprocess +import tempfile +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path +from threading import Event +from unittest.mock import patch +from uuid import UUID, uuid5 + +from PIL import Image, ImageDraw, ImageFont, ImageOps + +from apps.worker.inference_evidence import BoundedEvidencePrepare +from packages.application.inference_execution import AIRequestError, InferenceHTTPClient +from packages.domain.inference_execution import ( + InferenceImage, + InferenceInput, + InferenceLease, + validate_result, +) +from packages.inference_protocol.association import ALGORITHM_ID, select_bottle +from packages.inference_protocol.chemical import context_for_result, extract_entities +from packages.inference_protocol.fields import ALGORITHM_ID as FIELDS_ID +from packages.inference_protocol.fields import extract_fields +from packages.inference_protocol.hashing import request_hash +from tools.ml.analysis_fixture import payload_for +from tools.ml.evidence_fixture import evidence_store +from tools.ml.smoke_worker_evidence import worker_environment + + +def lease_for(source): + value = InferenceInput( + **{ + key: source[key] + for key in ( + "tenant_id", + "laboratory_id", + "item_id", + "run_id", + "submission_revision", + "model_bundle_id", + "model_checksum", + "dictionary_version_id", + "dictionary_sha256", + "pipeline_version", + "device_profile", + ) + }, + images=tuple(InferenceImage(**row) for row in source["image_refs"]), + ) + return InferenceLease( + value.tenant_id, + source["item_id"], + source["item_id"], + source["attempt_id"], + source["fencing_token"], + 0, + 1, + value, + ) + + +def main(): + parser = argparse.ArgumentParser() + for name in ("ai-python", "evidence-python", "model", "det-dir", "rec-dir", "font", "output"): + parser.add_argument(f"--{name}", type=Path, required=True) + parser.add_argument( + "--input", type=Path, help="Optional user-provided photo; never copied into Git" + ) + args = parser.parse_args() + raws = [] + expected_text = ["NAME", "Ethanol", "EXP 2027-12-31", "乙醇"] + font = ImageFont.truetype(str(args.font), 42) + for size in ((800, 500), (500, 800)): + with Image.new("RGB", size, (180, 180, 180)) as image: + draw = ImageDraw.Draw(image) + for index, text in enumerate(expected_text): + draw.text((40, 50 + 100 * index), text, font=font, fill="black") + stream = io.BytesIO() + image.save(stream, format="PNG") + raws.append(stream.getvalue()) + photo_sha = None + if args.input: + photo_sha = hashlib.sha256(args.input.read_bytes()).hexdigest() + with Image.open(args.input) as image: + upright = ImageOps.exif_transpose(image) + try: + with upright.convert("RGB") as rgb: + stream = io.BytesIO() + rgb.save(stream, format="PNG") + raws.append(stream.getvalue()) + finally: + upright.close() + source = payload_for(raws) + with tempfile.TemporaryDirectory(prefix="labsafe-http-smoke-") as directory: + root = Path(directory) + generated = subprocess.run( + [ + str(args.ai_python), + "-B", + "-m", + "tools.ml.create_cpu_bundle", + "--output", + str(root / "bundle"), + "--model", + str(args.model), + "--det-dir", + str(args.det_dir), + "--rec-dir", + str(args.rec_dir), + ], + check=True, + capture_output=True, + text=True, + ) + metadata = json.loads(generated.stdout) + identity = json.loads(Path(metadata["expected_version_file"]).read_text(encoding="utf-8")) + for key in ( + "model_bundle_id", + "model_checksum", + "dictionary_version_id", + "dictionary_sha256", + "pipeline_version", + "device_profile", + ): + source[key] = identity[key] + source["request_hash"] = request_hash(source) + source["deadline_at"] = (datetime.now(timezone.utc) + timedelta(seconds=200)).isoformat() + with evidence_store(source, raws) as (settings, uploads, seen): + token = "synthetic-http-smoke-token-at-least-32bytes" + (root / "token").write_text(token, encoding="ascii") + (root / "ai-access").write_text(settings.access_key, encoding="ascii") + (root / "ai-secret").write_text(settings.secret_key, encoding="ascii") + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + env = { + **os.environ, + "APP_ENV": "test", + "AI_MODE": "cpu", + "AI_TOKEN_FILE": str(root / "token"), + "AI_ALLOWED_TENANTS": source["tenant_id"], + "AI_CPU_BUNDLE_FILE": metadata["bundle_file"], + "AI_CPU_BUNDLE_SHA256": metadata["bundle_sha256"], + "AI_ARTIFACT_ROOTS": os.pathsep.join(metadata["artifact_roots"]), + "AI_S3_ENDPOINT": settings.endpoint, + "AI_S3_ALLOWED_ENDPOINTS": settings.endpoint, + "AI_S3_ACCESS_KEY_FILE": str(root / "ai-access"), + "AI_S3_SECRET_KEY_FILE": str(root / "ai-secret"), + "AI_INFERENCE_PORT": str(port), + "SMOKE_CONTROL_DIRECTORY": directory, + "SERVICE_COMMIT": "local-development", + "AI_MAX_INFLIGHT": "1", + } + client = InferenceHTTPClient( + f"http://127.0.0.1:{port}", token, expected_version=identity + ) + with (root / "service.log").open("w", encoding="utf-8") as log: + process = subprocess.Popen( + [str(args.ai_python), "-B", "-m", "tools.ml.http_cpu_fixture"], + env=env, + stdout=log, + stderr=subprocess.STDOUT, + creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, + start_new_session=os.name != "nt", + ) + try: + end = time.monotonic() + 120 + health_seen = False + while time.monotonic() < end: + if process.poll() is not None: + raise AssertionError("CPU HTTP process exited during startup") + try: + client._exchange("health", time.monotonic() + 1) + client._preflight(source, time.monotonic() + 1) + break + except AIRequestError as error: + if error.code == "MODEL_NOT_READY": + health_seen = True + time.sleep(0.05) + else: + raise AssertionError("Real CPU failed to become ready") + pid = json.loads((root / "pid.json").read_text())["pid"] + assert health_seen + # Unknown pin is rejected without an analysis GET. + wrong = dict(source, model_checksum="0" * 64) + try: + client.quality(wrong) + except AIRequestError as error: + assert error.code == "MODEL_VERSION_UNAVAILABLE" + else: + raise AssertionError("Unknown CPU identity accepted") + assert not seen + quality = client.quality(source) + result = client.runs(source) + replay = client.runs(source) + assert quality["quality"] == result["quality"] == replay["quality"] + assert result["text_regions"] == replay["text_regions"] + assert result["ocr_fields"] == replay["ocr_fields"] + assert result["ocr_fields"] == extract_fields(result["text_regions"]) + assert all( + line["detection_id"] + == select_bottle(line["quad"], line["image_id"], result["detections"]) + for line in result["text_regions"] + ) + assert result["execution_identity"] == identity and not identity["is_simulated"] + assert [ + line["raw_text"] + for line in result["text_regions"] + if line["image_id"] in {r["image_id"] for r in source["image_refs"][:2]} + ] == expected_text * 2 + assert json.loads((root / "pid.json").read_text())["pid"] == pid + lease = lease_for(source) + validate_result(result, lease) + with patch.dict( + os.environ, + worker_environment(directory, settings.endpoint, args.evidence_python), + ): + artifacts = BoundedEvidencePrepare()(lease, result, Event()) + assert artifacts == BoundedEvidencePrepare()(lease, result, Event()) + assert len(artifacts) == len(uploads) == len(result["crops"]) + # Separate positive field probe: real OCR/crop pixels, EXPLICIT synthetic + # full-frame bottle detections, simulated result. Not model accuracy. + probe = copy.deepcopy(result) + probe["execution_identity"]["is_simulated"] = True + probe["detections"] = [ + { + "detection_id": str( + uuid5(UUID(source["run_id"]), f"{ref['image_id']}:bottle:0") + ), + "image_id": ref["image_id"], + "parent_detection_id": None, + "class_id": 39, + "type": "bottle", + "bbox": [0, 0, 1, 1], + "confidence": 0.9, + } + for ref in source["image_refs"][:2] + ] + for row in probe["text_regions"] + probe["crops"]: + row["detection_id"] = select_bottle( + row["quad"], row["image_id"], probe["detections"] + ) + probe["ocr_fields"] = extract_fields(probe["text_regions"]) + dictionary, thresholds = context_for_result(probe) + probe["entities"] = extract_entities( + probe["ocr_fields"], + dictionary, + thresholds["ocr_min"], + thresholds["entity_min"], + ) + assert len(probe["ocr_fields"]) == 4 + assert sum(len(f["source_lines"]) == 2 for f in probe["ocr_fields"]) == 2 + validate_result(probe, lease) + with patch.dict( + os.environ, + worker_environment(directory, settings.endpoint, args.evidence_python), + ): + probe_artifacts = BoundedEvidencePrepare()(lease, probe, Event()) + assert {a.crop_id for a in probe_artifacts} == {a.crop_id for a in artifacts} + assert all( + f["normalized_text"] in ("Ethanol", "2027-12-31") + for f in probe["ocr_fields"] + ) + # Actual resident child death must withdraw ready and reload the same pin. + (root / "crash").write_text("", encoding="ascii") + time.sleep(0.2) + try: + client._exchange("ready", time.monotonic() + 1) + except AIRequestError as error: + assert error.code == "MODEL_NOT_READY" + else: + raise AssertionError("Dead resident child stayed ready") + end = time.monotonic() + 125 + while time.monotonic() < end: + try: + client._preflight(source, time.monotonic() + 1) + break + except AIRequestError: + time.sleep(0.1) + else: + raise AssertionError("Real child did not recover") + recovered_pid = json.loads((root / "pid.json").read_text())["pid"] + assert recovered_pid != pid + source["deadline_at"] = ( + datetime.now(timezone.utc) + timedelta(seconds=200) + ).isoformat() + assert client.quality(source)["quality"] == quality["quality"] + finally: + (root / "stop").write_text("", encoding="ascii") + try: + process.wait(timeout=15) + except subprocess.TimeoutExpired: + # Test-only emergency cleanup, including the spawned numerical process. + if os.name == "nt": + subprocess.run( + ["taskkill", "/PID", str(process.pid), "/T", "/F"], + check=False, + capture_output=True, + creationflags=subprocess.CREATE_NO_WINDOW, + ) + else: + os.killpg(process.pid, signal.SIGKILL) + process.wait() + raise AssertionError("Graceful CPU HTTP shutdown failed") + closed = json.loads((root / "closed.json").read_text()) + assert all(closed.values()) + report = { + "status": "PASS", + "executed_at_utc": datetime.now(timezone.utc).isoformat(), + "inference_kind": "real-official-onnx-cpu-http", + "input_kind": "synthetic-rgb-pngs-and-user-photo" + if args.input + else "synthetic-rgb-pngs", + "photo_source_sha256": photo_sha, + "storage_kind": "versioned-loopback-s3-simulator", + "identity": identity, + "images": len(raws), + "text_regions": len(result["text_regions"]), + "uploaded_crops": len(artifacts), + "fields_algorithm": FIELDS_ID, + "ocr_fields": len(result["ocr_fields"]), + "fields_recomputed_by_worker": True, + "positive_field_probe": { + "detection_kind": "explicit-synthetic-full-frame-bottles", + "is_simulated": True, + "ocr_kind": "real-official-onnx", + "fields": len(probe["ocr_fields"]), + "two_line_fields": 2, + "all_source_crops_rebuilt": True, + }, + "per_image": [ + { + "input_kind": "user-photo" if index == 2 else "synthetic-label", + "quality": result["quality"][index], + "detections": sum( + d["image_id"] == ref["image_id"] for d in result["detections"] + ), + "bottles": sum( + d["image_id"] == ref["image_id"] and d["type"] == "bottle" + for d in result["detections"] + ), + "text_regions": sum( + t["image_id"] == ref["image_id"] for t in result["text_regions"] + ), + "associated_regions": sum( + t["image_id"] == ref["image_id"] and t["detection_id"] is not None + for t in result["text_regions"] + ), + "fields": sum(f["image_id"] == ref["image_id"] for f in result["ocr_fields"]), + } + for index, ref in enumerate(source["image_refs"]) + ], + "association_algorithm": ALGORITHM_ID, + "associated_regions": sum( + line["detection_id"] is not None for line in result["text_regions"] + ), + "unassociated_regions": sum( + line["detection_id"] is None for line in result["text_regions"] + ), + "bottle_detections": sum(row["type"] == "bottle" for row in result["detections"]), + "association_recomputed_by_worker": True, + "health_during_startup": health_seen, + "resident_pid_reused": True, + "unknown_pin_no_analysis_get": True, + "quality_runs_replay_equal": True, + "worker_exact_version_reuse": True, + "crash_ready_withdrawn_and_reloaded": True, + "shutdown": closed, + "limitations": [ + "No database commit in this smoke; MySQL tested separately", + "No live S3/IAM/TLS; one user photo is an engineering probe, " + "not annotated accuracy evaluation", + "No semantic facts or production approval", + "Synthetic tags may contain no detected bottles; positive association uses " + "explicit synthetic boxes and real pixels in separate tests", + ], + } + args.output.write_text( + json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + + +if __name__ == "__main__": + main() diff --git a/tools/ml/smoke_minio_cpu.py b/tools/ml/smoke_minio_cpu.py new file mode 100644 index 0000000..a356168 --- /dev/null +++ b/tools/ml/smoke_minio_cpu.py @@ -0,0 +1,352 @@ +"""Actual local MinIO IAM and CPU HTTP -> isolated Worker, using user-provided photos.""" + +import argparse +import copy +import hashlib +import io +import json +import os +import secrets +import subprocess +import tempfile +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path +from threading import Event +from unittest.mock import patch +from uuid import UUID, uuid4, uuid5 + +from PIL import Image, ImageDraw, ImageFont, ImageOps + +from apps.worker.inference_evidence import BoundedEvidencePrepare +from packages.application.inference_execution import AIRequestError, InferenceHTTPClient +from packages.domain.inference_execution import validate_result +from packages.inference_protocol.chemical import context_for_result, extract_entities +from packages.inference_protocol.fields import extract_fields +from packages.inference_protocol.hashing import request_hash +from packages.storage.s3 import BUCKET +from tools.ml.analysis_fixture import LAB, TENANT, payload_for +from tools.ml.minio_fixture import free_port, minio_store +from tools.ml.smoke_cpu_http import lease_for + + +def png(path): + with Image.open(path) as original: + with ImageOps.exif_transpose(original) as upright: + with upright.convert("RGB") as image: + stream = io.BytesIO() + image.save(stream, format="PNG") + return stream.getvalue() + + +def main(): + parser = argparse.ArgumentParser() + for name in ( + "minio", + "ai-python", + "evidence-python", + "model", + "det-dir", + "rec-dir", + "font", + "output", + ): + parser.add_argument(f"--{name}", type=Path, required=True) + parser.add_argument("--input", type=Path, nargs="+", required=True) + parser.add_argument( + "--synthetic-dictionary", + action="store_true", + help="Explicit test-only name/CAS mapping; never a chemistry data source", + ) + args = parser.parse_args() + photos = [(png(path), hashlib.sha256(path.read_bytes()).hexdigest()) for path in args.input] + # A separate synthetic label supplies an explicit positive evidence probe. + with Image.new("RGB", (800, 400), (180, 180, 180)) as image: + draw = ImageDraw.Draw(image) + font = ImageFont.truetype(str(args.font), 42) + for i, text in enumerate(("NAME", "Alpha", "CAS 64-17-5")): + draw.text((40, 50 + 100 * i), text, font=font, fill="black") + stream = io.BytesIO() + image.save(stream, format="PNG") + synthetic = stream.getvalue() + summaries, probe_summary = [], None + with tempfile.TemporaryDirectory(prefix="labsafe-minio-cpu-") as directory: + root = Path(directory) + dictionary_args = [] + if args.synthetic_dictionary: + dictionary_file = root / "synthetic-dictionary.json" + dictionary_file.write_text( + json.dumps( + { + "purpose": "development", + "dictionary_version_id": str(uuid4()), + "entries": [ + { + "entity_id": str(uuid4()), + "canonical_name": "Alpha", + "aliases": [], + "cas": "64-17-5", + "source": "synthetic test-only mapping; NOT chemistry/safety data", + } + ], + } + ), + encoding="utf-8", + ) + dictionary_args = ["--dictionary", str(dictionary_file)] + generated = subprocess.run( + [ + str(args.ai_python), + "-B", + "-m", + "tools.ml.create_cpu_bundle", + "--output", + str(root / "bundle"), + "--model", + str(args.model), + "--det-dir", + str(args.det_dir), + "--rec-dir", + str(args.rec_dir), + *dictionary_args, + ], + check=True, + capture_output=True, + text=True, + timeout=30, + ) + metadata = json.loads(generated.stdout) + identity = json.loads(Path(metadata["expected_version_file"]).read_text(encoding="utf-8")) + with minio_store(args.minio, TENANT, LAB) as (admin, ai, worker, iam_checks): + for name, value in ( + ("ai-access", ai.access_key), + ("ai-secret", ai.secret_key), + ("worker-access", worker.access_key), + ("worker-secret", worker.secret_key), + ("token", secrets.token_urlsafe(32)), + ): + (root / name).write_text(value, encoding="ascii") + port = free_port() + env = { + **os.environ, + "APP_ENV": "test", + "AI_MODE": "cpu", + "AI_TOKEN_FILE": str(root / "token"), + "AI_ALLOWED_TENANTS": TENANT, + "AI_CPU_BUNDLE_FILE": metadata["bundle_file"], + "AI_CPU_BUNDLE_SHA256": metadata["bundle_sha256"], + "AI_ARTIFACT_ROOTS": os.pathsep.join(metadata["artifact_roots"]), + "AI_S3_ENDPOINT": ai.endpoint, + "AI_S3_ALLOWED_ENDPOINTS": ai.endpoint, + "AI_S3_ACCESS_KEY_FILE": str(root / "ai-access"), + "AI_S3_SECRET_KEY_FILE": str(root / "ai-secret"), + "AI_INFERENCE_PORT": str(port), + "SMOKE_CONTROL_DIRECTORY": directory, + "SERVICE_COMMIT": "local-development", + "AI_MAX_INFLIGHT": "1", + } + worker_env = { + "APP_ENV": "test", + "PUBLIC_ORIGIN": "https://labsafe.test", + "S3_ENDPOINT": ai.endpoint, + "S3_PUBLIC_ENDPOINT": "https://labsafe.test", + "S3_WORKER_ACCESS_KEY_FILE": str(root / "worker-access"), + "S3_WORKER_SECRET_KEY_FILE": str(root / "worker-secret"), + "WORKER_EVIDENCE_PYTHON": str(args.evidence_python), + } + client = InferenceHTTPClient( + f"http://127.0.0.1:{port}", (root / "token").read_text(), expected_version=identity + ) + process = None + with (root / "http.log").open("wb") as log: + try: + process = subprocess.Popen( + [str(args.ai_python), "-B", "-m", "tools.ml.http_cpu_fixture"], + env=env, + stdout=log, + stderr=subprocess.STDOUT, + creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, + ) + end = time.monotonic() + 120 + while time.monotonic() < end: + if process.poll() is not None: + raise RuntimeError("CPU HTTP startup failed") + try: + if client._exchange("ready", time.monotonic() + 1)["status"] == "ready": + break + except AIRequestError: + time.sleep(0.1) + else: + raise RuntimeError("CPU startup timeout") + pid = json.loads((root / "pid.json").read_text())["pid"] + for index, (raw, source_sha) in enumerate([*photos, (synthetic, None)]): + source = payload_for([raw]) + for key in ( + "model_bundle_id", + "model_checksum", + "dictionary_version_id", + "dictionary_sha256", + "pipeline_version", + "device_profile", + ): + source[key] = identity[key] + ref = source["image_refs"][0] + ref["object_version"] = admin.put_object( + Bucket=BUCKET, Key=ref["object_key"], Body=raw, ContentType="image/png" + )["VersionId"] + # Replace latest bytes: all inference and Worker reads must still use V1. + latest = admin.put_object( + Bucket=BUCKET, + Key=ref["object_key"], + Body=b"synthetic-new-latest-invalid-png", + ContentType="image/png", + )["VersionId"] + assert latest != ref["object_version"] + source["request_hash"] = request_hash(source) + source["deadline_at"] = ( + datetime.now(timezone.utc) + timedelta(seconds=200) + ).isoformat() + quality, result = client.quality(source), client.runs(source) + assert quality["quality"] == result["quality"] + value = lease_for(source) + validate_result(result, value) + with patch.dict(os.environ, worker_env): + artifacts = BoundedEvidencePrepare()(value, result, Event()) + assert artifacts == BoundedEvidencePrepare()(value, result, Event()) + for artifact in artifacts: + response = admin.get_object( + Bucket=BUCKET, + Key=artifact.object_key, + VersionId=artifact.object_version, + ) + try: + assert ( + hashlib.sha256(response["Body"].read()).hexdigest() + == artifact.sha256 + ) + finally: + response["Body"].close() + assert json.loads((root / "pid.json").read_text())["pid"] == pid + if source_sha is not None: + summaries.append( + { + "photo_index": index + 1, + "source_sha256": source_sha, + "quality": result["quality"][0], + "outcome": result["outcome"], + "detections": len(result["detections"]), + "bottles": sum( + d["type"] == "bottle" for d in result["detections"] + ), + "text_regions": len(result["text_regions"]), + "associated_text_regions": sum( + t["detection_id"] is not None + for t in result["text_regions"] + ), + "detection_classes": { + kind: sum(d["type"] == kind for d in result["detections"]) + for kind in sorted( + {d["type"] for d in result["detections"]} + ) + }, + "fields": len(result["ocr_fields"]), + "entity_groups": len(result["entities"]), + "resolution_counts": { + status: sum( + e["resolution"] == status for e in result["entities"] + ) + for status in ("resolved", "candidate", "unknown") + }, + "uploaded_crops": len(artifacts), + } + ) + else: + probe = copy.deepcopy(result) + probe["execution_identity"]["is_simulated"] = True + probe["detections"] = [ + { + "detection_id": str( + uuid5(UUID(source["run_id"]), f"{ref['image_id']}:bottle:0") + ), + "image_id": ref["image_id"], + "parent_detection_id": None, + "class_id": 39, + "type": "bottle", + "bbox": [0, 0, 1, 1], + "confidence": 0.9, + } + ] + for row in probe["text_regions"] + probe["crops"]: + row["detection_id"] = probe["detections"][0]["detection_id"] + data, thresholds = context_for_result(probe) + probe["ocr_fields"] = extract_fields(probe["text_regions"], data) + probe["entities"] = extract_entities( + probe["ocr_fields"], + data, + thresholds["ocr_min"], + thresholds["entity_min"], + ) + expected_resolution = ( + "resolved" if args.synthetic_dictionary else "unknown" + ) + assert ( + len(probe["ocr_fields"]) == 2 + and probe["entities"][0]["resolution"] == expected_resolution + ) + validate_result(probe, value) + with patch.dict(os.environ, worker_env): + positive = BoundedEvidencePrepare()(value, probe, Event()) + assert len(positive) == len(artifacts) == 3 + probe_summary = { + "detection_kind": "explicit-synthetic-bottle", + "ocr_kind": "real-official-onnx", + "is_simulated": True, + "fields": 2, + "entity_groups": 1, + "resolution": expected_resolution, + "uploaded_crops": 3, + } + finally: + if process is not None: + (root / "stop").write_text("") + try: + process.wait(timeout=15) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=3) + raise RuntimeError("CPU HTTP failed to shut down") + shutdown = json.loads((root / "closed.json").read_text()) + assert all(shutdown.values()) + report = { + "status": "PASS", + "executed_at_utc": datetime.now(timezone.utc).isoformat(), + "storage_kind": "real-isolated-minio", + "server_version": subprocess.run( + [str(args.minio), "--version"], capture_output=True, text=True, check=True + ).stdout.splitlines()[0], + "inference_kind": "real-official-onnx-cpu-http", + "identity": identity, + "dictionary_kind": "synthetic-test-only" + if args.synthetic_dictionary + else "empty-development", + "images": summaries, + "positive_probe": probe_summary, + "iam_denials": iam_checks, + "original_version_used_after_latest_overwrite": True, + "derivative_version_sha_and_reuse": True, + "resident_pid_reused": True, + "shutdown": shutdown, + "limitations": [ + "No annotated accuracy evaluation or threshold calibration", + "No production chemistry dictionary/safety classification", + "No database commit in smoke; MySQL fence tested separately", + "Loopback HTTP only; no nginx/public presigned URL/TLS or production deployment", + ], + } + args.output.write_text( + json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + + +if __name__ == "__main__": + main() diff --git a/tools/ml/smoke_worker_evidence.py b/tools/ml/smoke_worker_evidence.py new file mode 100644 index 0000000..dca048e --- /dev/null +++ b/tools/ml/smoke_worker_evidence.py @@ -0,0 +1,219 @@ +"""Real CPU models to formal OCR evidence and bounded Worker S3 simulator upload.""" + +import argparse +import io +import json +import os +import tempfile +from datetime import datetime, timezone +from pathlib import Path +from threading import Event +from unittest.mock import patch + +from PIL import Image, ImageDraw, ImageFont + +from apps.ai_inference.analysis_cpu import run_analysis_pipeline +from apps.ai_inference.pipeline_cpu import PipelineOptions +from apps.worker.inference_evidence import BoundedEvidencePrepare +from packages.domain.inference_execution import ( + InferenceImage, + InferenceInput, + InferenceLease, + validate_result, +) +from packages.domain.security import ServiceError +from tools.ml.analysis_fixture import payload_for +from tools.ml.evidence_fixture import evidence_store + + +def worker_environment(directory, endpoint, executable): + access, secret = Path(directory) / "access", Path(directory) / "secret" + access.write_text("synthetic", encoding="ascii") + secret.write_text("test-only", encoding="ascii") + return { + "APP_ENV": "test", + "PUBLIC_ORIGIN": "https://labsafe.test", + "S3_ENDPOINT": endpoint, + "S3_PUBLIC_ENDPOINT": "https://labsafe.test", + "S3_WORKER_ACCESS_KEY_FILE": str(access), + "S3_WORKER_SECRET_KEY_FILE": str(secret), + "WORKER_EVIDENCE_PYTHON": str(executable), + } + + +def wire_for(source, report): + # Test-only identity envelope; this does not activate or claim an approved real bundle. + value = InferenceInput( + **{ + key: source[key] + for key in ( + "tenant_id", + "laboratory_id", + "item_id", + "run_id", + "submission_revision", + "model_bundle_id", + "model_checksum", + "dictionary_version_id", + "dictionary_sha256", + "pipeline_version", + "device_profile", + ) + }, + images=tuple(InferenceImage(**row) for row in source["image_refs"]), + ) + lease = InferenceLease( + value.tenant_id, + source["item_id"], + source["item_id"], + source["attempt_id"], + source["fencing_token"], + 0, + 1, + value, + ) + result = { + **{ + key: source[key] + for key in ( + "run_id", + "tenant_id", + "attempt_id", + "fencing_token", + "request_hash", + "model_bundle_id", + "model_checksum", + "dictionary_version_id", + "dictionary_sha256", + "pipeline_version", + ) + }, + "input_hashes": [ + {"image_id": ref["image_id"], "sha256": ref["sha256"]} for ref in source["image_refs"] + ], + "outcome": "needs_review", + "quality": [], + "detections": [d for row in report["images"] for d in row["detections"]], + "text_regions": report["text_regions"], + "crops": report["crops"], + "ocr_fields": [], + "entities": [], + "relations": [], + "timing_ms": { + key: 0 + for key in ( + "download_ms", + "quality_ms", + "detection_ms", + "ocr_ms", + "normalization_ms", + "total_ms", + ) + }, + } + for row in report["images"]: + quality = row["quality"] + result["quality"].append( + { + "image_id": row["image_id"], + "status": "pass" if quality["passed"] else "needs_retake", + **{ + key: quality[key] + for key in ("reasons", "blur_score", "brightness", "glare_ratio") + }, + } + ) + validate_result(result, lease) + return lease, result + + +def main(): + parser = argparse.ArgumentParser() + for name in ("model", "det-dir", "rec-dir", "font", "evidence-python", "output"): + parser.add_argument(f"--{name}", type=Path, required=True) + args = parser.parse_args() + os.environ["APP_ENV"] = "test" + font = ImageFont.truetype(str(args.font), 42) + expected, raws = ["ETHANOL", "EXP 2027-12-31", "乙醇"], [] + for size in ((800, 400), (400, 800)): + with Image.new("RGB", size, (180, 180, 180)) as image: + draw = ImageDraw.Draw(image) + for index, text in enumerate(expected): + draw.text((40, 50 + 100 * index), text, font=font, fill="black") + stream = io.BytesIO() + image.save(stream, format="PNG") + raws.append(stream.getvalue()) + source = payload_for(raws) + with tempfile.TemporaryDirectory(prefix="labsafe-worker-smoke-") as directory: + with evidence_store(source, raws) as (settings, uploads, seen): + report = run_analysis_pipeline( + PipelineOptions(str(args.model), str(args.det_dir), str(args.rec_dir)), + source, + settings, + ) + lease, result = wire_for(source, report) + assert [line["raw_text"] for line in result["text_regions"]] == expected * 2 + with patch.dict( + os.environ, worker_environment(directory, settings.endpoint, args.evidence_python) + ): + prepare = BoundedEvidencePrepare() + artifacts = prepare(lease, result, Event()) + replay = prepare(lease, result, Event()) + assert artifacts == replay and len(artifacts) == len(uploads) == 6 + assert all(row.detection_id is None for row in artifacts) + assert all( + row.object_version == uploaded["version"] and row.sha256 == uploaded["sha256"] + for row, uploaded in zip(artifacts, uploads) + ) + successful_gets = len([row for row in seen if row[0] == "GET"]) + with evidence_store(source, raws, fail_put_at=1) as (settings, failed_uploads, _): + with patch.dict( + os.environ, worker_environment(directory, settings.endpoint, args.evidence_python) + ): + try: + BoundedEvidencePrepare()(lease, result, Event()) + except ServiceError as error: + assert error.code == "DEPENDENCY_UNAVAILABLE" + else: + raise AssertionError("Second crop failure returned partial success") + assert len(failed_uploads) == 1 + output = { + "status": "PASS", + "executed_at_utc": datetime.now(timezone.utc).isoformat(), + "inference_kind": "real-official-onnx-cpu", + "storage_kind": "versioned-loopback-s3-simulator", + "input_kind": "synthetic-rgb-pngs", + "images": 2, + "text_regions": 6, + "uploaded_crops": 6, + "worker_replay_exact_versions": True, + "successful_pinned_gets": successful_gets, + "second_crop_failure_no_partial_success": True, + "unreferenced_orphan_on_failure": 1, + "artifacts": report["artifacts"], + "runtime": report["runtime"], + "checks": [ + "formal schema and run/image closure", + "source RGB/PNG/recognition digests", + "independent bounded Worker process", + "exact VersionId and verified reuse", + ], + "limitations": [ + "No approved bundle activation or real CPU HTTP service", + "No live S3/IAM/TLS", + "No database commit in this smoke; tested separately against MySQL", + "No field/entity/date/bottle association or field-photo evaluation", + ], + } + args.output.write_text( + json.dumps(output, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + print( + json.dumps( + {key: output[key] for key in ("status", "images", "text_regions", "uploaded_crops")} + ) + ) + + +if __name__ == "__main__": + main() From ae53d0a2c77bb040e1cc23995c145be66e10cc23 Mon Sep 17 00:00:00 2001 From: SayQuan1 <15246906705@163.com> Date: Sat, 10 Oct 2026 20:56:26 +0800 Subject: [PATCH 2/3] fix(i-ml01): preserve cancellation when CPU IPC send completes Replace wait_for around the bounded IPC send with Python 3.11 timeout so outer cancellation is not consumed by a concurrently completed send. Add a deterministic regression that fails against the previous supervisor and confirms cancellation reaps the CPU process and releases capacity. Validation: 76 CPU/protocol tests and 27 fixture/HTTP tests pass; both official CPU/real MinIO smoke records were rebuilt with the changed code lock. Ruff, generated contracts and design validation pass. Sync batch 36-41 Git delivery status to PR #13 without claiming independent approval. Rollback restores the matched previous application/contracts/environment and bundle/lock/pin after draining fixed-identity tasks. No new migration or change to historical evidence; retain additions and use forward repair. --- DEVELOPMENT.md | 2 +- apps/ai_inference/supervisor.py | 6 ++---- docs/08-delivery/01-implementation-plan.md | 14 ++++++------- docs/08-delivery/03-design-audit.md | 2 +- .../41-i-ml01-candidates-smoke.json | 14 ++++++------- .../41-i-ml01-chemical-candidates.md | 8 +++++++ docs/08-delivery/41-i-ml01-minio-smoke.json | 20 +++++++++--------- docs/08-delivery/README.md | 2 +- .../design-validation-results.json | 4 ++-- docs/README.md | 2 +- tests/ai/test_supervisor.py | 21 +++++++++++++++++++ 11 files changed, 61 insertions(+), 34 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 665482e..ad608cb 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -610,7 +610,7 @@ CI 必须先 --check,不能先生成来掩盖漂移。设计校验不是应用 ## 5. 提交和下一阶段 -2026-10-09当前状态:第36–41批已本地实现与验证,位于wsq/i-ml01-analysis-input,未提交/Push/PR;第27–35批已通过PR #11/#12合并。下一批第42批日期聚合;以下早期批次的未提交和PR基线说明保留为历史,最新交付以docs/08-delivery/01-implementation-plan.md为准。真实MinIO固定版本/限定IAM已有第41批临时loopback证据,完整生产部署门禁仍未完成。 +2026-10-10当前状态:第36–41批已本地实现与验证,位于wsq/i-ml01-analysis-input,实现提交726786c已Push并创建[PR #13](https://github.com/SayQuan1/LabSafe/pull/13),等待CI与独立审查;第27–35批已通过PR #11/#12合并。下一批第42批日期聚合;以下早期批次的未提交和PR基线说明保留为历史,最新交付以docs/08-delivery/01-implementation-plan.md为准。真实MinIO固定版本/限定IAM已有第41批临时loopback证据,完整生产部署门禁仍未完成。 遵循 [Git 协作规范](CONTRIBUTING.md)。提交特性分支、创建 PR;不直接推送 main,不自行合并或代替独立审查。 不提交虚拟环境、.local-secrets、真实图片、权重或 .env。 diff --git a/apps/ai_inference/supervisor.py b/apps/ai_inference/supervisor.py index 7558562..69d8afd 100644 --- a/apps/ai_inference/supervisor.py +++ b/apps/ai_inference/supervisor.py @@ -155,10 +155,8 @@ async def infer(self, payload, stage): raise ProtocolError("VALIDATION_ERROR", "Inference request exceeds capacity") self.busy = True try: - await asyncio.wait_for( - asyncio.to_thread(self.channel.send_bytes, raw), - timeout=max(0, deadline - time.monotonic()), - ) + async with asyncio.timeout(max(0, deadline - time.monotonic())): + await asyncio.to_thread(self.channel.send_bytes, raw) message = await self._message(deadline) if message.get("kind") != "result": code = message.get("code", "MODEL_ERROR") diff --git a/docs/08-delivery/01-implementation-plan.md b/docs/08-delivery/01-implementation-plan.md index a6cc701..d44503b 100644 --- a/docs/08-delivery/01-implementation-plan.md +++ b/docs/08-delivery/01-implementation-plan.md @@ -1,6 +1,6 @@ # 08.1 实施计划与当前批次 -状态更新于2026-10-09。已fetch核验origin/main=13a854f:PR #10包含截至第26批的持久任务、图像、推理/规则执行与复核;PR #11包含第27–30批整改及CSV报告受理/执行/下载;PR #12包含第31–35批官方80类模型、CPU检测/OCR、质量流水线及可重建裁剪。第36–41批已本地实现与验证,包括第41批冻结开发词典、名称候选/冲突聚合和实际MinIO验证,位于wsq/i-ml01-analysis-input,尚未提交/Push/PR。 +状态更新于2026-10-10。已fetch核验origin/main=13a854f:PR #10包含截至第26批的持久任务、图像、推理/规则执行与复核;PR #11包含第27–30批整改及CSV报告受理/执行/下载;PR #12包含第31–35批官方80类模型、CPU检测/OCR、质量流水线及可重建裁剪。第36–41批已本地实现与验证,包括第41批冻结开发词典、名称候选/冲突聚合和实际MinIO验证,位于wsq/i-ml01-analysis-input;实现提交726786c已Push,并创建[PR #13](https://github.com/SayQuan1/LabSafe/pull/13),尚未合并,等待远程CI及独立审查。最新Git交付状态见[第41批交付记录](41-i-ml01-chemical-candidates.md)。 当前主线为I-ML-01真实CPU推理接线:第41批冻结开发词典、CAS/别名/模糊候选及同瓶冲突聚合已本地完成;下一批确定为**第42批,同瓶日期聚合、冲突/低置信/未知保留与完整DateFact证据闭包**。I-02/I-03/I-ML-01仍进行中,局部工程成绩不替代完整业务、现场模型或生产验收。阶段标准见[阶段门禁](05-stage-gates.md),运行入口见[开发指南](../../DEVELOPMENT.md)。 @@ -51,12 +51,12 @@ | 33 | PP-OCRv6_small det/rec、DB/CTC、独立文字行与CLI | 已合并PR #12 | [OCR](33-i-ml01-ocrv6-cpu.md) | | 34 | quality-rgb-lap1-v1、同一子进程联合模型、整批质量门禁 | 已合并PR #12 | [联合流水线](34-i-ml01-quality-cpu-pipeline.md) | | 35 | perspective-rgb-v1共享重建、方向/PNG/识别像素摘要、9区域跨进程核对 | 已合并PR #12 | [可重建证据](35-i-ml01-rebuildable-ocr-evidence.md) | -| 36 | run_images准确版本冻结/0003、只读SigV4 GET、SHA/128MiB/像素校验、保留Worker图片ID、10/180秒预算 | 本地完成与验证;未提交/Push/PR | [受控输入](36-i-ml01-controlled-analysis-input.md)、[真实模型smoke](36-i-ml01-analysis-cpu-smoke.json) | -| 37 | 独立text_regions/闭包、0004、独立Worker重建/版本上传、围栏原子登记、只读文字投影 | 本地完成与验证;未提交/Push/PR | [OCR证据](37-i-ml01-worker-ocr-evidence.md)、[真实模型→Worker smoke](37-i-ml01-worker-evidence-smoke.json) | -| 38 | 常驻CPU加载/smoke、受控development bundle/lock、实际ready/version、正式RPC、Worker pin/有界socket和真实标记 | 本地完成与验证;未提交/Push/PR | [常驻CPU HTTP](38-i-ml01-resident-cpu-http.md)、[真实HTTP→Worker smoke](38-i-ml01-cpu-http-smoke.json) | -| 39 | text-bottle-quad80-v1实际quad/同图COCO bottle/唯一最小关联、Worker重算及NULL未知、证据不变 | 本地完成与验证;未提交/Push/PR | [文字/bottle关联](39-i-ml01-text-bottle-association.md)、[正式HTTP smoke](39-i-ml01-text-association-smoke.json) | -| 40 | ocr-fields-v1词法/严格日期CAS/全局连续配对,source_lines全部证据、Worker全量重算、正式CPU/HTTP/像素/MySQL;用户素材工程探针 | 本地完成与验证;未提交/Push/PR | [OCR字段](40-i-ml01-ocr-fields.md)、[真实HTTP/素材smoke](40-i-ml01-ocr-fields-smoke.json) | -| 41 | 冻结开发词典/身份快照、chemical-candidates-v1及全名称冲突聚合;Worker完整重算/保存;4张新素材及实际MinIO版本/IAM | 本地完成与验证;未提交/Push/PR | [名称候选](41-i-ml01-chemical-candidates.md)、[真实MinIO/素材](41-i-ml01-minio-smoke.json)、[明确synthetic候选probe](41-i-ml01-candidates-smoke.json) | +| 36 | run_images准确版本冻结/0003、只读SigV4 GET、SHA/128MiB/像素校验、保留Worker图片ID、10/180秒预算 | 已提交/Push,PR #13待审查 | [受控输入](36-i-ml01-controlled-analysis-input.md)、[真实模型smoke](36-i-ml01-analysis-cpu-smoke.json) | +| 37 | 独立text_regions/闭包、0004、独立Worker重建/版本上传、围栏原子登记、只读文字投影 | 已提交/Push,PR #13待审查 | [OCR证据](37-i-ml01-worker-ocr-evidence.md)、[真实模型→Worker smoke](37-i-ml01-worker-evidence-smoke.json) | +| 38 | 常驻CPU加载/smoke、受控development bundle/lock、实际ready/version、正式RPC、Worker pin/有界socket和真实标记 | 已提交/Push,PR #13待审查 | [常驻CPU HTTP](38-i-ml01-resident-cpu-http.md)、[真实HTTP→Worker smoke](38-i-ml01-cpu-http-smoke.json) | +| 39 | text-bottle-quad80-v1实际quad/同图COCO bottle/唯一最小关联、Worker重算及NULL未知、证据不变 | 已提交/Push,PR #13待审查 | [文字/bottle关联](39-i-ml01-text-bottle-association.md)、[正式HTTP smoke](39-i-ml01-text-association-smoke.json) | +| 40 | ocr-fields-v1词法/严格日期CAS/全局连续配对,source_lines全部证据、Worker全量重算、正式CPU/HTTP/像素/MySQL;用户素材工程探针 | 已提交/Push,PR #13待审查 | [OCR字段](40-i-ml01-ocr-fields.md)、[真实HTTP/素材smoke](40-i-ml01-ocr-fields-smoke.json) | +| 41 | 冻结开发词典/身份快照、chemical-candidates-v1及全名称冲突聚合;Worker完整重算/保存;4张新素材及实际MinIO版本/IAM | 已提交/Push,PR #13待审查 | [名称候选](41-i-ml01-chemical-candidates.md)、[真实MinIO/素材](41-i-ml01-minio-smoke.json)、[明确synthetic候选probe](41-i-ml01-candidates-smoke.json) | 截至第36批最新本机成绩:63项CPU+4项协议,无跳过;主业务/领域/安全/协议1552项;持久化单元21项;隔离MySQL8.0.33定向18项通过/429未选,含2项重叠单元。第36批验证时工作树head=0003_run_image_version,43表/115FK;远程main已合并的head仍为0002。最新真实权重工程smoke为两图固定版本读取、两次联合重放、quality一致与6区域裁剪重建。此前第29批完整持久化446项及第30批定向73项为历史成绩,不当作0003的完整重跑。各套件有重叠,不累加成唯一测试数。 diff --git a/docs/08-delivery/03-design-audit.md b/docs/08-delivery/03-design-audit.md index 8a40d57..871b46a 100644 --- a/docs/08-delivery/03-design-audit.md +++ b/docs/08-delivery/03-design-audit.md @@ -31,7 +31,7 @@ 命令:build_specs.py --check;validate_specs.py --report;git diff --check。确切执行时间、Python版本及最新计数以[机器报告](design-validation-results.json)为准;若报告不是本轮修订后生成,不能据此宣布本轮通过。 -第41批同步后的最新验证覆盖两份OpenAPI及94个公共/5个内部AI操作、180个正例/360个反例、11份JSON Schema、43表115条外键静态约束、8组状态枚举、规则真值与作用域、437个文档链接/42个用例ID,以及43项模拟发布门禁和112项D-FINE纯语义检查。新增门禁覆盖旧适配器拒绝、CPU/CUDA/精度/预后处理错配、换lock/代码却沿用报告;语义检查覆盖类别、qmax、几何/阈值/容量。另有6项模型文档与常量核对。契约生成源、输出和文档必须同步。 +第41批同步后的最新验证覆盖两份OpenAPI及94个公共/5个内部AI操作、180个正例/360个反例、11份JSON Schema、43表115条外键静态约束、8组状态枚举、规则真值与作用域、文档链接/42个用例ID,以及43项模拟发布门禁和112项D-FINE纯语义检查。链接数量以机器报告为准。新增门禁覆盖旧适配器拒绝、CPU/CUDA/精度/预后处理错配、换lock/代码却沿用报告;语义检查覆盖类别、qmax、几何/阈值/容量。另有6项模型文档与常量核对。契约生成源、输出和文档必须同步。 所有发布门禁测试使用明确synthetic fixture,不是真实生产批准或模型成绩。检查工具是设计辅助,不是应用实现。未执行真实图片resize、模型下载/训练/ONNX导出、CUDA推理、MySQL建表、Compose启动、故障/恢复/试点;这些未执行不误报为当前设计尚无方案。 diff --git a/docs/08-delivery/41-i-ml01-candidates-smoke.json b/docs/08-delivery/41-i-ml01-candidates-smoke.json index 4494857..5e0cb45 100644 --- a/docs/08-delivery/41-i-ml01-candidates-smoke.json +++ b/docs/08-delivery/41-i-ml01-candidates-smoke.json @@ -1,22 +1,22 @@ { "status": "PASS", - "executed_at_utc": "2026-10-09T15:41:43.030494+00:00", + "executed_at_utc": "2026-10-10T12:54:27.269119+00:00", "storage_kind": "real-isolated-minio", "server_version": "minio.exe version RELEASE.2025-07-23T15-54-02Z (commit-id=7ced9663e6a791fef9dc6be798ff24cda9c730ac)", "inference_kind": "real-official-onnx-cpu-http", "identity": { "service_commit": "local-development", "pipeline_version": "vision-v1", - "model_bundle_id": "44caa672-b34a-491f-95cc-d8b91fbf0d70", - "dictionary_version_id": "75c23226-100d-4549-92fb-450300cea7dd", + "model_bundle_id": "f1c9470b-adad-48fc-b626-d583c5aabe82", + "dictionary_version_id": "a6d20954-8816-4c93-b740-22822437c5bb", "device_profile": "cpu", "purpose": "development", "is_simulated": false, "adapter_id": "dfine-coco80-rgb-stretch-v1", "runtime_profile": "dfine-cpu-fp32-ocrv6smallcpu-chemical-v2", - "runtime_lock_sha256": "6debcd5b7851397ca1924b1ee4103e71caea2729d560a88618bfc4eccb72ef64", - "model_checksum": "6a66dde6d2c20943506e25f53299176dfd3ad13cfaf01105db7f1709e9ad23bf", - "dictionary_sha256": "129f9bcde8b08408b65f5ec1077f56f29f588b2d9d5db485271854ec02480635", + "runtime_lock_sha256": "585e1a69d879d3f6ea7028bb5027c52a4b8918865b3d6df9b62bff30faf2a578", + "model_checksum": "ca0261043db02a80ec1dd77a7fc8996b58f77f076ed73ed6134735ad2a216db3", + "dictionary_sha256": "b364bde3d46fd4df25a32198184b6825d8f826c304bfd02c12d7e0592bfd19fd", "detector_device": "cpu", "ocr_device": "cpu" }, @@ -26,7 +26,7 @@ "photo_index": 1, "source_sha256": "6cd697a4bf94c58a2f23a6c64b73852e4efd74c715f406f05455a5731d6b0f60", "quality": { - "image_id": "3acd3fe9-c140-498c-ba53-9b28ec543f15", + "image_id": "b8620ccd-9686-4a4c-8de9-41992522395a", "status": "pass", "reasons": [], "blur_score": 972.6889667588671, diff --git a/docs/08-delivery/41-i-ml01-chemical-candidates.md b/docs/08-delivery/41-i-ml01-chemical-candidates.md index 877d106..10c46d4 100644 --- a/docs/08-delivery/41-i-ml01-chemical-candidates.md +++ b/docs/08-delivery/41-i-ml01-chemical-candidates.md @@ -63,3 +63,11 @@ MySQL结构核对为0004/43表115FK,无错误;业务/MySQL有既有 Starlett 只允许dev/test,production开关仍禁止。升级先排空旧CPU/固定身份任务,在新空目录重新生成bundle/lock/pin;回滚恢复成套旧代码、契约、环境和身份,不重写历史结果或删除对象,无迁移downgrade。新词典未获数据/安全审核不得成为规则事实来源。 下一批第42批:同瓶日期字段聚合、冲突/低置信/未知保留及全部行/crop证据,先冻结DateFact契约与Worker重算;仍needs_review,完整事实快照/规则接线、化学安全数据、现场标注/校准、公共模型activation和部署审批分别后续。实际MinIO loopback固定版本/IAM已验,不等于nginx、公开下载签名、TLS、持久部署/备份与生产权限验收。 + +## 7. 2026-10-10 Git交付与CI收尾 + +第36–41批实现汇总为提交726786c,已Push到wsq/i-ml01-analysis-input并按.github/PULL_REQUEST_TEMPLATE.md创建[PR #13](https://github.com/SayQuan1/LabSafe/pull/13),基线main=13a854f,尚未合并。前文未提交/未运行远程CI为实施时的历史状态。 + +远程Push检查暴露请求取消恰逢IPC发送完成的竞态:Python3.11 asyncio.wait_for可能消费外层取消,导致调用等待到推理超时。发送期限改用asyncio.timeout以保留外层CancelledError和既有进程回收;新增确定性用例调度发送完成与取消的同一时序,原实现稳定出现AI_TIMEOUT,新实现正确取消并回收,无放宽超时预算。第41批两份真实MinIO/官方CPU smoke按修复后的代码锁重新运行,结果及现场局限保持。 + +提交前主回归1795、CPU/协议75、fixture/spawn/HTTP27、独立Worker9通过,静态/契约/三环境pip与隔离检查通过。取消修复后CPU/协议76、fixture/spawn/HTTP27再次通过,两份真实MinIO smoke通过,确定性新增用例已验证旧实现失败/新实现通过;套件重叠不累加。完整隔离MySQL及本次远程CI最终结果以PR描述和检查页为准,独立协作者批准另行确认,不自动合并。 diff --git a/docs/08-delivery/41-i-ml01-minio-smoke.json b/docs/08-delivery/41-i-ml01-minio-smoke.json index 1a235d5..d4ba72e 100644 --- a/docs/08-delivery/41-i-ml01-minio-smoke.json +++ b/docs/08-delivery/41-i-ml01-minio-smoke.json @@ -1,22 +1,22 @@ { "status": "PASS", - "executed_at_utc": "2026-10-09T15:40:46.109387+00:00", + "executed_at_utc": "2026-10-10T12:53:41.460773+00:00", "storage_kind": "real-isolated-minio", "server_version": "minio.exe version RELEASE.2025-07-23T15-54-02Z (commit-id=7ced9663e6a791fef9dc6be798ff24cda9c730ac)", "inference_kind": "real-official-onnx-cpu-http", "identity": { "service_commit": "local-development", "pipeline_version": "vision-v1", - "model_bundle_id": "2762e1a4-eeae-44da-b645-1e75a518c5b6", - "dictionary_version_id": "0e81fe49-9dbe-4fca-9bdf-c03888eda50b", + "model_bundle_id": "b8c259e2-8fa7-440e-b070-ff6ad651120d", + "dictionary_version_id": "3d2aa306-aaeb-4e23-be92-371ffee5a1bb", "device_profile": "cpu", "purpose": "development", "is_simulated": false, "adapter_id": "dfine-coco80-rgb-stretch-v1", "runtime_profile": "dfine-cpu-fp32-ocrv6smallcpu-chemical-v2", - "runtime_lock_sha256": "6debcd5b7851397ca1924b1ee4103e71caea2729d560a88618bfc4eccb72ef64", - "model_checksum": "d0de9c15a8716ebc79629a98ddbbc68a4905f02fc27d5222ad02cf338928f8eb", - "dictionary_sha256": "55ca4509732b5e771e8c6de4c95f0dc1ba0591f3acb79ce1b11198d3aa543132", + "runtime_lock_sha256": "585e1a69d879d3f6ea7028bb5027c52a4b8918865b3d6df9b62bff30faf2a578", + "model_checksum": "9587c97f3a6dafaabeda4b7c79cb18ebdd8d6fe8501b6830a7dfe53f73524d6f", + "dictionary_sha256": "5ca298c4e4e0b766904e0d47ffbbc51bfee27822c47b115c2667fc0b17ded701", "detector_device": "cpu", "ocr_device": "cpu" }, @@ -26,7 +26,7 @@ "photo_index": 1, "source_sha256": "6cd697a4bf94c58a2f23a6c64b73852e4efd74c715f406f05455a5731d6b0f60", "quality": { - "image_id": "42e2091d-263d-48be-879b-8d583e05286c", + "image_id": "93a99f20-afc8-4fb9-b7e6-f6fe070559c8", "status": "pass", "reasons": [], "blur_score": 972.6889667588671, @@ -54,7 +54,7 @@ "photo_index": 2, "source_sha256": "589b28e5ba1cfa546bdf6f78f9dddd04eebc274d95ba54a6b16fcf78d5894dea", "quality": { - "image_id": "7edff2bf-746f-466c-bceb-b353bda1ba33", + "image_id": "f9481f25-8850-4bde-b7bb-5304b88e0293", "status": "pass", "reasons": [], "blur_score": 309.47272641062483, @@ -82,7 +82,7 @@ "photo_index": 3, "source_sha256": "0754bade5cb8232de2e2793f747d43c01036ea1628682e41ef9535b6e2c1db4f", "quality": { - "image_id": "c75745f7-ef32-41c6-9d38-b987f48fdd42", + "image_id": "c3f7168a-78ab-443d-a773-45517fe4f896", "status": "pass", "reasons": [], "blur_score": 186.7326544807147, @@ -110,7 +110,7 @@ "photo_index": 4, "source_sha256": "178c3d8d58a89988632ac46a4081045b89e97d0611cbdcb4aff041a684f89a96", "quality": { - "image_id": "6cddfd08-be6a-4b3c-9793-23a564b3ee4e", + "image_id": "b451b6de-9958-4fd0-ab52-2d6dfe453746", "status": "pass", "reasons": [], "blur_score": 920.121898903142, diff --git a/docs/08-delivery/README.md b/docs/08-delivery/README.md index 85f470a..8167900 100644 --- a/docs/08-delivery/README.md +++ b/docs/08-delivery/README.md @@ -45,6 +45,6 @@ 40. [I-ML-01 OCR字段词法、严格格式与完整多行证据](40-i-ml01-ocr-fields.md)。 41. [I-ML-01 冻结开发词典、名称候选与实际MinIO验证](41-i-ml01-chemical-candidates.md)。 -当前进度与下一批以[实施计划](01-implementation-plan.md)为准:31–35批已合并,第36–41批本地完成与验证,尚未提交;下一批第42批接日期聚合与完整DateFact证据闭包。 +当前进度与下一批以[实施计划](01-implementation-plan.md)为准:31–35批已合并,第36–41批本地完成与验证,已提交/Push并创建[PR #13](https://github.com/SayQuan1/LabSafe/pull/13),待CI与独立审查;下一批第42批接日期聚合与完整DateFact证据闭包。 本页仅导航;现行设计状态见 [文档控制](../00-document-control.md),不把候选设计视为已经批准。 diff --git a/docs/08-delivery/design-validation-results.json b/docs/08-delivery/design-validation-results.json index 6307181..2b20993 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-10-09T16:00:40.374307+00:00", + "executed_at_utc": "2026-10-10T12:55:58.782559+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": 437, + "doc_links": 438, "public_operations": 94, "traceable_test_ids": 42, "synthetic_release_gate_checks": 43, diff --git a/docs/README.md b/docs/README.md index 0f928f8..60e2045 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,7 +2,7 @@ 状态:设计版本 1.1.0;实现进度更新于 2026-10-09。设计结论见 [最新审计](08-delivery/03-design-audit.md),不要把结构检查或局部实现通过当作全系统可上线。 -当前续批:[第41批冻结开发词典、名称候选与实际MinIO验证](08-delivery/41-i-ml01-chemical-candidates.md)已本地完成;第36–41批未提交/Push/PR。PR #10(截至26批)/#11(27–30批)/#12(31–35批)已合并。下一批第42批接同瓶日期聚合与完整DateFact证据,生产验收仍待完成。 +当前续批:[第41批冻结开发词典、名称候选与实际MinIO验证](08-delivery/41-i-ml01-chemical-candidates.md)已本地完成;第36–41批已提交/Push并创建[PR #13](https://github.com/SayQuan1/LabSafe/pull/13),待CI与独立审查。PR #10(截至26批)/#11(27–30批)/#12(31–35批)已合并。下一批第42批接同瓶日期聚合与完整DateFact证据,生产验收仍待完成。 ## 阅读顺序 diff --git a/tests/ai/test_supervisor.py b/tests/ai/test_supervisor.py index 33185eb..d90df78 100644 --- a/tests/ai/test_supervisor.py +++ b/tests/ai/test_supervisor.py @@ -9,6 +9,7 @@ import unittest from datetime import datetime, timedelta, timezone from types import SimpleNamespace +from unittest.mock import patch from apps.ai_inference.app.fixture import ProtocolError from apps.ai_inference.runtime_cpu import send @@ -266,6 +267,26 @@ async def test_cancel_and_close_during_loading_reap_process(self): await loading.close() self.assertIsNone(loading.process) + async def test_cancel_when_ipc_send_completes_reaps_process(self): + runtime = self.runtime("timeout") + await until(lambda: runtime.ready) + to_thread = asyncio.to_thread + + async def complete_send_and_cancel(operation, *args): + if operation.__name__ == "send_bytes": + # Schedule cancellation before the send completion wakes its waiter. + # asyncio.wait_for can consume this cancellation if the send is done. + asyncio.get_running_loop().call_soon(task.cancel) + return operation(*args) + return await to_thread(operation, *args) + + with patch("asyncio.to_thread", side_effect=complete_send_and_cancel): + task = asyncio.create_task(runtime.infer(payload(), "runs")) + with self.assertRaises(asyncio.CancelledError): + await task + self.assertIsNone(runtime.process) + self.assertFalse(runtime.busy) + async def test_idle_child_exit_reloads_and_expired_request_keeps_process(self): runtime = self.runtime() await until(lambda: runtime.ready) From 56f3d91027f17eb2bc76f50c0efe70334b48df5d Mon Sep 17 00:00:00 2001 From: SayQuan1 <15246906705@163.com> Date: Sat, 10 Oct 2026 21:10:16 +0800 Subject: [PATCH 3/3] docs(i-ml01): record full persistence regression and PR delivery Preserve the implementation-plan table formatting and record the final 458-test isolated MySQL regression. Link batch 36-41 delivery to PR #13 and distinguish local validation from pending CI and independent review. No runtime or migration changes; restore the prior document version for rollback. --- docs/08-delivery/01-implementation-plan.md | 64 ++++++++++--------- .../41-i-ml01-chemical-candidates.md | 2 +- 2 files changed, 34 insertions(+), 32 deletions(-) diff --git a/docs/08-delivery/01-implementation-plan.md b/docs/08-delivery/01-implementation-plan.md index d44503b..39bcfc4 100644 --- a/docs/08-delivery/01-implementation-plan.md +++ b/docs/08-delivery/01-implementation-plan.md @@ -4,41 +4,43 @@ 当前主线为I-ML-01真实CPU推理接线:第41批冻结开发词典、CAS/别名/模糊候选及同瓶冲突聚合已本地完成;下一批确定为**第42批,同瓶日期聚合、冲突/低置信/未知保留与完整DateFact证据闭包**。I-02/I-03/I-ML-01仍进行中,局部工程成绩不替代完整业务、现场模型或生产验收。阶段标准见[阶段门禁](05-stage-gates.md),运行入口见[开发指南](../../DEVELOPMENT.md)。 +2026-10-10 Git交付收尾:实现提交726786c与取消竞态修复ae53d0a已Push至PR #13;修复后CPU/协议76、HTTP27、真实MinIO两份smoke通过,完整隔离MySQL8.0.33持久化458项通过,无跳过,0004/43表115FK无差异。远程CI及独立审查以PR检查页为准,未合并;早期批次成绩保留为历史,不与本轮相加。 + ## 1. 当前任务进度 不要求先有完整应用再准备真实模型;业务和模型按各自依赖推进。采用用户已提供的官方D-FINE COCO 80类ONNX与PP-OCRv6_small det/rec ONNX,当前仅CPU,不安装CUDA/Paddle,不回退四类训练路线。 -| 编号 | 已交付及当前状态 | 证据 | 剩余边界 | -|---|---|---|---| -| I-01A | 工程/进程入口、独立Python3.11环境、CI;已合并PR #5 | [骨架验收](07-i01a-acceptance.md) | 开发骨架不代表完整服务或生产部署 | -| I-01B | 初始迁移、结构核对、bootstrap;已合并PR #7。0002已随#11合并,0003/0004为第36/37批本地增量 | [数据库验收](08-i01b-acceptance.md)、[第36批](36-i-ml01-controlled-analysis-input.md) | 恢复、部署与生产迁移另行验收;历史快照不改 | -| I-01C | Session/RBAC、租户事务、幂等、安全原语;已合并PR #8 | [安全原语](09-i01c-acceptance.md) | 实际部署权限及完整安全验收仍待完成 | -| I-02 | 身份、基础、上传、提交、规则管理、复核、整改、CSV报告API已分批合并;整体进行中 | 第9–30批交付记录 | 完整item complete/retry等其余命令、PDF/通知/过期清理、真实存储与完整UI仍待完成 | -| I-02A | 13个领域命令守卫;已合并PR #9 | [领域守卫](10-i02a-domain-guards.md) | 纯守卫不等于每个公共命令已接事务 | -| I-02B | 登录/会话/用户7个API、限流与并发安全;已合并PR #9 | [身份API](11-i02b-identity-api.md) | 仅dev/test;生产授权单独验收 | -| I-02C | 角色列表/授予/撤销、作用域与会话撤销;已合并PR #9 | [角色API](12-i02c-role-api.md) | 前端管理与部署验收待完成 | -| I-02D | 组织/模板/巡检草稿16个API;已合并PR #9 | [基础API](13-i02d-foundation-api.md) | 不等于巡检全部状态命令已实现 | -| I-02E | 巡检项列表/详情、动作投影基础;已合并PR #9 | [巡检项查询](14-i02e-item-query-api.md) | item allowed_actions仍需可信默认图片选择上下文 | -| I-02F1/F2 | 上传授权、完成受理、固定staging版本及图像任务入队;已合并PR #9 | [授权](15-i02f1-upload-grant-api.md)、[完成受理](16-i02f2-upload-completion-api.md) | 实际S3/IAM/nginx/TLS与浏览器联调待完成 | -| I-02F3 | 真实图像解码/一次EXIF/RGB PNG、O/A版本登记及ready/rejected事务;已合并PR #10 | [图像处理](19-i02f3-image-validation.md) | 真实存储部署验收、ImageValidated领域事件发布待完成 | -| I-02F4 | ready分析图READ/原图Admin审计、准确版本60秒下载签发;已合并PR #10 | [图片下载](21-i02f4-image-download.md) | 真实IAM/TLS验收待完成 | -| I-02G1 | submit锁定输入/配置,run/run_images/task/outbox原子入队;已合并PR #10。第36批补SHA及准确对象版本冻结 | [提交入队](22-i02g1-submit-inference-enqueue.md)、[版本冻结](36-i-ml01-controlled-analysis-input.md) | 常驻真实CPU HTTP已本地接通第38批;公共模型activation与语义事实闭环尚未完成 | -| I-02(规则管理) | 规则导入/审批/发布/退休、不可变bundle与治理API;已合并PR #10 | [规则管理](25-i02-rule-management.md) | 工程治理不代表真实化学安全规则获批 | -| I-02(人工复核) | 事实读取/人工修订、finding查询和三种决策;已合并PR #10 | [复核](26-i02-review.md) | 完整item complete及真实AI证据/事实接线待完成 | -| I-02H | 整改派发/认领/证据/独立复查/驳回/转派,事务审计与事件登记;已合并PR #11 | [整改](27-i02-remediation.md) | 通用领域事件发布/通知投递及完整UI待完成 | -| I-02I1 | 报告快照/异步受理/查询/failed重放;已合并PR #11 | [报告受理](28-i02-report-export.md) | 不再把CSV执行/下载列为待实现;PDF等见下两行 | -| I-02I2 | CSV公式防注入、q.reports发布、专属租约/心跳/重试/回收、版本/SHA/大小与ready登记;已合并PR #11 | [CSV执行](29-i02-report-csv-execution.md) | PDF、完成通知、expired转换/精确版本清理、实际存储与UI;EXPORT-01/JOB-04未整体关闭 | -| I-02I3 | 创建人/同范围Admin、完整授权/expiry重检、60秒准确VersionId CSV下载;已合并PR #11 | [报告下载](30-i02-report-download.md) | PDF与真实签名入口/IAM/TLS验收待完成 | -| I-03 | 图像/推理/规则/CSV的持久调度执行基础已交付;整体进行中 | [调度](17-i03a1-durable-dispatch.md)、第18/23/24/29批 | 通用领域事件publisher/inbox、完整非图像任务重放和真实AI闭环待完成 | -| I-03A1 | Outbox发布租约、有界发布、补发/回收及q.general调度;已合并PR #10,CSV q.reports已随#11合并 | [调度](17-i03a1-durable-dispatch.md)、[CSV执行](29-i02-report-csv-execution.md) | TaskDispatch已支持的类型不代表所有领域事件均已投递 | -| I-03A2 | validate_image/inference_pipeline/rule_evaluation及CSV report_export的执行租约、围栏和恢复已接;#10/#11已合并 | [图像执行](18-i03a2-image-execution.md)、[推理执行](23-i03a2-inference-execution.md)、[规则执行](24-i03a2-rule-evaluation.md)、[CSV执行](29-i02-report-csv-execution.md) | 独立OCR证据闭包已本地接通第37批;常驻真实CPU HTTP已本地接通第38批;完整JOB-04仍待关闭 | -| I-03A3 | 图像任务查询与管理员固定输入重放;已合并PR #10。报告failed重放随#11合并 | [图像重放](20-i03a3-image-job-replay.md)、[报告受理](28-i02-report-export.md) | 不宣称已实现所有任务类型的人工重放 | -| I-ML-01 | 官方80类适配、CPU检测/OCR/质量/可重建证据已合并#12;受控analysis/文字证据/常驻HTTP/关联/字段/开发词典候选已本地完成第36–41批;整体进行中 | 下方批次表 | 日期聚合/事实快照、可信化学安全词典、公共activation/多版本路由和现场评测仍待完成 | -| I-ML-02 | 未完成 | [模型/数据路线](../04-ai-rules/02-model-data-plan.md) | 授权台账、100张标注试行、完整数据/split/审核,未获授权不抓取替代 | -| I-ML-03/04 | 真实现场评测/校准未开始验收;确定性事实规范已有设计,业务算法仍待接线 | [事实规范](../04-ai-rules/03-fact-extraction-contract.md) | 数据、字段/词典/关联、真实照片和一致性/资源报告;当前CPU工程smoke不替代AI-05至AI-10/MODEL-02 | -| I-04 | 只有开发Web骨架,完整业务UI未完成 | [交互规范](../05-frontend/01-interaction-spec.md) | 登录、采集/复核/整改/admin/report与真实API联调 | -| I-05 | 开发入口/局部探针及第41批独立真实MinIO版本/限定IAM验证已交付,完整部署未完成 | [部署规范](../07-quality-operations/02-deployment-operations.md)、[第41批](41-i-ml01-chemical-candidates.md) | Compose/持久secret/公开签名入口/IAM/TLS、监控、备份恢复、离线演练;临时loopback不等于生产部署 | -| R-01 | 未进入,production仍禁止 | [发布门禁](05-stage-gates.md) | 数据/模型/专家规则、现场指标、安全恢复与双人批准 | +| 编号 | 已交付及当前状态 | 证据 | 剩余边界 | +| ---------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| I-01A | 工程/进程入口、独立Python3.11环境、CI;已合并PR #5 | [骨架验收](07-i01a-acceptance.md) | 开发骨架不代表完整服务或生产部署 | +| I-01B | 初始迁移、结构核对、bootstrap;已合并PR #7。0002已随#11合并,0003/0004为第36/37批本地增量 | [数据库验收](08-i01b-acceptance.md)、[第36批](36-i-ml01-controlled-analysis-input.md) | 恢复、部署与生产迁移另行验收;历史快照不改 | +| I-01C | Session/RBAC、租户事务、幂等、安全原语;已合并PR #8 | [安全原语](09-i01c-acceptance.md) | 实际部署权限及完整安全验收仍待完成 | +| I-02 | 身份、基础、上传、提交、规则管理、复核、整改、CSV报告API已分批合并;整体进行中 | 第9–30批交付记录 | 完整item complete/retry等其余命令、PDF/通知/过期清理、真实存储与完整UI仍待完成 | +| I-02A | 13个领域命令守卫;已合并PR #9 | [领域守卫](10-i02a-domain-guards.md) | 纯守卫不等于每个公共命令已接事务 | +| I-02B | 登录/会话/用户7个API、限流与并发安全;已合并PR #9 | [身份API](11-i02b-identity-api.md) | 仅dev/test;生产授权单独验收 | +| I-02C | 角色列表/授予/撤销、作用域与会话撤销;已合并PR #9 | [角色API](12-i02c-role-api.md) | 前端管理与部署验收待完成 | +| I-02D | 组织/模板/巡检草稿16个API;已合并PR #9 | [基础API](13-i02d-foundation-api.md) | 不等于巡检全部状态命令已实现 | +| I-02E | 巡检项列表/详情、动作投影基础;已合并PR #9 | [巡检项查询](14-i02e-item-query-api.md) | item allowed_actions仍需可信默认图片选择上下文 | +| I-02F1/F2 | 上传授权、完成受理、固定staging版本及图像任务入队;已合并PR #9 | [授权](15-i02f1-upload-grant-api.md)、[完成受理](16-i02f2-upload-completion-api.md) | 实际S3/IAM/nginx/TLS与浏览器联调待完成 | +| I-02F3 | 真实图像解码/一次EXIF/RGB PNG、O/A版本登记及ready/rejected事务;已合并PR #10 | [图像处理](19-i02f3-image-validation.md) | 真实存储部署验收、ImageValidated领域事件发布待完成 | +| I-02F4 | ready分析图READ/原图Admin审计、准确版本60秒下载签发;已合并PR #10 | [图片下载](21-i02f4-image-download.md) | 真实IAM/TLS验收待完成 | +| I-02G1 | submit锁定输入/配置,run/run_images/task/outbox原子入队;已合并PR #10。第36批补SHA及准确对象版本冻结 | [提交入队](22-i02g1-submit-inference-enqueue.md)、[版本冻结](36-i-ml01-controlled-analysis-input.md) | 常驻真实CPU HTTP已本地接通第38批;公共模型activation与语义事实闭环尚未完成 | +| I-02(规则管理) | 规则导入/审批/发布/退休、不可变bundle与治理API;已合并PR #10 | [规则管理](25-i02-rule-management.md) | 工程治理不代表真实化学安全规则获批 | +| I-02(人工复核) | 事实读取/人工修订、finding查询和三种决策;已合并PR #10 | [复核](26-i02-review.md) | 完整item complete及真实AI证据/事实接线待完成 | +| I-02H | 整改派发/认领/证据/独立复查/驳回/转派,事务审计与事件登记;已合并PR #11 | [整改](27-i02-remediation.md) | 通用领域事件发布/通知投递及完整UI待完成 | +| I-02I1 | 报告快照/异步受理/查询/failed重放;已合并PR #11 | [报告受理](28-i02-report-export.md) | 不再把CSV执行/下载列为待实现;PDF等见下两行 | +| I-02I2 | CSV公式防注入、q.reports发布、专属租约/心跳/重试/回收、版本/SHA/大小与ready登记;已合并PR #11 | [CSV执行](29-i02-report-csv-execution.md) | PDF、完成通知、expired转换/精确版本清理、实际存储与UI;EXPORT-01/JOB-04未整体关闭 | +| I-02I3 | 创建人/同范围Admin、完整授权/expiry重检、60秒准确VersionId CSV下载;已合并PR #11 | [报告下载](30-i02-report-download.md) | PDF与真实签名入口/IAM/TLS验收待完成 | +| I-03 | 图像/推理/规则/CSV的持久调度执行基础已交付;整体进行中 | [调度](17-i03a1-durable-dispatch.md)、第18/23/24/29批 | 通用领域事件publisher/inbox、完整非图像任务重放和真实AI闭环待完成 | +| I-03A1 | Outbox发布租约、有界发布、补发/回收及q.general调度;已合并PR #10,CSV q.reports已随#11合并 | [调度](17-i03a1-durable-dispatch.md)、[CSV执行](29-i02-report-csv-execution.md) | TaskDispatch已支持的类型不代表所有领域事件均已投递 | +| I-03A2 | validate_image/inference_pipeline/rule_evaluation及CSV report_export的执行租约、围栏和恢复已接;#10/#11已合并 | [图像执行](18-i03a2-image-execution.md)、[推理执行](23-i03a2-inference-execution.md)、[规则执行](24-i03a2-rule-evaluation.md)、[CSV执行](29-i02-report-csv-execution.md) | 独立OCR证据闭包已本地接通第37批;常驻真实CPU HTTP已本地接通第38批;完整JOB-04仍待关闭 | +| I-03A3 | 图像任务查询与管理员固定输入重放;已合并PR #10。报告failed重放随#11合并 | [图像重放](20-i03a3-image-job-replay.md)、[报告受理](28-i02-report-export.md) | 不宣称已实现所有任务类型的人工重放 | +| I-ML-01 | 官方80类适配、CPU检测/OCR/质量/可重建证据已合并#12;受控analysis/文字证据/常驻HTTP/关联/字段/开发词典候选已本地完成第36–41批;整体进行中 | 下方批次表 | 日期聚合/事实快照、可信化学安全词典、公共activation/多版本路由和现场评测仍待完成 | +| I-ML-02 | 未完成 | [模型/数据路线](../04-ai-rules/02-model-data-plan.md) | 授权台账、100张标注试行、完整数据/split/审核,未获授权不抓取替代 | +| I-ML-03/04 | 真实现场评测/校准未开始验收;确定性事实规范已有设计,业务算法仍待接线 | [事实规范](../04-ai-rules/03-fact-extraction-contract.md) | 数据、字段/词典/关联、真实照片和一致性/资源报告;当前CPU工程smoke不替代AI-05至AI-10/MODEL-02 | +| I-04 | 只有开发Web骨架,完整业务UI未完成 | [交互规范](../05-frontend/01-interaction-spec.md) | 登录、采集/复核/整改/admin/report与真实API联调 | +| I-05 | 开发入口/局部探针及第41批独立真实MinIO版本/限定IAM验证已交付,完整部署未完成 | [部署规范](../07-quality-operations/02-deployment-operations.md)、[第41批](41-i-ml01-chemical-candidates.md) | Compose/持久secret/公开签名入口/IAM/TLS、监控、备份恢复、离线演练;临时loopback不等于生产部署 | +| R-01 | 未进入,production仍禁止 | [发布门禁](05-stage-gates.md) | 数据/模型/专家规则、现场指标、安全恢复与双人批准 | “已合并”“本地实现”“本机验证”“远程CI”“独立批准”分别记录。各交付文件中未提交或未实现的描述为实施当时的历史状态;本表给出现行进度,后续批次覆盖的旧边界不继续当作待办。默认开关仍关闭,仅dev/test,不因合并改为生产可用。 diff --git a/docs/08-delivery/41-i-ml01-chemical-candidates.md b/docs/08-delivery/41-i-ml01-chemical-candidates.md index 10c46d4..b613aa8 100644 --- a/docs/08-delivery/41-i-ml01-chemical-candidates.md +++ b/docs/08-delivery/41-i-ml01-chemical-candidates.md @@ -70,4 +70,4 @@ MySQL结构核对为0004/43表115FK,无错误;业务/MySQL有既有 Starlett 远程Push检查暴露请求取消恰逢IPC发送完成的竞态:Python3.11 asyncio.wait_for可能消费外层取消,导致调用等待到推理超时。发送期限改用asyncio.timeout以保留外层CancelledError和既有进程回收;新增确定性用例调度发送完成与取消的同一时序,原实现稳定出现AI_TIMEOUT,新实现正确取消并回收,无放宽超时预算。第41批两份真实MinIO/官方CPU smoke按修复后的代码锁重新运行,结果及现场局限保持。 -提交前主回归1795、CPU/协议75、fixture/spawn/HTTP27、独立Worker9通过,静态/契约/三环境pip与隔离检查通过。取消修复后CPU/协议76、fixture/spawn/HTTP27再次通过,两份真实MinIO smoke通过,确定性新增用例已验证旧实现失败/新实现通过;套件重叠不累加。完整隔离MySQL及本次远程CI最终结果以PR描述和检查页为准,独立协作者批准另行确认,不自动合并。 +提交前主回归1795、CPU/协议75、fixture/spawn/HTTP27、独立Worker9通过,静态/契约/三环境pip与隔离检查通过。取消修复后CPU/协议76、fixture/spawn/HTTP27再次通过,两份真实MinIO smoke通过,确定性新增用例已验证旧实现失败/新实现通过;套件重叠不累加。本次完整隔离MySQL8.0.33持久化套件458项全部通过、无跳过,0004/43表115FK无差异,测试实例已回收;1条既有Starlette/anyio弃用警告。实现与修复提交为726786c/ae53d0a,远程CI最终结果以PR描述和检查页为准,独立协作者批准另行确认,不自动合并。