feat(vision): il grafo vive sul task, la quota nomina la sua uscita
This commit is contained in:
@@ -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')
|
||||||
@@ -21,6 +21,7 @@ class SubtaskCreate(BaseModel):
|
|||||||
ltl: Optional[float] = None
|
ltl: Optional[float] = None
|
||||||
unit: str = Field("mm", max_length=20)
|
unit: str = Field("mm", max_length=20)
|
||||||
image_path: Optional[str] = Field(None, max_length=500)
|
image_path: Optional[str] = Field(None, max_length=500)
|
||||||
|
vision_output: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class SubtaskUpdate(BaseModel):
|
class SubtaskUpdate(BaseModel):
|
||||||
@@ -35,6 +36,7 @@ class SubtaskUpdate(BaseModel):
|
|||||||
ltl: Optional[float] = None
|
ltl: Optional[float] = None
|
||||||
unit: Optional[str] = Field(None, max_length=20)
|
unit: Optional[str] = Field(None, max_length=20)
|
||||||
image_path: Optional[str] = Field(None, max_length=500)
|
image_path: Optional[str] = Field(None, max_length=500)
|
||||||
|
vision_output: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class SubtaskResponse(BaseModel):
|
class SubtaskResponse(BaseModel):
|
||||||
@@ -53,6 +55,7 @@ class SubtaskResponse(BaseModel):
|
|||||||
ltl: Optional[float] = None
|
ltl: Optional[float] = None
|
||||||
unit: str
|
unit: str
|
||||||
image_path: Optional[str] = None
|
image_path: Optional[str] = None
|
||||||
|
vision_output: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class TaskCreate(BaseModel):
|
class TaskCreate(BaseModel):
|
||||||
@@ -64,6 +67,7 @@ class TaskCreate(BaseModel):
|
|||||||
file_path: Optional[str] = Field(None, max_length=500)
|
file_path: Optional[str] = Field(None, max_length=500)
|
||||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||||
annotations_json: Optional[dict[str, Any]] = None
|
annotations_json: Optional[dict[str, Any]] = None
|
||||||
|
vision_json: Optional[dict] = None
|
||||||
subtasks: list[SubtaskCreate] = []
|
subtasks: list[SubtaskCreate] = []
|
||||||
|
|
||||||
|
|
||||||
@@ -76,6 +80,7 @@ class TaskUpdate(BaseModel):
|
|||||||
file_path: Optional[str] = Field(None, max_length=500)
|
file_path: Optional[str] = Field(None, max_length=500)
|
||||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||||
annotations_json: Optional[dict[str, Any]] = None
|
annotations_json: Optional[dict[str, Any]] = None
|
||||||
|
vision_json: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
class TaskResponse(BaseModel):
|
class TaskResponse(BaseModel):
|
||||||
@@ -93,6 +98,7 @@ class TaskResponse(BaseModel):
|
|||||||
file_path: Optional[str] = None
|
file_path: Optional[str] = None
|
||||||
file_type: Optional[str] = None
|
file_type: Optional[str] = None
|
||||||
annotations_json: Optional[dict[str, Any]] = None
|
annotations_json: Optional[dict[str, Any]] = None
|
||||||
|
vision_json: Optional[dict] = None
|
||||||
subtasks: list[SubtaskResponse] = []
|
subtasks: list[SubtaskResponse] = []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ if TYPE_CHECKING:
|
|||||||
# What a task is, declared by whoever writes the recipe instead of guessed from its
|
# 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
|
# 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.
|
# 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".
|
# Types the operator screen treats as "there are quotes to take here".
|
||||||
MEASURING_TASK_TYPES = ("measure", "camera_measure")
|
MEASURING_TASK_TYPES = ("measure", "camera_measure")
|
||||||
@@ -47,6 +47,10 @@ class RecipeTask(Base):
|
|||||||
Enum("image", "pdf", name="file_type_enum"), nullable=True
|
Enum("image", "pdf", name="file_type_enum"), nullable=True
|
||||||
)
|
)
|
||||||
annotations_json: Mapped[Optional[dict]] = mapped_column(JSON, 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
|
# Relationships
|
||||||
version: Mapped["RecipeVersion"] = relationship(back_populates="tasks")
|
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")
|
unit: Mapped[str] = mapped_column(String(20), nullable=False, default="mm")
|
||||||
image_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
image_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
# 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
|
||||||
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
task: Mapped["RecipeTask"] = relationship(back_populates="subtasks")
|
task: Mapped["RecipeTask"] = relationship(back_populates="subtasks")
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user