diff --git a/data/disease/symptom_rules.json b/data/disease/symptom_rules.json new file mode 100644 index 0000000..bb468bd --- /dev/null +++ b/data/disease/symptom_rules.json @@ -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." + } + ] +} diff --git a/src/plantguide/cli.py b/src/plantguide/cli.py index 2deb2b4..c911919 100644 --- a/src/plantguide/cli.py +++ b/src/plantguide/cli.py @@ -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)) diff --git a/src/plantguide/identify/disease.py b/src/plantguide/identify/disease.py index 639e45b..6f99881 100644 --- a/src/plantguide/identify/disease.py +++ b/src/plantguide/identify/disease.py @@ -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", } diff --git a/tests/test_disease.py b/tests/test_disease.py index 4ec252e..694bb74 100644 --- a/tests/test_disease.py +++ b/tests/test_disease.py @@ -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: