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..ad608cb 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-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/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..69d8afd --- /dev/null +++ b/apps/ai_inference/supervisor.py @@ -0,0 +1,217 @@ +"""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: + 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") + 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、持久部署/备份与生产权限验收。 + +## 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通过,确定性新增用例已验证旧实现失败/新实现通过;套件重叠不累加。本次完整隔离MySQL8.0.33持久化套件458项全部通过、无跳过,0004/43表115FK无差异,测试实例已回收;1条既有Starlette/anyio弃用警告。实现与修复提交为726786c/ae53d0a,远程CI最终结果以PR描述和检查页为准,独立协作者批准另行确认,不自动合并。 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..d4ba72e --- /dev/null +++ b/docs/08-delivery/41-i-ml01-minio-smoke.json @@ -0,0 +1,171 @@ +{ + "status": "PASS", + "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": "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": "585e1a69d879d3f6ea7028bb5027c52a4b8918865b3d6df9b62bff30faf2a578", + "model_checksum": "9587c97f3a6dafaabeda4b7c79cb18ebdd8d6fe8501b6830a7dfe53f73524d6f", + "dictionary_sha256": "5ca298c4e4e0b766904e0d47ffbbc51bfee27822c47b115c2667fc0b17ded701", + "detector_device": "cpu", + "ocr_device": "cpu" + }, + "dictionary_kind": "empty-development", + "images": [ + { + "photo_index": 1, + "source_sha256": "6cd697a4bf94c58a2f23a6c64b73852e4efd74c715f406f05455a5731d6b0f60", + "quality": { + "image_id": "93a99f20-afc8-4fb9-b7e6-f6fe070559c8", + "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": "f9481f25-8850-4bde-b7bb-5304b88e0293", + "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": "c3f7168a-78ab-443d-a773-45517fe4f896", + "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": "b451b6de-9958-4fd0-ab52-2d6dfe453746", + "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..8167900 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批本地完成与验证,已提交/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 f2fe140..2b20993 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-10T12:55:58.782559+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": 438, "public_operations": 94, "traceable_test_ids": 42, "synthetic_release_gate_checks": 43, diff --git a/docs/README.md b/docs/README.md index 85f5fc8..60e2045 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 #13](https://github.com/SayQuan1/LabSafe/pull/13),待CI与独立审查。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..d90df78 --- /dev/null +++ b/tests/ai/test_supervisor.py @@ -0,0 +1,303 @@ +"""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 unittest.mock import patch + +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_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) + 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()