Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
This commit is contained in:
@@ -8,6 +8,7 @@ import base64
|
|||||||
import binascii
|
import binascii
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import mimetypes
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
@@ -447,8 +448,50 @@ async def create_dxf_compare_recipe(
|
|||||||
placed, errors = vision_service.match_recipe_files(body["graph"], uploads)
|
placed, errors = vision_service.match_recipe_files(body["graph"], uploads)
|
||||||
if errors:
|
if errors:
|
||||||
raise _422(errors)
|
raise _422(errors)
|
||||||
|
# #105: l'immagine di riferimento resta nella cartella (non e' in meta, quindi
|
||||||
|
# /validate e l'esecuzione la ignorano) e il wizard puo' riaprirla; entra nel digest.
|
||||||
|
placed[f"riferimento.{_IMAGE_EXTENSIONS.get(image.content_type, 'png')}"] = image_bytes
|
||||||
recipe_dir, check = await _store_validate_assign(task, body["graph"], placed)
|
recipe_dir, check = await _store_validate_assign(task, body["graph"], placed)
|
||||||
await vision_service.ensure_points_quote(db, task, min_points_pct)
|
await vision_service.ensure_points_quote(db, task, min_points_pct)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
response.update(saved=True, recipe_dir=recipe_dir, outputs=check["outputs"])
|
response.update(saved=True, recipe_dir=recipe_dir, outputs=check["outputs"])
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks/{task_id}/recipe/dxf-compare/source")
|
||||||
|
async def get_dxf_compare_source(
|
||||||
|
task_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_maker),
|
||||||
|
) -> dict:
|
||||||
|
"""#105: i parametri della ricetta fatta con «Crea ricetta da DXF», per modificarla.
|
||||||
|
|
||||||
|
404 se il task non ha una ricetta del wizard (nessuna, o caricata a mano).
|
||||||
|
"""
|
||||||
|
task = await _task_or_404(db, task_id)
|
||||||
|
source = vision_service.dxf_compare_source(task.vision_json, task.vision_recipe_dir)
|
||||||
|
if source is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="nessuna ricetta da DXF su questo task",
|
||||||
|
)
|
||||||
|
return source
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks/{task_id}/recipe/files/{path:path}")
|
||||||
|
async def get_recipe_file(
|
||||||
|
task_id: int,
|
||||||
|
path: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_maker),
|
||||||
|
) -> FileResponse:
|
||||||
|
"""#105: un file della cartella ricetta del task (DXF, immagine di riferimento).
|
||||||
|
|
||||||
|
Percorso non sicuro, file assente o task senza ricetta: sempre 404.
|
||||||
|
"""
|
||||||
|
task = await _task_or_404(db, task_id)
|
||||||
|
target = vision_service.recipe_file_path(task.vision_recipe_dir, path)
|
||||||
|
if target is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="file not found")
|
||||||
|
media_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
|
||||||
|
return FileResponse(target, media_type=media_type)
|
||||||
|
|||||||
@@ -485,6 +485,84 @@ def list_recipe_files(recipe_dir: str | None) -> list[str]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# #105: nomi e id della ricetta costruita da «Crea ricetta da DXF» (src/vision/costruttori.py).
|
||||||
|
_WIZARD_DXF_FILE = "disegno.dxf"
|
||||||
|
_WIZARD_CALIBRATION_FILE = "calibrazione.json"
|
||||||
|
_WIZARD_REFERENCE_STEM = "riferimento"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_real_number(value: object) -> bool:
|
||||||
|
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
|
||||||
|
|
||||||
|
|
||||||
|
def dxf_compare_source(graph: dict | None, recipe_dir: str | None) -> dict | None:
|
||||||
|
"""#105: i parametri con cui il wizard ha costruito la ricetta, per riaprirlo.
|
||||||
|
|
||||||
|
Legge i valori dal grafo (tool `pezzo` LocateBlob, tool `dxf` CompareDxf) e i
|
||||||
|
file dalla cartella ricetta. None se il grafo non e' uno del wizard (il task
|
||||||
|
non ha ricetta, o l'ha caricata a mano): la rotta risponde 404. Pura: niente DB.
|
||||||
|
"""
|
||||||
|
if not isinstance(graph, dict) or not isinstance(graph.get("tools"), list):
|
||||||
|
return None
|
||||||
|
tools = {t.get("id"): t for t in graph["tools"] if isinstance(t, dict)}
|
||||||
|
pezzo, confronto = tools.get("pezzo"), tools.get("dxf")
|
||||||
|
if not pezzo or pezzo.get("kind") != "LocateBlob":
|
||||||
|
return None
|
||||||
|
if not confronto or confronto.get("kind") != "CompareDxf":
|
||||||
|
return None
|
||||||
|
p_values = pezzo.get("values") if isinstance(pezzo.get("values"), dict) else {}
|
||||||
|
c_values = confronto.get("values") if isinstance(confronto.get("values"), dict) else {}
|
||||||
|
band, min_pct = c_values.get("band_plus"), c_values.get("points_in_pct_nominal")
|
||||||
|
if not (_is_real_number(band) and _is_real_number(min_pct)):
|
||||||
|
return None
|
||||||
|
polarity = p_values.get("polarity")
|
||||||
|
if polarity not in ("dark", "light"):
|
||||||
|
polarity = "dark" # default di LocateBlob e della rotta di creazione
|
||||||
|
|
||||||
|
folder = None
|
||||||
|
if recipe_dir:
|
||||||
|
try:
|
||||||
|
folder = _safe_join(settings.upload_path, recipe_dir)
|
||||||
|
except (ValueError, OSError):
|
||||||
|
folder = None
|
||||||
|
if folder is not None and not folder.is_dir():
|
||||||
|
folder = None
|
||||||
|
|
||||||
|
px_per_mm = None
|
||||||
|
dxf_name = None
|
||||||
|
image_name = None
|
||||||
|
if folder is not None:
|
||||||
|
try:
|
||||||
|
cal = json.loads((folder / _WIZARD_CALIBRATION_FILE).read_text(encoding="utf-8"))
|
||||||
|
value = cal.get("px_per_mm") if isinstance(cal, dict) else None
|
||||||
|
if _is_real_number(value) and value > 0:
|
||||||
|
px_per_mm = float(value)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
px_per_mm = None # manca o illeggibile: il wizard chiedera' la scala
|
||||||
|
if (folder / _WIZARD_DXF_FILE).is_file():
|
||||||
|
dxf_name = _WIZARD_DXF_FILE
|
||||||
|
for candidate in sorted(folder.glob(f"{_WIZARD_REFERENCE_STEM}.*")):
|
||||||
|
if candidate.is_file():
|
||||||
|
image_name = candidate.name
|
||||||
|
break
|
||||||
|
return {
|
||||||
|
"band_mm": float(band), "min_points_pct": float(min_pct), "polarity": polarity,
|
||||||
|
"px_per_mm": px_per_mm, "dxf": dxf_name, "image": image_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def recipe_file_path(recipe_dir: str | None, relative: str) -> Path | None:
|
||||||
|
"""#105: percorso di un file della cartella ricetta, o None se non sicuro o assente."""
|
||||||
|
if not recipe_dir:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
folder = _safe_join(settings.upload_path, recipe_dir)
|
||||||
|
target = _safe_join(folder, relative)
|
||||||
|
except (ValueError, OSError):
|
||||||
|
return None
|
||||||
|
return target if target.is_file() else None
|
||||||
|
|
||||||
|
|
||||||
def save_result_files(
|
def save_result_files(
|
||||||
task_id: int, image_bytes: bytes, overlay: list, image_ext: str = "png",
|
task_id: int, image_bytes: bytes, overlay: list, image_ext: str = "png",
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
@@ -880,9 +958,27 @@ async def ensure_points_quote(
|
|||||||
Una sola volta: se esiste gia' una quota con `vision_output`, non si tocca.
|
Una sola volta: se esiste gia' una quota con `vision_output`, non si tocca.
|
||||||
Se la versione ha gia' misure non si scrive (il copy-on-write sta nel router
|
Se la versione ha gia' misure non si scrive (il copy-on-write sta nel router
|
||||||
dei task, qui non si aggira): ritorna False. Ritorna True se l'ha creata.
|
dei task, qui non si aggira): ritorna False. Ritorna True se l'ha creata.
|
||||||
|
#105: unica eccezione, la quota nata da qui (`dxf.points_in_pct`, «Punti in
|
||||||
|
tolleranza») senza misure segue la soglia nuova: ltl = min_points_pct, e
|
||||||
|
ritorna True anche in quel caso.
|
||||||
"""
|
"""
|
||||||
from src.backend.services import recipe_service # import locale: evita cicli
|
from src.backend.services import recipe_service # import locale: evita cicli
|
||||||
|
|
||||||
|
# #105: ricetta ripresa e salvata con un'altra soglia -> la quota la segue
|
||||||
|
own = (await db.execute(
|
||||||
|
select(RecipeSubtask)
|
||||||
|
.where(RecipeSubtask.task_id == task.id)
|
||||||
|
.where(RecipeSubtask.vision_output == "dxf.points_in_pct")
|
||||||
|
.where(RecipeSubtask.description == "Punti in tolleranza")
|
||||||
|
.limit(1)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if own is not None:
|
||||||
|
if (own.ltl is not None and float(own.ltl) == float(min_points_pct)) or await recipe_service.version_has_measurements(db, task.version_id):
|
||||||
|
return False
|
||||||
|
own.ltl = min_points_pct
|
||||||
|
await db.flush()
|
||||||
|
return True
|
||||||
|
|
||||||
existing = (await db.execute(
|
existing = (await db.execute(
|
||||||
select(RecipeSubtask.id)
|
select(RecipeSubtask.id)
|
||||||
.where(RecipeSubtask.task_id == task.id)
|
.where(RecipeSubtask.task_id == task.id)
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
"""#105: la ricetta «Crea ricetta da DXF» si puo' riaprire e modificare.
|
||||||
|
|
||||||
|
Il worker non gira nei test: si finge come in test_vision_dxf_compare.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
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.task import RecipeSubtask, RecipeTask
|
||||||
|
from src.backend.services import vision_service
|
||||||
|
from src.backend.tests.conftest import auth_headers
|
||||||
|
from src.backend.tests.test_vision_dxf_compare import ( # noqa: F401 (fixture)
|
||||||
|
redirect_uploads, validate_ok,
|
||||||
|
)
|
||||||
|
from src.backend.tests.test_vision_recipe_upload import an_image, make_task
|
||||||
|
|
||||||
|
DXF = b"0\nEOF"
|
||||||
|
|
||||||
|
|
||||||
|
def _wizard_graph(band=0.3, pct=90.0, polarity="light") -> dict:
|
||||||
|
"""Grafo come lo costruisce src/vision/costruttori.py::_graph."""
|
||||||
|
return {
|
||||||
|
"schema_version": 2, "name": "Confronto DXF",
|
||||||
|
"meta": {"dxf": {"disegno": {"path": "disegno.dxf", "sha256": "x"}},
|
||||||
|
"calibration": {"path": "calibrazione.json", "sha256": "y"}},
|
||||||
|
"tools": [
|
||||||
|
{"id": "pezzo", "name": "Pezzo", "kind": "LocateBlob",
|
||||||
|
"values": {"polarity": polarity}, "links": {}},
|
||||||
|
{"id": "dxf", "name": "Confronto DXF", "kind": "CompareDxf",
|
||||||
|
"values": {"band_plus": band, "band_minus": band, "points_in_pct_nominal": pct},
|
||||||
|
"links": {}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def w2(monkeypatch):
|
||||||
|
async def fake(image_bytes, dxf_bytes, **kwargs):
|
||||||
|
graph = _wizard_graph(kwargs["band_mm"], kwargs["min_points_pct"], kwargs["polarity"])
|
||||||
|
return {
|
||||||
|
"graph": graph,
|
||||||
|
"files": {
|
||||||
|
"disegno.dxf": base64.b64encode(dxf_bytes).decode(),
|
||||||
|
"calibrazione.json": base64.b64encode(
|
||||||
|
json.dumps({"px_per_mm": kwargs["px_per_mm"]}).encode()).decode(),
|
||||||
|
},
|
||||||
|
"outputs": {"dxf.points_in_pct": 99.0}, "overlay": [], "outcome": "pass",
|
||||||
|
}
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service.dxf_compare_recipe", fake)
|
||||||
|
|
||||||
|
|
||||||
|
def _bmp() -> bytes:
|
||||||
|
buf = io.BytesIO()
|
||||||
|
Image.new("L", (32, 32)).save(buf, format="BMP")
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _save(client, user, task, *, image=None, ctype="image/png", **data):
|
||||||
|
return client.post(
|
||||||
|
f"/api/vision/tasks/{task.id}/recipe/dxf-compare",
|
||||||
|
headers=auth_headers(user),
|
||||||
|
files={"image": ("p", image if image is not None else an_image(), ctype),
|
||||||
|
"dxf": ("d.dxf", DXF, "application/octet-stream")},
|
||||||
|
data={"save": "true", "px_per_mm": "10", **{k: str(v) for k, v in data.items()}},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_keeps_the_reference_image_png(client, db_session, maker_user, w2, validate_ok):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
image = an_image()
|
||||||
|
|
||||||
|
resp = await _save(client, maker_user, task, image=image)
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
folder = settings.upload_path / resp.json()["recipe_dir"]
|
||||||
|
assert (folder / "riferimento.png").read_bytes() == image
|
||||||
|
assert (folder / "disegno.dxf").read_bytes() == DXF # il resto della cartella non cambia
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_keeps_the_reference_image_bmp(client, db_session, maker_user, w2, validate_ok):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
image = _bmp()
|
||||||
|
|
||||||
|
resp = await _save(client, maker_user, task, image=image, ctype="image/bmp")
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
folder = settings.upload_path / resp.json()["recipe_dir"]
|
||||||
|
assert (folder / "riferimento.bmp").read_bytes() == image
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_source_returns_what_the_wizard_saved(client, db_session, maker_user, w2, validate_ok):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
await _save(client, maker_user, task, band_mm=0.3, min_points_pct=90, polarity="light")
|
||||||
|
|
||||||
|
resp = await client.get(
|
||||||
|
f"/api/vision/tasks/{task.id}/recipe/dxf-compare/source", headers=auth_headers(maker_user),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json() == {
|
||||||
|
"band_mm": 0.3, "min_points_pct": 90.0, "polarity": "light", "px_per_mm": 10.0,
|
||||||
|
"dxf": "disegno.dxf", "image": "riferimento.png",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_source_404_without_recipe_or_with_a_hand_made_graph(client, db_session, maker_user):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
url = f"/api/vision/tasks/{task.id}/recipe/dxf-compare/source"
|
||||||
|
|
||||||
|
assert (await client.get(url, headers=auth_headers(maker_user))).status_code == 404
|
||||||
|
|
||||||
|
task.vision_json = {"schema_version": 2, "name": "a mano", "tools": [], "meta": {}}
|
||||||
|
await db_session.commit()
|
||||||
|
resp = await client.get(url, headers=auth_headers(maker_user))
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert resp.json()["detail"] == "nessuna ricetta da DXF su questo task"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_source_needs_maker(client, db_session, maker_user, measurement_tec_user):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
resp = await client.get(
|
||||||
|
f"/api/vision/tasks/{task.id}/recipe/dxf-compare/source",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_of_an_old_recipe_has_no_image_and_no_unreadable_scale(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
|
||||||
|
folder = tmp_path / "vision" / "recipes" / "1" / "abc"
|
||||||
|
folder.mkdir(parents=True)
|
||||||
|
(folder / "disegno.dxf").write_bytes(DXF)
|
||||||
|
(folder / "calibrazione.json").write_text("{non json", encoding="utf-8")
|
||||||
|
|
||||||
|
source = vision_service.dxf_compare_source(_wizard_graph(), "vision/recipes/1/abc")
|
||||||
|
|
||||||
|
assert source["image"] is None and source["px_per_mm"] is None
|
||||||
|
assert source["dxf"] == "disegno.dxf"
|
||||||
|
assert vision_service.dxf_compare_source(None, None) is None
|
||||||
|
# senza cartella i valori del grafo restano
|
||||||
|
assert vision_service.dxf_compare_source(_wizard_graph(), None)["dxf"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_files_returns_the_bytes(client, db_session, maker_user, w2, validate_ok):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
image = _bmp()
|
||||||
|
await _save(client, maker_user, task, image=image, ctype="image/bmp")
|
||||||
|
|
||||||
|
img = await client.get(
|
||||||
|
f"/api/vision/tasks/{task.id}/recipe/files/riferimento.bmp", headers=auth_headers(maker_user),
|
||||||
|
)
|
||||||
|
dxf = await client.get(
|
||||||
|
f"/api/vision/tasks/{task.id}/recipe/files/disegno.dxf", headers=auth_headers(maker_user),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert img.status_code == 200 and img.content == image
|
||||||
|
assert img.headers["content-type"].startswith("image/")
|
||||||
|
assert dxf.status_code == 200 and dxf.content == DXF
|
||||||
|
assert dxf.headers["content-type"] # dal tipo dell'estensione, di ripiego octet-stream
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_files_404_on_unsafe_missing_or_no_recipe(client, db_session, maker_user, w2, validate_ok):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
base = f"/api/vision/tasks/{task.id}/recipe/files/"
|
||||||
|
headers = auth_headers(maker_user)
|
||||||
|
|
||||||
|
assert (await client.get(base + "disegno.dxf", headers=headers)).status_code == 404 # nessuna ricetta
|
||||||
|
|
||||||
|
await _save(client, maker_user, task)
|
||||||
|
assert (await client.get(base + "manca.bmp", headers=headers)).status_code == 404
|
||||||
|
for unsafe in ("../x", "%2e%2e/x", "..%2f..%2fx", "sub/../../x"):
|
||||||
|
assert (await client.get(base + unsafe, headers=headers)).status_code == 404, unsafe
|
||||||
|
# la funzione non lascia uscire dalla cartella nemmeno con un assoluto
|
||||||
|
assert vision_service.recipe_file_path("vision/recipes/1/abc", "../x") is None
|
||||||
|
assert vision_service.recipe_file_path("vision/recipes/1/abc", "/etc/passwd") is None
|
||||||
|
assert vision_service.recipe_file_path("../fuori", "x") is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resave_follows_the_new_threshold(client, db_session, maker_user, w2, validate_ok):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
task_id = task.id
|
||||||
|
await _save(client, maker_user, task, min_points_pct=95)
|
||||||
|
|
||||||
|
resp = await _save(client, maker_user, task, min_points_pct=80)
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
db_session.expire_all()
|
||||||
|
quotes = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.vision_output == "dxf.points_in_pct")
|
||||||
|
.where(RecipeSubtask.task_id == task_id))).scalars().all()
|
||||||
|
assert len(quotes) == 1 and float(quotes[0].ltl) == 80.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ensure_points_quote_leaves_quote_with_measurements(
|
||||||
|
client, db_session, maker_user, w2, validate_ok, monkeypatch,
|
||||||
|
):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
task_id = task.id
|
||||||
|
await _save(client, maker_user, task, min_points_pct=95)
|
||||||
|
|
||||||
|
async def has_measurements(db, version_id):
|
||||||
|
return True
|
||||||
|
monkeypatch.setattr("src.backend.services.recipe_service.version_has_measurements", has_measurements)
|
||||||
|
task = await db_session.get(RecipeTask, task_id)
|
||||||
|
|
||||||
|
changed = await vision_service.ensure_points_quote(db_session, task, 80)
|
||||||
|
|
||||||
|
assert changed is False
|
||||||
|
quote = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.vision_output == "dxf.points_in_pct")
|
||||||
|
.where(RecipeSubtask.task_id == task_id))).scalar_one()
|
||||||
|
assert float(quote.ltl) == 95.0
|
||||||
Reference in New Issue
Block a user