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
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
import re
from datetime import date
from datetime import date, datetime, timedelta
from decimal import Decimal, InvalidOperation
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"):
violations.extend(_doses(result, advice_json))
violations.extend(_structured(result))
violations.extend(_application_window(result, advice_json))
violations.extend(_model_codes(result))
return violations
@ -201,6 +202,35 @@ def _structured(result: _AdviceView) -> list[str]:
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]:
violations: list[str] = []
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. |
| `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_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`
must be `[]` and `is_treatment_recommended` must be `0`), even if

View File

@ -88,11 +88,14 @@ def _payload(
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:
@ -276,6 +279,35 @@ class ValidateAdviceTests(unittest.TestCase):
"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)])