126 lines
4.8 KiB
Python
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
|