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,66 @@
|
|||||||
|
"""The client asks for a measurement and gets an outcome.
|
||||||
|
|
||||||
|
It does not know, and must not know, whether the server or the station computed
|
||||||
|
the numbers: that is what lets a station's configuration change without touching
|
||||||
|
the frontend or the recipes.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.api.middleware.api_key import require_measurement_tec
|
||||||
|
from src.backend.database import get_db
|
||||||
|
from src.backend.models.api.vision import VisionExecuteResponse, VisionMeasurementResult
|
||||||
|
from src.backend.models.orm.task import RecipeTask
|
||||||
|
from src.backend.models.orm.user import User
|
||||||
|
from src.backend.services import vision_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/vision", tags=["vision"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _task_or_404(db: AsyncSession, task_id: int) -> RecipeTask:
|
||||||
|
"""Shared by the endpoints of this router."""
|
||||||
|
task = (await db.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.id == task_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if task is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
||||||
|
)
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/execute", response_model=VisionExecuteResponse)
|
||||||
|
async def execute(
|
||||||
|
task_id: int = Form(...),
|
||||||
|
image: UploadFile = File(...),
|
||||||
|
lot_number: str | None = Form(None),
|
||||||
|
serial_number: str | None = Form(None),
|
||||||
|
production_run_id: int | None = Form(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_measurement_tec),
|
||||||
|
) -> VisionExecuteResponse:
|
||||||
|
task = await _task_or_404(db, task_id)
|
||||||
|
|
||||||
|
result, saved = await vision_service.execute_task(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
await image.read(),
|
||||||
|
current_user.id,
|
||||||
|
version_id=task.version_id,
|
||||||
|
lot_number=lot_number,
|
||||||
|
serial_number=serial_number,
|
||||||
|
production_run_id=production_run_id,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return VisionExecuteResponse(
|
||||||
|
vision_result_id=result.id,
|
||||||
|
engine_version=result.engine_version,
|
||||||
|
measurements=[
|
||||||
|
VisionMeasurementResult(
|
||||||
|
subtask_id=m.subtask_id, value=float(m.value), pass_fail=m.pass_fail,
|
||||||
|
)
|
||||||
|
for m in saved
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -38,6 +38,9 @@ class Settings(BaseSettings):
|
|||||||
openrouter_api_key: str | None = None
|
openrouter_api_key: str | None = None
|
||||||
openrouter_model: str = "anthropic/claude-sonnet-4"
|
openrouter_model: str = "anthropic/claude-sonnet-4"
|
||||||
|
|
||||||
|
# Vision worker (internal network only, reachable as `vision` in Compose)
|
||||||
|
vision_worker_url: str = "http://vision:8100"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def database_url(self) -> str:
|
def database_url(self) -> str:
|
||||||
"""Async MySQL connection string."""
|
"""Async MySQL connection string."""
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from src.backend.api.routers.statistics import router as statistics_router
|
|||||||
from src.backend.api.routers.setup import router as setup_router
|
from src.backend.api.routers.setup import router as setup_router
|
||||||
from src.backend.api.routers.stations import router as stations_router
|
from src.backend.api.routers.stations import router as stations_router
|
||||||
from src.backend.api.routers.production import router as production_router
|
from src.backend.api.routers.production import router as production_router
|
||||||
|
from src.backend.api.routers.vision import router as vision_router
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -75,6 +76,7 @@ app.include_router(reports_router)
|
|||||||
app.include_router(setup_router)
|
app.include_router(setup_router)
|
||||||
app.include_router(stations_router)
|
app.include_router(stations_router)
|
||||||
app.include_router(production_router)
|
app.include_router(production_router)
|
||||||
|
app.include_router(vision_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""one row per vision execution, and camera as an input method
|
||||||
|
|
||||||
|
The vision surroundings do not go on `measurements`: statistics and the export
|
||||||
|
read that table on every pass. And it would be wrong modelling anyway - one
|
||||||
|
acquisition produces N quotes, and image, overlay, device and engine version are
|
||||||
|
the same for all of them.
|
||||||
|
|
||||||
|
Revision ID: 012_vision_results
|
||||||
|
Revises: 011_vision_graph
|
||||||
|
Create Date: 2026-08-16
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '012_vision_results'
|
||||||
|
down_revision: Union[str, None] = '011_vision_graph'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
OLD_METHODS = ('usb_caliper', 'manual')
|
||||||
|
NEW_METHODS = ('usb_caliper', 'manual', 'camera')
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'vision_results',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('task_id', sa.Integer(),
|
||||||
|
sa.ForeignKey('recipe_tasks.id'), nullable=False, index=True),
|
||||||
|
sa.Column('image_path', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('overlay_path', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('engine_version', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('executed_on',
|
||||||
|
sa.Enum('server', 'station', name='vision_executed_on_enum'),
|
||||||
|
nullable=False),
|
||||||
|
sa.Column('station_id', sa.Integer(),
|
||||||
|
sa.ForeignKey('stations.id'), nullable=True),
|
||||||
|
sa.Column('device_code', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('calibration_snapshot', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('graph_snapshot', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('duration_ms', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('executed_at', sa.DateTime(), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
)
|
||||||
|
|
||||||
|
with op.batch_alter_table('measurements') as batch:
|
||||||
|
batch.alter_column(
|
||||||
|
'input_method',
|
||||||
|
existing_type=sa.Enum(*OLD_METHODS, name='input_method_enum'),
|
||||||
|
type_=sa.Enum(*NEW_METHODS, name='input_method_enum'),
|
||||||
|
existing_nullable=False,
|
||||||
|
)
|
||||||
|
batch.add_column(
|
||||||
|
sa.Column('vision_result_id', sa.Integer(), nullable=True)
|
||||||
|
)
|
||||||
|
batch.create_foreign_key(
|
||||||
|
'fk_measurements_vision_result',
|
||||||
|
'vision_results', ['vision_result_id'], ['id'],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('measurements') as batch:
|
||||||
|
batch.drop_constraint('fk_measurements_vision_result',
|
||||||
|
type_='foreignkey')
|
||||||
|
batch.drop_column('vision_result_id')
|
||||||
|
batch.alter_column(
|
||||||
|
'input_method',
|
||||||
|
existing_type=sa.Enum(*NEW_METHODS, name='input_method_enum'),
|
||||||
|
type_=sa.Enum(*OLD_METHODS, name='input_method_enum'),
|
||||||
|
existing_nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_table('vision_results')
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""Pydantic schemas for the vision execution response."""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class VisionMeasurementResult(BaseModel):
|
||||||
|
"""One quote's outcome, as much of it as the client needs to show."""
|
||||||
|
subtask_id: int
|
||||||
|
value: float
|
||||||
|
pass_fail: str
|
||||||
|
|
||||||
|
|
||||||
|
class VisionExecuteResponse(BaseModel):
|
||||||
|
"""What `POST /api/vision/execute` returns: the run, and every quote it filled."""
|
||||||
|
vision_result_id: int
|
||||||
|
engine_version: str
|
||||||
|
measurements: list[VisionMeasurementResult]
|
||||||
@@ -7,6 +7,7 @@ from src.backend.models.orm.access_log import AccessLog
|
|||||||
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
|
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
|
||||||
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||||
from src.backend.models.orm.production import ProductionRun, ProductionEvent
|
from src.backend.models.orm.production import ProductionRun, ProductionEvent
|
||||||
|
from src.backend.models.orm.vision import VisionResult
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
@@ -22,4 +23,5 @@ __all__ = [
|
|||||||
"StationRecipeAssignment",
|
"StationRecipeAssignment",
|
||||||
"ProductionRun",
|
"ProductionRun",
|
||||||
"ProductionEvent",
|
"ProductionEvent",
|
||||||
|
"VisionResult",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -38,11 +38,17 @@ class Measurement(Base):
|
|||||||
|
|
||||||
# Input method
|
# Input method
|
||||||
input_method: Mapped[str] = mapped_column(
|
input_method: Mapped[str] = mapped_column(
|
||||||
Enum("usb_caliper", "manual", name="input_method_enum"),
|
Enum("usb_caliper", "manual", "camera", name="input_method_enum"),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
default="manual",
|
default="manual",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# The vision execution that produced this value, when there was one. Null for
|
||||||
|
# everything taken by caliper or typed by hand.
|
||||||
|
vision_result_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, ForeignKey("vision_results.id"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
# Time spent by the operator entering this value (client-reported)
|
# Time spent by the operator entering this value (client-reported)
|
||||||
input_duration_ms: Mapped[Optional[int]] = mapped_column(
|
input_duration_ms: Mapped[Optional[int]] = mapped_column(
|
||||||
Integer, nullable=True
|
Integer, nullable=True
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""One row per vision execution - not per quote."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, JSON, String, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from src.backend.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class VisionResult(Base):
|
||||||
|
__tablename__ = "vision_results"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
task_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("recipe_tasks.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
image_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
overlay_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
engine_version: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
executed_on: Mapped[str] = mapped_column(
|
||||||
|
Enum("server", "station", name="vision_executed_on_enum"), nullable=False
|
||||||
|
)
|
||||||
|
station_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, ForeignKey("stations.id"), nullable=True
|
||||||
|
)
|
||||||
|
device_code: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||||
|
# Copied, not referenced: a calibration redone six months from now must not
|
||||||
|
# rewrite what a measurement already taken means. Same for the graph.
|
||||||
|
calibration_snapshot: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
graph_snapshot: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||||
|
duration_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
executed_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = ({"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<VisionResult task={self.task_id} engine={self.engine_version}>"
|
||||||
@@ -224,6 +224,7 @@ async def save_measurement(
|
|||||||
input_method: str = "manual",
|
input_method: str = "manual",
|
||||||
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,
|
||||||
) -> Measurement:
|
) -> Measurement:
|
||||||
"""Save a single measurement with auto-calculated pass/fail."""
|
"""Save a single measurement with auto-calculated pass/fail."""
|
||||||
# Get subtask for tolerance values
|
# Get subtask for tolerance values
|
||||||
@@ -266,6 +267,7 @@ async def save_measurement(
|
|||||||
input_method=input_method,
|
input_method=input_method,
|
||||||
input_duration_ms=input_duration_ms,
|
input_duration_ms=input_duration_ms,
|
||||||
production_run_id=production_run_id,
|
production_run_id=production_run_id,
|
||||||
|
vision_result_id=vision_result_id,
|
||||||
)
|
)
|
||||||
db.add(measurement)
|
db.add(measurement)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""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 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.task import RecipeSubtask, RecipeTask
|
||||||
|
from src.backend.models.orm.vision import VisionResult
|
||||||
|
from src.backend.services import measurement_service
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_worker(image_bytes: bytes, graph: dict) -> dict:
|
||||||
|
"""The one place that talks to the worker; the tests replace it."""
|
||||||
|
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)},
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"the vision worker refused the job: {response.text}",
|
||||||
|
)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
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 not task.vision_json:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="this task carries no vision graph",
|
||||||
|
)
|
||||||
|
|
||||||
|
quotes = (await db.execute(
|
||||||
|
select(RecipeSubtask)
|
||||||
|
.where(RecipeSubtask.task_id == task.id)
|
||||||
|
.where(RecipeSubtask.vision_output.is_not(None))
|
||||||
|
)).scalars().all()
|
||||||
|
if not quotes:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="no quote on this task names a vision output",
|
||||||
|
)
|
||||||
|
|
||||||
|
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.
|
||||||
|
missing = [q.vision_output for q in quotes if q.vision_output not in outputs]
|
||||||
|
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,
|
||||||
|
))
|
||||||
|
|
||||||
|
return result, saved
|
||||||
@@ -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