feat(vision): immagini di riferimento, per comporre senza camera

VisionReferenceImage (migration 013, chained after 012_vision_results):
task_id, path, station_id, device_code, calibration_snapshot,
engine_version, note, expected_json, acquired_at. station_id/device_code/
calibration_snapshot/engine_version/expected_json stay nullable - nessun
endpoint di questo task li popola ancora, arrivano coi Piani 1b/2/4.

save_reference_image salva sotto settings.upload_path (non upload_dir,
come production_export_service). preview esegue il grafo e non salva
niente - comporre non e' misurare, lo asserisce il test.

POST /api/vision/reference-images e /api/vision/preview su require_maker
(non require_measurement_tec di execute): comporre e provare sono lavoro
del Maker, non misure.

Suite intera su Python 3.11.15 locale (uv run pytest -q, SQLite
in-memory per il backend): 1 failed, 373 passed, 4 skipped in 82.35s.
Il fallimento e' quello preesistente e non correlato di
test_no_first_party_script_calls_out (fabric-debug.js locale,
git-ignored) - 371 passed prima di questo task, +2 qui.

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:11:47 +02:00
parent 398af016f8
commit 3870cc3b0f
6 changed files with 268 additions and 3 deletions
+45 -1
View File
@@ -6,6 +6,8 @@ to the worker over HTTP and stays ignorant of how a number was produced.
from __future__ import annotations
import json
from pathlib import Path
from uuid import uuid4
import httpx
from fastapi import HTTPException, status
@@ -15,7 +17,7 @@ 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.models.orm.vision import VisionReferenceImage, VisionResult
from src.backend.services import measurement_service
@@ -133,3 +135,45 @@ async def execute_task(
))
return result, saved
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)