Enhance advice generation and context handling

- Updated README.md to reflect changes in the `json_for_advice_generation` structure, including the addition of `date_of_today` and clarification of `last_advice` fields.
- Modified job.py to load and pass the issuance date of the last advice.
- Enhanced advice_context.py to include the issuance date in the last advice retrieval.
- Updated assemble.py to include `date_of_today` in the JSON assembly for advice generation.
- Improved advice.py to sanitize the `advice_summary` by removing relative date references and ensuring compliance with new guidelines.
This commit is contained in:
Arsham Mirehvandi 2026-09-08 09:40:34 +02:00
parent 28250d6608
commit 8a6d48e5a1
8 changed files with 265 additions and 13 deletions

View File

@ -182,18 +182,22 @@ Per-job payload fields:
| Field | Description | | Field | Description |
|---|---| |---|---|
| `run` | Metadata (as_of, field, crop, disease, organic, station, …) | | `run` | Metadata (as_of, field, crop, disease, organic, station, …) |
| `json_for_advice_generation` | Full 11-day meteorological + forecast payload, plus `allowed_products`, `applied_treatment` and `last_advice`; this is what the advice LLM receives | | `json_for_advice_generation` | Full 11-day meteorological + forecast payload, plus `date_of_today`, `allowed_products`, `applied_treatment` and `last_advice`; this is what the advice LLM receives |
| `json_for_query_synthesis` | Subset of the above, sent to the LLM for search query generation | | `json_for_query_synthesis` | Subset of the above, sent to the LLM for search query generation |
| `generated_queries` | One or two natural-language search queries | | `generated_queries` | One or two natural-language search queries |
| `candidate_count` | Products remaining after the prefilter | | `candidate_count` | Products remaining after the prefilter |
| `recommended_products` | Up to 6 items with `product_name` + `registration_number` | | `recommended_products` | Up to 6 items with `product_name` + `registration_number` |
| `last_advice` | `advice_summary` + `suggested_products` of the most recent previous advisory | | `last_advice` | `date` (previous advisory's issuance date) + `advice_summary` + `suggested_products` of the most recent previous advisory. `advice_summary` is machine context for the next run: it is stored without relative day words, attention prefixes, or follow-the-label closers |
| `advice` | Parsed LLM output: `full_advice`, `advice_summary`, `apply_treatment`, `treatments`, `dosage`, `apply_date` | | `advice` | Parsed LLM output: `full_advice`, `advice_summary`, `apply_treatment`, `treatments`, `dosage`, `apply_date` |
| `sent_information` | The advice payload with every embedded product label collapsed back to a product name; mirrors `advice.sent_information` | | `sent_information` | The advice payload with every embedded product label collapsed back to a product name; mirrors `advice.sent_information` |
| `advice_inserted` | Whether the `advice` row was written | | `advice_inserted` | Whether the `advice` row was written |
| `status` | `ok`, `no_risk`, or `dry_run` (see `_run_report.json` for `failed` / `skipped_*`, which have no per-job file) | | `status` | `ok`, `no_risk`, or `dry_run` (see `_run_report.json` for `failed` / `skipped_*`, which have no per-job file) |
| `message` | Human-readable summary of the run outcome | | `message` | Human-readable summary of the run outcome |
`json_for_advice_generation.date_of_today` is oggi for this advisory (the run's
`as_of`, formatted `DD-MM-YYYY`). `last_advice.date` is the previous advisory's
issuance date, in the same format.
If a field's 11-day window has no `FASE2`, `FASE5`, or `INCUBAZPRIMARIA` day and If a field's 11-day window has no `FASE2`, `FASE5`, or `INCUBAZPRIMARIA` day and
no true `observation` day (`as_of-5 .. as_of`), the product search is skipped no true `observation` day (`as_of-5 .. as_of`), the product search is skipped
and `allowed_products` is empty, but advice is still generated and stored; that and `allowed_products` is empty, but advice is still generated and stored; that
@ -365,6 +369,6 @@ Run once per `(field, disease)` job by `pipeline/job.py::run_job`:
9. **Vector search** — Weaviate `near_text` on `ProductProfile`, filtered server-side to the allowlisted `product_id`s 9. **Vector search** — Weaviate `near_text` on `ProductProfile`, filtered server-side to the allowlisted `product_id`s
10. **Product JSON** — label details per product from `products`, `product_uses`, `label_chunks`, cached per `(crop, disease)` 10. **Product JSON** — label details per product from `products`, `product_uses`, `label_chunks`, cached per `(crop, disease)`
11. **Context enrichment**`allowed_products`, `applied_treatment` expansion, `last_advice` from the `advice` table 11. **Context enrichment**`allowed_products`, `applied_treatment` expansion, `last_advice` (with issuance `date`) from the `advice` table
12. **Advice generation** — second LLM profile returns the structured advisory 12. **Advice generation** — second LLM profile returns the structured advisory
13. **Persistence** — the advisory plus its collapsed input are written to `advice` inside one transaction 13. **Persistence** — the advisory plus its collapsed input are written to `advice` inside one transaction

View File

@ -286,7 +286,7 @@ def _run_job_inner(resources: Resources, job: Job, as_of: date, dry_run: bool) -
builder = resources.product_json_builder(job.crop_english, disease.disease_english) builder = resources.product_json_builder(job.crop_english, disease.disease_english)
with resources.sql_sem: with resources.sql_sem:
last_summary, last_treatments = load_last_advice( last_summary, last_treatments, last_date = load_last_advice(
conn, job.field_id, disease.disease_english, as_of conn, job.field_id, disease.disease_english, as_of
) )
enrich_advice_json( enrich_advice_json(
@ -296,6 +296,7 @@ def _run_job_inner(resources: Resources, job: Job, as_of: date, dry_run: bool) -
recommended=recommended, recommended=recommended,
last_advice_summary=last_summary, last_advice_summary=last_summary,
last_advice_treatments=last_treatments, last_advice_treatments=last_treatments,
last_advice_date=last_date,
) )
payload["last_advice"] = json_for_advice_generation["last_advice"] payload["last_advice"] = json_for_advice_generation["last_advice"]
@ -307,6 +308,7 @@ def _run_job_inner(resources: Resources, job: Job, as_of: date, dry_run: bool) -
advice_system_prompt, advice_system_prompt,
advice_user_prompt, advice_user_prompt,
json_for_advice_generation, json_for_advice_generation,
as_of,
llm_call=resources.call_llm, llm_call=resources.call_llm,
) )
sent_information = collapse_product_json(json_for_advice_generation) sent_information = collapse_product_json(json_for_advice_generation)

