Update weather metrics handling and LLM configuration

- Adjusted `max_tokens` in `config.yaml` for LLM models to optimize performance, setting `gpt-4o-mini` to 2048 and `gpt-4o` to 5120.
- Enhanced `assemble.py` to include a new `_weather_fields` function for improved metric handling, ensuring consistent data formatting across weather forecasts.
- Updated SQL queries in `weather.py` to incorporate additional wind speed metrics, enhancing the data model for weather analysis.
- Expanded query synthesis documentation in `_default.md` to include conditions for wind speed variations, improving advisory generation logic.
- Added unit tests in `test_assemble.py` to validate wind speed handling in weather forecasts and ensure robustness of the advisory generation process.
This commit is contained in:
Arsham Mirehvandi 2026-09-17 14:05:50 +02:00
parent 958a17bb1f
commit 884d42e125
5 changed files with 192 additions and 58 deletions

View File

@ -13,7 +13,7 @@ llm:
openai_model: gpt-4o-mini openai_model: gpt-4o-mini
anthropic_model: claude-haiku-4-5 anthropic_model: claude-haiku-4-5
gemini_model: gemini-2.5-flash gemini_model: gemini-2.5-flash
max_tokens: 8192 max_tokens: 2048
temperature: 0.0 temperature: 0.0
# Part_Two: the farmer-facing advisory generator. Receives the full # Part_Two: the farmer-facing advisory generator. Receives the full
@ -25,7 +25,7 @@ llm:
openai_model: gpt-4o openai_model: gpt-4o
anthropic_model: claude-opus-4-5 anthropic_model: claude-opus-4-5
gemini_model: gemini-2.5-pro gemini_model: gemini-2.5-pro
max_tokens: 4096 max_tokens: 5120
thinking_budget: 9216 thinking_budget: 9216
temperature: 0.0 temperature: 0.0

View File

