feat(vision): il server esegue tramite worker e salva misure vere
Aggiunge POST /api/vision/execute: route l'immagine al worker di visione, mappa le uscite del grafo sulle quote e le salva con save_measurement - stesso verdetto, stesso gate del fuori tolleranza di ogni altra misura. Nuova tabella vision_results (una riga per acquisizione, non per quota) e input_method 'camera' su measurements. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
"""A quote from a camera is a measurement like any other: same verdict, same gate."""
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from sqlalchemy import select
|
||||
|
||||
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.tests.conftest import auth_headers, create_test_recipe
|
||||
|
||||
|
||||
def an_image() -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
Image.new("L", (64, 64), color=0).save(buffer, format="PNG")
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
async def recipe_parts(db_session, recipe_id: int):
|
||||
"""`create_test_recipe` returns only the recipe: here it is broken apart."""
|
||||
version = (await db_session.execute(
|
||||
select(RecipeVersion).where(
|
||||
RecipeVersion.recipe_id == recipe_id,
|
||||
RecipeVersion.is_current == True, # noqa: E712
|
||||
)
|
||||
)).scalar_one()
|
||||
task = (await db_session.execute(
|
||||
select(RecipeTask).where(RecipeTask.version_id == version.id)
|
||||
)).scalars().first()
|
||||
subtask = (await db_session.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||
)).scalars().first()
|
||||
return version, task, subtask
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def worker_says(monkeypatch):
|
||||
"""The worker does not run in tests: its response is faked here."""
|
||||
def _install(outputs: dict, failures: list | None = None):
|
||||
async def fake_call(image_bytes: bytes, graph: dict) -> dict:
|
||||
return {
|
||||
"outputs": outputs,
|
||||
"failures": failures or [],
|
||||
"engine_version": "a" * 40,
|
||||
"duration_ms": 12,
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"src.backend.services.vision_service._call_worker", fake_call,
|
||||
)
|
||||
return _install
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_camera_quote_becomes_a_measurement(
|
||||
client, db_session, measurement_tec_user, worker_says,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
||||
task.task_type = "camera_measure"
|
||||
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||
subtask.vision_output = "t1.diameter"
|
||||
# The fixture creates the quote with nominal 10 and tight limits around it.
|
||||
await db_session.commit()
|
||||
worker_says({"t1.diameter": float(subtask.nominal)})
|
||||
|
||||
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), "lot_number": "L1"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
saved = (await db_session.execute(
|
||||
select(Measurement).where(Measurement.subtask_id == subtask.id)
|
||||
)).scalars().all()
|
||||
assert len(saved) == 1
|
||||
assert saved[0].pass_fail == "pass"
|
||||
assert saved[0].input_method == "camera"
|
||||
assert saved[0].vision_result_id is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_execution_records_which_engine_produced_it(
|
||||
client, db_session, measurement_tec_user, worker_says,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
||||
task.task_type = "camera_measure"
|
||||
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||
subtask.vision_output = "t1.diameter"
|
||||
await db_session.commit()
|
||||
worker_says({"t1.diameter": float(subtask.nominal)})
|
||||
|
||||
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), "lot_number": "L1"},
|
||||
)
|
||||
|
||||
result = (await db_session.execute(select(VisionResult))).scalars().one()
|
||||
assert result.engine_version == "a" * 40
|
||||
assert result.executed_on == "server"
|
||||
assert result.graph_snapshot == task.vision_json
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_missing_output_names_itself(
|
||||
client, db_session, measurement_tec_user, worker_says,
|
||||
):
|
||||
"""No half measurement: if an output is missing, the error names it."""
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
||||
task.task_type = "camera_measure"
|
||||
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||
subtask.vision_output = "t1.diameter"
|
||||
await db_session.commit()
|
||||
worker_says({"t1.radius": 5.0})
|
||||
|
||||
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), "lot_number": "L1"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert "t1.diameter" in response.json()["detail"]
|
||||
assert (await db_session.execute(select(Measurement))).scalars().all() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_task_without_a_graph_is_refused(
|
||||
client, db_session, measurement_tec_user, worker_says,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
||||
task.task_type = "camera_measure"
|
||||
await db_session.commit()
|
||||
worker_says({})
|
||||
|
||||
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 == 422
|
||||
assert "graph" in response.json()["detail"].lower()
|
||||
Reference in New Issue
Block a user