Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions data/disease/symptom_rules.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
{
"issues": [
{
"id": "scale_insects",
"name": "Scale insects",
"symptoms": [
"yellow spots",
"sticky leaves",
"brown bumps",
"sooty mold"
],
"likely_causes": [
"Sap-feeding scale insects attached to stems or leaf undersides",
"Sticky honeydew left by a scale infestation"
],
"remedies": [
"Isolate the plant and inspect stems and leaf undersides",
"Remove visible scale with a cotton swab dampened with 70% isopropyl alcohol",
"Apply insecticidal soap or horticultural oil according to its label and repeat as directed"
],
"when_to_escalate": "Seek local horticulture advice if the infestation persists after repeated treatment."
},
{
"id": "aphids",
"name": "Aphids",
"symptoms": [
"sticky leaves",
"curled leaves",
"distorted growth",
"clusters of insects"
],
"likely_causes": [
"Sap-feeding aphids on tender shoots or leaf undersides",
"Honeydew produced by an active aphid colony"
],
"remedies": [
"Isolate the plant and rinse aphids off with a gentle stream of water",
"Prune heavily infested growth",
"Use insecticidal soap according to its label if rinsing is not sufficient"
],
"when_to_escalate": "Ask a horticulture professional for help if new growth continues to deform."
},
{
"id": "spider_mites",
"name": "Spider mites",
"symptoms": [
"yellow spots",
"speckled leaves",
"fine webbing",
"dry leaves"
],
"likely_causes": [
"Spider mites feeding on foliage, often in warm and dry conditions"
],
"remedies": [
"Isolate the plant and rinse both sides of the leaves",
"Increase humidity when appropriate for the species",
"Apply insecticidal soap or horticultural oil according to its label"
],
"when_to_escalate": "Seek expert identification before using a miticide or treating a severe infestation."
},
{
"id": "fungal_leaf_spot",
"name": "Fungal leaf spot",
"symptoms": [
"yellow spots",
"brown spots",
"leaf spots",
"dark lesions"
],
"likely_causes": [
"Fungal growth encouraged by wet foliage, crowding, or poor air circulation"
],
"remedies": [
"Remove badly affected leaves with clean tools",
"Keep foliage dry and improve air circulation",
"Avoid splashing water between plants"
],
"when_to_escalate": "Get a local diagnosis if spots spread rapidly or affect valuable plants."
},
{
"id": "root_rot",
"name": "Root rot",
"symptoms": [
"mushy roots",
"soft stem",
"wilting in wet soil",
"yellow leaves"
],
"likely_causes": [
"Roots remaining waterlogged because of overwatering or poor drainage"
],
"remedies": [
"Stop watering until the root system can be inspected",
"Trim rotten roots with sanitized tools",
"Repot into fresh, well-draining mix and correct the watering routine"
],
"when_to_escalate": "Ask a horticulture professional for help if most roots or the crown are soft."
}
]
}
4 changes: 2 additions & 2 deletions src/plantguide/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -420,10 +420,10 @@ def app_demo(

@identify_app.command("disease")
def identify_disease(
symptoms: str = typer.Option(..., "--symptoms", "-s", help="e.g. yellow,rot,droop"),
symptoms: str = typer.Option(..., "--symptoms", "-s", help="e.g. yellow spots,sticky leaves"),
top: int = typer.Option(5, "--top", "-k", min=1, max=20),
) -> None:
"""Match symptom tags against species common issues."""
"""Suggest likely plant issues and remedies from symptom tags."""
from plantguide.identify.disease import match_diseases

console.print_json(data=match_diseases(symptoms, top_k=top))
Expand Down
102 changes: 50 additions & 52 deletions src/plantguide/identify/disease.py
Original file line number Diff line number Diff line change
@@ -1,71 +1,69 @@
"""Simple disease / symptom tag matcher against species common issues."""
"""Offline symptom matcher for common plant health issues."""

from __future__ import annotations

from plantguide.data.loader import list_species_files, load_species
import json
from pathlib import Path

from plantguide.config import data_dir

# symptom keyword → normalized issue labels
SYMPTOM_MAP: dict[str, list[str]] = {
"yellow": ["yellow leaves", "chlorosis", "yellowing"],
"yellowing": ["yellow leaves", "chlorosis"],
"brown": ["brown tips", "leaf scorch", "browning"],
"spots": ["leaf spots", "fungal spots"],
"soft": ["root rot", "soft leaves", "mushy"],
"rot": ["root rot", "stem rot"],
"droop": ["wilting", "drooping"],
"wilt": ["wilting", "underwatering"],
"crispy": ["underwatering", "low humidity", "brown tips"],
"mold": ["powdery mildew", "fungal"],
"mildew": ["powdery mildew"],
"bugs": ["pests", "spider mites", "mealybugs"],
"mites": ["spider mites", "pests"],
"leggy": ["leggy growth", "insufficient light"],
}

DEFAULT_RULES_PATH = data_dir() / "disease" / "symptom_rules.json"
DISCLAIMER = (
"Educational guidance only; symptoms can have multiple causes. Isolate affected plants, "
"follow product labels, and ask a qualified horticulture professional when damage is severe "
"or the cause is uncertain."
)


def _normalize_symptoms(symptoms: str | list[str]) -> list[str]:
values = symptoms.replace(";", ",").split(",") if isinstance(symptoms, str) else symptoms
return [" ".join(str(value).strip().lower().split()) for value in values if str(value).strip()]


def _load_rules(path: Path = DEFAULT_RULES_PATH) -> list[dict]:
payload = json.loads(path.read_text(encoding="utf-8"))
return list(payload.get("issues") or [])

def match_diseases(symptoms: str | list[str], top_k: int = 5) -> dict:
"""Rank species by overlap of symptoms with care.common_issues + tags."""
if isinstance(symptoms, str):
raw = [s.strip().lower() for s in symptoms.replace(";", ",").split(",") if s.strip()]
# also split spaces for free text
if len(raw) == 1 and " " in raw[0]:
raw = raw[0].split()
else:
raw = [str(s).strip().lower() for s in symptoms if str(s).strip()]

expanded: set[str] = set()
for s in raw:
expanded.add(s)
for key, vals in SYMPTOM_MAP.items():
if key in s or s in key:
expanded.update(vals)
def _matches(query: str, symptom: str) -> bool:
if query in symptom or symptom in query:
return True
query_words = set(query.split())
symptom_words = set(symptom.split())
return len(query_words & symptom_words) >= 2


def match_diseases(symptoms: str | list[str], top_k: int = 5) -> dict:
"""Suggest likely issues and remedies for comma-separated symptom tags."""
query = _normalize_symptoms(symptoms)
ranked: list[dict] = []
for path in list_species_files():
sp = load_species(path)
issues = [str(x).lower() for x in (sp.get("care") or {}).get("common_issues") or []]
tags = [str(t).lower() for t in (sp.get("tags") or [])]
hay = " ".join(issues + tags)
hits = sorted({e for e in expanded if e in hay or any(e in i for i in issues)})
if not hits and not issues:
continue
score = len(hits) / max(1, len(expanded))
if score <= 0 and not hits:

for rule in _load_rules():
known_symptoms = _normalize_symptoms(rule.get("symptoms") or [])
matched = [
observed
for observed in query
if any(_matches(observed, known) for known in known_symptoms)
]
if not matched:
continue
ranked.append(
{
"species_id": sp.get("id"),
"common_name": sp.get("common_name"),
"score": round(score, 3),
"matched": hits,
"common_issues": (sp.get("care") or {}).get("common_issues") or [],
"issue_id": rule["id"],
"name": rule["name"],
"score": round(len(matched) / max(1, len(query)), 3),
"matched_symptoms": matched,
"likely_causes": rule["likely_causes"],
"remedies": rule["remedies"],
"when_to_escalate": rule["when_to_escalate"],
}
)
ranked.sort(key=lambda r: r["score"], reverse=True)

ranked.sort(key=lambda item: (-item["score"], item["name"]))
return {
"query": raw,
"expanded": sorted(expanded),
"query": query,
"matches": ranked[: max(1, top_k)],
"disclaimer": DISCLAIMER,
"model": "DiseaseTagMatcher",
}
28 changes: 23 additions & 5 deletions tests/test_disease.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,29 @@
from plantguide.identify.disease import match_diseases
from plantguide.care.export_svg import care_card_svg
from plantguide.identify.disease import match_diseases


def test_disease_matcher_suggests_issue_and_remedies() -> None:
result = match_diseases("yellow spots, sticky leaves", top_k=3)

assert result["model"] == "DiseaseTagMatcher"
assert result["matches"][0]["issue_id"] == "scale_insects"
assert result["matches"][0]["matched_symptoms"] == ["yellow spots", "sticky leaves"]
assert result["matches"][0]["remedies"]
assert "Educational guidance only" in result["disclaimer"]


def test_disease_matcher_accepts_list_and_limits_results() -> None:
result = match_diseases([" YELLOW SPOTS ", "sticky leaves"], top_k=1)

assert result["query"] == ["yellow spots", "sticky leaves"]
assert len(result["matches"]) == 1


def test_disease_matcher_unknown_symptom_is_safe() -> None:
result = match_diseases("silver triangles")

def test_disease_matcher_finds_rot() -> None:
r = match_diseases("rot,yellow", top_k=5)
assert r["matches"]
assert r["model"] == "DiseaseTagMatcher"
assert result["matches"] == []
assert "qualified horticulture professional" in result["disclaimer"]


def test_care_svg_snake() -> None:
Expand Down