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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
This commit is contained in:
2026-08-16 18:58:46 +02:00
parent ddf7788d77
commit b86eeada86
3 changed files with 186 additions and 11 deletions
+21 -11
View File
@@ -225,8 +225,17 @@ async def save_measurement(
input_duration_ms: int | None = None, input_duration_ms: int | None = None,
production_run_id: int | None = None, production_run_id: int | None = None,
vision_result_id: int | None = None, vision_result_id: int | None = None,
enforce_tolerance_gate: bool = True,
) -> Measurement: ) -> 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 # Get subtask for tolerance values
result = await db.execute( result = await db.execute(
select(RecipeSubtask).where(RecipeSubtask.id == subtask_id) 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 # 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 # that same quote again is the way out that does not need the capoturno; the
# next quote is not. # next quote is not.
blocking = await pending_authorisation( if enforce_tolerance_gate:
db, version_id, measured_by, production_run_id, 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 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) pass_fail, deviation = calculate_pass_fail(value, subtask)
+28
View File
@@ -52,10 +52,15 @@ async def execute_task(
detail="this task carries no vision graph", 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( quotes = (await db.execute(
select(RecipeSubtask) select(RecipeSubtask)
.where(RecipeSubtask.task_id == task.id) .where(RecipeSubtask.task_id == task.id)
.where(RecipeSubtask.vision_output.is_not(None)) .where(RecipeSubtask.vision_output.is_not(None))
.order_by(RecipeSubtask.marker_number)
)).scalars().all() )).scalars().all()
if not quotes: if not quotes:
raise HTTPException( 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( result = VisionResult(
task_id=task.id, task_id=task.id,
engine_version=report["engine_version"], engine_version=report["engine_version"],
@@ -102,6 +129,7 @@ async def execute_task(
input_method="camera", input_method="camera",
production_run_id=production_run_id, production_run_id=production_run_id,
vision_result_id=result.id, vision_result_id=result.id,
enforce_tolerance_gate=False,
)) ))
return result, saved return result, saved
+137
View File
@@ -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.recipe import RecipeVersion
from src.backend.models.orm.task import RecipeSubtask, RecipeTask from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.vision import VisionResult 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 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: def an_image() -> bytes:
buffer = io.BytesIO() buffer = io.BytesIO()
@@ -35,6 +39,29 @@ async def recipe_parts(db_session, recipe_id: int):
return version, task, subtask 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 @pytest.fixture
def worker_says(monkeypatch): def worker_says(monkeypatch):
"""The worker does not run in tests: its response is faked here.""" """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 response.status_code == 422
assert "graph" in response.json()["detail"].lower() 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