fix(vision): il grafo di visione sopravvive a copy-on-write e creazione

_copy_tasks_to_version copiava una lista esplicita di campi che ometteva
vision_json e vision_output: la prima modifica a una ricetta con misure
produceva una versione nuova con i task camera senza grafo. Stessa
famiglia di bug nei percorsi di creazione (POST /api/recipes/{id}/tasks,
POST /api/tasks/{id}/subtasks): i campi erano dichiarati negli schemi ma
mai passati ai costruttori ORM, quindi l'API rispondeva 200 senza
salvare nulla. TDD: test_vision_copy_on_write.py fallisce prima del fix,
passa dopo.

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 19:36:18 +02:00
parent 3870cc3b0f
commit 533edc5be8
3 changed files with 176 additions and 0 deletions
+3
View File
@@ -179,6 +179,7 @@ async def create_task(
file_path=data.file_path,
file_type=data.file_type,
annotations_json=data.annotations_json,
vision_json=data.vision_json,
)
db.add(new_task)
await db.flush()
@@ -197,6 +198,7 @@ async def create_task(
ltl=sub_data.ltl,
unit=sub_data.unit,
image_path=sub_data.image_path,
vision_output=sub_data.vision_output,
)
db.add(sub)
@@ -340,6 +342,7 @@ async def create_subtask(
ltl=data.ltl,
unit=data.unit,
image_path=data.image_path,
vision_output=data.vision_output,
)
db.add(subtask)
await db.flush()
+6
View File
@@ -88,6 +88,11 @@ async def _copy_tasks_to_version(
file_path=task.file_path,
file_type=task.file_type,
annotations_json=task.annotations_json,
# Carried over explicitly, same reasoning as task_type above: a
# copy-on-write version that dropped the camera graph would leave
# a camera_measure task unable to execute from the moment a
# recipe with measurements is next edited.
vision_json=task.vision_json,
)
db.add(new_task)
await db.flush() # get new_task.id
@@ -105,6 +110,7 @@ async def _copy_tasks_to_version(
ltl=sub.ltl,
unit=sub.unit,
image_path=sub.image_path,
vision_output=sub.vision_output,
)
db.add(new_sub)
@@ -0,0 +1,167 @@
"""C1: the vision graph must survive copy-on-write, and creation must save it.
The spec assumed versioning was free for `vision_json`/`vision_output` - "un
campo in piu nella copia, non una logica nuova". `_copy_tasks_to_version`
copies an explicit field list that omitted both fields, so the first edit to
a recipe that already has measurements silently drops the camera graph from
the new version. The creation paths (`POST /api/recipes/{id}/tasks` and
`POST /api/tasks/{id}/subtasks`) have the same shape of bug: `TaskCreate` and
`SubtaskCreate` declare the fields, but the routers never pass them to the
ORM constructors, so the API answers 200 and silently does not store them.
Each test file here reads on its own - the same idiom `test_vision_graph.py`
and `test_recipes.py` already use.
"""
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
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.user import User
from src.backend.tests.conftest import auth_headers, create_test_recipe
async def _seed_measurement(db: AsyncSession, recipe_id: int, user_id: int) -> None:
"""Give the current version a measurement, so an update takes the
copy-on-write path instead of updating in place."""
version_id = (await db.execute(
select(RecipeVersion.id).where(
RecipeVersion.recipe_id == recipe_id,
RecipeVersion.is_current == True, # noqa: E712
)
)).scalar_one()
subtask_id = (await db.execute(
select(RecipeSubtask.id)
.join(RecipeSubtask.task)
.where(RecipeSubtask.task.has(version_id=version_id))
)).scalar_one()
db.add(Measurement(
subtask_id=subtask_id,
version_id=version_id,
measured_by=user_id,
value=10.0,
pass_fail="pass",
deviation=0.0,
input_method="manual",
))
await db.flush()
async def _current_task_and_subtask(db: AsyncSession, recipe_id: int):
version = (await db.execute(
select(RecipeVersion).where(
RecipeVersion.recipe_id == recipe_id,
RecipeVersion.is_current == True, # noqa: E712
)
)).scalar_one()
task = (await db.execute(
select(RecipeTask).where(RecipeTask.version_id == version.id)
)).scalars().first()
subtask = (await db.execute(
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
)).scalars().first()
return version, task, subtask
@pytest.mark.asyncio
async def test_copy_on_write_preserves_the_vision_graph(
client: AsyncClient, db_session: AsyncSession, maker_user: User,
):
"""A recipe edit that triggers copy-on-write must not drop the camera graph."""
recipe = await create_test_recipe(db_session, maker_user.id)
_, task, subtask = await _current_task_and_subtask(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.flush()
# A measurement on v1 forces the next PUT onto the copy-on-write path.
await _seed_measurement(db_session, recipe.id, maker_user.id)
db_session.expunge_all()
resp = await client.put(
f"/api/recipes/{recipe.id}",
headers=auth_headers(maker_user),
json={"change_notes": "Edit that must not drop the graph"},
)
assert resp.status_code == 200
db_session.expunge_all()
version, new_task, new_subtask = await _current_task_and_subtask(db_session, recipe.id)
assert version.version_number == 2, "the edit should have created v2"
assert new_task.vision_json == {"schema_version": 1, "name": "p", "tools": []}
assert new_subtask.vision_output == "t1.diameter"
@pytest.mark.asyncio
async def test_creating_a_task_persists_its_vision_graph(
client: AsyncClient, db_session: AsyncSession, maker_user: User,
):
"""POST /api/recipes/{id}/tasks must store vision_json and vision_output,
not just accept and echo them back."""
recipe = await create_test_recipe(db_session, maker_user.id)
resp = await client.post(
f"/api/recipes/{recipe.id}/tasks",
headers=auth_headers(maker_user),
json={
"title": "Camera task",
"task_type": "camera_measure",
"vision_json": {"schema_version": 1, "name": "p", "tools": []},
"subtasks": [
{
"marker_number": 1,
"description": "Diameter",
"nominal": 10.0,
"vision_output": "t1.diameter",
}
],
},
)
assert resp.status_code == 201
body = resp.json()
assert body["vision_json"] == {"schema_version": 1, "name": "p", "tools": []}
assert body["subtasks"][0]["vision_output"] == "t1.diameter"
# Reload from the database - the response reflects the ORM object right
# after flush, so this catches a constructor that dropped the field.
db_session.expunge_all()
task = (await db_session.execute(
select(RecipeTask).where(RecipeTask.id == body["id"])
)).scalar_one()
sub = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
)).scalars().one()
assert task.vision_json == {"schema_version": 1, "name": "p", "tools": []}
assert sub.vision_output == "t1.diameter"
@pytest.mark.asyncio
async def test_creating_a_subtask_persists_its_vision_output(
client: AsyncClient, db_session: AsyncSession, maker_user: User,
):
"""POST /api/tasks/{id}/subtasks (the standalone endpoint) must also store it."""
recipe = await create_test_recipe(db_session, maker_user.id)
_, task, _ = await _current_task_and_subtask(db_session, recipe.id)
resp = await client.post(
f"/api/tasks/{task.id}/subtasks",
headers=auth_headers(maker_user),
json={
"marker_number": 2,
"description": "Radius",
"nominal": 5.0,
"vision_output": "t1.radius",
},
)
assert resp.status_code == 201
assert resp.json()["vision_output"] == "t1.radius"
db_session.expunge_all()
sub = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.marker_number == 2)
)).scalar_one()
assert sub.vision_output == "t1.radius"