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.
This commit is contained in:
Arsham Mirehvandi 2026-10-19 09:30:10 +02:00
parent 4e5ede33df
commit aec2758714
3 changed files with 65 additions and 3 deletions

View File

@ -1,4 +1,4 @@
"""Reject advice replies that break the product, dose, or wording rules. """Reject advice replies that break the product, dose, date, or wording rules.
`validate_advice` inspects an already parsed reply against the payload that `validate_advice` inspects an already parsed reply against the payload that
was sent to the model. It returns every violation it finds. `generate_advice` was sent to the model. It returns every violation it finds. `generate_advice`
@ -8,7 +8,7 @@ turns a non-empty list into `AdviceRejected` and retries with that text.
from __future__ import annotations from __future__ import annotations
import re import re
from datetime import date from datetime import date, datetime, timedelta
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from typing import Any, Protocol from typing import Any, Protocol
@ -51,6 +51,7 @@ def validate_advice(result: _AdviceView, advice_json: dict[str, Any]) -> list[st
if advice_json.get("allowed_products"): if advice_json.get("allowed_products"):
violations.extend(_doses(result, advice_json)) violations.extend(_doses(result, advice_json))
violations.extend(_structured(result)) violations.extend(_structured(result))
violations.extend(_application_window(result, advice_json))
violations.extend(_model_codes(result)) violations.extend(_model_codes(result))
return violations return violations
@ -201,6 +202,35 @@ def _structured(result: _AdviceView) -> list[str]:
return violations return violations
def _application_window(result: _AdviceView, advice_json: dict[str, Any]) -> list[str]:
"""Reject a recommended date outside today through the next five days."""
if not result.is_treatment_recommended or result.recommended_application_date is None:
return []
today = _parse_today(advice_json.get("date_of_today"))
if today is None:
return []
proposed = result.recommended_application_date
last = today + timedelta(days=5)
if today <= proposed <= last:
return []
return [
f"recommended_application_date {proposed.isoformat()} is outside "
f"{today.isoformat()} .. {last.isoformat()} (today through the next five days)."
]
def _parse_today(value: Any) -> date | None:
if not isinstance(value, str):
return None
text = value.strip()
if not text:
return None
try:
return datetime.strptime(text, "%d-%m-%Y").date()
except ValueError:
return None
def _model_codes(result: _AdviceView) -> list[str]: def _model_codes(result: _AdviceView) -> list[str]:
violations: list[str] = [] violations: list[str] = []
for field, text in ( for field, text in (

View File

@ -10,7 +10,7 @@ introduction, no commentary outside the JSON.
| `is_treatment_recommended` | integer | `1` if you recommend applying a product, `0` otherwise. | | `is_treatment_recommended` | integer | `1` if you recommend applying a product, `0` otherwise. |
| `recommended_product_names` | array of strings | Names of the recommended product(s), exactly as written in the input payload. Empty array `[]` when you recommend no product. | | `recommended_product_names` | array of strings | Names of the recommended product(s), exactly as written in the input payload. Empty array `[]` when you recommend no product. |
| `recommended_doses` | array of strings | Prescribed dose for each product in `recommended_product_names`, in the same order (for example `"1.5 l/ha"`). Empty array `[]` when you recommend no product. | | `recommended_doses` | array of strings | Prescribed dose for each product in `recommended_product_names`, in the same order (for example `"1.5 l/ha"`). Empty array `[]` when you recommend no product. |
| `recommended_application_date` | string or null | Recommended application date as `YYYY-MM-DD`. `null` when you recommend no product. | | `recommended_application_date` | string or null | Recommended application date as `YYYY-MM-DD`. `null` when you recommend no product. When a treatment is recommended, the date must be `date_of_today` or one of the next five forecast days, and must not be in the past or beyond that horizon. |
If `allowed_products` is empty, you must not prescribe any product (`recommended_product_names` If `allowed_products` is empty, you must not prescribe any product (`recommended_product_names`
must be `[]` and `is_treatment_recommended` must be `0`), even if must be `[]` and `is_treatment_recommended` must be `0`), even if

View File

@ -88,11 +88,14 @@ def _payload(
suggested: list[object] | None = None, suggested: list[object] | None = None,
applied: list[object] | None = None, applied: list[object] | None = None,
last_applied: list[object] | None = None, last_applied: list[object] | None = None,
date_of_today: str | None = "14-04-2026",
) -> dict[str, object]: ) -> dict[str, object]:
payload: dict[str, object] = { payload: dict[str, object] = {
"allowed_products": allowed, "allowed_products": allowed,
"last_advice": {"suggested_products": suggested or []}, "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: if applied is not None:
payload["meteorological_data"] = [{"applied_treatment": applied}] payload["meteorological_data"] = [{"applied_treatment": applied}]
if last_applied is not None: if last_applied is not None:
@ -276,6 +279,35 @@ class ValidateAdviceTests(unittest.TestCase):
"is_treatment_recommended is not set but recommended_application_date is set.", "is_treatment_recommended is not set but recommended_application_date is set.",
violations, 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: def test_model_codes_are_rejected_and_italian_fase_is_kept(self) -> None:
payload = _payload([_product("MELODY FLEX", _MELODY_USES)]) payload = _payload([_product("MELODY FLEX", _MELODY_USES)])