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:
2026-08-16 18:40:12 +02:00
parent 05f2937611
commit ddf7788d77
11 changed files with 477 additions and 1 deletions
+16
View File
@@ -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]
+2
View File
@@ -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.station import Station, StationRecipeAssignment
from src.backend.models.orm.production import ProductionRun, ProductionEvent
from src.backend.models.orm.vision import VisionResult
__all__ = [
"User",
@@ -22,4 +23,5 @@ __all__ = [
"StationRecipeAssignment",
"ProductionRun",
"ProductionEvent",
"VisionResult",
]
+7 -1
View File
@@ -38,11 +38,17 @@ class Measurement(Base):
# Input method
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,
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)
input_duration_ms: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True
+40
View File
@@ -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}>"