From b86eeada867079953d90f458bebe4aa7e8ca2dfc Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sun, 16 Aug 2026 18:58:46 +0200 Subject: [PATCH] fix(vision): il gate del fuori tolleranza vale per l'acquisizione, non per la quota Un'acquisizione camera produce N quote nello stesso istante: non c'e "andare avanti" fra l'una e l'altra, quindi il gate sequenziale di save_measurement non deve scattare dentro il lotto. execute_task valuta pending_authorisation una sola volta per l'intera acquisizione (bloccando solo se il fail pendente non appartiene alle quote di questo task) e passa enforce_tolerance_gate=False a ogni save_measurement del lotto. Query delle quote ordinata per marker_number, cosi l'esito non dipende dall'ordine di ritorno di SQLite/MySQL. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU --- src/backend/services/measurement_service.py | 32 +++-- src/backend/services/vision_service.py | 28 ++++ src/backend/tests/test_vision_execute.py | 137 ++++++++++++++++++++ 3 files changed, 186 insertions(+), 11 deletions(-) diff --git a/src/backend/services/measurement_service.py b/src/backend/services/measurement_service.py index 37bab5c..23fadcb 100644 --- a/src/backend/services/measurement_service.py +++ b/src/backend/services/measurement_service.py @@ -225,8 +225,17 @@ async def save_measurement( input_duration_ms: int | None = None, production_run_id: int | None = None, vision_result_id: int | None = None, + enforce_tolerance_gate: bool = True, ) -> Measurement: - """Save a single measurement with auto-calculated pass/fail.""" + """Save a single measurement with auto-calculated pass/fail. + + ``enforce_tolerance_gate=False`` skips only the pending-authorisation check + below - everything else (verdict, recipe rules, deviation) still applies. + This is for a caller that has already evaluated the gate itself for a whole + batch of quotes taken at the same instant (see Ruling R10 / vision_service): + the gate models "moving on" between measurements taken one after another, + which does not exist inside a single camera acquisition. + """ # Get subtask for tolerance values result = await db.execute( select(RecipeSubtask).where(RecipeSubtask.id == subtask_id) @@ -241,17 +250,18 @@ async def save_measurement( # No moving on while a quote is out of tolerance and unauthorised. Measuring # that same quote again is the way out that does not need the capoturno; the # next quote is not. - blocking = await pending_authorisation( - db, version_id, measured_by, production_run_id, - ) - if blocking is not None and blocking.subtask_id != subtask_id: - 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" - ), + if enforce_tolerance_gate: + blocking = await pending_authorisation( + db, version_id, measured_by, production_run_id, ) + if blocking is not None and blocking.subtask_id != subtask_id: + 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" + ), + ) pass_fail, deviation = calculate_pass_fail(value, subtask) diff --git a/src/backend/services/vision_service.py b/src/backend/services/vision_service.py index 6f636fa..7cad125 100644 --- a/src/backend/services/vision_service.py +++ b/src/backend/services/vision_service.py @@ -52,10 +52,15 @@ async def execute_task( 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( @@ -79,6 +84,28 @@ async def execute_task( ), ) + # 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. + 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" + ), + ) + result = VisionResult( task_id=task.id, engine_version=report["engine_version"], @@ -102,6 +129,7 @@ async def execute_task( input_method="camera", production_run_id=production_run_id, vision_result_id=result.id, + enforce_tolerance_gate=False, )) return result, saved diff --git a/src/backend/tests/test_vision_execute.py b/src/backend/tests/test_vision_execute.py index 34800ca..695da9d 100644 --- a/src/backend/tests/test_vision_execute.py +++ b/src/backend/tests/test_vision_execute.py @@ -9,8 +9,12 @@ 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 VisionResult +from src.backend.services import measurement_service from src.backend.tests.conftest import auth_headers, create_test_recipe +GOOD = 10.0 # inside every limit of the fixture quote +OUT = 99.0 # far outside + def an_image() -> bytes: buffer = io.BytesIO() @@ -35,6 +39,29 @@ async def recipe_parts(db_session, recipe_id: int): return version, task, subtask +async def two_vision_quotes(db_session, recipe_id: int): + """The fixture recipe's task, with a second quote mapped to a vision output. + + Same shape as `test_out_of_tolerance.py::_two_quotes`, adapted so both + quotes belong to a single camera acquisition instead of two separate + manual readings. + """ + version, task, first = await recipe_parts(db_session, recipe_id) + first.vision_output = "t1.diameter" + + second = RecipeSubtask( + task_id=task.id, marker_number=2, description="Second quote", + nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm", + vision_output="t1.radius", + ) + db_session.add(second) + task.task_type = "camera_measure" + task.vision_json = {"schema_version": 1, "name": "p", "tools": []} + await db_session.commit() + await db_session.refresh(second) + return version, task, first, second + + @pytest.fixture def worker_says(monkeypatch): """The worker does not run in tests: its response is faked here.""" @@ -151,3 +178,113 @@ async def test_a_task_without_a_graph_is_refused( assert response.status_code == 422 assert "graph" in response.json()["detail"].lower() + + +# --------------------------------------------------------------------------- +# Ruling R10: the gate is evaluated once per acquisition, not once per quote. +# A camera acquisition produces every quote of the task at the same instant - +# there is no "moving on" between them, so the sequential gate must not fire +# inside the batch. It still has to fire between two separate acquisitions. +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_a_non_last_out_of_tolerance_quote_does_not_break_the_batch( + client, db_session, measurement_tec_user, worker_says, +): + """The quote evaluated first fails; the one after it must still be saved.""" + recipe = await create_test_recipe(db_session, measurement_tec_user.id) + version, task, first, second = await two_vision_quotes(db_session, recipe.id) + worker_says({"t1.diameter": OUT, "t1.radius": GOOD}) + + response = await client.post( + "/api/vision/execute", + headers=auth_headers(measurement_tec_user), + files={"image": ("p.png", an_image(), "image/png")}, + data={"task_id": str(task.id)}, + ) + + assert response.status_code == 200 + by_subtask = { + m.subtask_id: m.pass_fail + for m in (await db_session.execute(select(Measurement))).scalars().all() + } + assert by_subtask == {first.id: "fail", second.id: "pass"} + + +@pytest.mark.asyncio +async def test_a_pending_fail_outside_the_batch_still_blocks( + client, db_session, measurement_tec_user, worker_says, +): + """A fail left over from something else this camera task does not cover.""" + recipe = await create_test_recipe(db_session, measurement_tec_user.id) + version, task, first, second = await two_vision_quotes(db_session, recipe.id) + + # A third quote on the same task, not part of this acquisition (no vision + # output), already failed and is waiting on a supervisor. + third = RecipeSubtask( + task_id=task.id, marker_number=3, description="Not a vision quote", + nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm", + ) + db_session.add(third) + await db_session.commit() + await db_session.refresh(third) + await measurement_service.save_measurement( + db_session, subtask_id=third.id, version_id=version.id, + measured_by=measurement_tec_user.id, value=OUT, + ) + await db_session.commit() + + worker_says({"t1.diameter": GOOD, "t1.radius": GOOD}) + response = await client.post( + "/api/vision/execute", + headers=auth_headers(measurement_tec_user), + files={"image": ("p.png", an_image(), "image/png")}, + data={"task_id": str(task.id)}, + ) + + assert response.status_code == 409 + assert "supervisor" in response.json()["detail"].lower() + assert (await db_session.execute( + select(Measurement).where(Measurement.subtask_id.in_([first.id, second.id])) + )).scalars().all() == [] + + +@pytest.mark.asyncio +async def test_a_new_acquisition_can_remeasure_its_own_pending_quote( + client, db_session, measurement_tec_user, worker_says, +): + """Re-shooting the same task is the way out, same as re-measuring by hand.""" + recipe = await create_test_recipe(db_session, measurement_tec_user.id) + version, task, first, second = await two_vision_quotes(db_session, recipe.id) + + worker_says({"t1.diameter": OUT, "t1.radius": GOOD}) + first_shot = await client.post( + "/api/vision/execute", + headers=auth_headers(measurement_tec_user), + files={"image": ("p1.png", an_image(), "image/png")}, + data={"task_id": str(task.id)}, + ) + assert first_shot.status_code == 200 + + # first.id is now pending authorisation - but it belongs to this same + # task, so a new acquisition of it is not "moving on". + worker_says({"t1.diameter": GOOD, "t1.radius": GOOD}) + second_shot = await client.post( + "/api/vision/execute", + headers=auth_headers(measurement_tec_user), + files={"image": ("p2.png", an_image(), "image/png")}, + data={"task_id": str(task.id)}, + ) + assert second_shot.status_code == 200 + + readings = (await db_session.execute( + select(Measurement) + .where(Measurement.subtask_id == first.id) + .order_by(Measurement.id) + )).scalars().all() + assert [m.pass_fail for m in readings] == ["fail", "pass"] + + assert await measurement_service.pending_authorisation( + db_session, version.id, measurement_tec_user.id, + ) is None