@ -26,6 +26,7 @@ _TWO_DECIMAL_KEYS = frozenset({
"average_air_temperature_c", "average_air_temperature_c",
"rainfall_mm", "rainfall_mm",
"potential_evapotranspiration_mm", "potential_evapotranspiration_mm",
"average_wind_speed_m_s",
}) })
@ -35,6 +36,15 @@ def _metric_value(key: str, value: Any) -> Any:
return round(float(value), 2) return round(float(value), 2)
def _weather_fields(metrics: dict[str, Any]) -> dict[str, Any]:
"""Copy shared metric keys, then any extra keys already present in metrics."""
fields = {key: _metric_value(key, metrics.get(key)) for key in METRIC_KEYS}
for key, value in metrics.items():
if key not in METRIC_KEYS:
fields[key] = _metric_value(key, value)
return fields
def _day_entry( def _day_entry(
day: date, day: date,
as_of: date, as_of: date,
@ -45,9 +55,7 @@ def _day_entry(
observation: dict[date, bool] | None = None, observation: dict[date, bool] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
metrics = weather.get(day, {}) metrics = weather.get(day, {})
entry: dict[str, Any] = {"date": format_date(day)} entry: dict[str, Any] = {"date": format_date(day), **_weather_fields(metrics)}
for key in METRIC_KEYS:
entry[key] = _metric_value(key, metrics.get(key))
entry["disease_forecast"] = forecasts.get(day) entry["disease_forecast"] = forecasts.get(day)
# Phenology, observation, and applied_treatment are only ever known for # Phenology, observation, and applied_treatment are only ever known for
# as_of and earlier; future days have none, so those fields are omitted. # as_of and earlier; future days have none, so those fields are omitted.
@ -109,9 +117,7 @@ def build_json_for_query_synthesis(
weather_forecasts: list[dict[str, Any]] = [] weather_forecasts: list[dict[str, Any]] = []
for day in days: for day in days:
metrics = weather.get(day, {}) metrics = weather.get(day, {})
entry: dict[str, Any] = {"date": format_date(day)} entry: dict[str, Any] = {"date": format_date(day), **_weather_fields(metrics)}
for key in METRIC_KEYS:
entry[key] = _metric_value(key, metrics.get(key))
weather_forecasts.append(entry) weather_forecasts.append(entry)
return { return {

View File

@ -11,18 +11,28 @@ from pipeline.db import fetch_all
from pipeline.window import past_days, today_and_future from pipeline.window import past_days, today_and_future
_WEATHER_COLUMNS = """ # Shared historical + forecast columns: (SQL column, JSON metric key).
CONVERT(date, Data) AS weather_day, _SHARED_COLUMNS: tuple[tuple[str, str], ...] = (
TemperaturaAriaMin, ("TemperaturaAriaMin", "min_air_temperature_c"),
TemperaturaAriaMax, ("TemperaturaAriaMax", "max_air_temperature_c"),
TemperaturaAriaMedia, ("TemperaturaAriaMedia", "average_air_temperature_c"),
UmiditaRelativaMin, ("UmiditaRelativaMin", "min_relative_humidity_percent"),
UmiditaRelativaMax, ("UmiditaRelativaMax", "max_relative_humidity_percent"),
UmiditaRelativaMedia, ("UmiditaRelativaMedia", "average_relative_humidity_percent"),
BagnaturaFogliare, ("BagnaturaFogliare", "leaf_wetness_minutes"),
Pioggia, ("Pioggia", "rainfall_mm"),
EvapoPot ("EvapoPot", "potential_evapotranspiration_mm"),
""" )
# FRC-only columns. Historical TDatiMeteo_D is not assumed to have these.
_FRC_EXTRA_COLUMNS: tuple[tuple[str, str], ...] = (
("VelocitaVento2mMedia", "average_wind_speed_m_s"),
)
_WEATHER_COLUMNS = (
"CONVERT(date, Data) AS weather_day,\n "
+ ",\n ".join(sql_col for sql_col, _ in _SHARED_COLUMNS)
)
_METEO_D_SQL = f""" _METEO_D_SQL = f"""
SELECT {_WEATHER_COLUMNS} SELECT {_WEATHER_COLUMNS}
@ -33,7 +43,8 @@ WHERE CodiceStazione = ?
""" """
_METEO_FRC_SQL = f""" _METEO_FRC_SQL = f"""
SELECT {_WEATHER_COLUMNS} SELECT {_WEATHER_COLUMNS.rstrip()},
{", ".join(sql_col for sql_col, _ in _FRC_EXTRA_COLUMNS)}
FROM AI_agrosupport_TDatiMeteo_D_FRC FROM AI_agrosupport_TDatiMeteo_D_FRC
WHERE CodiceStazione = ? WHERE CodiceStazione = ?
AND CONVERT(date, Data) >= ? AND CONVERT(date, Data) >= ?
@ -47,29 +58,14 @@ def _to_date(value: Any) -> date:
return date(value.year, value.month, value.day) return date(value.year, value.month, value.day)
def _row_to_metrics(row: pyodbc.Row | None) -> dict[str, Any]: def _row_to_metrics(
if row is None: row: pyodbc.Row | None,
return { extra_columns: tuple[tuple[str, str], ...] = (),
"min_air_temperature_c": None, ) -> dict[str, Any]:
"max_air_temperature_c": None, columns = (*_SHARED_COLUMNS, *extra_columns)
"average_air_temperature_c": None,
"min_relative_humidity_percent": None,
"max_relative_humidity_percent": None,
"average_relative_humidity_percent": None,
"rainfall_mm": None,
"leaf_wetness_minutes": None,
"potential_evapotranspiration_mm": None,
}
return { return {
"min_air_temperature_c": row.TemperaturaAriaMin, json_key: None if row is None else getattr(row, sql_col)
"max_air_temperature_c": row.TemperaturaAriaMax, for sql_col, json_key in columns
"average_air_temperature_c": row.TemperaturaAriaMedia,
"min_relative_humidity_percent": row.UmiditaRelativaMin,
"max_relative_humidity_percent": row.UmiditaRelativaMax,
"average_relative_humidity_percent": row.UmiditaRelativaMedia,
"rainfall_mm": row.Pioggia,
"leaf_wetness_minutes": row.BagnaturaFogliare,
"potential_evapotranspiration_mm": row.EvapoPot,
} }
@ -112,5 +108,7 @@ def load_weather(
for day in past: for day in past:
result[day] = _row_to_metrics(past_rows.get(day)) result[day] = _row_to_metrics(past_rows.get(day))
for day in forward: for day in forward:
result[day] = _row_to_metrics(frc_rows.get(day)) result[day] = _row_to_metrics(
frc_rows.get(day), extra_columns=_FRC_EXTRA_COLUMNS
)
return result return result

View File

@ -14,11 +14,14 @@ The output query text will be embedded to retrieve chemical treatments (e.g., fu
* **NO EXPLICIT NAMES:** You MUST NOT mention the specific names of the crop (e.g., "grapevine", "Vitis vinifera") or disease (e.g., "peronospora", "downy mildew", "Plasmopara viticola") anywhere in the generated output text. Metadata filtering handles crop and disease downstream; naming them in the vector query degrades vector similarity scoring. * **NO EXPLICIT NAMES:** You MUST NOT mention the specific names of the crop (e.g., "grapevine", "Vitis vinifera") or disease (e.g., "peronospora", "downy mildew", "Plasmopara viticola") anywhere in the generated output text. Metadata filtering handles crop and disease downstream; naming them in the vector query degrades vector similarity scoring.
### 2. Multi-Condition Weather Analysis & Query Splitting ### 2. Multi-Condition Weather Analysis & Query Splitting
Analyze weather variations across the 16 day forecast window: Analyze weather variations across the 16 day forecast window. A **drastic shift** is a moisture change, a wind change, or both:
* **Single Query Condition (Uniform Weather):** If weather metrics across all days are consistent (e.g., all dry or all persistently wet), generate **one** synthesized search query covering the entire window. * **Moisture:** clear/mild baseline days transitioning into heavy rainfall, prolonged leaf wetness, or very high RH.
* **Dual Query Condition (Drastic Weather Shift):** If weather conditions vary significantly across the period (e.g., clear/mild baseline days transitioning into heavy rainfall and prolonged leaf wetness), generate **two separate queries**: * **Wind:** calm/light `average_wind_speed_m_s` staying below about 3 m/s transitioning to high wind about 5 m/s or above (or the reverse). Moderate wind (about 35 m/s) is not enough alone to split.
* **QUERY 1 (Baseline / Dry Window):** Focused on preventive, protective surface coverage and residual control during mild conditions.
* **QUERY 2 (High-Risk / Wet Window):** Focused on high rainfastness, wash-off resistance, rapid uptake, systemic/translaminar mobility, and curative/incubation-stopping activity during heavy rain or high leaf wetness. * **Single Query Condition (Uniform Weather):** If moisture and wind are both consistent across all days, generate **one** synthesized search query covering the entire window.
* **Dual Query Condition (Drastic Weather Shift):** If moisture and/or wind vary significantly, generate **two separate queries**. **Never emit more than two queries.** If both moisture and wind change, fold them into the same two windows (e.g. dry + light wind vs wet + windy); do not add a third query.
* **QUERY 1 (Baseline):** Preventive, protective surface coverage and residual control during mild/dry conditions, with even foliar deposit under light wind when wind is low.
* **QUERY 2 (High-risk):** Rainfastness, wash-off resistance, rapid uptake, systemic/translaminar mobility, and curative/incubation-stopping activity when the window is wet; **or** drift-reducing, coarse-deposit, not ultra-fine spray language when the window is windy; **both** when both apply.
--- ---
@ -28,18 +31,25 @@ Analyze weather variations across the 16 day forecast window:
* **Rainfall & Leaf Wetness:** * **Rainfall & Leaf Wetness:**
* Low rain / low wetness: Surface contact, preventive barrier, long residual protection. * Low rain / low wetness: Surface contact, preventive barrier, long residual protection.
* Heavy rain (>510 mm) / High wetness (>300 min) / RH (>80%): Severe wash-off risk and peak spore germination. Demands rainfastness after drying, translaminar/systemic redistribution, and early curative action. * Heavy rain (>510 mm) / High wetness (>300 min) / RH (>80%): Severe wash-off risk and peak spore germination. Demands rainfastness after drying, translaminar/systemic redistribution, and early curative action.
* **Wind speed (`average_wind_speed_m_s`):**
* Low (less than about 3 m/s): Even coverage, contact/protective films, fine foliar deposit, low drift risk.
* Moderate (about 35 m/s): Mention only as a mild application constraint; do not split on this alone.
* High (about 5 m/s or above): Drift risk. Prefer drift-reducing, rainfast/low-volatility products; not ultra-fine spray.
* If `average_wind_speed_m_s` is null for the days in the window, do not invent wind language.
--- ---
## OUTPUT FORMATTING INSTRUCTIONS ## OUTPUT FORMATTING INSTRUCTIONS
* Output your response **ONLY** as a valid JSON string list (e.g., `["QUERY1"]` or `["QUERY1", "QUERY2"]`). * Output your response **ONLY** as a valid JSON string list (e.g., `["QUERY1"]` or `["QUERY1", "QUERY2"]`).
* If weather is uniform: Return a list containing a single string query: `["QUERY1"]`. * If weather is uniform: Return a list containing a single string query: `["QUERY1"]`.
* If weather shifts drastically: Return a list containing two string queries: `["QUERY1", "QUERY2"]`. * If moisture and/or wind shifts drastically: Return a list containing two string queries: `["QUERY1", "QUERY2"]`.
* Do NOT include markdown code fences (such as ```json or ```), introductory text, greetings, headers, or explanations. Return strictly the raw JSON array string. * Do NOT include markdown code fences (such as ```json or ```), introductory text, greetings, headers, or explanations. Return strictly the raw JSON array string.
--- ---
## FEW-SHOT EXAMPLE ## FEW-SHOT EXAMPLES
### EXAMPLE 1 — moisture shift with wind folded in
### INPUT JSON: ### INPUT JSON:
{ {
@ -51,24 +61,64 @@ Analyze weather variations across the 16 day forecast window:
"date": "26-07-2026", "date": "26-07-2026",
"min_air_temperature_c": 9.6, "max_air_temperature_c": 23.9, "average_air_temperature_c": 16.2, "min_air_temperature_c": 9.6, "max_air_temperature_c": 23.9, "average_air_temperature_c": 16.2,
"min_relative_humidity_percent": 25, "max_relative_humidity_percent": 85, "average_relative_humidity_percent": 53, "min_relative_humidity_percent": 25, "max_relative_humidity_percent": 85, "average_relative_humidity_percent": 53,
"rainfall_mm": 0.0, "leaf_wetness_minutes": 300, "potential_evapotranspiration_mm": 3.6 "rainfall_mm": 0.0, "leaf_wetness_minutes": 300, "potential_evapotranspiration_mm": 3.6,
"average_wind_speed_m_s": 1.8
}, },
{ {
"date": "27-07-2026", "date": "27-07-2026",
"min_air_temperature_c": 10.6, "max_air_temperature_c": 21.3, "average_air_temperature_c": 15.8, "min_air_temperature_c": 10.6, "max_air_temperature_c": 21.3, "average_air_temperature_c": 15.8,
"min_relative_humidity_percent": 39, "max_relative_humidity_percent": 92, "average_relative_humidity_percent": 58, "min_relative_humidity_percent": 39, "max_relative_humidity_percent": 92, "average_relative_humidity_percent": 58,
"rainfall_mm": 0.8, "leaf_wetness_minutes": 720, "potential_evapotranspiration_mm": 2.9 "rainfall_mm": 0.8, "leaf_wetness_minutes": 720, "potential_evapotranspiration_mm": 2.9,
"average_wind_speed_m_s": 2.4
}, },
{ {
"date": "28-07-2026", "date": "28-07-2026",
"min_air_temperature_c": 10.6, "max_air_temperature_c": 15.6, "average_air_temperature_c": 12.9, "min_air_temperature_c": 10.6, "max_air_temperature_c": 15.6, "average_air_temperature_c": 12.9,
"min_relative_humidity_percent": 76, "max_relative_humidity_percent": 99, "average_relative_humidity_percent": 94, "min_relative_humidity_percent": 76, "max_relative_humidity_percent": 99, "average_relative_humidity_percent": 94,
"rainfall_mm": 17.4, "leaf_wetness_minutes": 300, "potential_evapotranspiration_mm": 4.0 "rainfall_mm": 17.4, "leaf_wetness_minutes": 300, "potential_evapotranspiration_mm": 4.0,
"average_wind_speed_m_s": 5.8
} }
] ]
} }
### OUTPUT: ### OUTPUT:
QUERY 1: Preventive protective treatment suitable for pre-flowering stage with visible inflorescences (BBCH 53) during dry to mild conditions with moderate humidity and low wash-off risk. The product must provide a durable protective barrier over delicate emerging floral structures to inhibit spore germination prior to high-moisture infection events. QUERY 1: Preventive protective treatment suitable for pre-flowering stage with visible inflorescences (BBCH 53) during dry to mild conditions with moderate humidity, light wind around 2 m/s, even foliar coverage, and low wash-off and drift risk. The product must provide a durable protective barrier over delicate emerging floral structures to inhibit spore germination prior to high-moisture infection events.
QUERY 2: High-performance systemic or translaminar fungicide for pre-flowering stage with visible inflorescences (BBCH 53) exposed to severe infection pressure, characterized by extreme relative humidity up to 99%, prolonged leaf wetness up to 720 minutes, and heavy rainfall reaching 17.4 mm. The treatment must feature rapid plant absorption, excellent rainfastness after drying, wash-off resistance, and early curative capability to halt fungal incubation during extended wet periods. QUERY 2: High-performance systemic or translaminar fungicide for pre-flowering stage with visible inflorescences (BBCH 53) exposed to severe infection pressure, characterized by extreme relative humidity up to 99%, prolonged leaf wetness up to 720 minutes, heavy rainfall reaching 17.4 mm, and high wind around 6 m/s. The treatment must feature rapid plant absorption, excellent rainfastness after drying, wash-off resistance, drift-reducing coarse deposit rather than ultra-fine spray, and early curative capability to halt fungal incubation during extended wet periods.
### EXAMPLE 2 — wind-only split (dry window)
### INPUT JSON:
{
"crop": "grapevine",
"disease": "peronospora",
"phenology_phase": "BBCH 53 - Infiorescenze visibili",
"weather_forecasts": [
{
"date": "26-07-2026",
"min_air_temperature_c": 12.0, "max_air_temperature_c": 24.0, "average_air_temperature_c": 17.5,
"min_relative_humidity_percent": 40, "max_relative_humidity_percent": 70, "average_relative_humidity_percent": 55,
"rainfall_mm": 0.0, "leaf_wetness_minutes": 60, "potential_evapotranspiration_mm": 4.1,
"average_wind_speed_m_s": 1.6
},
{
"date": "27-07-2026",
"min_air_temperature_c": 12.4, "max_air_temperature_c": 24.5, "average_air_temperature_c": 18.0,
"min_relative_humidity_percent": 42, "max_relative_humidity_percent": 72, "average_relative_humidity_percent": 56,
"rainfall_mm": 0.0, "leaf_wetness_minutes": 80, "potential_evapotranspiration_mm": 4.3,
"average_wind_speed_m_s": 2.1
},
{
"date": "28-07-2026",
"min_air_temperature_c": 13.0, "max_air_temperature_c": 25.0, "average_air_temperature_c": 18.4,
"min_relative_humidity_percent": 38, "max_relative_humidity_percent": 68, "average_relative_humidity_percent": 52,
"rainfall_mm": 0.0, "leaf_wetness_minutes": 50, "potential_evapotranspiration_mm": 4.5,
"average_wind_speed_m_s": 6.0
}
]
}
### OUTPUT:
QUERY 1: Preventive protective treatment suitable for pre-flowering stage with visible inflorescences (BBCH 53) during dry conditions with light wind around 2 m/s, even foliar coverage, fine deposit, and low drift risk. The product must provide a durable contact barrier over delicate emerging floral structures.
QUERY 2: Preventive protective treatment suitable for pre-flowering stage with visible inflorescences (BBCH 53) during dry conditions with high wind around 6 m/s. The product must provide a durable protective barrier with drift-reducing, coarse-deposit application rather than ultra-fine spray, and low volatility so coverage is retained under windy spraying conditions.

View File

@ -4,12 +4,21 @@ from __future__ import annotations
import unittest import unittest
from datetime import date, timedelta from datetime import date, timedelta
from typing import Any
from pipeline.stages.assemble import assemble_json_for_advice_generation from pipeline.stages.assemble import (
from pipeline.window import build_window, format_date assemble_json_for_advice_generation,
build_json_for_query_synthesis,
)
from pipeline.window import build_window, format_date, today_and_future
_AS_OF = date(2026, 4, 16) _AS_OF = date(2026, 4, 16)
_PHASE = "BBCH 15" _PHASE = "BBCH 15"
_WIND_KEY = "average_wind_speed_m_s"
def _frc_weather(value: float | None) -> dict[date, dict[str, Any]]:
return {day: {_WIND_KEY: value} for day in today_and_future(_AS_OF)}
class AdviceGenerationPhenologyTests(unittest.TestCase): class AdviceGenerationPhenologyTests(unittest.TestCase):
@ -40,5 +49,76 @@ class AdviceGenerationPhenologyTests(unittest.TestCase):
self.assertEqual(first_forecast["date"], format_date(_AS_OF + timedelta(days=1))) self.assertEqual(first_forecast["date"], format_date(_AS_OF + timedelta(days=1)))
class ForecastWindSpeedTests(unittest.TestCase):
def test_wind_on_today_and_future_not_past(self) -> None:
payload = assemble_json_for_advice_generation(
_AS_OF,
weather=_frc_weather(1.234),
phenology={},
forecasts={},
)
for entry in payload["meteorological_data"][:-1]:
self.assertNotIn(_WIND_KEY, entry)
today = payload["meteorological_data"][-1]
self.assertEqual(today["date"], format_date(_AS_OF))
self.assertEqual(today[_WIND_KEY], 1.23)
for entry in payload["weather_forecasts"]:
self.assertEqual(entry[_WIND_KEY], 1.23)
def test_missing_frc_wind_is_null(self) -> None:
payload = assemble_json_for_advice_generation(
_AS_OF,
weather=_frc_weather(None),
phenology={},
forecasts={},
)
for entry in payload["meteorological_data"][:-1]:
self.assertNotIn(_WIND_KEY, entry)
self.assertIsNone(payload["meteorological_data"][-1][_WIND_KEY])
for entry in payload["weather_forecasts"]:
self.assertIn(_WIND_KEY, entry)
self.assertIsNone(entry[_WIND_KEY])
def test_query_synthesis_includes_wind_on_every_date(self) -> None:
payload = build_json_for_query_synthesis(
_AS_OF,
"grapevine",
"downy mildew",
_frc_weather(2.567),
{_AS_OF: _PHASE},
None,
)
self.assertEqual(len(payload["weather_forecasts"]), 6)
self.assertEqual(payload["weather_forecasts"][0]["date"], format_date(_AS_OF))
for entry in payload["weather_forecasts"]:
self.assertEqual(entry[_WIND_KEY], 2.57)
def test_query_synthesis_missing_wind_is_null(self) -> None:
weather = {
day: {_WIND_KEY: None}
for day in today_and_future(_AS_OF)
if day <= _AS_OF + timedelta(days=2)
}
payload = build_json_for_query_synthesis(
_AS_OF,
"grapevine",
"downy mildew",
weather,
{},
_AS_OF + timedelta(days=2),
)
self.assertEqual(len(payload["weather_forecasts"]), 3)
for entry in payload["weather_forecasts"]:
self.assertIn(_WIND_KEY, entry)
self.assertIsNone(entry[_WIND_KEY])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()