"""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 hashlib import json import math from pathlib import Path from uuid import uuid4 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.recipe import RecipeVersion from src.backend.models.orm.station import Station from src.backend.models.orm.task import RecipeSubtask, RecipeTask from src.backend.models.orm.vision import VisionReferenceImage, VisionResult from src.backend.services import measurement_service # Worker responses whose status and reason are forwarded as-is rather than # flattened into a generic 502 - see `_call_worker`. _FORWARDED_WORKER_STATUSES = frozenset({ status.HTTP_422_UNPROCESSABLE_CONTENT, status.HTTP_503_SERVICE_UNAVAILABLE, status.HTTP_504_GATEWAY_TIMEOUT, }) def _is_measurable(value: object) -> bool: """A worker output counts as a quote only if it is a finite, non-bool number. None (absent, or explicitly null in a malformed response), a bool (a subclass of int), and NaN/inf all fail this - each one would otherwise reach `float()` and either crash with a bare TypeError or silently save a value nobody could have measured. """ return ( isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) ) async def _call_worker( image_bytes: bytes, graph: dict, *, recipe_dir: str | None = None, calibration: dict | None = None, worker_url: str | None = None, ) -> dict: """The one place that talks to the worker; the tests replace it. The worker's fault must not become the API's fault (spec's edge-case table: "il worker isola il guasto, l'API resta in piedi, il task riporta l'errore"). httpx.TimeoutException (ConnectTimeout, ReadTimeout, WriteTimeout, PoolTimeout) becomes 504; every other transport failure (ConnectError, ReadError, RemoteProtocolError when the worker dies mid-response, ...) becomes 502 - both would otherwise propagate past this function and surface to the operator as a naked 500. The response shape is validated here too: a proxy answering 200 with something that is not the worker's JSON contract must not turn into a bare KeyError further down. """ # recipe_dir (relativo, #46) e calibration sono opzionali: se assenti non # si mandano, il worker usa i suoi default. data = {"graph": json.dumps(graph)} # worker della stazione (#45) o, se manca, il centrale; mai un ripiego dall'uno all'altro base = (worker_url or settings.vision_worker_url).rstrip("/") if recipe_dir: data["recipe_dir"] = recipe_dir if calibration: data["calibration"] = json.dumps(calibration) try: async with httpx.AsyncClient(timeout=120.0) as client: response = await client.post( f"{base}/run", files={"image": ("frame.png", image_bytes, "image/png")}, data=data, ) except httpx.TimeoutException as exc: raise HTTPException( status_code=status.HTTP_504_GATEWAY_TIMEOUT, detail=f"the vision worker did not answer in time: {exc} ({base})", # #45 ) from exc except (httpx.TransportError, httpx.HTTPError) as exc: raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=f"the vision worker is unreachable: {exc} ({base})", # #45 ) from exc # The worker already gave this fault a name - forward its status and # reason instead of flattening every non-200 into a generic 502: 422 is a # bad request (undecodable image, malformed graph), 503 is "busy, try # again" (VISION_MAX_CONCURRENCY exceeded), 504 is its own run timeout # (VISION_RUN_TIMEOUT_S, always tighter than this client's own 120s so # the worker answers first). if response.status_code in _FORWARDED_WORKER_STATUSES: try: detail = response.json().get("detail", response.text) except ValueError: detail = response.text raise HTTPException(status_code=response.status_code, detail=detail) if response.status_code != 200: raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=f"the vision worker refused the job: {response.text}", ) try: report = response.json() except ValueError as exc: raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=f"the vision worker's answer was malformed: not valid JSON ({exc})", ) from exc if not isinstance(report, dict): raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail="the vision worker's answer was malformed: expected a JSON object", ) missing = {"outputs", "failures", "engine_version"} - report.keys() if missing: raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=( "the vision worker's answer was malformed: missing " f"{', '.join(sorted(missing))}" ), ) return report async def validate_recipe(graph: dict, recipe_dir: str | None) -> dict: """Ask the worker's /validate whether a v2 recipe is loadable (#46). Returns {"ok", "errors", "outputs"}. A worker 422 (e.g. recipe_dir refused) is folded into ok=False; transport faults become 502/504 as in `_call_worker`. Replaced by the tests. """ data = {"graph": json.dumps(graph)} if recipe_dir: data["recipe_dir"] = recipe_dir try: async with httpx.AsyncClient(timeout=60.0) as client: response = await client.post( f"{settings.vision_worker_url}/validate", data=data, ) except httpx.TimeoutException as exc: raise HTTPException( status_code=status.HTTP_504_GATEWAY_TIMEOUT, detail=f"the vision worker did not answer in time: {exc}", ) from exc except httpx.HTTPError as exc: raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=f"the vision worker is unreachable: {exc}", ) from exc if response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT: try: detail = response.json().get("detail", response.text) except ValueError: detail = response.text errors = detail if isinstance(detail, list) else [str(detail)] return {"ok": False, "errors": [str(e) for e in errors], "outputs": []} if response.status_code != 200: raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=f"the vision worker refused the job: {response.text}", ) try: body = response.json() except ValueError as exc: raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail="the vision worker's answer was malformed: not valid JSON", ) from exc if not isinstance(body, dict) or "ok" not in body: raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail="the vision worker's answer was malformed: missing ok", ) body.setdefault("errors", []) body.setdefault("outputs", []) return body # Task types that run a vision graph (#46). VISION_TASK_TYPES = ("camera_measure", "dxf_compare") def _safe_join(root: Path, relative: str) -> Path: """root/relative, refusing absolute paths, '..' and anything leaving root.""" candidate = Path(relative) if candidate.is_absolute() or ".." in candidate.parts or not candidate.parts: raise ValueError(f"unsafe path: {relative}") full = (root / candidate).resolve() if not full.is_relative_to(root.resolve()): raise ValueError(f"unsafe path: {relative}") return full def collect_meta_paths(node: object) -> list[str]: """Every `path` string found in any dict under the recipe's `meta`.""" found: list[str] = [] if isinstance(node, dict): for key, value in node.items(): if key == "path" and isinstance(value, str): found.append(value) else: found.extend(collect_meta_paths(value)) elif isinstance(node, list): for item in node: found.extend(collect_meta_paths(item)) return found def match_recipe_files( recipe: dict, uploads: list[tuple[str, bytes]], ) -> tuple[dict[str, bytes], list[str]]: """Pair each uploaded file with a meta path by base name (#46). Returns ({relative path: bytes}, errors). Errors list every upload that has no name or matches no meta path, and every unsafe meta path. A meta path left without a file is not an error here: /validate decides. """ paths = collect_meta_paths(recipe.get("meta", {})) by_name: dict[str, str] = {} for p in paths: by_name.setdefault(Path(p.replace("\\", "/")).name, p) placed: dict[str, bytes] = {} errors: list[str] = [] for name, content in uploads: if not name: errors.append("a file has no name") continue target = by_name.get(Path(name.replace("\\", "/")).name) if target is None: errors.append(f"file {name!r} matches no path in the recipe's meta") continue try: _safe_join(Path("."), target) except ValueError: errors.append(f"meta path {target!r} is not allowed") continue placed[target] = content return placed, errors def store_recipe_folder( task_id: int, recipe: dict, files: dict[str, bytes], ) -> str: """Write the immutable recipe folder; return its path relative to uploads. Folder name = first 16 hex of the recipe's sha256. If it already exists it is left untouched (immutable: other task versions may reference it). """ canonical = json.dumps(recipe, sort_keys=True, separators=(",", ":")) # Il digest copre anche i file (path ordinato + sha256 del contenuto): la # stessa ricetta con file diversi (es. primo upload senza il DXF) deve dare # una cartella diversa, altrimenti quella incompleta resterebbe per sempre # perche' immutabile (#46). h = hashlib.sha256(canonical.encode("utf-8")) for rel in sorted(files): h.update(b"\0" + rel.encode("utf-8") + b"\0") h.update(hashlib.sha256(files[rel]).digest()) digest = h.hexdigest()[:16] relative = f"vision/recipes/{task_id}/{digest}" folder = settings.upload_path / relative if folder.exists(): return relative folder.mkdir(parents=True, exist_ok=True) (folder / "recipe.json").write_text( json.dumps(recipe, indent=2, ensure_ascii=False), encoding="utf-8", ) for rel, content in files.items(): target = _safe_join(folder, rel) target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(content) return relative def list_recipe_files(recipe_dir: str | None) -> list[str]: """Files of a recipe folder, relative to it (recipe.json excluded).""" if not recipe_dir: return [] try: folder = _safe_join(settings.upload_path, recipe_dir) except ValueError: return [] if not folder.is_dir(): return [] return sorted( p.relative_to(folder).as_posix() for p in folder.rglob("*") if p.is_file() and p.name != "recipe.json" ) def save_result_files( task_id: int, image_bytes: bytes, overlay: list, image_ext: str = "png", ) -> tuple[str, str]: """Save the executed image and its overlay JSON side by side (#46). Returns (image_path, overlay_path), both relative to uploads. """ stem = uuid4().hex base = Path("vision") / "results" / str(task_id) (settings.upload_path / base).mkdir(parents=True, exist_ok=True) image_rel = (base / f"{stem}.{image_ext}").as_posix() overlay_rel = (base / f"{stem}.overlay.json").as_posix() (settings.upload_path / image_rel).write_bytes(image_bytes) (settings.upload_path / overlay_rel).write_text(json.dumps(overlay), encoding="utf-8") return image_rel, overlay_rel 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, image_ext: str = "png", ) -> tuple[VisionResult, list[Measurement], dict]: if task.task_type not in VISION_TASK_TYPES: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=f"task type {task.task_type!r} is not a camera measurement", ) version = (await db.execute( select(RecipeVersion).where(RecipeVersion.id == version_id) )).scalar_one_or_none() if version is None or not version.is_current: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="this task's recipe version is no longer current", ) recipe = await measurement_service.recipe_of_version(db, version_id) if recipe is None or not recipe.active: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="this recipe is not active", ) run = None if production_run_id is not None: # 404 if the run does not exist, 422 if it belongs to another recipe, # 409 if it is closed or paused - same rule a manual reading follows. run = await measurement_service.validate_run_for_version( db, production_run_id, version_id, ) # Worker della stazione della sessione di produzione (#45); senza run o senza URL: centrale station_url = None if run is not None: station = (await db.execute( select(Station).where(Station.id == run.station_id) )).scalar_one_or_none() station_url = (station.vision_worker_url or None) if station is not None else None if not task.vision_json: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="this task carries no vision graph", ) # Ordered by marker_number - the number the operator sees on the annotated # image and in reports (RecipeSubtask.__repr__, statistics.py, report_service.py # all key off it). Which quote is saved first must never be an accident of # how SQLite/MySQL happens to return rows. quotes = (await db.execute( select(RecipeSubtask) .where(RecipeSubtask.task_id == task.id) .where(RecipeSubtask.vision_output.is_not(None)) .order_by(RecipeSubtask.marker_number) )).scalars().all() if not quotes: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="no quote on this task names a vision output", ) # Ruling R10: the gate is evaluated once per acquisition, not once per # quote. `save_measurement`'s own gate models "moving on" between # measurements taken one after another - a real later moment where the # operator could leave a bad reading behind. A camera acquisition produces # every quote of this task at the same instant: there is no "moving on" # between them, so applying the sequential rule inside the batch would # block on a fail this very request just saved. Check it once here, # against the whole set this acquisition is about to produce, and pass # enforce_tolerance_gate=False to every save_measurement call below. # # Checked before the worker runs (M3): a blocked operator finds out # without burning a whole vision execution first - the worker is the # expensive part, the gate check is not. blocking = await measurement_service.pending_authorisation( db, version_id, measured_by, production_run_id, ) quote_ids = {q.id for q in quotes} if blocking is not None and blocking.subtask_id not in quote_ids: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=( "A measurement is out of tolerance and awaiting the supervisor: " "authorise it or measure that quote again before going on" ), ) # Nessuna fonte di calibrazione esiste oggi (ne' task ne' stazione): non si # passa `calibration`, la ricetta porta la sua (#46). report = await _call_worker( image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir, worker_url=station_url, # #45 ) 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. A quote counts as produced only if its # output is present *and* a finite, non-bool number: the in-process runner # already drops NaN/inf itself (see src/vision/runner.py), but the worker # is reached over HTTP - a different implementation of it, or a malformed # response, could still hand back None or a non-finite float for a mapped # key instead of omitting it outright. missing = [q.vision_output for q in quotes if not _is_measurable(outputs.get(q.vision_output))] 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 "") ), ) overlay = report.get("overlay") or [] image_path, overlay_path = save_result_files(task.id, image_bytes, overlay, image_ext) result = VisionResult( task_id=task.id, image_path=image_path, overlay_path=overlay_path, engine_version=report["engine_version"], # #45: dove e' girato davvero, e quale worker (copiato come lo snapshot) executed_on="station" if station_url else "server", station_id=run.station_id if run is not None else None, worker_url=(station_url or settings.vision_worker_url).rstrip("/"), 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, enforce_tolerance_gate=False, )) return result, saved, report def _reference_file_path(reference: VisionReferenceImage) -> Path: return settings.upload_path / reference.path def remove_reference_file(reference: VisionReferenceImage) -> None: """Delete a reference image's file, if it is still there. Called by the router only once the row's fate is durable: after a successful commit when the row was just deleted, or to undo a `save_reference_image` whose row failed to commit (see both call sites). `missing_ok` because "already gone" must never be an error here. """ _reference_file_path(reference).unlink(missing_ok=True) async def file_still_referenced(db: AsyncSession, path: str) -> bool: """True if some VisionReferenceImage row still points at *path*. Copy-on-write (#38.6) can leave several rows sharing the same file on disk (the copy carries over the source's `path` rather than duplicating the file). The router must call this, after the row being deleted has been committed, before it calls `remove_reference_file` - otherwise deleting one task's reference would take the file out from under every other task that still references it. """ result = await db.execute( select(VisionReferenceImage.id).where(VisionReferenceImage.path == path).limit(1) ) return result.scalar_one_or_none() is not None async def save_reference_image( db: AsyncSession, task_id: int, image_bytes: bytes, *, note: str | None = None, station_id: int | None = None, device_code: str | None = None, calibration_snapshot: dict | None = None, extension: str = "png", ) -> VisionReferenceImage: """Keep an image so the graph can be composed and re-tried without a camera. `extension` must match the upload's real content type (the router derives it from `image.content_type`) - writing a jpeg to disk as `.png` would be a lie the filename tells forever. The file is written only after the row has flushed successfully - a bad FK or constraint fails at `flush()`, before anything exists on disk, so there is nothing to clean up. The caller still has to remove the file if the *commit* that follows fails (`remove_reference_file`, from the router): a flush is not durable, and an orphaned file from a rolled-back row is exactly what #38 found. """ # `upload_path` is the resolved Path, `upload_dir` the bare setting: the # rest of the codebase uses the former (see production_export_service). name = f"{uuid4().hex}.{extension}" relative_path = str(Path("vision") / "reference" / str(task_id) / name) reference = VisionReferenceImage( task_id=task_id, path=relative_path, note=note, station_id=station_id, device_code=device_code, calibration_snapshot=calibration_snapshot, ) db.add(reference) await db.flush() folder = _reference_file_path(reference).parent folder.mkdir(parents=True, exist_ok=True) _reference_file_path(reference).write_bytes(image_bytes) await db.refresh(reference) return reference async def list_reference_images( db: AsyncSession, task_id: int, ) -> list[VisionReferenceImage]: result = await db.execute( select(VisionReferenceImage) .where(VisionReferenceImage.task_id == task_id) .order_by(VisionReferenceImage.acquired_at) ) return list(result.scalars().all()) async def delete_reference_image( db: AsyncSession, reference_id: int, ) -> VisionReferenceImage: """Flush the row's deletion. The caller (router) removes the file only once the commit that makes the deletion durable has actually succeeded - deleting it any earlier would leave the row pointing at a file that is already gone if the commit then failed. """ reference = (await db.execute( select(VisionReferenceImage).where(VisionReferenceImage.id == reference_id) )).scalar_one_or_none() if reference is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Reference image not found", ) await db.delete(reference) await db.flush() return reference async def preview(db: AsyncSession, task: RecipeTask, image_bytes: bytes) -> dict: """Run the graph and save nothing. Composing is not measuring.""" if not task.vision_json: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="this task carries no vision graph", ) return await _call_worker( image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir, )