Files
TieMeasureFlow/docs/superpowers/plans/2026-08-16-vision-fondamenta-server.md
T
Adriano 767bede43b docs(plan): fondamenta della visione lato server
Sei task TDD: grafo sul task, runner condiviso, worker separato,
esecuzione con salvataggio misure, prova del confine server/VisionSuite,
immagini di riferimento.

Primo dei quattro piani. Si collauda interamente via API, senza camera.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-16 16:42:25 +02:00

1754 lines
64 KiB
Markdown

# Fondamenta della visione lato server — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Un task `camera_measure` porta con sé un grafo di visione, viene eseguito sul server contro un'immagine fornita, e produce `measurements` indistinguibili da quelle prese col calibro.
**Architecture:** VisionSuite entra come sottomodulo git. Un *runner* condiviso traduce grafo + immagine in uscite numeriche; un *worker* separato lo espone dietro una API interna, così il container del server FastAPI non importa mai VisionSuite e resta leggero. Il server smista, applica il verdetto con la logica che già esiste, e salva.
**Tech Stack:** Python 3.11, uv, FastAPI, SQLAlchemy 2.0 async, Alembic, pytest + pytest-asyncio, VisionSuite (`vs-core`, `vs-task`, `vs-measure`, `vs-pm2d`).
**Spec:** [`docs/superpowers/specs/2026-08-16-visionsuite-integration-design.md`](../specs/2026-08-16-visionsuite-integration-design.md)
**Fuori da questo piano:** l'agente di stazione e i device dal vivo (piano 2), l'esecuzione sulla stazione (piano 3), il DXF compare (piano 4), e l'interfaccia di authoring nel Maker (piano 1b). Qui si arriva a un sistema completo e collaudabile **via API**: l'immagine si fornisce caricandola.
## Global Constraints
- Python **3.11**; dipendenze con **uv**, mai `pip install` diretto e mai `requirements.txt`.
- **Il server non importa mai VisionSuite.** Il Task 5 aggiunge la prova che lo verifica; se una modifica la fa fallire, la modifica è sbagliata, non la prova.
- Migrazioni Alembic in **batch mode** (`op.batch_alter_table`) per la compatibilità SQLite dei test, e verificate in resa MySQL con `uv run alembic -c src/backend/migrations/alembic.ini upgrade X:Y --sql` prima del deploy.
- Il flag `-c src/backend/migrations/alembic.ini` è **obbligatorio** in ogni comando alembic.
- I test girano su SQLite in memoria (`sqlite+aiosqlite://`, `StaticPool`): nessun MySQL richiesto.
- Nomi di variabili, funzioni e commenti in **inglese**; le stringhe di interfaccia in IT/EN via i18n.
- Type hints ovunque, `async`/`await` per ogni operazione di database.
- Ogni valore prodotto dalla visione porta scritta la **versione del motore** che l'ha prodotto.
- **Due scostamenti voluti rispetto alla spec**, entrambi migliorativi e da non "correggere" tornando alla lettera del documento: la spec descrive una migrazione 011 sola con cinque cose dentro, il piano la divide in **011, 012 e 013** perché ogni migrazione viaggi col task che la rende necessaria e sia rivedibile da sola; e dove la spec dice `device_id`, le tabelle usano `device_code` (stringa), perché il registro dei device arriva col piano 2 e una misura presa oggi deve restare leggibile anche se quel device domani sparisce.
- I nomi di importazione di VisionSuite non sono uniformi ed è voluto a monte: `vs-core` si importa come `visionsuite`, `vs-pm2d` come `pm2d`, `vs-dxf` come `dxf_compare`.
---
### Task 1: Grafo sul task e tipo corretto
Il primo mattone: il task sa portare un grafo, la subtask sa a quale uscita del grafo corrisponde, e il refuso `xf_compare` sparisce finché nessuna riga lo usa.
**Files:**
- Create: `src/backend/migrations/versions/011_vision_graph.py`
- Modify: `src/backend/models/orm/task.py:18` (costante `TASK_TYPES`), `src/backend/models/orm/task.py:49` (colonne di `RecipeTask`), `src/backend/models/orm/task.py:75+` (colonne di `RecipeSubtask`)
- Modify: `src/backend/models/api/task.py:61,73,89`
- Test: `src/backend/tests/test_vision_graph.py`
**Interfaces:**
- Consumes: niente, è il primo task.
- Produces: `RecipeTask.vision_json: dict | None`, `RecipeSubtask.vision_output: str | None`, e il valore `"dxf_compare"` nell'enum `task_type` al posto di `"xf_compare"`. Il formato di `vision_output` è la stringa `"<tool_id>.<nome_uscita>"`, che i Task 2 e 4 usano per mappare le uscite sulle quote.
- [ ] **Step 1: Write the failing test**
Crea `src/backend/tests/test_vision_graph.py`:
```python
"""Il grafo di visione vive sul task, e la quota sa quale uscita la riempie."""
import pytest
from sqlalchemy import select
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.task import TASK_TYPES, RecipeSubtask, RecipeTask
from src.backend.tests.conftest import create_test_recipe
async def recipe_parts(db_session, recipe_id: int):
"""`create_test_recipe` restituisce la sola ricetta: qui si scompone.
Versione corrente, il suo unico task, la sua prima quota. È l'idioma che
`test_out_of_tolerance.py` usa già, ripetuto perché ogni file di prova si
legge da solo.
"""
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.mark.asyncio
async def test_task_carries_a_vision_graph(db_session, maker_user):
"""Il grafo si salva e si rilegge identico: il server non lo interpreta."""
recipe = await create_test_recipe(db_session, maker_user.id, code="VIS-1")
version, task, subtask = await recipe_parts(db_session, recipe.id)
graph = {
"schema_version": 1,
"name": "diametro del foro",
"tools": [{"id": "t1", "kind": "DetectCircle", "name": "foro"}],
}
task.vision_json = graph
task.task_type = "camera_measure"
await db_session.flush()
db_session.expunge_all()
reloaded = (await db_session.execute(
select(RecipeTask).where(RecipeTask.id == task.id)
)).scalar_one()
assert reloaded.vision_json == graph
@pytest.mark.asyncio
async def test_subtask_names_the_output_that_fills_it(db_session, maker_user):
"""La quota dichiara quale uscita del grafo le compete."""
recipe = await create_test_recipe(db_session, maker_user.id, code="VIS-2")
version, task, subtask = await recipe_parts(db_session, recipe.id)
subtask.vision_output = "t1.diameter"
await db_session.flush()
db_session.expunge_all()
reloaded = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.id == subtask.id)
)).scalar_one()
assert reloaded.vision_output == "t1.diameter"
def test_the_typo_is_gone():
"""`xf_compare` era un refuso: si corregge finché nessuna riga lo usa."""
assert "dxf_compare" in TASK_TYPES
assert "xf_compare" not in TASK_TYPES
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run pytest src/backend/tests/test_vision_graph.py -v`
Expected: FAIL — `AttributeError: 'RecipeTask' object has no attribute 'vision_json'` e `assert 'dxf_compare' in TASK_TYPES`.
- [ ] **Step 3: Write the migration**
Crea `src/backend/migrations/versions/011_vision_graph.py`:
```python
"""the vision graph lives on the task, and the quote names its output
`xf_compare` was a typo for `dxf_compare`. No row uses either value yet, so
renaming costs one statement here; from the first saved task onwards it would
be a data migration and the typo would already be in the public API.
Revision ID: 011_vision_graph
Revises: 010_meas_authorisation
Create Date: 2026-08-16
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '011_vision_graph'
down_revision: Union[str, None] = '010_meas_authorisation'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
OLD_TYPES = ('note', 'measure', 'drawing', 'xf_compare', 'camera_measure')
NEW_TYPES = ('note', 'measure', 'drawing', 'dxf_compare', 'camera_measure')
def upgrade() -> None:
with op.batch_alter_table('recipe_tasks') as batch:
batch.add_column(sa.Column('vision_json', sa.JSON(), nullable=True))
batch.alter_column(
'task_type',
existing_type=sa.Enum(*OLD_TYPES, name='task_type_enum'),
type_=sa.Enum(*NEW_TYPES, name='task_type_enum'),
existing_nullable=False,
existing_server_default='note',
)
with op.batch_alter_table('recipe_subtasks') as batch:
batch.add_column(
sa.Column('vision_output', sa.String(length=120), nullable=True)
)
def downgrade() -> None:
with op.batch_alter_table('recipe_subtasks') as batch:
batch.drop_column('vision_output')
with op.batch_alter_table('recipe_tasks') as batch:
batch.alter_column(
'task_type',
existing_type=sa.Enum(*NEW_TYPES, name='task_type_enum'),
type_=sa.Enum(*OLD_TYPES, name='task_type_enum'),
existing_nullable=False,
existing_server_default='note',
)
batch.drop_column('vision_json')
```
- [ ] **Step 4: Update the ORM**
In `src/backend/models/orm/task.py`, sostituisci la costante a riga 18:
```python
TASK_TYPES = ("note", "measure", "drawing", "dxf_compare", "camera_measure")
```
Aggiungi in `RecipeTask`, subito dopo `annotations_json`:
```python
# The vision graph, serialised by vs-task. Opaque here on purpose: the
# server routes it and never interprets it, which is what keeps VisionSuite
# out of this container.
vision_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
```
Aggiungi in `RecipeSubtask`, dopo `image_path`:
```python
# Which output of the vision graph fills this quote: "<tool_id>.<output>".
# A link is an id, not a pointer - the same rule vs-task follows, and for
# the same reason: a pointer does not survive being written to disk.
vision_output: Mapped[Optional[str]] = mapped_column(
String(120), nullable=True
)
```
- [ ] **Step 5: Update the Pydantic schemas**
In `src/backend/models/api/task.py`, il tipo letterale `TaskType` deve elencare `dxf_compare` al posto di `xf_compare`, e i tre schemi (create, update, response) devono esporre i due campi nuovi:
```python
TaskType = Literal["note", "measure", "drawing", "dxf_compare", "camera_measure"]
```
Nello schema di creazione e in quello di aggiornamento aggiungi:
```python
vision_json: Optional[dict] = None
```
Nello schema di risposta del task aggiungi lo stesso campo, e in quelli della subtask:
```python
vision_output: Optional[str] = None
```
- [ ] **Step 6: Run the tests to verify they pass**
Run: `uv run pytest src/backend/tests/test_vision_graph.py src/backend/tests/test_task_type.py -v`
Expected: PASS. `test_task_type.py` esisteva già e deve restare verde: se cita `xf_compare`, aggiornalo — è il solo posto dove il vecchio nome è lecito cambiarlo.
- [ ] **Step 7: Run the whole backend suite**
Run: `uv run pytest src/backend/tests/ -q`
Expected: 212 test più i tre nuovi, zero fallimenti.
- [ ] **Step 8: Verify the MySQL rendering**
Run: `uv run alembic -c src/backend/migrations/alembic.ini upgrade 010_meas_authorisation:011_vision_graph --sql`
Expected: un `ALTER TABLE recipe_tasks ADD COLUMN vision_json JSON`, un `MODIFY` dell'enum con `dxf_compare`, un `ADD COLUMN vision_output`. Nessun `CREATE TABLE _alembic_tmp_*`: se compare, il batch mode sta ricostruendo la tabella anche su MySQL e va corretto.
- [ ] **Step 9: Commit**
```bash
git add src/backend/migrations/versions/011_vision_graph.py \
src/backend/models/orm/task.py \
src/backend/models/api/task.py \
src/backend/tests/test_vision_graph.py
git commit -m "feat(vision): il grafo vive sul task, la quota nomina la sua uscita"
```
---
### Task 2: Il runner, come libreria pura
Il pezzo che traduce grafo più immagine in numeri. È l'unica implementazione dell'esecuzione e i due host la condividono, quindi non sa nulla né di HTTP né di database.
**Files:**
- Create: `vendor/visionsuite` (sottomodulo git)
- Create: `src/vision/__init__.py`, `src/vision/runner.py`
- Create: `src/vision/tests/__init__.py`, `src/vision/tests/test_runner.py`
- Modify: `pyproject.toml` (nuovo extra `vision`, sezione `[tool.uv.sources]`)
- Modify: `.gitmodules` (creato dal comando di sottomodulo)
**Interfaces:**
- Consumes: il formato di `vision_output` definito nel Task 1 (`"<tool_id>.<nome_uscita>"`).
- Produces:
- `RunOutcome` — dataclass con `outputs: dict[str, float]` (chiavi `"<tool_id>.<uscita>"`), `failures: list[ToolFailure]`, `engine_version: str`, `duration_ms: int`
- `ToolFailure` — dataclass con `tool_id: str`, `tool_name: str`, `error: str`
- `run_graph(image, graph: dict, calibration: dict | None = None) -> RunOutcome`
- `engine_version() -> str`
- [ ] **Step 1: Add the submodule and pin it**
```bash
git submodule add ssh://git@git.tielogic.xyz:222/Adriano/visionsuite.git vendor/visionsuite
git -C vendor/visionsuite log -1 --format=%H
```
Annota il commit stampato: è la versione del motore, e il Task 5 ne verifica la coerenza.
- [ ] **Step 2: Declare the dependency**
In `pyproject.toml`, aggiungi l'extra e le sorgenti. Il runner **non** dipende da `vs-camera`: non tocca hardware, riceve immagini già acquisite.
```toml
[project.optional-dependencies]
vision = ["visionsuite", "vs-task", "vs-measure", "vs-pm2d"]
[tool.uv.sources]
visionsuite = { path = "vendor/visionsuite/packages/vs-core", editable = true }
vs-task = { path = "vendor/visionsuite/packages/vs-task", editable = true }
vs-measure = { path = "vendor/visionsuite/packages/vs-measure", editable = true }
vs-pm2d = { path = "vendor/visionsuite/packages/vs-pm2d", editable = true }
```
Poi: `uv sync --extra vision --extra dev`
- [ ] **Step 3: Write the failing test**
Crea `src/vision/tests/test_runner.py`:
```python
"""Il runner: grafo più immagine, numeri fuori. Niente HTTP, niente database."""
import numpy as np
import pytest
from src.vision.runner import RunOutcome, engine_version, run_graph
def a_disc(radius: int = 60, size: int = 400) -> np.ndarray:
"""Un disco chiaro su fondo scuro: un pezzo finto ma misurabile."""
image = np.zeros((size, size), dtype=np.uint8)
yy, xx = np.ogrid[:size, :size]
centre = size // 2
image[(yy - centre) ** 2 + (xx - centre) ** 2 <= radius ** 2] = 255
return image
def a_circle_graph() -> dict:
"""Un grafo di due strumenti, costruito con l'API di vs-task."""
from visionsuite.task import Task
from visionsuite.task.tools import DetectCircleTool, DiameterTool
task = Task(name="prova")
found = task.add(DetectCircleTool(name="disco"))
found.center_row, found.center_col, found.radius = 200.0, 200.0, 60.0
found.scan_size = 30.0
quota = task.add(DiameterTool(name="diametro"))
quota.link("circle", found, "circle")
return task.to_dict()
def test_the_graph_produces_named_outputs():
outcome = run_graph(a_disc(), a_circle_graph())
assert isinstance(outcome, RunOutcome)
assert outcome.failures == []
# Le chiavi sono «id dello strumento punto nome dell'uscita»: è la stessa
# forma che la subtask salva in vision_output.
assert any(key.endswith(".diameter") for key in outcome.outputs)
diameter = next(v for k, v in outcome.outputs.items() if k.endswith(".diameter"))
assert diameter == pytest.approx(120.0, abs=4.0)
def test_a_broken_tool_does_not_sink_the_others():
"""vs-task non ferma l'esecuzione su un errore: il runner lo riporta."""
graph = a_circle_graph()
graph["tools"].append(
{"id": "ghost", "kind": "DetectCircle", "name": "assente",
"properties": {"center_row": 5.0, "center_col": 5.0, "radius": 900.0}}
)
outcome = run_graph(a_disc(), graph)
assert [f.tool_id for f in outcome.failures] == ["ghost"]
assert any(key.endswith(".diameter") for key in outcome.outputs)
def test_the_engine_says_which_version_it_is():
version = engine_version()
assert len(version) == 40 # un SHA di git, per intero
```
- [ ] **Step 4: Run test to verify it fails**
Run: `uv run pytest src/vision/tests/test_runner.py -v`
Expected: FAIL con `ModuleNotFoundError: No module named 'src.vision.runner'`.
- [ ] **Step 5: Write the runner**
Crea `src/vision/__init__.py` vuoto e `src/vision/runner.py`:
```python
"""Graph plus image in, numbers out.
The one implementation of vision execution: the worker beside the server and
the station agent both call this, and they must agree to the digit. No HTTP, no
database, no filesystem beyond reading the pinned submodule's commit.
"""
from __future__ import annotations
import subprocess
import time
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path
from typing import Any
from visionsuite.task import Task
_SUBMODULE = Path(__file__).resolve().parents[2] / "vendor" / "visionsuite"
@dataclass
class ToolFailure:
tool_id: str
tool_name: str
error: str
@dataclass
class RunOutcome:
outputs: dict[str, float] = field(default_factory=dict)
failures: list[ToolFailure] = field(default_factory=list)
engine_version: str = ""
duration_ms: int = 0
@lru_cache(maxsize=1)
def engine_version() -> str:
"""The pinned VisionSuite commit: stamped on every number produced."""
return subprocess.check_output(
["git", "-C", str(_SUBMODULE), "rev-parse", "HEAD"],
text=True,
).strip()
def run_graph(
image: Any,
graph: dict,
calibration: dict | None = None,
) -> RunOutcome:
"""Run a serialised vs-task graph against one image.
A tool that fails does not stop the others - on a crooked piece an edge may
simply not be there, and the other quotes are still wanted. Failures come
back named, they are not swallowed.
"""
task = Task.from_dict(graph)
context: dict[str, Any] = {"image": image}
if calibration is not None:
context["calibration"] = calibration
started = time.monotonic()
report = task.run(context=context)
elapsed_ms = int((time.monotonic() - started) * 1000)
outputs: dict[str, float] = {}
failures: list[ToolFailure] = []
for result in report:
if not result.ok:
failures.append(ToolFailure(
tool_id=result.tool_id,
tool_name=result.tool_name,
error=result.error or "unknown failure",
))
continue
for name, value in result.outputs.items():
if isinstance(value, (int, float)):
outputs[f"{result.tool_id}.{name}"] = float(value)
return RunOutcome(
outputs=outputs,
failures=failures,
engine_version=engine_version(),
duration_ms=elapsed_ms,
)
```
Solo le uscite numeriche entrano in `outputs`: gli strumenti restituiscono anche primitive geometriche — un `Circle`, una `Line` — che servono ai legami interni al grafo ma non sono quote e non hanno nulla da fare in una `measurement`.
- [ ] **Step 6: Run the tests to verify they pass**
Run: `uv run pytest src/vision/tests/test_runner.py -v`
Expected: PASS, tutti e tre.
Se `test_the_graph_produces_named_outputs` fallisce sul valore e non sulla chiave, il problema è la soglia di `DetectCircleTool` sull'immagine sintetica, non il runner: alza `scan_size` o ammorbidisci la tolleranza del `pytest.approx`, e annota nel commit che il valore è tarato su un'immagine finta.
- [ ] **Step 7: Commit**
```bash
git add .gitmodules vendor/visionsuite pyproject.toml uv.lock \
src/vision/__init__.py src/vision/runner.py \
src/vision/tests/__init__.py src/vision/tests/test_runner.py
git commit -m "feat(vision): il runner, unica implementazione dell'esecuzione"
```
---
### Task 3: Il worker che espone il runner
Il container pesante. Esiste perché l'immagine dell'API non diventi da cinque gigabyte, e perché un'elaborazione che va in crash non porti giù le richieste degli altri tablet.
**Files:**
- Create: `src/vision_worker/__init__.py`, `src/vision_worker/main.py`
- Create: `src/vision_worker/tests/__init__.py`, `src/vision_worker/tests/test_worker.py`
- Create: `Dockerfile.vision`
- Modify: `docker-compose.dev.yml` (servizio `vision`), `docker-compose.yml` (idem, senza etichette Traefik: è interno)
- Modify: `.env.example` (`VISION_WORKER_URL`)
**Interfaces:**
- Consumes: `run_graph`, `RunOutcome`, `engine_version` dal Task 2.
- Produces: `POST /run` — multipart con `image` (file) e `graph` (stringa JSON), risposta
`{"outputs": {...}, "failures": [{"tool_id","tool_name","error"}], "engine_version": "...", "duration_ms": 0}`;
e `GET /health``{"status": "ok", "engine_version": "..."}`.
- [ ] **Step 1: Write the failing test**
Crea `src/vision_worker/tests/test_worker.py`:
```python
"""Il worker è un guscio sottile: riceve immagine e grafo, risponde numeri."""
import io
import json
import numpy as np
import pytest
from httpx import ASGITransport, AsyncClient
from PIL import Image
from src.vision_worker.main import app
def a_disc_png(radius: int = 60, size: int = 400) -> bytes:
image = np.zeros((size, size), dtype=np.uint8)
yy, xx = np.ogrid[:size, :size]
centre = size // 2
image[(yy - centre) ** 2 + (xx - centre) ** 2 <= radius ** 2] = 255
buffer = io.BytesIO()
Image.fromarray(image).save(buffer, format="PNG")
return buffer.getvalue()
def a_circle_graph() -> dict:
from visionsuite.task import Task
from visionsuite.task.tools import DetectCircleTool, DiameterTool
task = Task(name="prova")
found = task.add(DetectCircleTool(name="disco"))
found.center_row, found.center_col, found.radius = 200.0, 200.0, 60.0
found.scan_size = 30.0
quota = task.add(DiameterTool(name="diametro"))
quota.link("circle", found, "circle")
return task.to_dict()
@pytest.mark.asyncio
async def test_health_says_which_engine_it_carries():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://worker") as client:
response = await client.get("/health")
assert response.status_code == 200
assert len(response.json()["engine_version"]) == 40
@pytest.mark.asyncio
async def test_run_returns_the_named_outputs():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://worker") as client:
response = await client.post(
"/run",
files={"image": ("piece.png", a_disc_png(), "image/png")},
data={"graph": json.dumps(a_circle_graph())},
)
assert response.status_code == 200
body = response.json()
assert body["failures"] == []
assert any(key.endswith(".diameter") for key in body["outputs"])
@pytest.mark.asyncio
async def test_a_malformed_graph_is_refused_not_crashed():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://worker") as client:
response = await client.post(
"/run",
files={"image": ("piece.png", a_disc_png(), "image/png")},
data={"graph": json.dumps({"schema_version": 99, "tools": []})},
)
assert response.status_code == 422
assert "99" in response.json()["detail"]
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run pytest src/vision_worker/tests/test_worker.py -v`
Expected: FAIL con `ModuleNotFoundError: No module named 'src.vision_worker.main'`.
- [ ] **Step 3: Write the worker**
Crea `src/vision_worker/__init__.py` vuoto e `src/vision_worker/main.py`:
```python
"""The heavy container: the runner behind an internal API.
Separate from the FastAPI server on purpose. The API image stays light, a
VisionSuite upgrade does not restart production traffic, and an execution that
crashes does not take the other tablets' requests down with it.
"""
from __future__ import annotations
import json
import numpy as np
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
from PIL import Image
from src.vision.runner import engine_version, run_graph
app = FastAPI(title="TieMeasureFlow Vision Worker", version="0.1.0")
@app.get("/health")
async def health() -> dict:
return {"status": "ok", "engine_version": engine_version()}
@app.post("/run")
async def run(
image: UploadFile = File(...),
graph: str = Form(...),
) -> dict:
try:
parsed_graph = json.loads(graph)
except json.JSONDecodeError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"graph is not valid JSON: {exc}",
) from exc
frame = np.array(Image.open(image.file).convert("L"))
try:
outcome = run_graph(frame, parsed_graph)
except ValueError as exc:
# from_dict refuses a schema version it does not handle; that is a bad
# request, not a server fault.
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
return {
"outputs": outcome.outputs,
"failures": [
{"tool_id": f.tool_id, "tool_name": f.tool_name, "error": f.error}
for f in outcome.failures
],
"engine_version": outcome.engine_version,
"duration_ms": outcome.duration_ms,
}
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `uv run pytest src/vision_worker/tests/test_worker.py -v`
Expected: PASS, tutti e tre.
- [ ] **Step 5: Write the Dockerfile**
Crea `Dockerfile.vision`:
```dockerfile
FROM python:3.11-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
git libgl1 libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
COPY vendor/ ./vendor/
RUN uv sync --extra vision --frozen --no-dev
COPY src/vision/ ./src/vision/
COPY src/vision_worker/ ./src/vision_worker/
EXPOSE 8100
# Two workers, not four: each holds the vision stack in memory.
CMD ["uv", "run", "uvicorn", "src.vision_worker.main:app", \
"--host", "0.0.0.0", "--port", "8100", "--workers", "2"]
```
`git` è installato perché `engine_version()` legge il commit del sottomodulo con `git rev-parse`. Se in futuro l'immagine dovesse restare senza git, la versione va scritta in un file al momento del build — ma allora va scritta anche nell'agente, e le due strade devono continuare a coincidere.
- [ ] **Step 6: Add the service to both compose files**
In `docker-compose.dev.yml` e in `docker-compose.yml`, accanto a `server`:
```yaml
vision:
build:
context: .
dockerfile: Dockerfile.vision
container_name: tmflow-vision
restart: unless-stopped
networks:
- tmflow-net
```
Nessuna porta pubblicata e nessuna etichetta Traefik: il worker è interno e lo raggiunge solo il server, all'indirizzo `http://vision:8100`.
In `docker-compose.yml` il servizio `server` prende inoltre:
```yaml
environment:
DB_HOST: mysql
UPLOAD_DIR: uploads
VISION_WORKER_URL: http://vision:8100
```
e lo stesso in `docker-compose.dev.yml`. In `.env.example`, sotto la sezione del server:
```
# --- Vision ---
# Internal address of the vision worker. Never exposed outside tmflow-net.
VISION_WORKER_URL=http://vision:8100
```
- [ ] **Step 7: Verify the worker builds and answers**
```bash
docker compose -f docker-compose.dev.yml up -d --build vision
docker compose -f docker-compose.dev.yml exec server \
python -c "import urllib.request;print(urllib.request.urlopen('http://vision:8100/health',timeout=30).read().decode())"
```
Expected: `{"status":"ok","engine_version":"<40 caratteri>"}`.
- [ ] **Step 8: Commit**
```bash
git add src/vision_worker/ Dockerfile.vision \
docker-compose.dev.yml docker-compose.yml .env.example
git commit -m "feat(vision): il worker, container separato per non appesantire l'API"
```
---
### Task 4: Il server esegue e salva
Il punto in cui una quota di visione diventa una `measurement` come le altre. Da qui in poi statistica, gate del fuori tolleranza, autorizzazione del capoturno ed export funzionano senza sapere che esiste una camera.
**Files:**
- Create: `src/backend/migrations/versions/012_vision_results.py`
- Create: `src/backend/models/orm/vision.py`
- Create: `src/backend/models/api/vision.py`
- Create: `src/backend/services/vision_service.py`
- Create: `src/backend/api/routers/vision.py`
- Create: `src/backend/tests/test_vision_execute.py`
- Modify: `src/backend/models/orm/__init__.py` (esporta `VisionResult`)
- Modify: `src/backend/models/orm/measurement.py:40` (enum `input_method`), più la colonna `vision_result_id`
- Modify: `src/backend/services/measurement_service.py:216` (parametro `vision_result_id`)
- Modify: `src/backend/main.py:24,72` (registrazione del router)
- Modify: `src/backend/config.py` (impostazione `vision_worker_url`)
**Interfaces:**
- Consumes: `vision_json` e `vision_output` dal Task 1; la risposta di `POST /run` dal Task 3.
- Produces:
- `VisionResult` ORM con `image_path`, `overlay_path`, `engine_version`, `executed_on`, `station_id`, `device_id`, `calibration_snapshot`, `graph_snapshot`, `duration_ms`, `executed_at`
- `vision_service.execute_task(db, task, image_bytes, measured_by, *, version_id, lot_number, serial_number, production_run_id) -> tuple[VisionResult, list[Measurement]]`
- `POST /api/vision/execute` (multipart: `task_id`, `image`, opzionali `lot_number`, `serial_number`, `production_run_id`)
- [ ] **Step 1: Write the failing test**
Crea `src/backend/tests/test_vision_execute.py`:
```python
"""Una quota da camera è una misura come le altre: stesso verdetto, stesso gate."""
import io
import numpy as np
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.fromarray(np.zeros((64, 64), dtype=np.uint8)).save(buffer, format="PNG")
return buffer.getvalue()
async def recipe_parts(db_session, recipe_id: int):
"""`create_test_recipe` restituisce la sola ricetta: qui si scompone."""
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):
"""Il worker non gira nei test: se ne finge la risposta."""
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"
# Il fixture crea la quota con nominale 10 e limiti stretti attorno.
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,
):
"""Nessuna misura a metà: se manca un'uscita, l'errore dice quale."""
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()
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run pytest src/backend/tests/test_vision_execute.py -v`
Expected: FAIL con `ModuleNotFoundError: No module named 'src.backend.models.orm.vision'`.
- [ ] **Step 3: Write the migration**
Crea `src/backend/migrations/versions/012_vision_results.py`:
```python
"""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')
```
`device_code` è una stringa e non una chiave esterna: il registro dei device arriva col piano 2, e una misura presa oggi deve restare leggibile anche se quel device domani sparisce.
- [ ] **Step 4: Write the ORM model**
Crea `src/backend/models/orm/vision.py`:
```python
"""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"},)
```
`Base` viene da `src.backend.database`, come in tutti gli altri modelli ORM del progetto. Aggiungi `VisionResult` a `src/backend/models/orm/__init__.py` accanto agli altri.
In `src/backend/models/orm/measurement.py`, estendi l'enum a riga 40 e aggiungi la colonna:
```python
input_method: Mapped[str] = mapped_column(
Enum("usb_caliper", "manual", "camera", name="input_method_enum"),
nullable=False,
default="manual",
)
```
```python
vision_result_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("vision_results.id"), nullable=True
)
```
- [ ] **Step 5: Extend save_measurement**
In `src/backend/services/measurement_service.py`, aggiungi il parametro in coda alla firma di `save_measurement` (riga 216) e passalo al costruttore:
```python
production_run_id: int | None = None,
vision_result_id: int | None = None,
) -> Measurement:
```
```python
production_run_id=production_run_id,
vision_result_id=vision_result_id,
)
```
Non serve toccare `_enforce_recipe_rules`: blocca solo `manual` quando la ricetta lo vieta, quindi `camera` passa come passa `usb_caliper`. E non serve toccare il gate del fuori tolleranza: è già in `save_measurement` e vale per chiunque.
- [ ] **Step 6: Write the service**
Crea `src/backend/services/vision_service.py`:
```python
"""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
```
In `src/backend/config.py`, aggiungi accanto alle altre impostazioni:
```python
vision_worker_url: str = "http://vision:8100"
```
- [ ] **Step 7: Write the router**
Crea `src/backend/api/routers/vision.py`:
```python
"""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 get_current_user
from src.backend.database import get_db
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 three 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")
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(get_current_user),
) -> dict:
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 {
"vision_result_id": result.id,
"engine_version": result.engine_version,
"measurements": [
{"subtask_id": m.subtask_id, "value": float(m.value),
"pass_fail": m.pass_fail}
for m in saved
],
}
```
Registralo in `src/backend/main.py`, con l'import accanto agli altri a riga 24 e la chiamata accanto alle altre a riga 72:
```python
from src.backend.api.routers.vision import router as vision_router
```
```python
app.include_router(vision_router)
```
- [ ] **Step 8: Run the tests to verify they pass**
Run: `uv run pytest src/backend/tests/test_vision_execute.py -v`
Expected: PASS, tutti e quattro.
- [ ] **Step 9: Run the whole backend suite**
Run: `uv run pytest src/backend/tests/ -q`
Expected: zero fallimenti. In particolare `test_out_of_tolerance.py` e `test_production_runs.py` devono restare verdi senza modifiche: se una di quelle si rompe, la logica del verdetto è stata toccata e non doveva esserlo.
- [ ] **Step 10: Verify the MySQL rendering**
Run: `uv run alembic -c src/backend/migrations/alembic.ini upgrade 011_vision_graph:012_vision_results --sql`
Expected: `CREATE TABLE vision_results`, un `MODIFY` di `input_method` con `camera`, `ADD COLUMN vision_result_id` e la chiave esterna.
- [ ] **Step 11: Commit**
```bash
git add src/backend/migrations/versions/012_vision_results.py \
src/backend/models/orm/vision.py src/backend/models/orm/__init__.py \
src/backend/models/orm/measurement.py \
src/backend/services/vision_service.py \
src/backend/services/measurement_service.py \
src/backend/api/routers/vision.py src/backend/main.py \
src/backend/config.py src/backend/tests/test_vision_execute.py
git commit -m "feat(vision): il server esegue tramite worker e salva misure vere"
```
---
### Task 5: Il confine, sorvegliato
La decisione 4 della spec regge su un confine che nessuno vede rompersi. Questo task lo rende visibile.
**Files:**
- Create: `src/backend/tests/test_vision_boundary.py`
**Interfaces:**
- Consumes: l'albero dei sorgenti di `src/backend/` e il file `.gitmodules` dal Task 2.
- Produces: nessuna API. È una prova che vincola le altre.
- [ ] **Step 1: Write the test**
Segue la specie dei tre test che il progetto ha già e che leggono i sorgenti invece di renderizzare — `test_offline.py`, `test_layout_shell.py`, `test_template_js_syntax.py`.
Crea `src/backend/tests/test_vision_boundary.py`:
```python
"""Il server non importa VisionSuite, e questo lo verifica.
Senza qualcuno che lo controlli, il confine si perde in silenzio: basta un
import comodo perché l'immagine dell'API torni da cinque gigabyte e il motivo
per cui il worker esiste svanisca senza che nessuna prova diventi rossa.
"""
import ast
from pathlib import Path
BACKEND = Path(__file__).resolve().parents[1]
FORBIDDEN = {"visionsuite", "pm2d", "dxf_compare", "Acquire", "torch"}
def _imported_roots(source: Path) -> set[str]:
tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source))
roots: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
roots.update(alias.name.split(".")[0] for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
roots.add(node.module.split(".")[0])
return roots
def test_the_server_never_imports_visionsuite():
offenders = []
for source in BACKEND.rglob("*.py"):
if "tests" in source.parts or "migrations" in source.parts:
continue
forbidden = _imported_roots(source) & FORBIDDEN
if forbidden:
offenders.append(f"{source.relative_to(BACKEND)}: {sorted(forbidden)}")
assert offenders == [], (
"il server deve restare cieco alla visione; usa il worker via HTTP:\n"
+ "\n".join(offenders)
)
def test_the_submodule_is_pinned():
"""Server e stazione devono montare lo stesso commit: qui c'è la fonte."""
gitmodules = Path(__file__).resolve().parents[3] / ".gitmodules"
assert gitmodules.exists(), "vendor/visionsuite non è un sottomodulo"
assert "vendor/visionsuite" in gitmodules.read_text(encoding="utf-8")
```
- [ ] **Step 2: Run the test to verify it passes**
Run: `uv run pytest src/backend/tests/test_vision_boundary.py -v`
Expected: PASS entrambe. Se la prima fallisce, il Task 4 ha introdotto un import che non doveva esserci: si toglie l'import, non si allarga la prova.
- [ ] **Step 3: Verify the test actually bites**
Aggiungi temporaneamente `import pm2d` in cima a `src/backend/services/vision_service.py`, rilancia la prova e verifica che diventi **rossa** nominando quel file. Poi togli l'import.
Una prova che non è mai stata vista fallire non è una prova: è una riga che passa.
- [ ] **Step 4: Commit**
```bash
git add src/backend/tests/test_vision_boundary.py
git commit -m "test(vision): il confine fra server e VisionSuite è sorvegliato"
```
---
### Task 6: Le immagini di riferimento
Ciò che permette al Maker di comporre alla scrivania, di provare una ricetta prima di metterla in produzione, e — più avanti — di rieseguire il parco ricette dopo un aggiornamento del motore.
**Files:**
- Create: `src/backend/migrations/versions/013_reference_images.py`
- Create: `src/backend/tests/test_vision_reference.py`
- Modify: `src/backend/models/orm/vision.py` (classe `VisionReferenceImage`)
- Modify: `src/backend/models/orm/__init__.py`
- Modify: `src/backend/services/vision_service.py` (funzioni `save_reference_image`, `preview`)
- Modify: `src/backend/api/routers/vision.py` (due endpoint)
**Interfaces:**
- Consumes: `execute_task` e `_call_worker` dal Task 4.
- Produces:
- `VisionReferenceImage` ORM con `task_id`, `path`, `station_id`, `device_code`, `calibration_snapshot`, `engine_version`, `acquired_at`, `note`, `expected_json`
- `vision_service.save_reference_image(db, task_id, image_bytes, *, note=None) -> VisionReferenceImage`
- `vision_service.preview(db, task, image_bytes) -> dict` — esegue e **non salva misure**
- `POST /api/vision/reference-images`, `POST /api/vision/preview`
- [ ] **Step 1: Write the failing test**
Crea `src/backend/tests/test_vision_reference.py`:
```python
"""Il Maker compone alla scrivania: immagine salvata, grafo provato, niente misure."""
import io
import numpy as np
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 VisionReferenceImage
from src.backend.tests.conftest import auth_headers, create_test_recipe
def an_image() -> bytes:
buffer = io.BytesIO()
Image.fromarray(np.zeros((64, 64), dtype=np.uint8)).save(buffer, format="PNG")
return buffer.getvalue()
async def recipe_parts(db_session, recipe_id: int):
"""`create_test_recipe` restituisce la sola ricetta: qui si scompone."""
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):
def _install(outputs: dict):
async def fake_call(image_bytes: bytes, graph: dict) -> dict:
return {"outputs": outputs, "failures": [],
"engine_version": "b" * 40, "duration_ms": 7}
monkeypatch.setattr(
"src.backend.services.vision_service._call_worker", fake_call,
)
return _install
@pytest.mark.asyncio
async def test_a_reference_image_keeps_its_provenance(
client, db_session, maker_user,
):
recipe = await create_test_recipe(db_session, maker_user.id)
version, task, subtask = await recipe_parts(db_session, recipe.id)
await db_session.commit()
response = await client.post(
"/api/vision/reference-images",
headers=auth_headers(maker_user),
files={"image": ("ref.png", an_image(), "image/png")},
data={"task_id": str(task.id), "note": "pezzo campione"},
)
assert response.status_code == 200
saved = (await db_session.execute(
select(VisionReferenceImage)
)).scalars().one()
assert saved.task_id == task.id
assert saved.note == "pezzo campione"
assert saved.acquired_at is not None
assert saved.path
@pytest.mark.asyncio
async def test_preview_runs_the_graph_and_saves_no_measurement(
client, db_session, maker_user, worker_says,
):
recipe = await create_test_recipe(db_session, maker_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": 42.0})
response = await client.post(
"/api/vision/preview",
headers=auth_headers(maker_user),
files={"image": ("ref.png", an_image(), "image/png")},
data={"task_id": str(task.id)},
)
assert response.status_code == 200
assert response.json()["outputs"]["t1.diameter"] == 42.0
# È il punto di questa prova: comporre non misura.
assert (await db_session.execute(select(Measurement))).scalars().all() == []
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run pytest src/backend/tests/test_vision_reference.py -v`
Expected: FAIL con `ImportError: cannot import name 'VisionReferenceImage'`.
- [ ] **Step 3: Write the migration**
Crea `src/backend/migrations/versions/013_reference_images.py`:
```python
"""reference images, with their provenance
A reference image without provenance is a trap: the graph is re-run months later,
different numbers come out, and there is no way to tell an updated engine from a
moved lens.
`expected_json` holds what the graph is supposed to produce on this image. It is
what makes a regression run possible after a VisionSuite upgrade - the safety net
that makes bumping the submodule sustainable with an engine on two hosts.
Revision ID: 013_reference_images
Revises: 012_vision_results
Create Date: 2026-08-16
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '013_reference_images'
down_revision: Union[str, None] = '012_vision_results'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'vision_reference_images',
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('path', sa.String(length=500), 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('engine_version', sa.String(length=64), nullable=True),
sa.Column('note', sa.String(length=500), nullable=True),
sa.Column('expected_json', sa.JSON(), nullable=True),
sa.Column('acquired_at', sa.DateTime(), nullable=False,
server_default=sa.func.now()),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
)
def downgrade() -> None:
op.drop_table('vision_reference_images')
```
- [ ] **Step 4: Write the model**
In `src/backend/models/orm/vision.py`, sotto `VisionResult`:
```python
class VisionReferenceImage(Base):
"""An image kept so a graph can be composed, tried and re-tried without a
camera - which is exactly what VisionSuite's source abstraction is for."""
__tablename__ = "vision_reference_images"
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
)
path: Mapped[str] = mapped_column(String(500), 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)
calibration_snapshot: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
engine_version: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
note: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
expected_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
acquired_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now()
)
__table_args__ = ({"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},)
```
Esportala da `src/backend/models/orm/__init__.py`.
- [ ] **Step 5: Extend the service**
In `src/backend/services/vision_service.py`:
```python
async def save_reference_image(
db: AsyncSession,
task_id: int,
image_bytes: bytes,
*,
note: str | None = None,
station_id: int | None = None,
device_code: str | None = None,
calibration_snapshot: dict | None = None,
) -> VisionReferenceImage:
"""Keep an image so the graph can be composed and re-tried without a camera."""
# `upload_path` is the resolved Path, `upload_dir` the bare setting: the
# rest of the codebase uses the former (see production_export_service).
folder = settings.upload_path / "vision" / "reference" / str(task_id)
folder.mkdir(parents=True, exist_ok=True)
name = f"{uuid4().hex}.png"
(folder / name).write_bytes(image_bytes)
reference = VisionReferenceImage(
task_id=task_id,
path=str(Path("vision") / "reference" / str(task_id) / name),
note=note,
station_id=station_id,
device_code=device_code,
calibration_snapshot=calibration_snapshot,
)
db.add(reference)
await db.flush()
await db.refresh(reference)
return reference
async def preview(db: AsyncSession, task: RecipeTask, image_bytes: bytes) -> dict:
"""Run the graph and save nothing. Composing is not measuring."""
if not task.vision_json:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="this task carries no vision graph",
)
return await _call_worker(image_bytes, task.vision_json)
```
Aggiungi in testa al modulo gli import che servono: `from pathlib import Path`, `from uuid import uuid4`, e `VisionReferenceImage` accanto a `VisionResult`.
- [ ] **Step 6: Extend the router**
In `src/backend/api/routers/vision.py`, due endpoint che riusano `_task_or_404`, già definita nel Task 4.
```python
@router.post("/reference-images")
async def add_reference_image(
task_id: int = Form(...),
image: UploadFile = File(...),
note: str | None = Form(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
task = await _task_or_404(db, task_id)
reference = await vision_service.save_reference_image(
db, task.id, await image.read(), note=note,
)
await db.commit()
return {"id": reference.id, "path": reference.path, "note": reference.note}
@router.post("/preview")
async def preview(
task_id: int = Form(...),
image: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
task = await _task_or_404(db, task_id)
return await vision_service.preview(db, task, await image.read())
```
- [ ] **Step 7: Run the tests to verify they pass**
Run: `uv run pytest src/backend/tests/test_vision_reference.py -v`
Expected: PASS entrambe.
- [ ] **Step 8: Run everything**
Run: `uv run pytest -q`
Expected: la suite intera — backend, frontend, runner, worker — senza fallimenti. Annota il totale nel messaggio di commit: un conteggio di test vale solo se si dichiara l'ambiente in cui è stato fatto, ed è una regola che VisionSuite si è già data.
- [ ] **Step 9: Commit**
```bash
git add src/backend/migrations/versions/013_reference_images.py \
src/backend/models/orm/vision.py src/backend/models/orm/__init__.py \
src/backend/services/vision_service.py \
src/backend/api/routers/vision.py \
src/backend/tests/test_vision_reference.py
git commit -m "feat(vision): immagini di riferimento, per comporre senza camera"
```
---
## Dopo questo piano
Il sistema esegue un grafo di visione e produce misure vere, provabile interamente via API e senza hardware. Restano, in ordine:
- **Piano 1b — Authoring nel Maker:** scelta dell'immagine di riferimento, aggancio delle uscite alle quote, pulsante «prova».
- **Piano 2 — Agente di stazione e registro device:** `station_devices`, WebSocket di registrazione, fotogrammi dal vivo.
- **Piano 3 — Esecuzione sulla stazione:** policy per stazione, ripiego alla caduta del server, rifiuto su versione discorde del motore.
- **Piano 4 — DXF compare:** il secondo tipo di task, con quote sintetiche e rapporto allegato.
Prima del piano 2 vanno chiuse le tre verifiche a monte elencate nella spec: lo stato reale di VisionSuite rispetto al suo README, il test di calibrazione dato per rosso, e il peso effettivo del sottoinsieme di pacchetti che il worker installa.