Compare commits

..

2 Commits

Author SHA1 Message Date
Adriano e44ac3129f Statistiche sessioni: conteggio misure solo sui run della ricetta (#58)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:31:53 +02:00
Adriano 4e52095423 Statistiche per sessione di produzione: filtro production_run_id, elenco sessioni, select Sessione (#58)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:28:35 +02:00
17 changed files with 309 additions and 623 deletions
-2
View File
@@ -92,8 +92,6 @@ SESSION_COOKIE_SECURE=true
VISION_RUN_TIMEOUT_S=100
# Quante esecuzioni di visione in parallelo accetta il worker.
VISION_MAX_CONCURRENCY=2
# #46: radice sotto cui il worker legge le cartelle ricetta (recipe_dir relativo).
VISION_RECIPES_ROOT=/app/uploads
# --- Docker ---
# DB_ROOT_PASSWORD: in produzione (docker-compose.yml) obbligatoria, senza default.
-2
View File
@@ -289,8 +289,6 @@ Nginx (dev): gzip abilitato, `client_max_body_size 50M`, `proxy_read_timeout 120
Il runner di visione (`src/vision/`) e il suo worker FastAPI (`src/vision_worker/`) dipendono da `vendor/visionsuite`, un **submodule** su `ssh://git.tielogic.xyz:222`: un `git clone` senza `--recurse-submodules` lascia la cartella vuota (chi ha già clonato: `git submodule update --init`). Le librerie di VisionSuite richiedono **Python 3.13**, mentre backend e frontend restano su Python 3.11 — è un albero a parte nel monorepo, isolato con il proprio extra `uv`.
Regola (Adriano, 07/10/2026): il submodule `vendor/visionsuite` segue `origin/main`: a ogni integrazione si porta all'ultimo commit (`git -C vendor/visionsuite fetch origin && git -C vendor/visionsuite checkout origin/main`) e si rilanciano i test della visione. Pin attuale: bc0df16.
Il worker gira in un container separato (`Dockerfile.vision`), non pubblica porte verso l'esterno ed è raggiungibile solo dal server all'indirizzo interno `http://vision:8100`. Il container non ha accesso al repository Git del progetto principale, quindi il commit di VisionSuite in uso (`engine_version`) non può essere scoperto dentro l'immagine: va stampato al momento del build leggendo il commit dalla macchina che esegue `docker compose build`, con la variabile `VISION_ENGINE_VERSION`. Una build senza questa variabile fallisce subito, invece di produrre un worker che risponde con una versione indovinata:
```bash
-3
View File
@@ -71,9 +71,6 @@ services:
VISION_ENGINE_VERSION: ${VISION_ENGINE_VERSION:-}
container_name: tmflow-vision
restart: unless-stopped
environment:
# #46: radice delle cartelle ricetta (volume uploads, sola lettura)
VISION_RECIPES_ROOT: /app/uploads
volumes:
- upload_data:/app/uploads:ro
healthcheck:
-4
View File
@@ -63,8 +63,6 @@ vision = [
"vs-task; python_version>='3.13'",
"vs-measure; python_version>='3.13'",
"vs-pm2d; python_version>='3.13'",
# #46: tutti gli strumenti della suite (locate/MatchPattern e dxf/CompareDxf)
"vs-tools[locate,dxf]; python_version>='3.13'",
"numpy; python_version>='3.13'",
]
@@ -103,8 +101,6 @@ visionsuite = { path = "vendor/visionsuite/packages/vs-core", editable = true }
vs-task = { path = "vendor/visionsuite/packages/vs-task", editable = true }
vs-measure = { path = "vendor/visionsuite/packages/vs-measure", editable = true }
vs-pm2d = { path = "vendor/visionsuite/packages/vs-pm2d", editable = true }
vs-tools = { path = "vendor/visionsuite/packages/vs-tools", editable = true } # #46
vs-dxf = { path = "vendor/visionsuite/packages/vs-dxf", editable = true } # #46
[tool.pytest.ini_options]
asyncio_mode = "auto"
+4
View File
@@ -23,6 +23,7 @@ async def download_spc_report(
operator_id: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
production_run_id: int | None = Query(None), # #58
user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db),
) -> Response:
@@ -32,6 +33,7 @@ async def download_spc_report(
db, recipe_id, subtask_id,
version_id, date_from, date_to,
operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58
)
except Exception as e:
raise HTTPException(
@@ -57,6 +59,7 @@ async def download_measurement_report(
operator_id: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
production_run_id: int | None = Query(None), # #58
user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db),
) -> Response:
@@ -66,6 +69,7 @@ async def download_measurement_report(
db, recipe_id, subtask_id,
version_id, date_from, date_to,
operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58
)
except Exception as e:
raise HTTPException(
+60 -1
View File
@@ -3,12 +3,13 @@ from datetime import datetime
from src.backend.models.api.tipi import UtcNaiveInput
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import and_, select
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.database import get_db
from src.backend.api.middleware.api_key import require_metrologist
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.production import ProductionRun
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
@@ -38,6 +39,7 @@ async def _query_measurements(
operator_id: int | None = None,
lot_number: str | None = None,
serial_number: str | None = None,
production_run_id: int | None = None, # #58: solo le misure di una sessione
) -> list[Measurement]:
"""Query measurements with common filters.
@@ -67,6 +69,9 @@ async def _query_measurements(
if serial_number is not None:
filters.append(Measurement.serial_number == serial_number)
if production_run_id is not None:
filters.append(Measurement.production_run_id == production_run_id)
query = (
select(Measurement)
.where(and_(*filters) if filters else True)
@@ -106,6 +111,7 @@ async def get_summary(
operator_id: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
production_run_id: int | None = Query(None), # #58
user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db),
) -> SummaryData:
@@ -113,6 +119,7 @@ async def get_summary(
measurements = await _query_measurements(
db, recipe_id, version_id, subtask_id,
date_from, date_to, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58
)
pass_fail_values = [m.pass_fail for m in measurements]
return compute_summary(pass_fail_values)
@@ -128,6 +135,7 @@ async def get_capability(
operator_id: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
production_run_id: int | None = Query(None), # #58
user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db),
) -> CapabilityData:
@@ -135,6 +143,7 @@ async def get_capability(
measurements = await _query_measurements(
db, recipe_id, version_id, subtask_id,
date_from, date_to, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58
)
values = [float(m.value) for m in measurements]
tol = await _get_subtask_tolerances(db, subtask_id)
@@ -151,6 +160,7 @@ async def get_control_chart(
operator_id: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
production_run_id: int | None = Query(None), # #58
user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db),
) -> ControlChartData:
@@ -158,6 +168,7 @@ async def get_control_chart(
measurements = await _query_measurements(
db, recipe_id, version_id, subtask_id,
date_from, date_to, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58
)
values = [float(m.value) for m in measurements]
timestamps = [m.measured_at for m in measurements]
@@ -178,6 +189,7 @@ async def get_histogram(
operator_id: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
production_run_id: int | None = Query(None), # #58
n_bins: int = Query(20, ge=5, le=100),
user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db),
@@ -186,6 +198,7 @@ async def get_histogram(
measurements = await _query_measurements(
db, recipe_id, version_id, subtask_id,
date_from, date_to, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58
)
values = [float(m.value) for m in measurements]
return compute_histogram(values, n_bins)
@@ -235,3 +248,49 @@ async def get_recipe_subtasks(
})
return subtasks_list
@router.get("/runs")
async def get_recipe_runs(
recipe_id: int = Query(...),
user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db),
) -> list[dict]:
"""#58: production runs of a recipe (for the "Sessione" filter), newest first."""
runs = (await db.execute(
select(ProductionRun)
.where(ProductionRun.recipe_id == recipe_id)
.order_by(ProductionRun.started_at.desc(), ProductionRun.id.desc())
)).scalars().all()
# #58: conta solo le misure dei run della ricetta, non tutta la tabella.
run_ids = [r.id for r in runs]
counts = {}
if run_ids:
counts = dict((await db.execute(
select(Measurement.production_run_id, func.count(Measurement.id))
.where(Measurement.production_run_id.in_(run_ids))
.group_by(Measurement.production_run_id)
)).all())
operator_ids = {r.operator_id for r in runs}
names = {}
if operator_ids:
names = dict((await db.execute(
select(User.id, User.display_name).where(User.id.in_(operator_ids))
)).all())
return [
{
"id": r.id,
"station_id": r.station_id,
"station_name": r.station.name if r.station else None,
"operator_id": r.operator_id,
"operator_name": names.get(r.operator_id),
"started_at": r.started_at.isoformat() if r.started_at else None,
"closed_at": r.closed_at.isoformat() if r.closed_at else None,
"status": r.status,
"lot_number": r.lot_number,
"serial_number": r.serial_number,
"cycle_count": r.cycle_count,
"measurement_count": counts.get(r.id, 0),
}
for r in runs
]
+12
View File
@@ -48,6 +48,7 @@ async def _query_measurements(
operator_id: int | None = None,
lot_number: str | None = None,
serial_number: str | None = None,
production_run_id: int | None = None, # #58
) -> list[Measurement]:
"""Query measurements with common filters (duplicated from statistics router)."""
filters = []
@@ -73,6 +74,9 @@ async def _query_measurements(
if serial_number is not None:
filters.append(Measurement.serial_number == serial_number)
if production_run_id is not None:
filters.append(Measurement.production_run_id == production_run_id)
query = (
select(Measurement)
.where(and_(*filters) if filters else True)
@@ -281,6 +285,7 @@ async def generate_spc_report(
operator_id: int | None = None,
lot_number: str | None = None,
serial_number: str | None = None,
production_run_id: int | None = None, # #58
) -> bytes:
"""Generate SPC PDF report and return bytes.
@@ -290,6 +295,7 @@ async def generate_spc_report(
measurements = await _query_measurements(
db, recipe_id, version_id, subtask_id,
date_from, date_to, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58
)
# 2. Compute SPC data
@@ -336,6 +342,8 @@ async def generate_spc_report(
filters_desc.append(f"Lot: {lot_number}")
if serial_number:
filters_desc.append(f"Serial: {serial_number}")
if production_run_id is not None:
filters_desc.append(f"Run: {production_run_id}") # #58
# 6. Render HTML template
template = _jinja_env.get_template("spc_report.html")
@@ -367,12 +375,14 @@ async def generate_measurement_report(
operator_id: int | None = None,
lot_number: str | None = None,
serial_number: str | None = None,
production_run_id: int | None = None, # #58
) -> bytes:
"""Generate measurement table PDF report and return bytes."""
# 1. Query measurements
measurements = await _query_measurements(
db, recipe_id, version_id, subtask_id,
date_from, date_to, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58
)
# 2. Get recipe info
@@ -427,6 +437,8 @@ async def generate_measurement_report(
filters_desc.append(f"Lot: {lot_number}")
if serial_number:
filters_desc.append(f"Serial: {serial_number}")
if production_run_id is not None:
filters_desc.append(f"Run: {production_run_id}") # #58
# 7. Render template
template = _jinja_env.get_template("measurement_report.html")
+76
View File
@@ -211,3 +211,79 @@ class TestDateFilter:
assert resp.status_code == 200
# All measurements should be within this wide range
assert resp.json()["total"] >= 2
class TestRunFilter:
"""#58: statistiche per sessione di produzione."""
async def _setup(self, db_session, user, metro):
from datetime import datetime
from src.backend.models.orm.production import ProductionRun
from src.backend.models.orm.station import Station
from src.backend.models.orm.recipe import RecipeVersion
from sqlalchemy import select
recipe = await create_test_recipe(db_session, user.id)
version = (await db_session.execute(
select(RecipeVersion).where(RecipeVersion.recipe_id == recipe.id)
)).scalars().first()
station = Station(code="ST-58", name="Stazione 58", active=True, created_by=user.id)
db_session.add(station)
await db_session.commit()
runs = []
for i, lot in enumerate(["L1", "L2"]):
run = ProductionRun(
station_id=station.id, recipe_id=recipe.id, version_id=version.id,
operator_id=user.id, status="closed", lot_number=lot,
started_at=datetime(2026, 1, 1 + i),
)
db_session.add(run)
runs.append(run)
await db_session.commit()
return recipe, runs
async def test_filter_by_run_and_unfiltered(
self, client, measurement_tec_user, metrologist_user, db_session,
):
from src.backend.models.orm.measurement import Measurement
recipe, runs = await self._setup(db_session, measurement_tec_user, metrologist_user)
subtask_id, version_id = await _seed_measurements(
client, measurement_tec_user, recipe.id, [10.0, 10.1, 10.2],
)
# Assegna le prime due misure al run 1, la terza al run 2.
from sqlalchemy import select
ms = (await db_session.execute(
select(Measurement).order_by(Measurement.id)
)).scalars().all()
ms[0].production_run_id = runs[0].id
ms[1].production_run_id = runs[0].id
ms[2].production_run_id = runs[1].id
await db_session.commit()
h = auth_headers(metrologist_user)
r1 = await client.get("/api/statistics/summary", headers=h,
params={"recipe_id": recipe.id, "production_run_id": runs[0].id})
assert r1.json()["total"] == 2
r2 = await client.get("/api/statistics/summary", headers=h,
params={"recipe_id": recipe.id, "production_run_id": runs[1].id})
assert r2.json()["total"] == 1
r_all = await client.get("/api/statistics/summary", headers=h,
params={"recipe_id": recipe.id})
assert r_all.json()["total"] == 3
runs_resp = await client.get("/api/statistics/runs", headers=h,
params={"recipe_id": recipe.id})
assert runs_resp.status_code == 200
data = runs_resp.json()
assert [r["id"] for r in data] == [runs[1].id, runs[0].id] # più recente prima
assert [r["measurement_count"] for r in data] == [1, 2]
assert data[0]["lot_number"] == "L2"
assert data[0]["station_name"] == "Stazione 58"
assert data[0]["closed_at"] is None
async def test_runs_require_metrologist(
self, client, measurement_tec_user, db_session,
):
resp = await client.get("/api/statistics/runs",
headers=auth_headers(measurement_tec_user),
params={"recipe_id": 1})
assert resp.status_code == 403
@@ -50,7 +50,7 @@ def _proxy_statistics(endpoint: str):
for key in [
"recipe_id", "version_id", "subtask_id",
"date_from", "date_to", "operator_id",
"lot_number", "serial_number", "n_bins",
"lot_number", "serial_number", "n_bins", "production_run_id", # #58
]:
val = request.args.get(key)
if val is not None and val != "":
@@ -127,6 +127,27 @@ def api_subtasks():
return jsonify(resp)
@statistics_bp.route("/api/runs")
@login_required
@role_required("Metrologist")
def api_runs():
"""Proxy: production runs of a recipe (#58, "Sessione" filter)."""
params = {}
recipe_id = request.args.get("recipe_id")
if recipe_id:
params["recipe_id"] = recipe_id
resp = api_client.get("/api/statistics/runs", params=params)
if isinstance(resp, dict) and resp.get("error"):
error_body = {"error": True, "detail": resp.get("detail", "")}
if resp.get("session_expired"):
error_body["session_expired"] = True
return jsonify(error_body), resp.get("status_code", 500)
return jsonify(resp)
# ============================================================================
# PROXY REPORT PDF → FastAPI
# ============================================================================
@@ -137,7 +158,7 @@ def _proxy_report(endpoint: str):
for key in [
"recipe_id", "version_id", "subtask_id",
"date_from", "date_to", "operator_id",
"lot_number", "serial_number",
"lot_number", "serial_number", "production_run_id", # #58
]:
val = request.args.get(key)
if val is not None and val != "":
@@ -35,6 +35,19 @@
</select>
</div>
<!-- Sessione di produzione (#58) -->
<div>
<label class="block text-xs font-medium text-steel mb-1">{{ _("Sessione") }}</label>
<select x-model="selectedRunId"
:disabled="runs.length === 0"
class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50">
<option value="">{{ _("Tutte le sessioni") }}</option>
<template x-for="r in runs" :key="r.id">
<option :value="r.id" x-text="runLabel(r)"></option>
</template>
</select>
</div>
<!-- Subtask -->
<div>
<label class="block text-xs font-medium text-steel mb-1">{{ _("Punto di misura") }}</label>
@@ -258,6 +271,8 @@ function spcDashboard() {
// Filter state
selectedRecipeId: '',
selectedSubtaskId: '',
selectedRunId: '', // #58
runs: [], // #58 sessioni della ricetta
dateFrom: '',
dateTo: '',
subtasks: [],
@@ -281,6 +296,8 @@ function spcDashboard() {
async onRecipeChange() {
this.selectedSubtaskId = '';
this.selectedRunId = ''; // #58
this.runs = [];
this.subtasks = [];
this.hasData = false;
this.summary = null;
@@ -290,6 +307,15 @@ function spcDashboard() {
if (!this.selectedRecipeId) return;
// #58: sessioni della ricetta per il filtro «Sessione»
try {
const rresp = await fetch('/statistics/api/runs?recipe_id=' + this.selectedRecipeId);
const rdata = await rresp.json();
if (Array.isArray(rdata)) this.runs = rdata;
} catch (e) {
console.error('Error loading runs:', e);
}
// Load subtasks for dropdown
try {
const resp = await fetch('/statistics/api/subtasks?recipe_id=' + this.selectedRecipeId);
@@ -302,6 +328,12 @@ function spcDashboard() {
}
},
// #58: etichetta sessione = data, stazione, operatore, lotto
runLabel(r) {
const d = r.started_at ? new Date(r.started_at + (r.started_at.endsWith('Z') ? '' : 'Z')).toLocaleString() : '';
return [d, r.station_name, r.operator_name, r.lot_number].filter(Boolean).join(' · ');
},
async loadData() {
if (!this.selectedRecipeId) return;
@@ -312,6 +344,7 @@ function spcDashboard() {
const params = new URLSearchParams();
params.set('recipe_id', this.selectedRecipeId);
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
if (this.selectedRunId) params.set('production_run_id', this.selectedRunId); // #58
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
@@ -417,6 +450,7 @@ function spcDashboard() {
const params = new URLSearchParams();
params.set('recipe_id', this.selectedRecipeId);
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
if (this.selectedRunId) params.set('production_run_id', this.selectedRunId); // #58
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
@@ -112,3 +112,21 @@ class TestSPCProxy:
assert resp.status_code == 200
data = resp.get_json()
assert "cp" in data
class TestRunsProxy:
"""#58: filtro per sessione di produzione."""
def test_runs_proxy(self, logged_in_client, mock_api_client):
mock_api_client.get.return_value = [{"id": 5, "measurement_count": 3}]
resp = logged_in_client.get("/statistics/api/runs?recipe_id=7")
assert resp.status_code == 200
assert resp.get_json()[0]["id"] == 5
args, kwargs = mock_api_client.get.call_args
assert args[0] == "/api/statistics/runs"
assert kwargs["params"] == {"recipe_id": "7"}
def test_summary_forwards_production_run_id(self, logged_in_client, mock_api_client):
mock_api_client.get.return_value = {"total": 1}
logged_in_client.get("/statistics/api/summary?recipe_id=1&production_run_id=5")
assert mock_api_client.get.call_args.kwargs["params"]["production_run_id"] == "5"
+22 -117
View File
@@ -9,7 +9,6 @@ from __future__ import annotations
import math
import os
import subprocess
import tempfile
import time
from dataclasses import dataclass, field
from functools import lru_cache
@@ -19,9 +18,6 @@ from typing import Any
import numpy as np
from visionsuite.core.image import Image
from visionsuite.task import Task
from visionsuite.task.graph import RecipeError # #46: errori di ricetta v2
from visionsuite.task.registry import load_tools
from visionsuite.task.session import Session
_SUBMODULE = Path(__file__).resolve().parents[2] / "vendor" / "visionsuite"
@@ -53,10 +49,6 @@ class RunOutcome:
# Kept apart from `failures`: the tool did not raise, it just produced a
# number the caller must not measure a part by. Value is the reason.
invalid_outputs: dict[str, str] = field(default_factory=dict)
# #46: contorni di tutti gli strumenti (px immagine, con tool_id) e esito
# del TaskReport (informativo: il verdetto quote lo dà il backend).
overlay: list[dict] = field(default_factory=list)
outcome: str | None = None
@lru_cache(maxsize=1)
@@ -105,46 +97,12 @@ def engine_version() -> str:
return _engine_version_from_git()
def _scale_calibration(calibration: dict | None):
"""Dict {"mm_per_px"|"px_per_mm"} -> ScaleCalibration (gli strumenti di
quota convertono con `in_millimetres`, che non accetta un dict)."""
if calibration is None:
return None
from visionsuite.calibration import ScaleCalibration
if not isinstance(calibration, dict) or not (
calibration.get("mm_per_px") or calibration.get("px_per_mm")
):
raise GraphBuildError(
"calibration must be {'mm_per_px': float} or {'px_per_mm': float}"
)
key = "mm_per_px" if calibration.get("mm_per_px") else "px_per_mm"
return ScaleCalibration(**{key: float(calibration[key])})
def _open_session(graph: dict, recipe_dir: Any, calibration: dict | None,
workdir: str) -> Session:
"""Costruisce la Session v2; RecipeError e grafo malformato -> GraphBuildError."""
cal = _scale_calibration(calibration)
try:
return Session.from_dict(
graph, Path(recipe_dir) if recipe_dir else Path(workdir), cal,
)
except RecipeError as exc:
raise GraphBuildError(f"malformed graph: {exc}") from exc
except (KeyError, TypeError, AttributeError, ValueError) as exc:
raise GraphBuildError(
f"malformed graph: {type(exc).__name__}: {exc}"
) from exc
def run_graph(
image: Any,
graph: dict,
calibration: dict | None = None,
recipe_dir: Any = None,
) -> RunOutcome:
"""Run a serialised vs-task v2 graph against one image.
"""Run a serialised vs-task graph against one image.
A tool that fails does not stop the others - on a crooked piece an edge may
simply not be there, and the other quotes are still wanted. Failures come
@@ -154,35 +112,33 @@ def run_graph(
wrapped into the `visionsuite.core.image.Image` that vs-measure's tools
actually read via `.pixels`; an `Image` passed in is used as-is.
`recipe_dir` is the folder of the recipe's files (meta.dxf, meta.models,
...); without it an empty temporary folder is used, so a recipe that needs
a file fails as a malformed graph instead of reading from the CWD.
Two phases, on purpose: building the session can fail on nothing but the
caller's input (a bad schema version, a tool with no "kind", a missing
file) - that is `GraphBuildError`, a 422 for the worker. Once it exists,
running is the engine's own business; an exception from there is not the
caller's mistake, and the worker must not call it one.
Two phases, on purpose: building the task from `graph` can fail on
nothing but the caller's input (a bad schema version, a tool with no
"kind", a graph that is not a dict at all) - that is `GraphBuildError`,
a 422 for the worker. Once the task exists, `task.run()` is the engine's
own business; an exception from there is not the caller's mistake, and
the worker must not call it one.
"""
load_tools() # #46: registra gli strumenti degli entry point di vs-tools
with tempfile.TemporaryDirectory() as empty_dir:
session = _open_session(graph, recipe_dir, calibration, empty_dir)
wrapped_image = (
image if isinstance(image, Image) else Image(pixels=np.asarray(image))
)
started = time.monotonic()
report = session.run(wrapped_image)
elapsed_ms = int((time.monotonic() - started) * 1000)
try:
task = Task.from_dict(graph)
except (KeyError, TypeError, AttributeError, ValueError) as exc:
raise GraphBuildError(
f"malformed graph: {type(exc).__name__}: {exc}"
) from exc
wrapped_image = image if isinstance(image, Image) else Image(pixels=np.asarray(image))
context: dict[str, Any] = {"image": wrapped_image}
if calibration is not None:
context["calibration"] = calibration
started = time.monotonic()
report = task.run(context=context)
elapsed_ms = int((time.monotonic() - started) * 1000)
outputs: dict[str, float] = {}
failures: list[ToolFailure] = []
invalid_outputs: dict[str, str] = {}
overlay: list[dict] = []
for result in report:
# #46: overlay di ogni strumento, con il tool_id di chi lo ha prodotto
for item in result.overlay or []:
if isinstance(item, dict):
overlay.append({**item, "tool_id": result.tool_id})
if not result.ok:
failures.append(ToolFailure(
tool_id=result.tool_id,
@@ -208,55 +164,4 @@ def run_graph(
engine_version=engine_version(),
duration_ms=elapsed_ms,
invalid_outputs=invalid_outputs,
overlay=overlay,
outcome=report.outcome,
)
def _is_number(value: Any) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool)
def validate_graph(
graph: dict, recipe_dir: Any = None
) -> tuple[bool, list[str], list[dict]]:
"""Validate a v2 recipe without an image: (ok, errors, numeric outputs).
Not a draft: a missing meta file is an error. `outputs` lists the numeric
outputs the tools declare (OUT properties with a numeric default, plus the
`values` of dimension tools), with quantity/unit when declared.
"""
load_tools()
if not isinstance(graph, dict):
return False, ["graph must be a JSON object"], []
try:
task = Task.from_dict(
graph, recipe_dir=Path(recipe_dir) if recipe_dir else None
)
except RecipeError as exc:
return False, list(exc.errors), []
except (KeyError, TypeError, AttributeError, ValueError) as exc:
return False, [f"{type(exc).__name__}: {exc}"], []
outputs: list[dict] = []
for tool in task.tools:
value_quantities = getattr(tool, "value_quantities", None) or {}
values = tuple(getattr(tool, "values", ()) or ())
for prop in tool.outputs():
if not (_is_number(prop.default) or prop.name in values):
continue
quantity = value_quantities.get(prop.name, getattr(tool, "quantity", None))
if not isinstance(quantity, str):
quantity = None
unit = None
if quantity == "length":
nominal_unit = getattr(tool, "nominal_unit", None)
unit = nominal_unit if isinstance(nominal_unit, str) else None
elif quantity == "angle":
unit = "deg"
outputs.append({
"key": f"{tool.id}.{prop.name}",
"quantity": quantity,
"unit": unit,
})
return True, [], outputs
+13 -146
View File
@@ -34,16 +34,14 @@ def a_disc(radius: int = 60, size: int = 400) -> np.ndarray:
def a_circle_graph() -> dict:
"""A two-tool graph, built with vs-task's own API."""
from visionsuite.task import Task
from visionsuite.tools.detect import DetectCircle
from visionsuite.tools.dimensions import Diameter
from visionsuite.task.tools import DetectCircleTool, DiameterTool
task = Task(name="prova")
found = task.add(DetectCircle(name="disco"))
found = task.add(DetectCircleTool(name="disco"))
found.center_row, found.center_col, found.radius = 200.0, 200.0, 60.0
found.scan_size = 30.0
quota = task.add(Diameter(name="diametro"))
quota.nominal_unit = "px" # #46: v2, senza calibrazione la quota resta in px
quota = task.add(DiameterTool(name="diametro"))
quota.link("circle", found, "circle")
return task.to_dict()
@@ -65,7 +63,7 @@ def test_a_broken_tool_does_not_sink_the_others():
graph = a_circle_graph()
graph["tools"].append(
{"id": "ghost", "kind": "DetectCircle", "name": "assente",
"values": {"center_row": 5.0, "center_col": 5.0, "radius": 900.0}}
"properties": {"center_row": 5.0, "center_col": 5.0, "radius": 900.0}}
)
outcome = run_graph(a_disc(), graph)
@@ -83,21 +81,14 @@ class _FakeResult:
self.ok = ok
self.outputs = outputs or {}
self.error = error
self.overlay = [] # #46: v2, ogni risultato porta l'overlay
class _FakeReport(list):
"""#46: run_graph ora legge `.outcome` dal report di Session.run."""
outcome = "pass"
def test_a_nan_output_is_dropped_not_measured(monkeypatch):
"""vs-measure emits NaN on an empty slice (caliper.py:112 per the
revisione) - a NaN must not become a quote's value (#16)."""
def fake_run(self, image, tag=None):
return _FakeReport([_FakeResult("t1", "diametro", True, outputs={"diameter": float("nan")})])
monkeypatch.setattr(runner.Session, "run", fake_run)
def fake_run(self, context=None):
return [_FakeResult("t1", "diametro", True, outputs={"diameter": float("nan")})]
monkeypatch.setattr(runner.Task, "run", fake_run)
outcome = run_graph(a_disc(), a_circle_graph())
@@ -106,9 +97,9 @@ def test_a_nan_output_is_dropped_not_measured(monkeypatch):
def test_an_infinite_output_is_dropped_not_measured(monkeypatch):
def fake_run(self, image, tag=None):
return _FakeReport([_FakeResult("t1", "diametro", True, outputs={"diameter": float("inf")})])
monkeypatch.setattr(runner.Session, "run", fake_run)
def fake_run(self, context=None):
return [_FakeResult("t1", "diametro", True, outputs={"diameter": float("inf")})]
monkeypatch.setattr(runner.Task, "run", fake_run)
outcome = run_graph(a_disc(), a_circle_graph())
@@ -118,9 +109,9 @@ def test_an_infinite_output_is_dropped_not_measured(monkeypatch):
def test_a_bool_output_is_not_treated_as_a_measurable_number(monkeypatch):
"""bool is a subclass of int - a flag output must not become a quote."""
def fake_run(self, image, tag=None):
return _FakeReport([_FakeResult("t1", "flag", True, outputs={"present": True})])
monkeypatch.setattr(runner.Session, "run", fake_run)
def fake_run(self, context=None):
return [_FakeResult("t1", "flag", True, outputs={"present": True})]
monkeypatch.setattr(runner.Task, "run", fake_run)
outcome = run_graph(a_disc(), a_circle_graph())
@@ -164,127 +155,3 @@ def test_engine_version_raises_rather_than_inventing_one(monkeypatch):
engine_version()
finally:
runner._engine_version_from_git.cache_clear()
# ---------------------------------------------------------------------------
# #46: ricette v2 complete - overlay, calibrazione, recipe_dir con DXF.
# ---------------------------------------------------------------------------
import hashlib
import shutil
from pathlib import Path
import cv2
_VS = Path(__file__).resolve().parents[3] / "vendor" / "visionsuite"
_SAMPLE_DXF = _VS / "packages" / "vs-dxf" / "tests" / "fixtures" / "sample.dxf"
# Il disegno di prova: rettangolo 200x100 mm con foro r=10 mm.
_PX_PER_MM = 4.0
def _dxf_scene() -> np.ndarray:
"""Il pezzo del disegno rasterizzato a 4 px/mm, margine 60 mm."""
margin = 60.0
w = int((200.0 + 2 * margin) * _PX_PER_MM)
h = int((100.0 + 2 * margin) * _PX_PER_MM)
img = np.full((h, w), 255, dtype=np.uint8)
cx, cy = w // 2, h // 2
hw, hh = 200.0 * _PX_PER_MM / 2, 100.0 * _PX_PER_MM / 2
cv2.rectangle(img, (int(cx - hw), int(cy - hh)), (int(cx + hw), int(cy + hh)), 0, -1)
cv2.circle(img, (cx, cy), int(10.0 * _PX_PER_MM), 255, -1)
return img
def _dxf_recipe() -> dict:
"""CompareDxf con ancora a due punti, collegati a due DetectPointScan.
Le due scansioni trovano i bordi sinistro e destro del pezzo una riga
sopra il lato in basso: origine del disegno = angolo in basso a sinistra,
direzione a -> b = asse x del disegno.
"""
h, w = _dxf_scene().shape
cx, cy = w // 2, h // 2
hw, hh = 200.0 * _PX_PER_MM / 2, 100.0 * _PX_PER_MM / 2
row = cy + hh - 1.0
def scan(tid: str, col: float) -> dict:
return {"id": tid, "name": tid, "kind": "DetectPointScan",
"values": {"start_row": row, "start_col": col - 30.0,
"end_row": row, "end_col": col + 30.0,
"width": 5.0, "select": "first"},
"links": {}}
return {
"schema_version": 2,
"name": "dxf",
"meta": {"dxf": {"disegno": { # sha256 obbligatorio in meta (v2)
"path": "disegno.dxf",
"sha256": hashlib.sha256(_SAMPLE_DXF.read_bytes()).hexdigest()}}},
"tools": [
scan("t-a", cx - hw),
scan("t-b", cx + hw),
{"id": "t-dxf", "name": "confronto", "kind": "CompareDxf",
"values": {"dxf": "disegno", "mm_per_px": 1.0 / _PX_PER_MM,
"anchor": "two_points",
"band_plus": 8.0, "band_minus": 8.0},
"links": {"point_a": {"tool": "t-a", "property": "point"},
"point_b": {"tool": "t-b", "property": "point"}}},
],
}
def test_a_circle_recipe_returns_overlay_and_outcome():
outcome = run_graph(a_disc(), a_circle_graph())
assert outcome.outcome in ("pass", "warning", "fail")
assert outcome.overlay, "DetectCircle must draw something"
assert all("tool_id" in item for item in outcome.overlay)
def test_calibration_dict_gives_mm_quotes():
graph = a_circle_graph()
for tool in graph["tools"]:
if tool["kind"] == "Diameter":
tool["values"]["nominal_unit"] = "mm"
outcome = run_graph(a_disc(), graph, calibration={"mm_per_px": 0.5})
assert outcome.failures == []
diameter = next(v for k, v in outcome.outputs.items() if k.endswith(".value"))
assert diameter == pytest.approx(60.0, abs=2.0) # 120 px * 0.5 mm/px
def test_compare_dxf_two_points_with_recipe_dir(tmp_path):
shutil.copy(_SAMPLE_DXF, tmp_path / "disegno.dxf")
outcome = run_graph(_dxf_scene(), _dxf_recipe(), recipe_dir=tmp_path)
assert outcome.failures == []
assert outcome.outputs["t-dxf.n_points"] > 1000
assert outcome.outputs["t-dxf.points_in_pct"] > 95.0
roles = {item.get("role") for item in outcome.overlay}
assert {"reference", "found"} <= roles
assert "t-dxf" in {item["tool_id"] for item in outcome.overlay}
def test_missing_meta_file_is_a_graph_build_error(tmp_path):
with pytest.raises(runner.GraphBuildError):
run_graph(_dxf_scene(), _dxf_recipe(), recipe_dir=tmp_path)
def test_validate_lists_numeric_outputs(tmp_path):
shutil.copy(_SAMPLE_DXF, tmp_path / "disegno.dxf")
graph = _dxf_recipe()
ok, errors, outputs = runner.validate_graph(graph, tmp_path)
assert ok, errors
keys = {o["key"] for o in outputs}
assert {"t-dxf.points_in_pct", "t-dxf.n_points"} <= keys
assert all(set(o) == {"key", "quantity", "unit"} for o in outputs)
def test_validate_reports_a_missing_file(tmp_path):
ok, errors, outputs = runner.validate_graph(_dxf_recipe(), tmp_path)
assert not ok and errors and outputs == []
+17 -109
View File
@@ -10,20 +10,12 @@ import asyncio
import io
import json
import os
import re
from pathlib import Path
import numpy as np
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
from PIL import Image, UnidentifiedImageError
from src.vision.runner import (
GraphBuildError,
RunOutcome,
engine_version,
run_graph,
validate_graph,
)
from src.vision.runner import GraphBuildError, RunOutcome, engine_version, run_graph
app = FastAPI(title="TieMeasureFlow Vision Worker", version="0.1.0")
@@ -39,69 +31,6 @@ _MAX_CONCURRENCY = int(os.environ.get("VISION_MAX_CONCURRENCY", "2"))
_run_slots = asyncio.Semaphore(_MAX_CONCURRENCY)
# #46: radice sotto cui vivono le cartelle delle ricette (volume uploads,
# montato in sola lettura nel worker). `recipe_dir` e' relativo a questa radice.
_RECIPES_ROOT_DEFAULT = "/app/uploads"
def _unprocessable(detail: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=detail
)
def _resolve_recipe_dir(recipe_dir: str | None) -> Path | None:
"""Percorso RELATIVO sotto VISION_RECIPES_ROOT -> cartella assoluta.
Assoluto, `..` o fuori radice (anche via symlink) -> 422. Vuoto -> None.
"""
if recipe_dir is None or not recipe_dir.strip():
return None
rel = recipe_dir.strip()
# Letto a ogni richiesta (non a import) cosi' i test possono cambiarlo.
root = Path(os.environ.get("VISION_RECIPES_ROOT", _RECIPES_ROOT_DEFAULT)).resolve()
if (
Path(rel).is_absolute()
# #46: lettera di unita (C:/x) rifiutata su ogni OS; UNC coperto da startswith("\\")
or re.match(r"^[A-Za-z]:", rel)
or rel.startswith(("/", "\\"))
or ".." in Path(rel.replace("\\", "/")).parts
):
raise _unprocessable("recipe_dir must be a relative path under the recipes root")
resolved = (root / rel).resolve()
if resolved != root and root not in resolved.parents:
raise _unprocessable("recipe_dir is outside the recipes root")
return resolved
def _parse_graph(graph: str) -> dict:
try:
parsed = json.loads(graph)
except json.JSONDecodeError as exc:
raise _unprocessable(f"graph is not valid JSON: {exc}") from exc
if not isinstance(parsed, dict):
raise _unprocessable(
f"graph must be a JSON object, got {type(parsed).__name__}"
)
return parsed
def _parse_calibration(calibration: str | None) -> dict | None:
if calibration is None or not calibration.strip():
return None
try:
parsed = json.loads(calibration)
except json.JSONDecodeError as exc:
raise _unprocessable(f"calibration is not valid JSON: {exc}") from exc
if not isinstance(parsed, dict) or not (
parsed.get("mm_per_px") or parsed.get("px_per_mm")
):
raise _unprocessable(
"calibration must be {'mm_per_px': float} or {'px_per_mm': float}"
)
return parsed
class _ImageDecodeError(Exception):
"""The upload is not a decodable image - raised inside the worker thread
so it survives the trip through `asyncio.to_thread`."""
@@ -115,12 +44,7 @@ async def health() -> dict:
return {"status": "ok", "engine_version": engine_version()}
def _decode_and_run(
content: bytes,
graph: dict,
calibration: dict | None = None,
recipe_dir: Path | None = None,
) -> RunOutcome:
def _decode_and_run(content: bytes, graph: dict) -> RunOutcome:
"""The synchronous, CPU/IO-bound half of a request: PIL decode plus
`run_graph`. Runs in a worker thread (see `run` below) so the event loop -
and therefore `/health` - stays responsive while it executes. Once this
@@ -132,19 +56,27 @@ def _decode_and_run(
frame = np.array(Image.open(io.BytesIO(content)).convert("L"))
except (UnidentifiedImageError, OSError) as exc:
raise _ImageDecodeError(str(exc)) from exc
return run_graph(frame, graph, calibration=calibration, recipe_dir=recipe_dir)
return run_graph(frame, graph)
@app.post("/run")
async def run(
image: UploadFile = File(...),
graph: str = Form(...),
recipe_dir: str | None = Form(None),
calibration: str | None = Form(None),
) -> dict:
parsed_graph = _parse_graph(graph)
resolved_dir = _resolve_recipe_dir(recipe_dir)
parsed_calibration = _parse_calibration(calibration)
try:
parsed_graph = json.loads(graph)
except json.JSONDecodeError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"graph is not valid JSON: {exc}",
) from exc
if not isinstance(parsed_graph, dict):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"graph must be a JSON object, got {type(parsed_graph).__name__}",
)
content = await image.read()
@@ -157,13 +89,7 @@ async def run(
async with _run_slots:
try:
outcome = await asyncio.wait_for(
asyncio.to_thread(
_decode_and_run,
content,
parsed_graph,
parsed_calibration,
resolved_dir,
),
asyncio.to_thread(_decode_and_run, content, parsed_graph),
timeout=_RUN_TIMEOUT_S,
)
except asyncio.TimeoutError as exc:
@@ -204,22 +130,4 @@ async def run(
"engine_version": outcome.engine_version,
"duration_ms": outcome.duration_ms,
"invalid_outputs": outcome.invalid_outputs,
# #46: contorni per l'overlay sull'immagine e esito VisionSuite.
"overlay": outcome.overlay,
"outcome": outcome.outcome,
}
@app.post("/validate")
async def validate(
graph: str = Form(...),
recipe_dir: str | None = Form(None),
) -> dict:
"""Validates a v2 recipe (not a draft: a missing meta file is an error)
and lists its numeric outputs. No image, so it is cheap: no run slot."""
parsed_graph = _parse_graph(graph)
resolved_dir = _resolve_recipe_dir(recipe_dir)
ok, errors, outputs = await asyncio.to_thread(
validate_graph, parsed_graph, resolved_dir
)
return {"ok": ok, "errors": errors, "outputs": outputs}
+7 -94
View File
@@ -45,15 +45,13 @@ def a_disc_png(radius: int = 60, size: int = 400) -> bytes:
def a_circle_graph() -> dict:
from visionsuite.task import Task
from visionsuite.tools.detect import DetectCircle
from visionsuite.tools.dimensions import Diameter
from visionsuite.task.tools import DetectCircleTool, DiameterTool
task = Task(name="prova")
found = task.add(DetectCircle(name="disco"))
found = task.add(DetectCircleTool(name="disco"))
found.center_row, found.center_col, found.radius = 200.0, 200.0, 60.0
found.scan_size = 30.0
quota = task.add(Diameter(name="diametro"))
quota.nominal_unit = "px" # #46: v2, senza calibrazione la quota resta in px
quota = task.add(DiameterTool(name="diametro"))
quota.link("circle", found, "circle")
return task.to_dict()
@@ -142,7 +140,7 @@ async def test_a_graph_that_is_not_an_object_is_refused():
@pytest.mark.asyncio
async def test_a_tool_without_a_kind_is_refused_not_crashed():
graph = {"schema_version": 2, "name": "p", "tools": [{"id": "a", "name": "x"}]}
graph = {"schema_version": 1, "name": "p", "tools": [{"id": "a", "name": "x"}]}
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://worker") as client:
response = await client.post(
@@ -153,7 +151,7 @@ async def test_a_tool_without_a_kind_is_refused_not_crashed():
assert response.status_code == 422
assert "malformed graph" in response.json()["detail"].lower()
assert "tipo sconosciuto" in response.json()["detail"].lower() # #46: messaggio v2
assert "kind" in response.json()["detail"].lower()
# ---------------------------------------------------------------------------
@@ -169,7 +167,7 @@ async def test_a_run_past_its_budget_answers_504_and_health_still_answers(
):
monkeypatch.setattr(worker_main, "_RUN_TIMEOUT_S", 0.1)
def slow_run_graph(frame, graph, **kwargs):
def slow_run_graph(frame, graph):
time.sleep(0.5) # off the event loop - it runs inside asyncio.to_thread
return runner.RunOutcome(engine_version="a" * 40)
@@ -199,7 +197,7 @@ async def test_a_third_concurrent_run_is_refused_not_queued(monkeypatch):
monkeypatch.setattr(worker_main, "_run_slots", asyncio.Semaphore(1))
monkeypatch.setattr(worker_main, "_RUN_TIMEOUT_S", 2.0)
def slow_run_graph(frame, graph, **kwargs):
def slow_run_graph(frame, graph):
time.sleep(0.3)
return runner.RunOutcome(engine_version="a" * 40)
@@ -224,88 +222,3 @@ async def test_a_third_concurrent_run_is_refused_not_queued(monkeypatch):
assert second_response.status_code == 503
assert first_response.status_code == 200
# ---------------------------------------------------------------------------
# #46: recipe_dir, calibration, overlay, /validate.
# ---------------------------------------------------------------------------
async def _post(path: str, **kwargs):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://worker") as client:
return await client.post(path, **kwargs)
@pytest.mark.asyncio
async def test_run_returns_overlay_and_outcome():
response = await _post(
"/run",
files={"image": ("piece.png", a_disc_png(), "image/png")},
data={"graph": json.dumps(a_circle_graph())},
)
body = response.json()
assert response.status_code == 200
assert body["overlay"] and "tool_id" in body["overlay"][0]
assert body["outcome"] in ("pass", "warning", "fail")
@pytest.mark.asyncio
async def test_run_accepts_a_calibration():
graph = a_circle_graph()
for tool in graph["tools"]:
if tool["kind"] == "Diameter":
tool["values"]["nominal_unit"] = "mm"
response = await _post(
"/run",
files={"image": ("piece.png", a_disc_png(), "image/png")},
data={"graph": json.dumps(graph),
"calibration": json.dumps({"px_per_mm": 2.0})},
)
body = response.json()
assert response.status_code == 200, body
assert body["failures"] == []
@pytest.mark.asyncio
@pytest.mark.parametrize("bad", ["../etc", "a/../../b", "/abs/path", "C:/x/y"])
async def test_a_recipe_dir_outside_the_root_is_refused(bad, monkeypatch, tmp_path):
monkeypatch.setenv("VISION_RECIPES_ROOT", str(tmp_path))
response = await _post(
"/run",
files={"image": ("piece.png", a_disc_png(), "image/png")},
data={"graph": json.dumps(a_circle_graph()), "recipe_dir": bad},
)
assert response.status_code == 422
response = await _post(
"/validate", data={"graph": json.dumps(a_circle_graph()), "recipe_dir": bad}
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_validate_ok_and_with_a_missing_file(monkeypatch, tmp_path):
monkeypatch.setenv("VISION_RECIPES_ROOT", str(tmp_path))
response = await _post("/validate", data={"graph": json.dumps(a_circle_graph())})
body = response.json()
assert response.status_code == 200 and body["ok"] is True
assert any(o["key"].endswith(".diameter") for o in body["outputs"])
(tmp_path / "r1").mkdir()
graph = {
"schema_version": 2, "name": "d",
"meta": {"dxf": {"d": {"path": "mancante.dxf"}}},
"tools": [{"id": "t", "name": "c", "kind": "CompareDxf",
"values": {"dxf": "d", "mm_per_px": 0.25,
"band_plus": 8.0, "band_minus": 8.0},
"links": {}}],
}
response = await _post(
"/validate", data={"graph": json.dumps(graph), "recipe_dir": "r1"}
)
body = response.json()
assert response.status_code == 200
assert body["ok"] is False and body["errors"] and body["outputs"] == []
Generated
+22 -142
View File
@@ -626,45 +626,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/21/0e/8459ca4413e1a21a06c97d134bfaf18adfd27cea068813dc0faae06cbf00/cssselect2-0.9.0-py3-none-any.whl", hash = "sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563", size = 15453, upload-time = "2026-02-12T17:16:38.317Z" },
]
[[package]]
name = "ezdxf"
version = "1.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fonttools" },
{ name = "numpy" },
{ name = "pyparsing" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/d7/1b7be8db364f1c4838dfc1a40ca96577aba405deabf896a4eb3aaeb15a62/ezdxf-1.4.4.tar.gz", hash = "sha256:da5a5e0e6bdbb6656f9c017b47edc7eafceb419d61a2b5de64ffb344c168e593", size = 1866886, upload-time = "2026-05-14T09:19:19.511Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/d0/bc515b7f5596bc3d55b93fc9f2c2b4926e2587af6bfae0486ef7cc7ddf43/ezdxf-1.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:39aadab5bc7a05deddbd0db9b984c8a4bd28bd4c1ac2c5ee03f0d1b096272a88", size = 3553590, upload-time = "2026-05-14T09:25:28.042Z" },
{ url = "https://files.pythonhosted.org/packages/a7/73/207132f13a5280d186f2441d9125639fdc7ddeabf56c2d5da4d691e81ce6/ezdxf-1.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2ed65b191113a82133ba47daa6ebdd540b487b17394b1e327e9511dde87ad924", size = 3008276, upload-time = "2026-05-14T09:25:29.424Z" },
{ url = "https://files.pythonhosted.org/packages/82/73/b01af87269726e3719abc5ff694b843b710ef8fc345d21013e90d70e44d8/ezdxf-1.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7c3c8f2c2b62fe5b6c9b62ab8257b697535d62b0c79acf3a5040bd95edfab451", size = 3001750, upload-time = "2026-05-14T09:25:31.146Z" },
{ url = "https://files.pythonhosted.org/packages/dd/92/284d7ca6a9da107966c7bd9d81c92245985235bc432fe0837982ef80630a/ezdxf-1.4.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d68d5ab9e1071319030da3af27590f34c38c8544a5ea638d9a4a800a96abb3c2", size = 5802382, upload-time = "2026-05-14T09:27:28.462Z" },
{ url = "https://files.pythonhosted.org/packages/7d/fe/e4f37730ca5b5bf7de3a968a26126b404f60324d0b8ae9d59d11d6e55740/ezdxf-1.4.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f75a4f2924ebdda0f5b2779ff2135ba92de2596c95a8fa9b1d9ebcabea1be41", size = 5794671, upload-time = "2026-05-14T09:27:09.867Z" },
{ url = "https://files.pythonhosted.org/packages/fe/3f/c3e7ab74f58cb69ef985fb71eda859b5c287cc506c65cbffe7e55c7cf2d4/ezdxf-1.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0eec8a2095b645420a67560e99041c0d25afefd39eab5bff8c736fdb32211a75", size = 5767366, upload-time = "2026-05-14T09:27:30.108Z" },
{ url = "https://files.pythonhosted.org/packages/66/7a/b7070bf05e75b3e709a6fe5ae5bcd3bd899e987ef84ff25cf28327519c03/ezdxf-1.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d87a85052e92b9c28e54605dbad65f779a3b61e6288e992d6ef00df7bde4bed3", size = 5830221, upload-time = "2026-05-14T09:27:12.074Z" },
{ url = "https://files.pythonhosted.org/packages/78/cf/a990289808c261c52f5b219484d136a8748773c99a6d25c84f55a0353fea/ezdxf-1.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:473d68c8f2b04ace513efa290a1cd0d3a4a589cdff48124f015f0e51034668b2", size = 2957599, upload-time = "2026-05-14T09:23:46.571Z" },
{ url = "https://files.pythonhosted.org/packages/18/3d/98648d221ffa047b5ba898c8e152010861dc71a66b531dcf15053075010e/ezdxf-1.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d723d35d08223318c17080a32a05192a1449e544dee867c9d9c6f25998fac303", size = 3563916, upload-time = "2026-05-14T09:25:32.879Z" },
{ url = "https://files.pythonhosted.org/packages/2c/f1/84bbaa571412f840c22deeae185e54d4a67aa52f03e1728fd8425748c548/ezdxf-1.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6b0c73b46085521bdbd4a3e7d88998da7bec5713ba89bbb502fdeffe6b42fc7e", size = 3016857, upload-time = "2026-05-14T09:25:34.607Z" },
{ url = "https://files.pythonhosted.org/packages/b0/ff/f2eb77d22d26aa98abbe0961470871d8b85ef4e73e5527f544d25f958d6e/ezdxf-1.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ac3e83a70c1f866dd5114192a6fab6e75cc83b75e907bc08c5d94b98cce14cc0", size = 3003336, upload-time = "2026-05-14T09:25:35.947Z" },
{ url = "https://files.pythonhosted.org/packages/65/7d/2a6ddd6beef5d9a8593078619f3920a0cd76b1db4c6a974ac37045543219/ezdxf-1.4.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ebe09717ab6636bf48780eabdab51e3ab2135f7963bb52452444b6d95e03d12", size = 5788485, upload-time = "2026-05-14T09:27:32.176Z" },
{ url = "https://files.pythonhosted.org/packages/e7/29/2094fd7719146ca7bb236be9751b5929d431f643cbab669aed89cf121883/ezdxf-1.4.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44af248af0860fe8b1a39ca40e1cd2fa8ac37b22142b14b20c6a0caf1b50f932", size = 5816781, upload-time = "2026-05-14T09:27:13.96Z" },
{ url = "https://files.pythonhosted.org/packages/19/8d/669b8047cca162204c53b874183d73b5f6c4b1d5d48fa1e515a32d56f5ac/ezdxf-1.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02a097794625e9fc948e637dfe07057e98167b4fe215357a59645759fef6db3f", size = 5754006, upload-time = "2026-05-14T09:27:33.858Z" },
{ url = "https://files.pythonhosted.org/packages/5b/44/b753997173327250e43ae50f11f0c69016b1e78482e0bd3857c3495236e7/ezdxf-1.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f093bd2228f5782d483aed3bd89cdb74b672b5f90c677c4125cd236ee5649151", size = 5861860, upload-time = "2026-05-14T09:27:15.758Z" },
{ url = "https://files.pythonhosted.org/packages/0b/99/2e81cb168e0f09adfb05fb40e5f890ff87ac2c00e98079cf09837dd76330/ezdxf-1.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:3e518d9ed4dcbe525d57f14c25c5e592f82660a690fdec39b03b092e6cdc4771", size = 2963238, upload-time = "2026-05-14T09:23:47.924Z" },
{ url = "https://files.pythonhosted.org/packages/be/ed/97aa3ba1ba923e4098169de5bca380ee1430e2b34d0dd85b73007e34df16/ezdxf-1.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:278a09a845d67a4f893aebe9ef8dcd8894ece9f255d7d8bb21719ffd902109b8", size = 3555283, upload-time = "2026-05-14T09:25:37.346Z" },
{ url = "https://files.pythonhosted.org/packages/4e/b1/8ca9408a2b382a806837e72d35f10931fdd9f53fe66cf208a6340358fbc5/ezdxf-1.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b0a787f8ebcbdcb9798c6fcfe90c23afd1a0993c8d4302b6cce5d8f19ae052ae", size = 3012646, upload-time = "2026-05-14T09:25:39.01Z" },
{ url = "https://files.pythonhosted.org/packages/8b/aa/1e8d3130eaabb780c5e8adfdc1ead023bb04468779b18169d60f7ce5b291/ezdxf-1.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c451d1c20f08b30c735ca4d650fee5b44147402f4b5662f8550053ed8a3009c2", size = 2999048, upload-time = "2026-05-14T09:25:40.348Z" },
{ url = "https://files.pythonhosted.org/packages/cd/ca/1dd390b79df9c104c2d4b5964e80db25362edf704ac742c9db9b279658a5/ezdxf-1.4.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d3d9a6663993fc644751647fd7843057b51742ffac27c48ce28ea9f81239613", size = 5747890, upload-time = "2026-05-14T09:27:36.028Z" },
{ url = "https://files.pythonhosted.org/packages/8f/1e/226a6e636ae533e0bc0afe4435d1058fac173564e4af4102862f055a46d9/ezdxf-1.4.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82c022cf094d21ad3db68557aa83628e4152355e8a8bef0fffd4f71a9ea325df", size = 5774707, upload-time = "2026-05-14T09:27:17.625Z" },
{ url = "https://files.pythonhosted.org/packages/24/a3/7e33c9036de944b5446982bb629d698356e48b00c4d173768f52bdeeff4b/ezdxf-1.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3be59c2a3a93585412a2a37af29b3ad7574fa3e429e624924c3285d0573388b3", size = 5713753, upload-time = "2026-05-14T09:27:37.55Z" },
{ url = "https://files.pythonhosted.org/packages/36/9e/ac0cdc3a8623fc38aa16670f6537aa810bc1381217a51ac3481299bbc986/ezdxf-1.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:44a9716860b59dddd49a3708c04ff4580ad1e7de92ac93bb3213d85fba5ac93f", size = 5813895, upload-time = "2026-05-14T09:27:19.514Z" },
{ url = "https://files.pythonhosted.org/packages/dd/22/b511a8d9ea8f23447a67fe8977213fac5e4e69df30130a3ef31043613eaf/ezdxf-1.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:207eed544417464ffaf2570880d58a792ecd2534c9c2dede45e895f3200b77e6", size = 2960937, upload-time = "2026-05-14T09:23:49.361Z" },
{ url = "https://files.pythonhosted.org/packages/a0/09/5ecb6f82d35a2f4a4334d0a608fcf764827fa69dccdf5987ce309c16b6c1/ezdxf-1.4.4-py3-none-any.whl", hash = "sha256:666edda631ba717270293b734f5d58dd97a1d1aba4787187f09d0cc584645865", size = 1331217, upload-time = "2026-05-14T09:19:26.601Z" },
]
[[package]]
name = "fastapi"
version = "0.136.1"
@@ -1118,8 +1079,8 @@ name = "numba"
version = "0.67.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "llvmlite" },
{ name = "numpy" },
{ name = "llvmlite", marker = "python_full_version >= '3.13'" },
{ name = "numpy", marker = "python_full_version >= '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7a/90/2544f4e3a61e501d6c9a5418fd4b905323222693d54a02cab0106a0af865/numba-0.67.0.tar.gz", hash = "sha256:cd75aa535b33fa05d9d930b1ae8af9f97a2881e96d72dfb38ec9b78284d9f851", size = 2836515, upload-time = "2026-08-11T23:04:00.174Z" }
wheels = [
@@ -1224,7 +1185,7 @@ name = "opencv-python"
version = "5.0.0.93"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "numpy", marker = "python_full_version >= '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/4c/a438d23e09ce2033c09f7b784ad2fbdb0adf529e434101ed28f142226f98/opencv_python-5.0.0.93.tar.gz", hash = "sha256:66aac3e5b5faa48d4025816592f3af19e4bfc2c68dec067bae2dbb4ca10aa9e2", size = 81802749, upload-time = "2026-07-02T06:59:53.815Z" }
wheels = [
@@ -1609,15 +1570,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pyparsing"
version = "3.3.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/11/b213bebff182584360cb8d17c72c1677fec5c5c228de439e63bcf8ab1c8f/pyparsing-3.3.3.tar.gz", hash = "sha256:928ae7e20211f3b6f3915a72f06a0cfd29ab9d24279dd6346b6b1a7146397d36", size = 1050487, upload-time = "2026-09-20T20:59:05.609Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl", hash = "sha256:ece8c00a69cf01b45d0b1dedabb469c90d8caf996d4fda40f147627a122849a4", size = 126420, upload-time = "2026-09-20T20:59:04.025Z" },
]
[[package]]
name = "pypdfium2"
version = "5.8.0"
@@ -1799,7 +1751,7 @@ name = "scipy"
version = "1.18.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "numpy", marker = "python_full_version >= '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" }
wheels = [
@@ -2028,7 +1980,6 @@ vision = [
{ name = "vs-measure", marker = "python_full_version >= '3.13'" },
{ name = "vs-pm2d", marker = "python_full_version >= '3.13'" },
{ name = "vs-task", marker = "python_full_version >= '3.13'" },
{ name = "vs-tools", extra = ["dxf", "locate"], marker = "python_full_version >= '3.13'" },
]
vision-worker = [
{ name = "fastapi", marker = "python_full_version >= '3.13'" },
@@ -2040,7 +1991,6 @@ vision-worker = [
{ name = "vs-measure", marker = "python_full_version >= '3.13'" },
{ name = "vs-pm2d", marker = "python_full_version >= '3.13'" },
{ name = "vs-task", marker = "python_full_version >= '3.13'" },
{ name = "vs-tools", extra = ["dxf", "locate"], marker = "python_full_version >= '3.13'" },
]
[package.metadata]
@@ -2082,7 +2032,6 @@ requires-dist = [
{ name = "vs-measure", marker = "python_full_version >= '3.13' and extra == 'vision'", editable = "vendor/visionsuite/packages/vs-measure" },
{ name = "vs-pm2d", marker = "python_full_version >= '3.13' and extra == 'vision'", editable = "vendor/visionsuite/packages/vs-pm2d" },
{ name = "vs-task", marker = "python_full_version >= '3.13' and extra == 'vision'", editable = "vendor/visionsuite/packages/vs-task" },
{ name = "vs-tools", extras = ["locate", "dxf"], marker = "python_full_version >= '3.13' and extra == 'vision'", editable = "vendor/visionsuite/packages/vs-tools" },
{ name = "weasyprint", marker = "extra == 'server'", specifier = ">=62.0" },
]
provides-extras = ["server", "client", "dev", "vision", "vision-worker"]
@@ -2208,14 +2157,8 @@ name = "visionsuite"
version = "0.0.1"
source = { editable = "vendor/visionsuite/packages/vs-core" }
dependencies = [
{ name = "numpy" },
{ name = "opencv-python" },
{ name = "pillow" },
]
[package.optional-dependencies]
dxf = [
{ name = "ezdxf" },
{ name = "numpy", marker = "python_full_version >= '3.13'" },
{ name = "opencv-python", marker = "python_full_version >= '3.13'" },
]
[package.metadata]
@@ -2225,70 +2168,42 @@ requires-dist = [
{ name = "numba", marker = "extra == 'fast'", specifier = ">=0.59" },
{ name = "numpy", specifier = ">=1.24" },
{ name = "opencv-python", specifier = ">=4.8" },
{ name = "pillow", specifier = ">=12.2.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7" },
]
provides-extras = ["dev", "fast", "dxf"]
[[package]]
name = "vs-dxf"
version = "0.1.0"
source = { editable = "vendor/visionsuite/packages/vs-dxf" }
dependencies = [
{ name = "numpy" },
{ name = "opencv-python" },
{ name = "pydantic" },
{ name = "scipy" },
{ name = "visionsuite", extra = ["dxf"] },
{ name = "vs-measure" },
]
[package.metadata]
requires-dist = [
{ name = "numpy", specifier = ">=1.26" },
{ name = "opencv-python", specifier = ">=4.9" },
{ name = "pydantic", specifier = ">=2.6" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1" },
{ name = "scipy", specifier = ">=1.12" },
{ name = "visionsuite", extras = ["dxf"], editable = "vendor/visionsuite/packages/vs-core" },
{ name = "vs-measure", editable = "vendor/visionsuite/packages/vs-measure" },
]
provides-extras = ["dev"]
[[package]]
name = "vs-measure"
version = "0.1.0"
source = { editable = "vendor/visionsuite/packages/vs-measure" }
dependencies = [
{ name = "numpy" },
{ name = "scipy" },
{ name = "visionsuite" },
{ name = "numpy", marker = "python_full_version >= '3.13'" },
{ name = "scipy", marker = "python_full_version >= '3.13'" },
{ name = "visionsuite", marker = "python_full_version >= '3.13'" },
]
[package.metadata]
requires-dist = [
{ name = "numba", marker = "extra == 'fast'", specifier = ">=0.59" },
{ name = "numpy", specifier = ">=1.24" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7" },
{ name = "scipy", specifier = ">=1.11" },
{ name = "visionsuite", editable = "vendor/visionsuite/packages/vs-core" },
]
provides-extras = ["dev", "fast"]
provides-extras = ["dev"]
[[package]]
name = "vs-pm2d"
version = "0.1.0"
source = { editable = "vendor/visionsuite/packages/vs-pm2d" }
dependencies = [
{ name = "fastapi" },
{ name = "numba" },
{ name = "numpy" },
{ name = "opencv-python" },
{ name = "pillow" },
{ name = "python-multipart" },
{ name = "uvicorn", extra = ["standard"] },
{ name = "visionsuite" },
{ name = "fastapi", marker = "python_full_version >= '3.13'" },
{ name = "numba", marker = "python_full_version >= '3.13'" },
{ name = "numpy", marker = "python_full_version >= '3.13'" },
{ name = "opencv-python", marker = "python_full_version >= '3.13'" },
{ name = "pillow", marker = "python_full_version >= '3.13'" },
{ name = "python-multipart", marker = "python_full_version >= '3.13'" },
{ name = "uvicorn", extra = ["standard"], marker = "python_full_version >= '3.13'" },
{ name = "visionsuite", marker = "python_full_version >= '3.13'" },
]
[package.metadata]
@@ -2311,54 +2226,19 @@ name = "vs-task"
version = "0.1.0"
source = { editable = "vendor/visionsuite/packages/vs-task" }
dependencies = [
{ name = "numpy" },
{ name = "visionsuite" },
{ name = "numpy", marker = "python_full_version >= '3.13'" },
{ name = "visionsuite", marker = "python_full_version >= '3.13'" },
{ name = "vs-measure", marker = "python_full_version >= '3.13'" },
]
[package.metadata]
requires-dist = [
{ name = "fastapi", marker = "extra == 'server'", specifier = ">=0.132" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" },
{ name = "numpy", specifier = ">=1.24" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7" },
{ name = "uvicorn", marker = "extra == 'server'", specifier = ">=0.34" },
{ name = "visionsuite", editable = "vendor/visionsuite/packages/vs-core" },
{ name = "vs-stat", marker = "extra == 'stat'", editable = "vendor/visionsuite/packages/vs-stat" },
]
provides-extras = ["dev", "stat", "server"]
[[package]]
name = "vs-tools"
version = "0.1.0"
source = { editable = "vendor/visionsuite/packages/vs-tools" }
dependencies = [
{ name = "numpy" },
{ name = "visionsuite" },
{ name = "vs-measure" },
{ name = "vs-task" },
]
[package.optional-dependencies]
dxf = [
{ name = "vs-dxf" },
]
locate = [
{ name = "vs-pm2d" },
]
[package.metadata]
requires-dist = [
{ name = "ezdxf", marker = "extra == 'dev'", specifier = ">=1.0" },
{ name = "numpy", specifier = ">=1.24" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7" },
{ name = "visionsuite", editable = "vendor/visionsuite/packages/vs-core" },
{ name = "vs-dxf", marker = "extra == 'dxf'", editable = "vendor/visionsuite/packages/vs-dxf" },
{ name = "vs-measure", editable = "vendor/visionsuite/packages/vs-measure" },
{ name = "vs-pm2d", marker = "extra == 'locate'", editable = "vendor/visionsuite/packages/vs-pm2d" },
{ name = "vs-task", editable = "vendor/visionsuite/packages/vs-task" },
{ name = "vs-tools", extras = ["locate", "dxf"], marker = "extra == 'dev'", editable = "vendor/visionsuite/packages/vs-tools" },
]
provides-extras = ["locate", "dxf", "dev"]
provides-extras = ["dev"]
[[package]]
name = "watchfiles"