fix(vision): NaN/inf/bool di uno strumento non diventano più una quota (#16)
runner.run_graph esclude bool e NaN/inf dagli output numerici, con il motivo in RunOutcome.invalid_outputs. vision_service tratta una quota mappata con valore None/assente/non finito come "non prodotta" (422), invece di arrivare a float(None) e sollevare TypeError. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ to the worker over HTTP and stays ignorant of how a number was produced.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -21,6 +22,21 @@ from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
||||
from src.backend.services import measurement_service
|
||||
|
||||
|
||||
def _is_measurable(value: object) -> bool:
|
||||
"""A worker output counts as a quote only if it is a finite, non-bool number.
|
||||
|
||||
None (absent, or explicitly null in a malformed response), a bool (a
|
||||
subclass of int), and NaN/inf all fail this - each one would otherwise
|
||||
reach `float()` and either crash with a bare TypeError or silently save a
|
||||
value nobody could have measured.
|
||||
"""
|
||||
return (
|
||||
isinstance(value, (int, float))
|
||||
and not isinstance(value, bool)
|
||||
and math.isfinite(value)
|
||||
)
|
||||
|
||||
|
||||
async def _call_worker(image_bytes: bytes, graph: dict) -> dict:
|
||||
"""The one place that talks to the worker; the tests replace it.
|
||||
|
||||
@@ -159,8 +175,13 @@ async def execute_task(
|
||||
outputs = report["outputs"]
|
||||
|
||||
# Every quote or none. A half-measured piece read as a whole one is worse
|
||||
# than a piece not measured at all.
|
||||
missing = [q.vision_output for q in quotes if q.vision_output not in outputs]
|
||||
# than a piece not measured at all. A quote counts as produced only if its
|
||||
# output is present *and* a finite, non-bool number: the in-process runner
|
||||
# already drops NaN/inf itself (see src/vision/runner.py), but the worker
|
||||
# is reached over HTTP - a different implementation of it, or a malformed
|
||||
# response, could still hand back None or a non-finite float for a mapped
|
||||
# key instead of omitting it outright.
|
||||
missing = [q.vision_output for q in quotes if not _is_measurable(outputs.get(q.vision_output))]
|
||||
if missing:
|
||||
failed = ", ".join(f["tool_name"] for f in report["failures"])
|
||||
raise HTTPException(
|
||||
|
||||
@@ -159,6 +159,50 @@ async def test_a_missing_output_names_itself(
|
||||
assert (await db_session.execute(select(Measurement))).scalars().all() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_null_output_counts_as_not_produced(
|
||||
client, db_session, measurement_tec_user, worker_says,
|
||||
):
|
||||
"""A worker answering with the key present but null (not omitted) must not
|
||||
reach float(None) as a bare TypeError - it is "not produced", same as a
|
||||
missing key (#16)."""
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||
worker_says({"t1.diameter": None, "t1.radius": GOOD})
|
||||
|
||||
response = 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 response.status_code == 422
|
||||
assert "t1.diameter" in response.json()["detail"]
|
||||
assert (await db_session.execute(select(Measurement))).scalars().all() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_non_finite_output_counts_as_not_produced(
|
||||
client, db_session, measurement_tec_user, worker_says,
|
||||
):
|
||||
"""NaN/inf must not become a saved measurement (#16)."""
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||
worker_says({"t1.diameter": float("nan"), "t1.radius": GOOD})
|
||||
|
||||
response = 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 response.status_code == 422
|
||||
assert "t1.diameter" in response.json()["detail"]
|
||||
assert (await db_session.execute(select(Measurement))).scalars().all() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_task_without_a_graph_is_refused(
|
||||
client, db_session, measurement_tec_user, worker_says,
|
||||
|
||||
Reference in New Issue
Block a user