"""Route the image to the worker, map the outputs onto the quotes, save. This module never imports VisionSuite - see test_vision_boundary.py. It speaks to the worker over HTTP and stays ignorant of how a number was produced. """ from __future__ import annotations import json import httpx from fastapi import HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from src.backend.config import settings from src.backend.models.orm.measurement import Measurement from src.backend.models.orm.task import RecipeSubtask, RecipeTask from src.backend.models.orm.vision import VisionResult from src.backend.services import measurement_service async def _call_worker(image_bytes: bytes, graph: dict) -> dict: """The one place that talks to the worker; the tests replace it.""" async with httpx.AsyncClient(timeout=120.0) as client: response = await client.post( f"{settings.vision_worker_url}/run", files={"image": ("frame.png", image_bytes, "image/png")}, data={"graph": json.dumps(graph)}, ) if response.status_code != 200: raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=f"the vision worker refused the job: {response.text}", ) return response.json() async def execute_task( db: AsyncSession, task: RecipeTask, image_bytes: bytes, measured_by: int, *, version_id: int, lot_number: str | None = None, serial_number: str | None = None, production_run_id: int | None = None, ) -> tuple[VisionResult, list[Measurement]]: if not task.vision_json: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="this task carries no vision graph", ) quotes = (await db.execute( select(RecipeSubtask) .where(RecipeSubtask.task_id == task.id) .where(RecipeSubtask.vision_output.is_not(None)) )).scalars().all() if not quotes: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="no quote on this task names a vision output", ) report = await _call_worker(image_bytes, task.vision_json) outputs = report["outputs"] # Every quote or none. A half-measured piece read as a whole one is worse # than a piece not measured at all. missing = [q.vision_output for q in quotes if q.vision_output not in outputs] if missing: failed = ", ".join(f["tool_name"] for f in report["failures"]) raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=( f"the graph did not produce {', '.join(missing)}" + (f" (tools that failed: {failed})" if failed else "") ), ) result = VisionResult( task_id=task.id, engine_version=report["engine_version"], executed_on="server", graph_snapshot=task.vision_json, duration_ms=report.get("duration_ms"), ) db.add(result) await db.flush() saved: list[Measurement] = [] for quote in quotes: saved.append(await measurement_service.save_measurement( db, subtask_id=quote.id, version_id=version_id, measured_by=measured_by, value=float(outputs[quote.vision_output]), lot_number=lot_number, serial_number=serial_number, input_method="camera", production_run_id=production_run_id, vision_result_id=result.id, )) return result, saved