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:
parent
4e5ede33df
commit
aec2758714
@ -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 (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)])
|
||||
|
||||
Loading…
Reference in New Issue
Block a user