View File

@ -14,6 +14,7 @@ import pyodbc
from pipeline.config import Settings from pipeline.config import Settings
from pipeline.db import execute, transaction from pipeline.db import execute, transaction
from pipeline.errors import PipelineError from pipeline.errors import PipelineError
from pipeline.stages.advice_summary import sanitize_advice_summary
from pipeline.stages.llm import call_llm from pipeline.stages.llm import call_llm
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -71,6 +72,7 @@ def generate_advice(
system_prompt: str, system_prompt: str,
user_prompt: str, user_prompt: str,
advice_json: dict[str, Any], advice_json: dict[str, Any],
as_of: date,
llm_call: Callable[..., str] = None, llm_call: Callable[..., str] = None,
) -> AdviceResult: ) -> AdviceResult:
""" """
@ -93,15 +95,24 @@ def generate_advice(
user_content, user_content,
json_output=True, json_output=True,
) )
return parse_advice(raw) return parse_advice(raw, as_of)
def parse_advice(raw: str) -> AdviceResult: def parse_advice(raw: str, as_of: date) -> AdviceResult:
"""Parse and validate the advice JSON returned by the LLM.""" """Parse and validate the advice JSON returned by the LLM."""
payload = _load_json_object(raw) payload = _load_json_object(raw)
full_advice = _require_text(payload, "full_advice") full_advice = _require_text(payload, "full_advice")
summary = _require_text(payload, "advice_summary") summary = _require_text(payload, "advice_summary")
cleaned = sanitize_advice_summary(summary, as_of)
if not cleaned:
raise PipelineError("advice_summary is empty after sanitization.")
if cleaned != summary:
logger.warning(
"advice_summary was rewritten to remove relative dates, prefixes, "
"or boilerplate."
)
summary = cleaned
if len(summary) > SUMMARY_MAX_CHARS: if len(summary) > SUMMARY_MAX_CHARS:
logger.warning( logger.warning(
"advice_summary is %d characters; truncating to %d.", "advice_summary is %d characters; truncating to %d.",

View File

@ -4,7 +4,7 @@ from __future__ import annotations
import copy import copy
import logging import logging
from datetime import date from datetime import date, datetime
from typing import Any, Iterable from typing import Any, Iterable
import pyodbc import pyodbc
@ -13,6 +13,7 @@ from pipeline.db import fetch_one
from pipeline.jsonutils import parse_json_list from pipeline.jsonutils import parse_json_list
from pipeline.stages.product_json import ProductJsonBuilder, normalize_name from pipeline.stages.product_json import ProductJsonBuilder, normalize_name
from pipeline.stages.vector import RecommendedProduct from pipeline.stages.vector import RecommendedProduct
from pipeline.window import format_date
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -20,6 +21,7 @@ _PRODUCT_LIST_KEYS = ("allowed_products", "applied_treatment", "suggested_produc
_LAST_ADVICE_SQL = """ _LAST_ADVICE_SQL = """
SELECT TOP 1 SELECT TOP 1
[date] AS issued_on,
advice_summary, advice_summary,
treatments treatments
FROM advice FROM advice
@ -30,13 +32,23 @@ ORDER BY [date] DESC
""" """
def _row_date(value: Any) -> date | None:
if value is None:
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
return None
def load_last_advice( def load_last_advice(
conn: pyodbc.Connection, conn: pyodbc.Connection,
field_id: int, field_id: int,
disease_english: str, disease_english: str,
as_of: date, as_of: date,
) -> tuple[str, list[str]]: ) -> tuple[str, list[str], date | None]:
"""Return (advice_summary, treatment names) of the most recent advice before as_of.""" """Return (advice_summary, treatment names, issuance date) of the most recent advice before as_of."""
row = fetch_one(conn, _LAST_ADVICE_SQL, (field_id, disease_english, as_of)) row = fetch_one(conn, _LAST_ADVICE_SQL, (field_id, disease_english, as_of))
if row is None: if row is None:
logger.info( logger.info(
@ -45,12 +57,13 @@ def load_last_advice(
disease_english, disease_english,
as_of.isoformat(), as_of.isoformat(),
) )
return "", [] return "", [], None
summary = (row.advice_summary or "").strip() summary = (row.advice_summary or "").strip()
treatments = [name.strip() for name in parse_json_list(row.treatments) if name.strip()] treatments = [name.strip() for name in parse_json_list(row.treatments) if name.strip()]
issued_on = _row_date(row.issued_on)
logger.info("Loaded previous advice with %d suggested product(s).", len(treatments)) logger.info("Loaded previous advice with %d suggested product(s).", len(treatments))
return summary, treatments return summary, treatments, issued_on
def enrich_advice_json( def enrich_advice_json(
@ -61,6 +74,7 @@ def enrich_advice_json(
recommended: Iterable[RecommendedProduct], recommended: Iterable[RecommendedProduct],
last_advice_summary: str, last_advice_summary: str,
last_advice_treatments: Iterable[str], last_advice_treatments: Iterable[str],
last_advice_date: date | None = None,
) -> None: ) -> None:
""" """
Add allowed_products and last_advice, and expand applied_treatment in place. Add allowed_products and last_advice, and expand applied_treatment in place.
@ -90,6 +104,7 @@ def enrich_advice_json(
known_names = vector_names | applied_names known_names = vector_names | applied_names
advice_json["last_advice"] = { advice_json["last_advice"] = {
"date": format_date(last_advice_date) if last_advice_date else None,
"advice_summary": last_advice_summary, "advice_summary": last_advice_summary,
"suggested_products": [ "suggested_products": [
name if normalize_name(name) in known_names else builder.build(conn, name) name if normalize_name(name) in known_names else builder.build(conn, name)

View File

@ -0,0 +1,78 @@
"""Rewrite advice_summary so it is safe to feed back as last_advice.
The field is machine context for the next run, not farmer-facing prose.
Relative day words, attention prefixes, and follow-the-label closers are
stripped here as a backstop for the output-format prompt.
"""
from __future__ import annotations
import re
from datetime import date, datetime, timedelta
from pipeline.window import format_date
_DEICTIC = r"(?:oggi|ieri|domani|dopodomani|today|yesterday|tomorrow)"
_PREFIX = re.compile(
r"^\s*(?:attenzione|nota|importante|avviso|warning|attention)\s*[:!]\s*",
re.IGNORECASE,
)
# "oggi, 13 aprile 2026" / "today, Apr 13th" — drop the deictic + comma, keep the date.
_DEICTIC_BEFORE_DATE = re.compile(rf"\b{_DEICTIC}\s*,\s*", re.IGNORECASE)
_STANDALONE_DEICTIC = re.compile(rf"\b{_DEICTIC}\b", re.IGNORECASE)
# Whole sentence that is only a follow-the-label reminder. Requires
# istruzioni/indicazioni so a real label constraint (season cap, PHI) survives.
_ETICHETTA_CLOSER = re.compile(
r"(?:^|(?<=[.!?]))\s*"
r"(?:Seguire|Rispettare|Attenersi(?:\s+a)?)"
r".{0,80}?"
r"(?:istruzioni|indicazioni)"
r".{0,40}?"
r"etichetta\s*\.",
re.IGNORECASE,
)
_MULTI_SPACE = re.compile(r"[ \t]{2,}")
_DEICTIC_OFFSETS = {
"oggi": 0,
"today": 0,
"domani": 1,
"tomorrow": 1,
"ieri": -1,
"yesterday": -1,
"dopodomani": 2,
}
def _as_date(issued_on: date) -> date:
if isinstance(issued_on, datetime):
return issued_on.date()
return issued_on
def _replace_standalone(match: re.Match[str], issued_on: date) -> str:
offset = _DEICTIC_OFFSETS[match.group(0).casefold()]
return format_date(issued_on + timedelta(days=offset))
def sanitize_advice_summary(text: str, issued_on: date) -> str:
"""Strip prefixes, relative day words, and label-instruction closers."""
issued_on = _as_date(issued_on)
cleaned = (text or "").strip()
if not cleaned:
return ""
cleaned = _PREFIX.sub("", cleaned, count=1)
cleaned = _DEICTIC_BEFORE_DATE.sub("", cleaned)
cleaned = _STANDALONE_DEICTIC.sub(
lambda match: _replace_standalone(match, issued_on),
cleaned,
)
cleaned = _ETICHETTA_CLOSER.sub("", cleaned)
cleaned = _MULTI_SPACE.sub(" ", cleaned)
return cleaned.strip()

View File

@ -66,7 +66,7 @@ def assemble_json_for_advice_generation(
applied_treatments: dict[date, list[str]] | None = None, applied_treatments: dict[date, list[str]] | None = None,
observation: dict[date, bool] | None = None, observation: dict[date, bool] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Build meteorological_data (as_of-5..as_of) and weather_forecasts (as_of+1..as_of+5).""" """Build meteorological_data (as_of-5..as_of), weather_forecasts (as_of+1..as_of+5), and date_of_today."""
window = build_window(as_of) window = build_window(as_of)
applied = applied_treatments or {} applied = applied_treatments or {}
meteorological = [ meteorological = [
@ -80,6 +80,7 @@ def assemble_json_for_advice_generation(
if day > as_of if day > as_of
] ]
return { return {
"date_of_today": format_date(as_of),
"meteorological_data": meteorological, "meteorological_data": meteorological,
"weather_forecasts": forecasts_section, "weather_forecasts": forecasts_section,
} }

View File

@ -6,7 +6,7 @@ introduction, no commentary outside the JSON.
| Key | Type | Rules | | Key | Type | Rules |
|---|---|---| |---|---|---|
| `full_advice` | string | Your complete advisory text, in Italian. | | `full_advice` | string | Your complete advisory text, in Italian. |
| `advice_summary` | string | Italian summary of `full_advice`, **at most 1010 characters**. It must state the recommendation: whether a treatment should be administered or not; if yes, for what reason, on what date (and why that date), and with which product; if no, for what reason. | | `advice_summary` | string | Italian summary of `full_advice`, **at most 1010 characters**. It must state the recommendation: whether a treatment should be administered or not; if yes, for what reason, on what date (and why that date), and with which product; if no, for what reason. Use calendar dates only (for example `13 aprile 2026` or `13-04-2026`). Never use relative day words such as oggi, ieri, domani, dopodomani, today, yesterday, or tomorrow — even when a calendar date follows them. Do not start with labels such as `Attenzione:`, `Nota:`, or `Importante:`. Do not close with a reminder to follow the product label; that belongs in `full_advice`. |
| `apply_treatment` | integer | `1` if you recommend applying a product, `0` otherwise. | | `apply_treatment` | integer | `1` if you recommend applying a product, `0` otherwise. |
| `treatments` | array of strings | Names of the recommended product(s), exactly as written in the input payload. Empty array `[]` when you recommend no product. | | `treatments` | array of strings | Names of the recommended product(s), exactly as written in the input payload. Empty array `[]` when you recommend no product. |
| `dosage` | array of strings | Prescribed dose for each product in `treatments`, in the same order (for example `"1.5 l/ha"`). Empty array `[]` when you recommend no product. | | `dosage` | array of strings | Prescribed dose for each product in `treatments`, in the same order (for example `"1.5 l/ha"`). Empty array `[]` when you recommend no product. |
@ -15,6 +15,9 @@ introduction, no commentary outside the JSON.
Only recommend products that appear in the `allowed_products` list of the input payload. Only recommend products that appear in the `allowed_products` list of the input payload.
If `allowed_products` is empty, you must not prescribe any product. If `allowed_products` is empty, you must not prescribe any product.
`date_of_today` is oggi for this advisory. `last_advice` was issued on `last_advice.date`,
which is an earlier day; do not read dates in that block as the current day.
### Example shape ### Example shape
{ {

View File

@ -0,0 +1,138 @@
"""Unit tests for advice_summary sanitization and parse_advice wiring."""
from __future__ import annotations
import json
import unittest
from datetime import date, datetime
from pipeline.errors import PipelineError
from pipeline.stages.advice import SUMMARY_MAX_CHARS, parse_advice
from pipeline.stages.advice_summary import sanitize_advice_summary
_APR_13 = date(2026, 4, 13)
_APR13_SUMMARY = (
"Attenzione: il modello segnala un'infezione primaria di peronospora "
"(FASE5) per oggi, 13 aprile 2026. La vite si trova in una fase fenologica "
"di elevata suscettibilità (BBCH 15) e non risulta protetta. È "
"indispensabile intervenire per bloccare l'infezione in corso. Si raccomanda "
"di effettuare un trattamento con azione curativa domani, 14 aprile 2026. "
"Si consiglia l'impiego del prodotto PROFILER, dotato di attività sistemica "
"e curativa, alla dose di 3.0 kg/ha. Questo intervento è fondamentale per "
"impedire che l'infezione si stabilisca e per prevenire l'inizio "
"dell'epidemia stagionale. Seguire attentamente le istruzioni in etichetta."
)
def _advice_json(*, summary: str, full_advice: str = "Testo completo.") -> str:
return json.dumps(
{
"full_advice": full_advice,
"advice_summary": summary,
"apply_treatment": 1,
"treatments": ["PROFILER"],
"dosage": ["3.0 kg/ha"],
"apply_date": "2026-04-14",
},
ensure_ascii=False,
)
class SanitizeAdviceSummaryTests(unittest.TestCase):
def test_apr13_summary_strips_prefix_deictics_and_etichetta(self) -> None:
cleaned = sanitize_advice_summary(_APR13_SUMMARY, _APR_13)
self.assertFalse(cleaned.lower().startswith("attenzione"))
self.assertNotIn("oggi", cleaned.casefold())
self.assertNotIn("domani", cleaned.casefold())
self.assertNotIn("etichetta", cleaned.casefold())
self.assertIn("13 aprile 2026", cleaned)
self.assertIn("14 aprile 2026", cleaned)
self.assertIn("PROFILER", cleaned)
def test_english_today_tomorrow_before_date(self) -> None:
text = "Spray today, Apr 13th and again tomorrow, Apr 15th."
cleaned = sanitize_advice_summary(text, _APR_13)
self.assertEqual(cleaned, "Spray Apr 13th and again Apr 15th.")
self.assertNotIn("today", cleaned.casefold())
self.assertNotIn("tomorrow", cleaned.casefold())
def test_standalone_oggi_and_domani(self) -> None:
text = "Le condizioni di oggi restano favorevoli e domani peggiorano."
cleaned = sanitize_advice_summary(text, _APR_13)
self.assertEqual(
cleaned,
"Le condizioni di 13-04-2026 restano favorevoli e 14-04-2026 peggiorano.",
)
def test_standalone_ieri(self) -> None:
cleaned = sanitize_advice_summary("Pioggia ieri.", _APR_13)
self.assertEqual(cleaned, "Pioggia 12-04-2026.")
def test_etichetta_closer_variants(self) -> None:
variants = (
"Intervenire subito. Seguire attentamente le istruzioni in etichetta.",
"Intervenire subito. Seguire attentamente le istruzioni riportate in etichetta.",
"Intervenire subito. Rispettare scrupolosamente le indicazioni riportate in etichetta.",
)
for text in variants:
with self.subTest(text=text):
cleaned = sanitize_advice_summary(text, _APR_13)
self.assertEqual(cleaned, "Intervenire subito.")
self.assertNotIn("etichetta", cleaned.casefold())
def test_etichetta_with_real_content_is_kept(self) -> None:
text = (
"Non superare i 2 trattamenti per stagione previsti in etichetta "
"per questo formulato."
)
cleaned = sanitize_advice_summary(text, _APR_13)
self.assertEqual(cleaned, text)
def test_prefix_only(self) -> None:
cleaned = sanitize_advice_summary(
"Attenzione: trattare il 13 aprile 2026.",
_APR_13,
)
self.assertEqual(cleaned, "trattare il 13 aprile 2026.")
def test_already_clean_is_idempotent(self) -> None:
text = "Trattare il 13 aprile 2026 con PROFILER a 3.0 kg/ha."
once = sanitize_advice_summary(text, _APR_13)
twice = sanitize_advice_summary(once, _APR_13)
self.assertEqual(once, text)
self.assertEqual(twice, text)
def test_apr13_summary_is_idempotent(self) -> None:
once = sanitize_advice_summary(_APR13_SUMMARY, _APR_13)
twice = sanitize_advice_summary(once, _APR_13)
self.assertEqual(once, twice)
def test_datetime_issued_on(self) -> None:
cleaned = sanitize_advice_summary("Trattare oggi.", datetime(2026, 4, 13, 8, 0))
self.assertEqual(cleaned, "Trattare 13-04-2026.")
class ParseAdviceSanitizeTests(unittest.TestCase):
def test_parse_advice_sanitizes_summary(self) -> None:
result = parse_advice(_advice_json(summary=_APR13_SUMMARY), as_of=_APR_13)
self.assertNotIn("oggi", result.advice_summary.casefold())
self.assertFalse(result.advice_summary.lower().startswith("attenzione"))
self.assertIn("13 aprile 2026", result.advice_summary)
def test_empty_after_sanitize_raises(self) -> None:
summary = "Attenzione: Seguire attentamente le istruzioni in etichetta."
with self.assertRaises(PipelineError) as ctx:
parse_advice(_advice_json(summary=summary), as_of=_APR_13)
self.assertIn("empty after sanitization", str(ctx.exception))
def test_truncation_after_sanitize(self) -> None:
body = "Trattare il 13 aprile 2026. " * 80
self.assertGreater(len(body), SUMMARY_MAX_CHARS)
result = parse_advice(_advice_json(summary=body), as_of=_APR_13)
self.assertEqual(len(result.advice_summary), SUMMARY_MAX_CHARS)
self.assertTrue(result.advice_summary.startswith("Trattare il 13 aprile 2026."))
if __name__ == "__main__":
unittest.main()