- Updated `advice_guardrails.py` to include a new validation function that rejects recommended application dates outside the range of today through the next five days. - Modified the documentation in `_output_format.md` to specify the new constraints on `recommended_application_date`. - Added unit tests in `test_advice_guardrails.py` to verify the new application date validation logic and ensure correct behavior for various scenarios.
389 lines
16 KiB
Python
389 lines
16 KiB
Python
"""Guardrails on the advice reply, and the retry that sends violations back."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import unittest
|
|
from datetime import date
|
|
from types import SimpleNamespace
|
|
|
|
from pipeline.errors import PipelineError
|
|
from pipeline.stages.advice import AdviceResult, generate_advice, parse_advice
|
|
from pipeline.stages.advice_guardrails import AdviceRejected, validate_advice
|
|
|
|
_WHEN = date(2026, 4, 14)
|
|
|
|
_MELODY_USES = [
|
|
{
|
|
"dose_min": 0.9,
|
|
"dose_max": 0.9,
|
|
"dose_unit": "kg/ha",
|
|
"concentration_min": 180,
|
|
"concentration_max": 450,
|
|
"concentration_unit": "g/hl",
|
|
},
|
|
{
|
|
"dose_min": 1.8,
|
|
"dose_max": 1.8,
|
|
"dose_unit": "kg/ha",
|
|
"concentration_min": 180,
|
|
"concentration_max": 900,
|
|
"concentration_unit": "g/hl",
|
|
},
|
|
]
|
|
|
|
|
|
def _use(**overrides: object) -> dict[str, object]:
|
|
row: dict[str, object] = {
|
|
"dose_min": 0.9,
|
|
"dose_max": 0.9,
|
|
"dose_unit": "kg/ha",
|
|
"concentration_min": None,
|
|
"concentration_max": None,
|
|
"concentration_unit": None,
|
|
}
|
|
row.update(overrides)
|
|
return row
|
|
|
|
|
|
def _product(name: str, uses: list[dict[str, object]]) -> dict[str, dict[str, object]]:
|
|
return {name: {"uses": uses}}
|
|
|
|
|
|
def _result(
|
|
*,
|
|
full_advice: str = "La fase fenologica segnalata consente un trattamento.",
|
|
advice_summary: str = "Trattare il 14 aprile 2026 con MELODY FLEX.",
|
|
is_treatment_recommended: bool = True,
|
|
recommended_product_names: list[str] | None = None,
|
|
recommended_doses: list[str] | None = None,
|
|
recommended_application_date: date | None = _WHEN,
|
|
) -> AdviceResult:
|
|
return AdviceResult(
|
|
full_advice=full_advice,
|
|
advice_summary=advice_summary,
|
|
is_treatment_recommended=is_treatment_recommended,
|
|
recommended_product_names=(
|
|
["MELODY FLEX"] if recommended_product_names is None else recommended_product_names
|
|
),
|
|
recommended_doses=["0.90 kg/ha"] if recommended_doses is None else recommended_doses,
|
|
recommended_application_date=recommended_application_date,
|
|
)
|
|
|
|
|
|
def _no_treatment(*, recommended_application_date: date | None = None) -> AdviceResult:
|
|
return _result(
|
|
full_advice="Non e necessario un trattamento.",
|
|
advice_summary="Non trattare: il modello non indica rischio.",
|
|
is_treatment_recommended=False,
|
|
recommended_product_names=[],
|
|
recommended_doses=[],
|
|
recommended_application_date=recommended_application_date,
|
|
)
|
|
|
|
|
|
def _payload(
|
|
allowed: list[object],
|
|
*,
|
|
suggested: list[object] | None = None,
|
|
applied: list[object] | None = None,
|
|
last_applied: list[object] | None = None,
|
|
date_of_today: str | None = "14-04-2026",
|
|
) -> dict[str, object]:
|
|
payload: dict[str, object] = {
|
|
"allowed_products": allowed,
|
|
"last_advice": {"suggested_products": suggested or []},
|
|
}
|
|
if date_of_today is not None:
|
|
payload["date_of_today"] = date_of_today
|
|
if applied is not None:
|
|
payload["meteorological_data"] = [{"applied_treatment": applied}]
|
|
if last_applied is not None:
|
|
payload["last_applied_treatment"] = {"date": "09-04-2026", "products": last_applied}
|
|
return payload
|
|
|
|
|
|
def _reply(**overrides: object) -> str:
|
|
body: dict[str, object] = {
|
|
"full_advice": "La fase fenologica segnalata consente un trattamento.",
|
|
"advice_summary": "Trattare il 14 aprile 2026 con MELODY FLEX.",
|
|
"is_treatment_recommended": 1,
|
|
"recommended_product_names": ["MELODY FLEX"],
|
|
"recommended_doses": ["0.90 kg/ha"],
|
|
"recommended_application_date": "2026-04-14",
|
|
}
|
|
body.update(overrides)
|
|
return json.dumps(body, ensure_ascii=False)
|
|
|
|
|
|
class ValidateAdviceTests(unittest.TestCase):
|
|
def test_empty_allowlist_with_no_prescription_passes(self) -> None:
|
|
self.assertEqual(validate_advice(_no_treatment(), _payload([])), [])
|
|
|
|
def test_empty_allowlist_rejects_a_last_advice_product(self) -> None:
|
|
payload = _payload([], suggested=[_product("MELODY FLEX", _MELODY_USES)])
|
|
violations = validate_advice(_result(), payload)
|
|
self.assertEqual(
|
|
violations,
|
|
["allowed_products is empty, so no product may be recommended."],
|
|
)
|
|
|
|
def test_missing_allowlist_key_is_treated_as_empty(self) -> None:
|
|
violations = validate_advice(_result(), {})
|
|
self.assertEqual(
|
|
violations,
|
|
["allowed_products is empty, so no product may be recommended."],
|
|
)
|
|
|
|
def test_allowed_product_and_dose_pass(self) -> None:
|
|
payload = _payload([_product("MELODY FLEX", _MELODY_USES)])
|
|
self.assertEqual(validate_advice(_result(), payload), [])
|
|
|
|
def test_unknown_product_is_rejected(self) -> None:
|
|
payload = _payload([_product("MELODY FLEX", _MELODY_USES)])
|
|
result = _result(recommended_product_names=["OTHER"], recommended_doses=["1.0 kg/ha"])
|
|
violations = validate_advice(result, payload)
|
|
self.assertIn(
|
|
"recommended product 'OTHER' is not in allowed_products "
|
|
"or last_advice.suggested_products.",
|
|
violations,
|
|
)
|
|
|
|
def test_last_advice_product_absent_from_allowed_passes(self) -> None:
|
|
payload = _payload(
|
|
[_product("OTHER", [_use()])],
|
|
suggested=[_product("MELODY FLEX", _MELODY_USES)],
|
|
)
|
|
self.assertEqual(validate_advice(_result(), payload), [])
|
|
|
|
def test_name_match_ignores_case_and_whitespace(self) -> None:
|
|
payload = _payload([_product(" melody flex ", _MELODY_USES)])
|
|
self.assertEqual(validate_advice(_result(), payload), [])
|
|
|
|
def test_dose_inside_either_registered_row_passes(self) -> None:
|
|
payload = _payload([_product("MELODY FLEX", _MELODY_USES)])
|
|
low = _result()
|
|
high = _result(recommended_doses=["1.80 kg/ha"])
|
|
cased = _result(recommended_doses=["0.90 KG/HA"])
|
|
self.assertEqual(validate_advice(low, payload), [])
|
|
self.assertEqual(validate_advice(high, payload), [])
|
|
self.assertEqual(validate_advice(cased, payload), [])
|
|
|
|
def test_dose_above_every_row_fails(self) -> None:
|
|
payload = _payload([_product("MELODY FLEX", _MELODY_USES)])
|
|
result = _result(recommended_doses=["2.00 kg/ha"])
|
|
violations = validate_advice(result, payload)
|
|
self.assertTrue(any("outside the registered dose bounds" in item for item in violations))
|
|
|
|
def test_dose_below_dose_min_fails(self) -> None:
|
|
payload = _payload([_product("MELODY FLEX", [_use(dose_min=1.5, dose_max=2.0)])])
|
|
result = _result(recommended_doses=["1,00 kg/ha"])
|
|
violations = validate_advice(result, payload)
|
|
self.assertTrue(any("outside the registered dose bounds" in item for item in violations))
|
|
|
|
def test_range_must_lie_inside_one_row(self) -> None:
|
|
payload = _payload([_product("MELODY FLEX", [_use(dose_min=1.0, dose_max=2.0)])])
|
|
inside = _result(recommended_doses=["1.2-1.8 kg/ha"])
|
|
spanning = _result(recommended_doses=["0.5-1.8 kg/ha"])
|
|
self.assertEqual(validate_advice(inside, payload), [])
|
|
self.assertTrue(
|
|
any("outside the registered dose bounds" in item for item in validate_advice(spanning, payload))
|
|
)
|
|
|
|
def test_all_null_dose_bounds_trust_the_model(self) -> None:
|
|
uses = [_use(dose_min=None, dose_max=None, concentration_min=180, concentration_max=450)]
|
|
payload = _payload([_product("MELODY FLEX", uses)])
|
|
result = _result(recommended_doses=["vedere etichetta"])
|
|
self.assertEqual(validate_advice(result, payload), [])
|
|
|
|
def test_null_dose_row_is_ignored_when_another_row_has_bounds(self) -> None:
|
|
uses = [
|
|
_use(dose_min=None, dose_max=None),
|
|
_use(dose_min=0.9, dose_max=0.9),
|
|
]
|
|
payload = _payload([_product("MELODY FLEX", uses)])
|
|
self.assertEqual(validate_advice(_result(), payload), [])
|
|
too_high = _result(recommended_doses=["2.00 kg/ha"])
|
|
self.assertTrue(any("outside the registered dose bounds" in item for item in validate_advice(too_high, payload)))
|
|
|
|
def test_partial_dose_bounds_are_not_a_reference(self) -> None:
|
|
payload = _payload([_product("MELODY FLEX", [_use(dose_min=None, dose_max=1.8)])])
|
|
violations = validate_advice(_result(), payload)
|
|
self.assertTrue(any("no complete dose bounds" in item for item in violations))
|
|
|
|
def test_concentration_unit_uses_concentration_bounds(self) -> None:
|
|
payload = _payload([_product("MELODY FLEX", _MELODY_USES)])
|
|
result = _result(recommended_doses=["200 g/hl"])
|
|
self.assertEqual(validate_advice(result, payload), [])
|
|
|
|
def test_unknown_unit_fails(self) -> None:
|
|
payload = _payload([_product("MELODY FLEX", _MELODY_USES)])
|
|
result = _result(recommended_doses=["2.0 bananas"])
|
|
violations = validate_advice(result, payload)
|
|
self.assertTrue(any("unknown unit 'bananas'" in item for item in violations))
|
|
|
|
def test_unparseable_dose_fails_when_bounds_exist(self) -> None:
|
|
payload = _payload([_product("MELODY FLEX", _MELODY_USES)])
|
|
result = _result(recommended_doses=["vedere etichetta"])
|
|
violations = validate_advice(result, payload)
|
|
self.assertTrue(any("unparseable dose" in item for item in violations))
|
|
|
|
def test_bare_name_without_uses_fails(self) -> None:
|
|
payload = _payload(["MELODY FLEX"])
|
|
violations = validate_advice(_result(), payload)
|
|
self.assertTrue(any("no registered uses" in item for item in violations))
|
|
|
|
def test_uses_on_applied_treatment_when_suggestion_is_a_bare_name(self) -> None:
|
|
payload = _payload(
|
|
[_product("OTHER", [_use()])],
|
|
suggested=["MELODY FLEX"],
|
|
applied=[_product("MELODY FLEX", _MELODY_USES)],
|
|
)
|
|
self.assertEqual(validate_advice(_result(), payload), [])
|
|
|
|
def test_dose_list_must_match_product_list(self) -> None:
|
|
payload = _payload(
|
|
[
|
|
_product("MELODY FLEX", _MELODY_USES),
|
|
_product("OTHER", [_use()]),
|
|
]
|
|
)
|
|
result = _result(
|
|
recommended_product_names=["MELODY FLEX", "OTHER"],
|
|
recommended_doses=["0.90 kg/ha"],
|
|
)
|
|
violations = validate_advice(result, payload)
|
|
self.assertIn(
|
|
"recommended_doses must have one entry for each recommended product.",
|
|
violations,
|
|
)
|
|
|
|
def test_recommendation_requires_a_product(self) -> None:
|
|
payload = _payload([_product("MELODY FLEX", _MELODY_USES)])
|
|
result = _result(
|
|
recommended_product_names=[],
|
|
recommended_doses=[],
|
|
recommended_application_date=_WHEN,
|
|
)
|
|
violations = validate_advice(result, payload)
|
|
self.assertIn(
|
|
"is_treatment_recommended is set but recommended_product_names is empty.",
|
|
violations,
|
|
)
|
|
|
|
def test_no_recommendation_rejects_a_date(self) -> None:
|
|
payload = _payload([_product("MELODY FLEX", _MELODY_USES)])
|
|
result = _no_treatment(recommended_application_date=_WHEN)
|
|
violations = validate_advice(result, payload)
|
|
self.assertIn(
|
|
"is_treatment_recommended is not set but recommended_application_date is set.",
|
|
violations,
|
|
)
|
|
self.assertFalse(any("today through the next five days" in item for item in violations))
|
|
|
|
def test_application_date_on_today_and_on_day_five_passes(self) -> None:
|
|
payload = _payload([_product("MELODY FLEX", _MELODY_USES)])
|
|
today = _result(recommended_application_date=date(2026, 4, 14))
|
|
day_five = _result(recommended_application_date=date(2026, 4, 19))
|
|
self.assertEqual(validate_advice(today, payload), [])
|
|
self.assertEqual(validate_advice(day_five, payload), [])
|
|
|
|
def test_application_date_before_today_or_after_day_five_fails(self) -> None:
|
|
payload = _payload([_product("MELODY FLEX", _MELODY_USES)])
|
|
yesterday = _result(recommended_application_date=date(2026, 4, 13))
|
|
day_six = _result(recommended_application_date=date(2026, 4, 20))
|
|
for result, proposed in ((yesterday, "2026-04-13"), (day_six, "2026-04-20")):
|
|
violations = validate_advice(result, payload)
|
|
self.assertIn(
|
|
f"recommended_application_date {proposed} is outside "
|
|
"2026-04-14 .. 2026-04-19 (today through the next five days).",
|
|
violations,
|
|
)
|
|
|
|
def test_no_recommendation_does_not_check_the_window(self) -> None:
|
|
payload = _payload([_product("MELODY FLEX", _MELODY_USES)])
|
|
self.assertEqual(validate_advice(_no_treatment(), payload), [])
|
|
|
|
def test_missing_date_of_today_skips_the_window(self) -> None:
|
|
payload = _payload([_product("MELODY FLEX", _MELODY_USES)], date_of_today=None)
|
|
result = _result(recommended_application_date=date(2026, 4, 20))
|
|
self.assertEqual(validate_advice(result, payload), [])
|
|
|
|
def test_model_codes_are_rejected_and_italian_fase_is_kept(self) -> None:
|
|
payload = _payload([_product("MELODY FLEX", _MELODY_USES)])
|
|
self.assertEqual(validate_advice(_result(), payload), [])
|
|
self.assertIn("fase", _result().full_advice)
|
|
|
|
coded = _result(
|
|
full_advice="Il modello indica FASE5.",
|
|
advice_summary="Incubazione INCUBAZPRIMARIA2_80.7 in corso.",
|
|
)
|
|
violations = validate_advice(coded, payload)
|
|
self.assertIn("full_advice contains model code 'FASE5'.", violations)
|
|
self.assertIn("advice_summary contains model code 'INCUBAZPRIMARIA'.", violations)
|
|
|
|
|
|
class GenerateAdviceRetryTests(unittest.TestCase):
|
|
def _settings(self, attempts: int = 3) -> SimpleNamespace:
|
|
return SimpleNamespace(
|
|
retry=SimpleNamespace(attempts=attempts),
|
|
llm_advice_generation=object(),
|
|
)
|
|
|
|
def test_second_attempt_receives_the_violation_and_stops(self) -> None:
|
|
payload = _payload([_product("MELODY FLEX", _MELODY_USES)])
|
|
replies = [
|
|
_reply(recommended_doses=["9.0 kg/ha"]),
|
|
_reply(),
|
|
]
|
|
calls: list[str] = []
|
|
|
|
def llm_call(_settings, _cfg, _system, user_content, json_output=False) -> str:
|
|
self.assertTrue(json_output)
|
|
calls.append(user_content)
|
|
return replies.pop(0)
|
|
|
|
result = generate_advice(
|
|
self._settings(),
|
|
"system",
|
|
"user",
|
|
payload,
|
|
_WHEN,
|
|
llm_call=llm_call,
|
|
)
|
|
self.assertEqual(result.recommended_doses, ["0.90 kg/ha"])
|
|
self.assertEqual(len(calls), 2)
|
|
self.assertIn("outside the registered dose bounds", calls[1])
|
|
self.assertIn("9.0 kg/ha", calls[1])
|
|
|
|
def test_exhausted_attempts_raise_and_do_not_continue(self) -> None:
|
|
payload = _payload([_product("MELODY FLEX", _MELODY_USES)])
|
|
calls: list[str] = []
|
|
|
|
def llm_call(_settings, _cfg, _system, user_content, json_output=False) -> str:
|
|
calls.append(user_content)
|
|
return _reply(recommended_doses=["9.0 kg/ha"])
|
|
|
|
with self.assertRaises(AdviceRejected):
|
|
generate_advice(
|
|
self._settings(attempts=3),
|
|
"system",
|
|
"user",
|
|
payload,
|
|
_WHEN,
|
|
llm_call=llm_call,
|
|
)
|
|
self.assertEqual(len(calls), 3)
|
|
|
|
|
|
class ParseAdviceDateTests(unittest.TestCase):
|
|
def test_unparseable_application_date_raises(self) -> None:
|
|
raw = _reply(recommended_application_date="not-a-date")
|
|
with self.assertRaises(PipelineError) as caught:
|
|
parse_advice(raw, _WHEN)
|
|
self.assertIn("Could not parse recommended_application_date", str(caught.exception))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|