AI_Agro_Support/tests/test_advice_guardrails.py
Arsham Mirehvandi aec2758714 Enhance advice validation with application date checks
- 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.
2026-10-19 09:30:10 +02:00

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()