96dd8d6ea3
_copy_tasks_to_version non copiava le righe VisionReferenceImage del task sorgente: il task copiato perdeva le immagini di riferimento del grafo di visione. Ora copia le righe sul task nuovo, stesso path e stessi metadati (stesso file su disco, non duplicato). Di conseguenza il file va cancellato solo se nessun'altra riga VisionReferenceImage referenzia lo stesso path: nuova vision_service.file_still_referenced(), usata dalla route DELETE prima di remove_reference_file. Test: copy-on-write elenca l'immagine sulla nuova versione; DELETE di una delle due righe lascia il file; DELETE dell'ultima lo rimuove. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
381 lines
15 KiB
Python
381 lines
15 KiB
Python
"""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 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.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) -> 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.
|
|
"""
|
|
try:
|
|
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)},
|
|
)
|
|
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.TransportError, httpx.HTTPError) as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=f"the vision worker is unreachable: {exc}",
|
|
) 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 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 task.task_type != "camera_measure":
|
|
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",
|
|
)
|
|
|
|
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.
|
|
await measurement_service.validate_run_for_version(
|
|
db, production_run_id, version_id,
|
|
)
|
|
|
|
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"
|
|
),
|
|
)
|
|
|
|
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. 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 "")
|
|
),
|
|
)
|
|
|
|
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,
|
|
enforce_tolerance_gate=False,
|
|
))
|
|
|
|
return result, saved
|
|
|
|
|
|
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 `<uuid>.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)
|