From b578ac9e1bf92f4e0d3a3117ab99c3447bcac143 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sun, 16 Aug 2026 17:10:20 +0200 Subject: [PATCH] feat(vision): il grafo vive sul task, la quota nomina la sua uscita --- .../migrations/versions/011_vision_graph.py | 55 ++++++++++++++ src/backend/models/api/task.py | 6 ++ src/backend/models/orm/task.py | 12 +++- src/backend/tests/test_vision_graph.py | 71 +++++++++++++++++++ 4 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 src/backend/migrations/versions/011_vision_graph.py create mode 100644 src/backend/tests/test_vision_graph.py diff --git a/src/backend/migrations/versions/011_vision_graph.py b/src/backend/migrations/versions/011_vision_graph.py new file mode 100644 index 0000000..6cfcd3e --- /dev/null +++ b/src/backend/migrations/versions/011_vision_graph.py @@ -0,0 +1,55 @@ +"""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') diff --git a/src/backend/models/api/task.py b/src/backend/models/api/task.py index f2e50de..c5ef1fa 100644 --- a/src/backend/models/api/task.py +++ b/src/backend/models/api/task.py @@ -21,6 +21,7 @@ class SubtaskCreate(BaseModel): ltl: Optional[float] = None unit: str = Field("mm", max_length=20) image_path: Optional[str] = Field(None, max_length=500) + vision_output: Optional[str] = None class SubtaskUpdate(BaseModel): @@ -35,6 +36,7 @@ class SubtaskUpdate(BaseModel): ltl: Optional[float] = None unit: Optional[str] = Field(None, max_length=20) image_path: Optional[str] = Field(None, max_length=500) + vision_output: Optional[str] = None class SubtaskResponse(BaseModel): @@ -53,6 +55,7 @@ class SubtaskResponse(BaseModel): ltl: Optional[float] = None unit: str image_path: Optional[str] = None + vision_output: Optional[str] = None class TaskCreate(BaseModel): @@ -64,6 +67,7 @@ class TaskCreate(BaseModel): file_path: Optional[str] = Field(None, max_length=500) file_type: Optional[str] = Field(None, pattern="^(image|pdf)$") annotations_json: Optional[dict[str, Any]] = None + vision_json: Optional[dict] = None subtasks: list[SubtaskCreate] = [] @@ -76,6 +80,7 @@ class TaskUpdate(BaseModel): file_path: Optional[str] = Field(None, max_length=500) file_type: Optional[str] = Field(None, pattern="^(image|pdf)$") annotations_json: Optional[dict[str, Any]] = None + vision_json: Optional[dict] = None class TaskResponse(BaseModel): @@ -93,6 +98,7 @@ class TaskResponse(BaseModel): file_path: Optional[str] = None file_type: Optional[str] = None annotations_json: Optional[dict[str, Any]] = None + vision_json: Optional[dict] = None subtasks: list[SubtaskResponse] = [] diff --git a/src/backend/models/orm/task.py b/src/backend/models/orm/task.py index 9fdd6a3..1e2d155 100644 --- a/src/backend/models/orm/task.py +++ b/src/backend/models/orm/task.py @@ -15,7 +15,7 @@ if TYPE_CHECKING: # What a task is, declared by whoever writes the recipe instead of guessed from its # contents. The last two are not built yet; they are in the enum from the start # because widening a MySQL enum later is an ALTER on a live table. -TASK_TYPES = ("note", "measure", "drawing", "xf_compare", "camera_measure") +TASK_TYPES = ("note", "measure", "drawing", "dxf_compare", "camera_measure") # Types the operator screen treats as "there are quotes to take here". MEASURING_TASK_TYPES = ("measure", "camera_measure") @@ -47,6 +47,10 @@ class RecipeTask(Base): Enum("image", "pdf", name="file_type_enum"), nullable=True ) annotations_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) + # 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) # Relationships version: Mapped["RecipeVersion"] = relationship(back_populates="tasks") @@ -89,6 +93,12 @@ class RecipeSubtask(Base): unit: Mapped[str] = mapped_column(String(20), nullable=False, default="mm") image_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + # Which output of the vision graph fills this quote: ".". + # 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 + ) # Relationships task: Mapped["RecipeTask"] = relationship(back_populates="subtasks") diff --git a/src/backend/tests/test_vision_graph.py b/src/backend/tests/test_vision_graph.py new file mode 100644 index 0000000..ec7a151 --- /dev/null +++ b/src/backend/tests/test_vision_graph.py @@ -0,0 +1,71 @@ +"""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