From 18d0db8a0c7f6e6696ad516e5dc9051180d94ed8 Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Wed, 7 Oct 2026 12:08:29 +0200 Subject: [PATCH 1/4] Migrazione 016 vision_recipe_dir, copy-on-write, dxf_compare tra i task di misura (#46) Co-Authored-By: Claude Sonnet 5.5 --- .../versions/016_vision_recipe_dir.py | 30 +++++++++++++++++++ src/backend/models/orm/task.py | 6 +++- src/backend/services/recipe_service.py | 2 ++ 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 src/backend/migrations/versions/016_vision_recipe_dir.py diff --git a/src/backend/migrations/versions/016_vision_recipe_dir.py b/src/backend/migrations/versions/016_vision_recipe_dir.py new file mode 100644 index 0000000..3caa815 --- /dev/null +++ b/src/backend/migrations/versions/016_vision_recipe_dir.py @@ -0,0 +1,30 @@ +"""recipe_tasks.vision_recipe_dir: cartella della ricetta VisionSuite v2 (#46) + +Percorso relativo sotto upload_path della cartella immutabile che contiene +recipe.json e i file di meta (DXF, modelli .npz, ...). + +Revision ID: 016_vision_recipe_dir +Revises: 015_api_key_hash +Create Date: 2026-10-07 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = '016_vision_recipe_dir' +down_revision: Union[str, None] = '015_api_key_hash' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # batch: SQLite non sa fare DROP COLUMN nativo su tutte le versioni + with op.batch_alter_table('recipe_tasks') as batch: + batch.add_column(sa.Column('vision_recipe_dir', sa.String(500), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table('recipe_tasks') as batch: + batch.drop_column('vision_recipe_dir') diff --git a/src/backend/models/orm/task.py b/src/backend/models/orm/task.py index 1e2d155..7f29422 100644 --- a/src/backend/models/orm/task.py +++ b/src/backend/models/orm/task.py @@ -18,7 +18,8 @@ if TYPE_CHECKING: 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") +# dxf_compare produce quote come camera_measure (#46) +MEASURING_TASK_TYPES = ("measure", "camera_measure", "dxf_compare") class RecipeTask(Base): @@ -51,6 +52,9 @@ class RecipeTask(Base): # 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) + # Cartella immutabile (relativa a upload_path) con recipe.json e i file di + # meta della ricetta VisionSuite v2 (#46). + vision_recipe_dir: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) # Relationships version: Mapped["RecipeVersion"] = relationship(back_populates="tasks") diff --git a/src/backend/services/recipe_service.py b/src/backend/services/recipe_service.py index 7faaeb1..f7d1789 100644 --- a/src/backend/services/recipe_service.py +++ b/src/backend/services/recipe_service.py @@ -115,6 +115,8 @@ async def _copy_tasks_to_version( # a camera_measure task unable to execute from the moment a # recipe with measurements is next edited. vision_json=task.vision_json, + # Cartella immutabile: basta il riferimento (#46). + vision_recipe_dir=task.vision_recipe_dir, ) db.add(new_task) await db.flush() # get new_task.id From ee0f65ed3922bb2b7583d83314bfc994d6637361 Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Wed, 7 Oct 2026 12:12:50 +0200 Subject: [PATCH 2/4] Endpoint ricetta VisionSuite v2, execute con overlay e immagine salvata (#46) Co-Authored-By: Claude Sonnet 5.5 --- src/backend/api/routers/vision.py | 118 +++++++++- src/backend/models/api/vision.py | 28 +++ src/backend/services/vision_service.py | 213 +++++++++++++++++- src/backend/tests/test_vision_execute.py | 4 +- .../tests/test_vision_execute_gates.py | 2 +- src/backend/tests/test_vision_reference.py | 2 +- .../tests/test_vision_upload_limits.py | 2 +- 7 files changed, 354 insertions(+), 15 deletions(-) diff --git a/src/backend/api/routers/vision.py b/src/backend/api/routers/vision.py index e93c30b..94bc95f 100644 --- a/src/backend/api/routers/vision.py +++ b/src/backend/api/routers/vision.py @@ -5,19 +5,25 @@ the numbers: that is what lets a station's configuration change without touching the frontend or the recipes. """ import io +import json +from fastapi.responses import FileResponse from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status from PIL import Image from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from src.backend.api.middleware.api_key import require_maker, require_measurement_tec -from src.backend.api.routers.files import ALLOWED_IMAGE_TYPES, validate_file_size +from src.backend.api.routers.files import ( + ALLOWED_IMAGE_TYPES, resolve_upload_path, validate_file_size, +) from src.backend.config import settings from src.backend.database import get_db from src.backend.models.api.vision import ( - VisionExecuteResponse, VisionMeasurementResult, VisionReferenceImageResponse, + VisionExecuteResponse, VisionMeasurementResult, VisionRecipeResponse, + VisionRecipeUploadResponse, VisionReferenceImageResponse, ) +from src.backend.models.orm.vision import VisionResult from src.backend.models.orm.task import RecipeTask from src.backend.models.orm.user import User from src.backend.services import vision_service @@ -126,7 +132,7 @@ async def execute( ) -> VisionExecuteResponse: task = await _task_or_404(db, task_id) - result, saved = await vision_service.execute_task( + result, saved, report = await vision_service.execute_task( db, task, await _read_image(image), @@ -135,6 +141,7 @@ async def execute( lot_number=lot_number, serial_number=serial_number, production_run_id=production_run_id, + image_ext=_IMAGE_EXTENSIONS.get(image.content_type, "png"), ) await db.commit() @@ -147,6 +154,111 @@ async def execute( ) for m in saved ], + outcome=report.get("outcome"), + overlay=report.get("overlay") or [], + image_url=f"/api/vision/results/{result.id}/image", + failures=report.get("failures") or [], + ) + + +@router.get("/results/{result_id}/image") +async def get_result_image( + result_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_measurement_tec), +) -> FileResponse: + """The image a vision run measured, as saved by `execute` (#46).""" + result = (await db.execute( + select(VisionResult).where(VisionResult.id == result_id) + )).scalar_one_or_none() + if result is None or not result.image_path: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Result image not found", + ) + return FileResponse(resolve_upload_path(result.image_path)) + + +def _422(errors: list[str]) -> HTTPException: + # detail e' una lista di stringhe: il frontend legge i 422 solo da `detail`. + return HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=errors, + ) + + +@router.post("/tasks/{task_id}/recipe", response_model=VisionRecipeUploadResponse) +async def upload_recipe( + task_id: int, + recipe: UploadFile = File(...), + files: list[UploadFile] = File(default=[]), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_maker), +) -> VisionRecipeUploadResponse: + """Load a VisionSuite v2 recipe plus its meta files onto a task (#46).""" + task = await _task_or_404(db, task_id) + if task.task_type not in vision_service.VISION_TASK_TYPES: + raise _422([f"task type {task.task_type!r} does not run a vision recipe"]) + + raw = await recipe.read() + if not validate_file_size(len(raw)): + raise _422([f"recipe exceeds {settings.max_upload_size_mb}MB"]) + try: + graph = json.loads(raw) + except ValueError: + raise _422(["the recipe is not valid JSON"]) + if not isinstance(graph, dict): + raise _422(["the recipe must be a JSON object"]) + + uploads: list[tuple[str, bytes]] = [] + for f in files: + content = await f.read() + if not validate_file_size(len(content)): + raise _422([f"file {f.filename!r} exceeds {settings.max_upload_size_mb}MB"]) + uploads.append((f.filename or "", content)) + placed, errors = vision_service.match_recipe_files(graph, uploads) + if errors: + raise _422(errors) + + # The folder is content-addressed and immutable; writing it before the + # validation is harmless (an orphan folder is never referenced) and the + # worker needs it on disk to validate meta files. + try: + recipe_dir = vision_service.store_recipe_folder(task.id, graph, placed) + except ValueError as exc: + raise _422([str(exc)]) + check = await vision_service.validate_recipe(graph, recipe_dir) + if not check["ok"]: + raise _422([str(e) for e in check["errors"]] or ["the recipe is not valid"]) + + task.vision_json = graph + task.vision_recipe_dir = recipe_dir + await db.commit() + return VisionRecipeUploadResponse( + task_id=task.id, + recipe_dir=recipe_dir, + files=vision_service.list_recipe_files(recipe_dir), + outputs=check["outputs"], + ) + + +@router.get("/tasks/{task_id}/recipe", response_model=VisionRecipeResponse) +async def get_recipe( + task_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_maker), +) -> VisionRecipeResponse: + task = await _task_or_404(db, task_id) + outputs: list[dict] = [] + if task.vision_json: + check = await vision_service.validate_recipe( + task.vision_json, task.vision_recipe_dir, + ) + outputs = check.get("outputs", []) + return VisionRecipeResponse( + task_id=task.id, + recipe_dir=task.vision_recipe_dir, + recipe=task.vision_json, + files=vision_service.list_recipe_files(task.vision_recipe_dir), + outputs=outputs, ) diff --git a/src/backend/models/api/vision.py b/src/backend/models/api/vision.py index 56b58b7..d1d1782 100644 --- a/src/backend/models/api/vision.py +++ b/src/backend/models/api/vision.py @@ -16,6 +16,34 @@ class VisionExecuteResponse(BaseModel): vision_result_id: int engine_version: str measurements: list[VisionMeasurementResult] + # Esito VisionSuite (informativo: il verdetto lo danno utl/ltl), overlay + # in px immagine, URL dell'immagine salvata e strumenti falliti (#46). + outcome: str | None = None + overlay: list[dict] = [] + image_url: str | None = None + failures: list[dict] = [] + + +class VisionRecipeOutput(BaseModel): + """One numeric output a recipe offers to the quotes.""" + key: str + quantity: str | None = None + unit: str | None = None + + +class VisionRecipeUploadResponse(BaseModel): + task_id: int + recipe_dir: str + files: list[str] + outputs: list[VisionRecipeOutput] + + +class VisionRecipeResponse(BaseModel): + task_id: int + recipe_dir: str | None = None + recipe: dict | None = None + files: list[str] + outputs: list[VisionRecipeOutput] class VisionReferenceImageResponse(BaseModel): diff --git a/src/backend/services/vision_service.py b/src/backend/services/vision_service.py index 58b1146..8b74e2d 100644 --- a/src/backend/services/vision_service.py +++ b/src/backend/services/vision_service.py @@ -5,6 +5,7 @@ to the worker over HTTP and stays ignorant of how a number was produced. """ from __future__ import annotations +import hashlib import json import math from pathlib import Path @@ -46,7 +47,13 @@ def _is_measurable(value: object) -> bool: ) -async def _call_worker(image_bytes: bytes, graph: dict) -> dict: +async def _call_worker( + image_bytes: bytes, + graph: dict, + *, + recipe_dir: str | None = None, + calibration: dict | None = None, +) -> dict: """The one place that talks to the worker; the tests replace it. The worker's fault must not become the API's fault (spec's edge-case @@ -60,12 +67,19 @@ async def _call_worker(image_bytes: bytes, graph: dict) -> dict: is not the worker's JSON contract must not turn into a bare KeyError further down. """ + # recipe_dir (relativo, #46) e calibration sono opzionali: se assenti non + # si mandano, il worker usa i suoi default. + data = {"graph": json.dumps(graph)} + if recipe_dir: + data["recipe_dir"] = recipe_dir + if calibration: + data["calibration"] = json.dumps(calibration) try: 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)}, + data=data, ) except httpx.TimeoutException as exc: raise HTTPException( @@ -122,6 +136,180 @@ async def _call_worker(image_bytes: bytes, graph: dict) -> dict: return report +async def validate_recipe(graph: dict, recipe_dir: str | None) -> dict: + """Ask the worker's /validate whether a v2 recipe is loadable (#46). + + Returns {"ok", "errors", "outputs"}. A worker 422 (e.g. recipe_dir + refused) is folded into ok=False; transport faults become 502/504 as in + `_call_worker`. Replaced by the tests. + """ + data = {"graph": json.dumps(graph)} + if recipe_dir: + data["recipe_dir"] = recipe_dir + try: + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post( + f"{settings.vision_worker_url}/validate", data=data, + ) + except httpx.TimeoutException as exc: + raise HTTPException( + status_code=status.HTTP_504_GATEWAY_TIMEOUT, + detail=f"the vision worker did not answer in time: {exc}", + ) from exc + except httpx.HTTPError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"the vision worker is unreachable: {exc}", + ) from exc + if response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT: + try: + detail = response.json().get("detail", response.text) + except ValueError: + detail = response.text + errors = detail if isinstance(detail, list) else [str(detail)] + return {"ok": False, "errors": [str(e) for e in errors], "outputs": []} + if response.status_code != 200: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"the vision worker refused the job: {response.text}", + ) + try: + body = response.json() + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="the vision worker's answer was malformed: not valid JSON", + ) from exc + if not isinstance(body, dict) or "ok" not in body: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="the vision worker's answer was malformed: missing ok", + ) + body.setdefault("errors", []) + body.setdefault("outputs", []) + return body + + +# Task types that run a vision graph (#46). +VISION_TASK_TYPES = ("camera_measure", "dxf_compare") + + +def _safe_join(root: Path, relative: str) -> Path: + """root/relative, refusing absolute paths, '..' and anything leaving root.""" + candidate = Path(relative) + if candidate.is_absolute() or ".." in candidate.parts or not candidate.parts: + raise ValueError(f"unsafe path: {relative}") + full = (root / candidate).resolve() + if not full.is_relative_to(root.resolve()): + raise ValueError(f"unsafe path: {relative}") + return full + + +def collect_meta_paths(node: object) -> list[str]: + """Every `path` string found in any dict under the recipe's `meta`.""" + found: list[str] = [] + if isinstance(node, dict): + for key, value in node.items(): + if key == "path" and isinstance(value, str): + found.append(value) + else: + found.extend(collect_meta_paths(value)) + elif isinstance(node, list): + for item in node: + found.extend(collect_meta_paths(item)) + return found + + +def match_recipe_files( + recipe: dict, uploads: list[tuple[str, bytes]], +) -> tuple[dict[str, bytes], list[str]]: + """Pair each uploaded file with a meta path by base name (#46). + + Returns ({relative path: bytes}, errors). Errors list every upload that + has no name or matches no meta path, and every unsafe meta path. A meta + path left without a file is not an error here: /validate decides. + """ + paths = collect_meta_paths(recipe.get("meta", {})) + by_name: dict[str, str] = {} + for p in paths: + by_name.setdefault(Path(p.replace("\\", "/")).name, p) + placed: dict[str, bytes] = {} + errors: list[str] = [] + for name, content in uploads: + if not name: + errors.append("a file has no name") + continue + target = by_name.get(Path(name.replace("\\", "/")).name) + if target is None: + errors.append(f"file {name!r} matches no path in the recipe's meta") + continue + try: + _safe_join(Path("."), target) + except ValueError: + errors.append(f"meta path {target!r} is not allowed") + continue + placed[target] = content + return placed, errors + + +def store_recipe_folder( + task_id: int, recipe: dict, files: dict[str, bytes], +) -> str: + """Write the immutable recipe folder; return its path relative to uploads. + + Folder name = first 16 hex of the recipe's sha256. If it already exists it + is left untouched (immutable: other task versions may reference it). + """ + canonical = json.dumps(recipe, sort_keys=True, separators=(",", ":")) + digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16] + relative = f"vision/recipes/{task_id}/{digest}" + folder = settings.upload_path / relative + if folder.exists(): + return relative + folder.mkdir(parents=True, exist_ok=True) + (folder / "recipe.json").write_text( + json.dumps(recipe, indent=2, ensure_ascii=False), encoding="utf-8", + ) + for rel, content in files.items(): + target = _safe_join(folder, rel) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(content) + return relative + + +def list_recipe_files(recipe_dir: str | None) -> list[str]: + """Files of a recipe folder, relative to it (recipe.json excluded).""" + if not recipe_dir: + return [] + try: + folder = _safe_join(settings.upload_path, recipe_dir) + except ValueError: + return [] + if not folder.is_dir(): + return [] + return sorted( + p.relative_to(folder).as_posix() + for p in folder.rglob("*") if p.is_file() and p.name != "recipe.json" + ) + + +def save_result_files( + task_id: int, image_bytes: bytes, overlay: list, image_ext: str = "png", +) -> tuple[str, str]: + """Save the executed image and its overlay JSON side by side (#46). + + Returns (image_path, overlay_path), both relative to uploads. + """ + stem = uuid4().hex + base = Path("vision") / "results" / str(task_id) + (settings.upload_path / base).mkdir(parents=True, exist_ok=True) + image_rel = (base / f"{stem}.{image_ext}").as_posix() + overlay_rel = (base / f"{stem}.overlay.json").as_posix() + (settings.upload_path / image_rel).write_bytes(image_bytes) + (settings.upload_path / overlay_rel).write_text(json.dumps(overlay), encoding="utf-8") + return image_rel, overlay_rel + + async def execute_task( db: AsyncSession, task: RecipeTask, @@ -132,8 +320,9 @@ async def execute_task( lot_number: str | None = None, serial_number: str | None = None, production_run_id: int | None = None, -) -> tuple[VisionResult, list[Measurement]]: - if task.task_type != "camera_measure": + image_ext: str = "png", +) -> tuple[VisionResult, list[Measurement], dict]: + if task.task_type not in VISION_TASK_TYPES: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=f"task type {task.task_type!r} is not a camera measurement", @@ -210,7 +399,11 @@ async def execute_task( ), ) - report = await _call_worker(image_bytes, task.vision_json) + # Nessuna fonte di calibrazione esiste oggi (ne' task ne' stazione): non si + # passa `calibration`, la ricetta porta la sua (#46). + report = await _call_worker( + image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir, + ) outputs = report["outputs"] # Every quote or none. A half-measured piece read as a whole one is worse @@ -231,8 +424,12 @@ async def execute_task( ), ) + overlay = report.get("overlay") or [] + image_path, overlay_path = save_result_files(task.id, image_bytes, overlay, image_ext) result = VisionResult( task_id=task.id, + image_path=image_path, + overlay_path=overlay_path, engine_version=report["engine_version"], executed_on="server", graph_snapshot=task.vision_json, @@ -257,7 +454,7 @@ async def execute_task( enforce_tolerance_gate=False, )) - return result, saved + return result, saved, report def _reference_file_path(reference: VisionReferenceImage) -> Path: @@ -377,4 +574,6 @@ async def preview(db: AsyncSession, task: RecipeTask, image_bytes: bytes) -> dic status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="this task carries no vision graph", ) - return await _call_worker(image_bytes, task.vision_json) + return await _call_worker( + image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir, + ) diff --git a/src/backend/tests/test_vision_execute.py b/src/backend/tests/test_vision_execute.py index e9cff58..2073bf9 100644 --- a/src/backend/tests/test_vision_execute.py +++ b/src/backend/tests/test_vision_execute.py @@ -66,7 +66,7 @@ async def two_vision_quotes(db_session, recipe_id: int): def worker_says(monkeypatch): """The worker does not run in tests: its response is faked here.""" def _install(outputs: dict, failures: list | None = None): - async def fake_call(image_bytes: bytes, graph: dict) -> dict: + async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict: return { "outputs": outputs, "failures": failures or [], @@ -316,7 +316,7 @@ async def test_a_pending_fail_blocks_before_the_worker_is_called( ) await db_session.commit() - async def worker_must_not_be_called(image_bytes: bytes, graph: dict) -> dict: + async def worker_must_not_be_called(image_bytes: bytes, graph: dict, **kwargs) -> dict: raise AssertionError("the worker must not run while the operator is blocked") monkeypatch.setattr( "src.backend.services.vision_service._call_worker", worker_must_not_be_called, diff --git a/src/backend/tests/test_vision_execute_gates.py b/src/backend/tests/test_vision_execute_gates.py index 8d96923..895d9b3 100644 --- a/src/backend/tests/test_vision_execute_gates.py +++ b/src/backend/tests/test_vision_execute_gates.py @@ -53,7 +53,7 @@ async def a_camera_task(db_session, recipe_id: int): @pytest.fixture def worker_says(monkeypatch): def _install(outputs: dict): - async def fake_call(image_bytes: bytes, graph: dict) -> dict: + async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict: return {"outputs": outputs, "failures": [], "engine_version": "e" * 40, "duration_ms": 3} monkeypatch.setattr( diff --git a/src/backend/tests/test_vision_reference.py b/src/backend/tests/test_vision_reference.py index 3b5eeae..8d02fd3 100644 --- a/src/backend/tests/test_vision_reference.py +++ b/src/backend/tests/test_vision_reference.py @@ -39,7 +39,7 @@ async def recipe_parts(db_session, recipe_id: int): @pytest.fixture def worker_says(monkeypatch): def _install(outputs: dict): - async def fake_call(image_bytes: bytes, graph: dict) -> dict: + async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict: return {"outputs": outputs, "failures": [], "engine_version": "b" * 40, "duration_ms": 7} monkeypatch.setattr( diff --git a/src/backend/tests/test_vision_upload_limits.py b/src/backend/tests/test_vision_upload_limits.py index 4718781..34b3c58 100644 --- a/src/backend/tests/test_vision_upload_limits.py +++ b/src/backend/tests/test_vision_upload_limits.py @@ -69,7 +69,7 @@ async def recipe_parts(db_session, recipe_id: int): @pytest.fixture def worker_says(monkeypatch): def _install(outputs: dict): - async def fake_call(image_bytes: bytes, graph: dict) -> dict: + async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict: return {"outputs": outputs, "failures": [], "engine_version": "c" * 40, "duration_ms": 5} monkeypatch.setattr( From 86290274e52b5e06905369d9f97763a915461724 Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Wed, 7 Oct 2026 12:15:02 +0200 Subject: [PATCH 3/4] Test ricetta v2, execute dxf_compare, migrazione 016 (#46) Co-Authored-By: Claude Sonnet 5.5 --- src/backend/tests/test_migrations.py | 18 ++ .../tests/test_vision_recipe_upload.py | 265 ++++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 src/backend/tests/test_vision_recipe_upload.py diff --git a/src/backend/tests/test_migrations.py b/src/backend/tests/test_migrations.py index 9b61d71..3938829 100644 --- a/src/backend/tests/test_migrations.py +++ b/src/backend/tests/test_migrations.py @@ -131,3 +131,21 @@ class TestApiKeyHashMigration: command.upgrade(cfg, "head") # and up again assert "api_keys" in _inspect(db_path).get_table_names() + + +class TestVisionRecipeDirMigration: + """016_vision_recipe_dir: recipe_tasks.vision_recipe_dir, su e giu (#46).""" + + def test_016_upgrade_and_downgrade(self, sqlite_database_url): + db_path, _ = sqlite_database_url + cfg = _alembic_config() + + def cols(): + return {c["name"] for c in _inspect(db_path).get_columns("recipe_tasks")} + + command.upgrade(cfg, "head") + assert "vision_recipe_dir" in cols() + command.downgrade(cfg, "015_api_key_hash") + assert "vision_recipe_dir" not in cols() + command.upgrade(cfg, "head") # e di nuovo su + assert "vision_recipe_dir" in cols() diff --git a/src/backend/tests/test_vision_recipe_upload.py b/src/backend/tests/test_vision_recipe_upload.py new file mode 100644 index 0000000..ed922fb --- /dev/null +++ b/src/backend/tests/test_vision_recipe_upload.py @@ -0,0 +1,265 @@ +"""Ricetta VisionSuite v2 su un task, execute dxf_compare, copy-on-write (#46).""" +import io +import json + +import pytest +from PIL import Image +from sqlalchemy import select + +from src.backend.config import settings +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 + +RECIPE = { + "schema_version": 2, "name": "dxf", "tools": [], + "meta": {"dxf": {"d1": {"path": "dxf/part.dxf"}}, "models": {"m": {"path": "models/m.npz"}}}, +} +OUTPUTS = [{"key": "cmp.points_in_pct", "quantity": "ratio", "unit": "%"}] +GOOD_FILES = (("part.dxf", b"0 EOF"), ("m.npz", b"npz")) + + +@pytest.fixture(autouse=True) +def redirect_uploads(monkeypatch, tmp_path): + # upload_path = parents[2] / upload_dir: un assoluto sostituisce la base + monkeypatch.setattr(settings, "upload_dir", str(tmp_path)) + + +@pytest.fixture +def validate_says(monkeypatch): + """Il worker /validate non gira nei test: risposta finta.""" + def _install(ok=True, errors=None, outputs=None): + async def fake(graph, recipe_dir): + return { + "ok": ok, "errors": errors or [], + "outputs": outputs if outputs is not None else OUTPUTS, + } + monkeypatch.setattr("src.backend.services.vision_service.validate_recipe", fake) + return _install + + +def an_image() -> bytes: + buf = io.BytesIO() + Image.new("L", (32, 32)).save(buf, format="PNG") + return buf.getvalue() + + +async def make_task(db_session, user, task_type="dxf_compare"): + recipe = await create_test_recipe(db_session, user.id) + version = (await db_session.execute( + select(RecipeVersion).where(RecipeVersion.recipe_id == recipe.id) + )).scalars().first() + 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() + task.task_type = task_type + await db_session.commit() + return task, subtask + + +def upload(client, user, task, recipe=RECIPE, files=()): + return client.post( + f"/api/vision/tasks/{task.id}/recipe", + headers=auth_headers(user), + files=[("recipe", ("r.json", json.dumps(recipe).encode(), "application/json"))] + + [("files", (n, c, "application/octet-stream")) for n, c in files], + ) + + +@pytest.mark.asyncio +async def test_upload_ok_stores_folder_and_task(client, db_session, maker_user, validate_says): + validate_says() + task, _ = await make_task(db_session, maker_user) + resp = await upload(client, maker_user, task, files=GOOD_FILES) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["outputs"] == OUTPUTS + assert sorted(body["files"]) == ["dxf/part.dxf", "models/m.npz"] + assert body["recipe_dir"].startswith(f"vision/recipes/{task.id}/") + folder = settings.upload_path / body["recipe_dir"] + assert (folder / "recipe.json").is_file() and (folder / "dxf" / "part.dxf").is_file() + await db_session.refresh(task) + assert task.vision_json == RECIPE and task.vision_recipe_dir == body["recipe_dir"] + + got = await client.get(f"/api/vision/tasks/{task.id}/recipe", headers=auth_headers(maker_user)) + assert got.status_code == 200 + assert got.json()["recipe"] == RECIPE and got.json()["outputs"] == OUTPUTS + + +@pytest.mark.asyncio +async def test_get_without_recipe_is_empty(client, db_session, maker_user, validate_says): + validate_says() + task, _ = await make_task(db_session, maker_user) + got = await client.get(f"/api/vision/tasks/{task.id}/recipe", headers=auth_headers(maker_user)) + assert got.json() == { + "task_id": task.id, "recipe_dir": None, "recipe": None, "files": [], "outputs": [], + } + + +@pytest.mark.asyncio +async def test_unmatched_file_name_is_422_list(client, db_session, maker_user, validate_says): + validate_says() + task, _ = await make_task(db_session, maker_user) + resp = await upload(client, maker_user, task, files=[("other.dxf", b"x")]) + assert resp.status_code == 422 + assert isinstance(resp.json()["detail"], list) and "other.dxf" in resp.json()["detail"][0] + await db_session.refresh(task) + assert task.vision_json is None and task.vision_recipe_dir is None + + +@pytest.mark.asyncio +async def test_traversal_in_meta_path_is_422(client, db_session, maker_user, validate_says): + validate_says() + task, _ = await make_task(db_session, maker_user) + evil = {"schema_version": 2, "tools": [], "meta": {"dxf": {"d": {"path": "../../evil.dxf"}}}} + resp = await upload(client, maker_user, task, recipe=evil, files=[("evil.dxf", b"x")]) + assert resp.status_code == 422 + assert not (settings.upload_path.parent / "evil.dxf").exists() + + +@pytest.mark.asyncio +async def test_wrong_task_type_is_422(client, db_session, maker_user, validate_says): + validate_says() + task, _ = await make_task(db_session, maker_user, task_type="measure") + resp = await upload(client, maker_user, task, files=GOOD_FILES) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_invalid_json_is_422(client, db_session, maker_user, validate_says): + validate_says() + task, _ = await make_task(db_session, maker_user) + resp = await client.post( + f"/api/vision/tasks/{task.id}/recipe", headers=auth_headers(maker_user), + files=[("recipe", ("r.json", b"{nope", "application/json"))], + ) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_worker_not_ok_is_422_and_task_unchanged(client, db_session, maker_user, validate_says): + validate_says(ok=False, errors=["meta file missing"]) + task, _ = await make_task(db_session, maker_user) + task.vision_json = {"old": 1} + task.vision_recipe_dir = "vision/recipes/old" + await db_session.commit() + resp = await upload(client, maker_user, task, files=GOOD_FILES) + assert resp.status_code == 422 + assert resp.json()["detail"] == ["meta file missing"] + await db_session.refresh(task) + assert task.vision_json == {"old": 1} and task.vision_recipe_dir == "vision/recipes/old" + + +@pytest.mark.asyncio +async def test_existing_folder_is_not_rewritten(client, db_session, maker_user, validate_says): + validate_says() + task, _ = await make_task(db_session, maker_user) + first = (await upload(client, maker_user, task, files=GOOD_FILES)).json() + marker = settings.upload_path / first["recipe_dir"] / "dxf" / "part.dxf" + marker.write_bytes(b"CHANGED") + second = (await upload(client, maker_user, task, files=GOOD_FILES)).json() + assert second["recipe_dir"] == first["recipe_dir"] + assert marker.read_bytes() == b"CHANGED" + + +@pytest.mark.asyncio +async def test_upload_requires_maker( + client, db_session, maker_user, measurement_tec_user, validate_says, +): + validate_says() + task, _ = await make_task(db_session, maker_user) + resp = await upload(client, measurement_tec_user, task, files=GOOD_FILES) + assert resp.status_code in (401, 403) + + +@pytest.mark.asyncio +async def test_execute_dxf_compare_saves_files_and_extended_response( + client, db_session, measurement_tec_user, monkeypatch, +): + task, subtask = await make_task(db_session, measurement_tec_user) + task.vision_json = RECIPE + task.vision_recipe_dir = "vision/recipes/1/abc" + subtask.vision_output = "cmp.points_in_pct" + await db_session.commit() + seen = {} + overlay = [{"type": "contour", "role": "found", "rows": [1], "cols": [2], "tool_id": "cmp"}] + + async def fake_call(image_bytes, graph, **kwargs): + seen.update(kwargs) + return { + "outputs": {"cmp.points_in_pct": float(subtask.nominal)}, + "failures": [], "engine_version": "e" * 40, "duration_ms": 5, + "overlay": overlay, "outcome": "OK", + } + monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call) + + resp = 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 resp.status_code == 200, resp.text + body = resp.json() + assert seen["recipe_dir"] == "vision/recipes/1/abc" and "calibration" not in seen + assert body["outcome"] == "OK" and body["overlay"] == overlay and body["failures"] == [] + assert body["image_url"] == f"/api/vision/results/{body['vision_result_id']}/image" + assert body["measurements"][0]["pass_fail"] == "pass" + + result = (await db_session.execute(select(VisionResult))).scalars().one() + assert (settings.upload_path / result.image_path).read_bytes() == an_image() + assert json.loads((settings.upload_path / result.overlay_path).read_text()) == overlay + + img = await client.get(body["image_url"], headers=auth_headers(measurement_tec_user)) + assert img.status_code == 200 and img.content == an_image() + + +@pytest.mark.asyncio +async def test_execute_dxf_compare_out_of_tolerance_fails( + client, db_session, measurement_tec_user, monkeypatch, +): + task, subtask = await make_task(db_session, measurement_tec_user) + task.vision_json = RECIPE + subtask.vision_output = "cmp.points_in_pct" + await db_session.commit() + + async def fake_call(image_bytes, graph, **kwargs): + return {"outputs": {"cmp.points_in_pct": 99.0}, "failures": [], + "engine_version": "e" * 40, "outcome": "OK"} + monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call) + resp = 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 resp.status_code == 200 + # il verdetto e' delle tolleranze del subtask, non di outcome di VisionSuite + assert resp.json()["measurements"][0]["pass_fail"] == "fail" + assert resp.json()["overlay"] == [] + + +@pytest.mark.asyncio +async def test_copy_on_write_carries_recipe_dir(client, db_session, maker_user): + from src.backend.tests.test_vision_copy_on_write import ( + _current_task_and_subtask, _seed_measurement, + ) + recipe = await create_test_recipe(db_session, maker_user.id) + _, task, _ = await _current_task_and_subtask(db_session, recipe.id) + task.task_type = "dxf_compare" + task.vision_json = RECIPE + task.vision_recipe_dir = "vision/recipes/1/abc" + await db_session.flush() + 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": "cow"}, + ) + assert resp.status_code == 200 + db_session.expunge_all() + version, new_task, _ = await _current_task_and_subtask(db_session, recipe.id) + assert version.version_number == 2 + assert new_task.vision_recipe_dir == "vision/recipes/1/abc" and new_task.vision_json == RECIPE From fc1902a1bbe416932ec8d41e24d8857e0a96baad Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Wed, 7 Oct 2026 12:27:04 +0200 Subject: [PATCH 4/4] Ricetta: il digest della cartella copre anche i file di meta (#46) Co-Authored-By: Claude Sonnet 5.5 --- src/backend/services/vision_service.py | 10 +++++++++- src/backend/tests/test_vision_recipe_upload.py | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/backend/services/vision_service.py b/src/backend/services/vision_service.py index 8b74e2d..400472f 100644 --- a/src/backend/services/vision_service.py +++ b/src/backend/services/vision_service.py @@ -261,7 +261,15 @@ def store_recipe_folder( is left untouched (immutable: other task versions may reference it). """ canonical = json.dumps(recipe, sort_keys=True, separators=(",", ":")) - digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16] + # Il digest copre anche i file (path ordinato + sha256 del contenuto): la + # stessa ricetta con file diversi (es. primo upload senza il DXF) deve dare + # una cartella diversa, altrimenti quella incompleta resterebbe per sempre + # perche' immutabile (#46). + h = hashlib.sha256(canonical.encode("utf-8")) + for rel in sorted(files): + h.update(b"\0" + rel.encode("utf-8") + b"\0") + h.update(hashlib.sha256(files[rel]).digest()) + digest = h.hexdigest()[:16] relative = f"vision/recipes/{task_id}/{digest}" folder = settings.upload_path / relative if folder.exists(): diff --git a/src/backend/tests/test_vision_recipe_upload.py b/src/backend/tests/test_vision_recipe_upload.py index ed922fb..6a7262e 100644 --- a/src/backend/tests/test_vision_recipe_upload.py +++ b/src/backend/tests/test_vision_recipe_upload.py @@ -263,3 +263,20 @@ async def test_copy_on_write_carries_recipe_dir(client, db_session, maker_user): version, new_task, _ = await _current_task_and_subtask(db_session, recipe.id) assert version.version_number == 2 assert new_task.vision_recipe_dir == "vision/recipes/1/abc" and new_task.vision_json == RECIPE + + +@pytest.mark.asyncio +async def test_retry_with_missing_file_then_complete_succeeds( + client, db_session, maker_user, monkeypatch, +): + # worker finto: ok solo se il DXF esiste nella cartella della ricetta + async def fake(graph, recipe_dir): + present = (settings.upload_path / recipe_dir / "dxf" / "part.dxf").is_file() + return {"ok": present, "errors": [] if present else ["dxf missing"], "outputs": OUTPUTS} + monkeypatch.setattr("src.backend.services.vision_service.validate_recipe", fake) + task, _ = await make_task(db_session, maker_user) + + first = await upload(client, maker_user, task, files=[]) + assert first.status_code == 422 + second = await upload(client, maker_user, task, files=GOOD_FILES) + assert second.status_code == 200, second.text