AI_Agro_Support/pipeline/prompts.py
Arsham Mirehvandi 099d1e0af9 Initial commit
2026-08-22 08:47:56 +02:00

126 lines
4.8 KiB
Python

"""Per-crop-disease prompt registry.
Every crop grows a specific set of diseases, and each crop-disease pair needs a
tailored advice prompt (its own phytopathological model semantics, phenology
notes, etc.). Prompts live on disk under `prompts/` and are resolved by a
`(crop, disease)` slug, with a shared `_default` prompt for pairs that have not
been given a dedicated one yet:
prompts/_output_format.md shared advice JSON contract
prompts/query_synthesis/_default.md generic query-synthesis system prompt
prompts/query_synthesis/<crop>__<disease>.md optional per-pair override
prompts/advice/_default/{system.md,user.md} fallback advice prompts
prompts/advice/<crop>__<disease>/{system.md,user.md} per-pair advice prompts
All prompts are read once and cached in memory, since the batch resolves them
from multiple worker threads.
"""
from __future__ import annotations
import logging
import re
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
from pipeline.errors import PipelineError
logger = logging.getLogger(__name__)
_DEFAULT_SLUG = "_default"
def slugify(text: str) -> str:
"""Normalise a crop or disease name into a filesystem-safe slug."""
slug = re.sub(r"[^a-z0-9]+", "_", text.strip().lower())
return slug.strip("_")
def pair_slug(crop: str, disease: str) -> str:
"""The directory / filename stem identifying a crop-disease pair."""
return f"{slugify(crop)}__{slugify(disease)}"
@dataclass(frozen=True)
class _Resolved:
text: str
is_default: bool
class PromptRegistry:
"""Loads and caches advice / query-synthesis prompts from `prompts_dir`."""
def __init__(self, prompts_dir: Path) -> None:
self._dir = prompts_dir
self._output_format = self._read(prompts_dir / "_output_format.md")
self._lock = threading.Lock()
self._advice_cache: dict[tuple[str, str], tuple[_Resolved, _Resolved]] = {}
self._query_cache: dict[tuple[str, str], str] = {}
@staticmethod
def _read(path: Path) -> str:
if not path.is_file():
raise PipelineError(f"Prompt file not found: {path}")
return path.read_text(encoding="utf-8").strip()
def _resolve_advice_file(self, slug: str, filename: str) -> _Resolved:
advice_dir = self._dir / "advice"
specific = advice_dir / slug / filename
if specific.is_file():
return _Resolved(text=self._read(specific), is_default=False)
default = advice_dir / _DEFAULT_SLUG / filename
return _Resolved(text=self._read(default), is_default=True)
def _get_advice(self, crop: str, disease: str) -> tuple[_Resolved, _Resolved]:
slug = pair_slug(crop, disease)
with self._lock:
cached = self._advice_cache.get(slug)
if cached is not None:
return cached
resolved = (
self._resolve_advice_file(slug, "system.md"),
self._resolve_advice_file(slug, "user.md"),
)
self._advice_cache[slug] = resolved
return resolved
def advice_prompts(self, crop: str, disease: str) -> tuple[str, str]:
"""Return (system_prompt, user_prompt) for a crop-disease pair.
The system prompt is the crop-disease-specific (or default) prompt
followed by the shared output-format contract, so every advice call
gets the same strict JSON schema regardless of which prompt matched.
"""
system, user = self._get_advice(crop, disease)
full_system = f"{system.text}\n\n---\n\n{self._output_format}"
return full_system, user.text
def query_prompt(self, crop: str, disease: str) -> str:
"""Return the query-synthesis system prompt for a crop-disease pair."""
slug = pair_slug(crop, disease)
with self._lock:
cached = self._query_cache.get(slug)
if cached is not None:
return cached
qs_dir = self._dir / "query_synthesis"
specific = qs_dir / f"{slug}.md"
text = self._read(specific) if specific.is_file() else self._read(qs_dir / f"{_DEFAULT_SLUG}.md")
self._query_cache[slug] = text
return text
def validate(self, pairs: Iterable[tuple[str, str]]) -> list[str]:
"""
Return "crop / disease" strings for pairs without a dedicated advice prompt.
Call before starting the worker pool so missing prompts are reported
up front instead of discovered mid-batch.
"""
fallbacks: list[str] = []
for crop, disease in pairs:
system, user = self._get_advice(crop, disease)
if system.is_default or user.is_default:
fallbacks.append(f"{crop} / {disease}")
return fallbacks