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 |
|---|---|
| `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 |
| `generated_queries` | One or two natural-language search queries |
| `candidate_count` | Products remaining after the prefilter |
| `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` |
| `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 |
| `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 |
`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
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
@ -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
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
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)
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
)
enrich_advice_json(
@ -296,6 +296,7 @@ def _run_job_inner(resources: Resources, job: Job, as_of: date, dry_run: bool) -
recommended=recommended,
last_advice_summary=last_summary,
last_advice_treatments=last_treatments,
last_advice_date=last_date,
)
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_user_prompt,
json_for_advice_generation,
as_of,
llm_call=resources.call_llm,
)
sent_information = collapse_product_json(json_for_advice_generation)

View File

@ -14,6 +14,7 @@ import pyodbc
from pipeline.config import Settings
from pipeline.db import execute, transaction
from pipeline.errors import PipelineError
from pipeline.stages.advice_summary import sanitize_advice_summary
from pipeline.stages.llm import call_llm
logger = logging.getLogger(__name__)
@ -71,6 +72,7 @@ def generate_advice(
system_prompt: str,
user_prompt: str,
advice_json: dict[str, Any],
as_of: date,
llm_call: Callable[..., str] = None,
) -> AdviceResult:
"""
@ -93,15 +95,24 @@ def generate_advice(
user_content,
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."""
payload = _load_json_object(raw)
full_advice = _require_text(payload, "full_advice")
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:
logger.warning(
"advice_summary is %d characters; truncating to %d.",

View File

@ -4,7 +4,7 @@ from __future__ import annotations
import copy
import logging
from datetime import date
from datetime import date, datetime
from typing import Any, Iterable
import pyodbc
@ -13,6 +13,7 @@ from pipeline.db import fetch_one
from pipeline.jsonutils import parse_json_list
from pipeline.stages.product_json import ProductJsonBuilder, normalize_name
from pipeline.stages.vector import RecommendedProduct
from pipeline.window import format_date
logger = logging.getLogger(__name__)
@ -20,6 +21,7 @@ _PRODUCT_LIST_KEYS = ("allowed_products", "applied_treatment", "suggested_produc
_LAST_ADVICE_SQL = """
SELECT TOP 1
[date] AS issued_on,
advice_summary,
treatments
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(
conn: pyodbc.Connection,
field_id: int,
disease_english: str,
as_of: date,
) -> tuple[str, list[str]]:
"""Return (advice_summary, treatment names) of the most recent advice before as_of."""
) -> tuple[str, list[str], date | None]:
"""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))
if row is None:
logger.info(
@ -45,12 +57,13 @@ def load_last_advice(
disease_english,
as_of.isoformat(),
)
return "", []
return "", [], None
summary = (row.advice_summary or "").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))
return summary, treatments
return summary, treatments, issued_on
def enrich_advice_json(
@ -61,6 +74,7 @@ def enrich_advice_json(
recommended: Iterable[RecommendedProduct],
last_advice_summary: str,
last_advice_treatments: Iterable[str],
last_advice_date: date | None = None,
) -> None:
"""
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
advice_json["last_advice"] = {
"date": format_date(last_advice_date) if last_advice_date else None,
"advice_summary": last_advice_summary,
"suggested_products": [
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,
observation: dict[date, bool] | None = None,
) -> 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)
applied = applied_treatments or {}
meteorological = [
@ -80,6 +80,7 @@ def assemble_json_for_advice_generation(
if day > as_of
]
return {
"date_of_today": format_date(as_of),
"meteorological_data": meteorological,
"weather_forecasts": forecasts_section,
}

View File

@ -6,7 +6,7 @@ introduction, no commentary outside the JSON.
| Key | Type | Rules |
|---|---|---|
| `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. |
| `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. |
@ -15,6 +15,9 @@ introduction, no commentary outside the JSON.
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.
`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
{

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()