From aec27587140c51be6ce849af85e4bf6d7719ab4e Mon Sep 17 00:00:00 2001 From: Arsham Mirehvandi <130353205+arshammi@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:30:10 +0200 Subject: [PATCH] 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. --- pipeline/stages/advice_guardrails.py | 34 ++++++++++++++++++++++++++-- prompts/_output_format.md | 2 +- tests/test_advice_guardrails.py | 32 ++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/pipeline/stages/advice_guardrails.py b/pipeline/stages/advice_guardrails.py index ce86254..a90ed74 100644 --- a/pipeline/stages/advice_guardrails.py +++ b/pipeline/stages/advice_guardrails.py @@ -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 ( diff --git a/prompts/_output_format.md b/prompts/_output_format.md index 11ae4bc..a498e49 100644 --- a/prompts/_output_format.md +++ b/prompts/_output_format.md @@ -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 diff --git a/tests/test_advice_guardrails.py b/tests/test_advice_guardrails.py index 8f79785..e9fb0d9 100644 --- a/tests/test_advice_guardrails.py +++ b/tests/test_advice_guardrails.py @@ -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)])