"""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/__.md optional per-pair override prompts/advice/_default/{system.md,user.md} fallback advice prompts prompts/advice/__/{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