"""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, ) -> dict[str, object]: payload: dict[str, object] = { "allowed_products": allowed, "last_advice": {"suggested_products": suggested or []}, } 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, ) 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()