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
+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.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