github-actions[bot]
deploy: HF Spaces snapshot from d9099ec
7c0570f
Raw
History Blame Contribute Delete
9.77 kB
"""
kb_loader.py — Load and manage the structured disease knowledge base.
Reads diseases.json at startup, provides query functions for the reasoning engine.
Supports hot-reload: call reload() to pick up KB edits without restarting the server.
"""
import json
from pathlib import Path
from dataclasses import dataclass, field
from loguru import logger
from datetime import datetime
_KB_PATH = Path(__file__).parent / "diseases.json"
# ── Data structures ──
@dataclass
class DiseaseProfile:
"""Complete disease profile loaded from JSON KB."""
name: str
display_name: str
type: str # "disease_fungal" | "disease_bacterial" | "pest_insect" | "healthy"
severity: float
crop: str
symptoms: list[str]
color_signatures: list[dict]
texture_keywords: list[str]
affected_parts: list[str]
confusion_diseases: list[str]
treatment: list[str]
urgency: str
yield_loss_pct: str
pathogen: str = ""
favorable_conditions: str = ""
@dataclass
class DifferentialRule:
"""Pairwise comparison between two diseases."""
disease_a: str
disease_b: str
key_differences: list[dict] # [{feature, a_value, b_value}, ...]
rule: str # Human-readable decision rule
@dataclass
class SeasonalRisk:
"""Disease risk levels for a crop growth stage."""
stage: str
months: list[str]
temperature_range_c: list[float]
high_risk: list[str]
moderate_risk: list[str]
low_risk: list[str]
unlikely: list[str]
# ── Module-level state ──
_PROFILES: dict[str, DiseaseProfile] = {}
_DIFFERENTIALS: dict[str, DifferentialRule] = {}
_SEASONAL: dict[str, dict[str, SeasonalRisk]] = {} # crop → stage → risk
_META: dict = {}
_LOADED: bool = False
def _parse_profiles(data: dict) -> dict[str, DiseaseProfile]:
"""Parse profile dicts from JSON into DiseaseProfile dataclasses."""
profiles = {}
for key, p in data.get("profiles", {}).items():
try:
profiles[key] = DiseaseProfile(
name=key,
display_name=p.get("display_name", key.replace("_", " ").title()),
type=p.get("type", "disease_fungal"),
severity=p.get("severity", 0.5),
crop=p.get("crop", "wheat"),
symptoms=p.get("symptoms", []),
color_signatures=p.get("color_signatures", []),
texture_keywords=p.get("texture_keywords", []),
affected_parts=p.get("affected_parts", []),
confusion_diseases=p.get("confusion_diseases", []),
treatment=p.get("treatment", []),
urgency=p.get("urgency", "within_7_days"),
yield_loss_pct=p.get("yield_loss_pct", "unknown"),
pathogen=p.get("pathogen", ""),
favorable_conditions=p.get("favorable_conditions", ""),
)
except Exception as exc:
logger.warning(f"Skipping malformed KB profile '{key}': {exc}")
return profiles
def _parse_differentials(data: dict) -> dict[str, DifferentialRule]:
"""Parse differential diagnosis tables from JSON."""
diffs = {}
for key, d in data.get("differential_diagnosis", {}).items():
if key.startswith("_"):
continue
try:
diffs[key] = DifferentialRule(
disease_a=d["disease_a"],
disease_b=d["disease_b"],
key_differences=d.get("key_differences", []),
rule=d.get("rule", ""),
)
except Exception as exc:
logger.warning(f"Skipping malformed differential '{key}': {exc}")
return diffs
def _parse_seasonal(data: dict) -> dict[str, dict[str, SeasonalRisk]]:
"""Parse seasonal context from JSON."""
seasonal = {}
for crop, stages in data.get("seasonal_context", {}).items():
if crop.startswith("_"):
continue
seasonal[crop] = {}
for stage_name, s in stages.items():
if stage_name.startswith("_"):
continue
try:
seasonal[crop][stage_name] = SeasonalRisk(
stage=stage_name,
months=s.get("months", []),
temperature_range_c=s.get("temperature_range_c", []),
high_risk=s.get("high_risk", []),
moderate_risk=s.get("moderate_risk", []),
low_risk=s.get("low_risk", []),
unlikely=s.get("unlikely", []),
)
except Exception as exc:
logger.warning(f"Skipping malformed seasonal stage '{crop}/{stage_name}': {exc}")
return seasonal
# ── Public API ──
def load(path: Path | str | None = None) -> None:
"""Load (or reload) the knowledge base from JSON file."""
global _PROFILES, _DIFFERENTIALS, _SEASONAL, _META, _LOADED
kb_path = Path(path) if path else _KB_PATH
if not kb_path.is_file():
logger.error(f"Knowledge base not found: {kb_path}")
_LOADED = False
return
try:
with open(kb_path, "r", encoding="utf-8") as f:
data = json.load(f)
_META = data.get("_meta", {})
_PROFILES = _parse_profiles(data)
_DIFFERENTIALS = _parse_differentials(data)
_SEASONAL = _parse_seasonal(data)
_LOADED = True
logger.info(
f"Knowledge base loaded: {len(_PROFILES)} profiles, "
f"{len(_DIFFERENTIALS)} differentials, "
f"{sum(len(s) for s in _SEASONAL.values())} seasonal stages "
f"(v{_META.get('version', '?')})"
)
except json.JSONDecodeError as exc:
logger.error(f"Invalid JSON in KB file {kb_path}: {exc}")
_LOADED = False
except Exception as exc:
logger.error(f"Failed to load KB: {exc}")
_LOADED = False
def reload() -> None:
"""Hot-reload the KB from disk. Call after editing diseases.json."""
load()
def is_loaded() -> bool:
return _LOADED
def get_all_profiles() -> dict[str, DiseaseProfile]:
"""Return all disease profiles."""
if not _LOADED:
load()
return _PROFILES
def get_profile(key: str) -> DiseaseProfile | None:
"""Get a single disease profile by key."""
if not _LOADED:
load()
return _PROFILES.get(key)
def get_profiles_for_crop(crop: str) -> dict[str, DiseaseProfile]:
"""Get all profiles for a specific crop (wheat/rice)."""
if not _LOADED:
load()
return {k: p for k, p in _PROFILES.items() if p.crop == crop or p.crop == "both"}
def get_disease_profiles(crop: str | None = None) -> dict[str, DiseaseProfile]:
"""Get disease profiles (excluding healthy), optionally filtered by crop."""
if not _LOADED:
load()
profiles = _PROFILES
if crop:
profiles = {k: p for k, p in profiles.items() if p.crop == crop or p.crop == "both"}
return {k: p for k, p in profiles.items() if p.type != "healthy"}
def get_differential(disease_a: str, disease_b: str) -> DifferentialRule | None:
"""Get the differential diagnosis rule for two diseases (order-independent)."""
if not _LOADED:
load()
key1 = f"{disease_a}__vs__{disease_b}"
key2 = f"{disease_b}__vs__{disease_a}"
return _DIFFERENTIALS.get(key1) or _DIFFERENTIALS.get(key2)
def get_all_differentials() -> dict[str, DifferentialRule]:
"""Return all differential diagnosis rules."""
if not _LOADED:
load()
return _DIFFERENTIALS
def get_seasonal_risk(crop: str, month: str | None = None) -> list[SeasonalRisk]:
"""Get seasonal risk stages for a crop. If month given, return only matching stages."""
if not _LOADED:
load()
stages = _SEASONAL.get(crop, {})
if month is None:
return list(stages.values())
matching = []
for stage in stages.values():
if month in stage.months:
matching.append(stage)
return matching
def get_current_seasonal_risk(crop: str) -> SeasonalRisk | None:
"""Get the seasonal risk for the current month."""
current_month = datetime.now().strftime("%B") # e.g., "April"
risks = get_seasonal_risk(crop, current_month)
return risks[0] if risks else None
def get_seasonal_adjustment(disease_key: str, crop: str, month: str | None = None) -> float:
"""Return a confidence adjustment multiplier based on seasonal likelihood.
Returns:
1.2 if disease is high_risk this season
1.0 if moderate_risk
0.8 if low_risk
0.5 if unlikely
1.0 if no seasonal data available
"""
if month is None:
month = datetime.now().strftime("%B")
risks = get_seasonal_risk(crop, month)
if not risks:
return 1.0
for stage in risks:
if disease_key in stage.high_risk:
return 1.2
if disease_key in stage.moderate_risk:
return 1.0
if disease_key in stage.low_risk:
return 0.8
if disease_key in stage.unlikely:
return 0.5
return 1.0
def get_kb_info() -> dict:
"""Return KB metadata for API info endpoints."""
if not _LOADED:
load()
return {
"version": _META.get("version", "unknown"),
"last_updated": _META.get("last_updated", "unknown"),
"total_profiles": len(_PROFILES),
"total_differentials": len(_DIFFERENTIALS),
"crops": list(set(p.crop for p in _PROFILES.values())),
"types": list(set(p.type for p in _PROFILES.values())),
"disease_count": len([p for p in _PROFILES.values() if p.type != "healthy"]),
"pest_count": len([p for p in _PROFILES.values() if p.type == "pest_insect"]),
}