From 1fbd8d9d511dba8c4fa9aad6ee20644101cfe502 Mon Sep 17 00:00:00 2001 From: hualuoz Date: Wed, 22 Jul 2026 15:57:39 +0800 Subject: [PATCH 1/2] feat: add pet-safe species filter --- README.md | 1 + docs/toxicity-filter-cli.md | 174 ++++---------------------------- src/plantguide/cli.py | 30 +++++- src/plantguide/data/loader.py | 5 +- src/plantguide/data/toxicity.py | 81 +++++++++++++++ tests/test_toxicity.py | 50 +++++++++ 6 files changed, 183 insertions(+), 158 deletions(-) create mode 100644 src/plantguide/data/toxicity.py create mode 100644 tests/test_toxicity.py diff --git a/README.md b/README.md index b6e3281..d2e37c8 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ plantguide identify image -i data/samples/photos/monstera_demo.jpg -k 3 plantguide care show -s monstera_deliciosa plantguide care water -s monstera_deliciosa --season summer plantguide care svg -s monstera_deliciosa +plantguide species list --pet-safe plantguide species filter --light bright --water dry ``` diff --git a/docs/toxicity-filter-cli.md b/docs/toxicity-filter-cli.md index a730038..244b64c 100644 --- a/docs/toxicity-filter-cli.md +++ b/docs/toxicity-filter-cli.md @@ -1,159 +1,29 @@ -I need to first explore the repository structure to understand the codebase and then create a complete solution for the toxicity filter CLI. +# Toxicity and pet-safety flags - -. -.*\.(js|ts|py|go|rs|java)$ - +PlantGuide exposes a normalized `toxicity` list on every species returned by the data +loader. The supported tags are: - -. - +- `pet_safe`: the catalog explicitly describes the plant as safe or non-toxic to pets, + cats, or dogs. +- `toxic_to_pets`: the catalog contains a toxicity warning. Toxic warnings take + precedence over safe language when both appear. +- `unknown`: the catalog does not contain a clear pet-safety statement. +- `toxic_to_horses`: an additional horse-specific warning. +- `edible`: an additional human-edibility tag; it does not imply pet safety. - -. -package\.json|requirements\.txt|go\.mod|Cargo\.toml|pom\.xml - +Exactly one of `pet_safe`, `toxic_to_pets`, or `unknown` is present after loading. Legacy +string fields and care-card notes are normalized at load time, so callers do not need to +handle multiple toxicity shapes. - -. - +## CLI filter - -. -.*CLI.* - +```console +plantguide species list --pet-safe +``` - -. -.*toxicity.*|.*species.*|.*catalog.* - +The filter includes only records normalized to `pet_safe`. Records marked `unknown` are +excluded rather than assumed safe. - -src - - - -cmd - - - -README.md - - - -go.mod - - - -species - - - -toxicity - - - -internal/species/species.go - - - -cmd/plantguide/main.go - - - -internal - - - -internal/catalog/catalog.go - - - -cmd/plantguide - - - -cmd/plantguide/filter.go - - - -FilterCommand - - - -internal/catalog/catalog_test.go - - - -cmd/plantguide/filter_test.go - - - -toxic - - -package main - -import ( - "encoding/json" - "flag" - "fmt" - "os" - "strings" - - "github.com/mergeos-bounties/PlantGuide/internal/catalog" -) - -func filterToxicityCommand() { - fs := flag.NewFlagSet("filter-toxicity", flag.ExitOnError) - toxicity := fs.String("toxicity", "", "Filter by toxicity level (safe, toxic, highly-toxic)") - format := fs.String("format", "json", "Output format (json, text)") - - fs.Parse(os.Args[2:]) - - if *toxicity == "" { - fmt.Fprintln(os.Stderr, "Error: --toxicity flag is required") - fs.Usage() - os.Exit(1) - } - - toxicityLevel := strings.ToLower(*toxicity) - validLevels := map[string]bool{ - "safe": true, - "toxic": true, - "highly-toxic": true, - } - - if !validLevels[toxicityLevel] { - fmt.Fprintf(os.Stderr, "Error: invalid toxicity level '%s'. Must be one of: safe, toxic, highly-toxic\n", *toxicity) - os.Exit(1) - } - - cat, err := catalog.Load() - if err != nil { - fmt.Fprintf(os.Stderr, "Error loading catalog: %v\n", err) - os.Exit(1) - } - - filtered := catalog.FilterByToxicity(cat, toxicityLevel) - - if *format == "json" { - encoder := json.NewEncoder(os.Stdout) - encoder.SetIndent("", " ") - if err := encoder.Encode(filtered); err != nil { - fmt.Fprintf(os.Stderr, "Error encoding JSON: %v\n", err) - os.Exit(1) - } - } else if *format == "text" { - for _, species := range filtered { - fmt.Printf("%s (%s) - Toxicity: %s\n", species.CommonName, species.ScientificName, species.Toxicity) - } - } else { - fmt.Fprintf(os.Stderr, "Error: invalid format '%s'. Must be 'json' or 'text'\n", *format) - os.Exit(1) - } -} - -func init() { - commands["filter-toxicity"] = filterToxicityCommand -} \ No newline at end of file +Pet-safety flags are informational only, not veterinary advice. Confirm a plant's +identity and safety with a veterinarian or trusted toxicology source before allowing a +pet access to it. Toxicity can vary by animal, plant part, dose, and exposure route. diff --git a/src/plantguide/cli.py b/src/plantguide/cli.py index 2deb2b4..b09676c 100644 --- a/src/plantguide/cli.py +++ b/src/plantguide/cli.py @@ -14,6 +14,7 @@ from plantguide.care.filtering import filter_species_by_care from plantguide.collection import add_plant, due_soon, list_plants from plantguide.data.loader import list_species_files, load_species +from plantguide.data.toxicity import PET_SAFETY_DISCLAIMER from plantguide.identify.pipeline import ( identify_from_image, identify_from_sample, @@ -74,20 +75,32 @@ def stats_cmd() -> None: @species_app.command("list") -def species_list() -> None: +def species_list( + pet_safe: bool = typer.Option( + False, + "--pet-safe", + help="Show only species explicitly flagged as pet-safe", + ), +) -> None: files = list_species_files() if not files: console.print("[yellow]No species in data/species[/yellow]") raise typer.Exit() - table = Table(title=f"Species ({len(files)})") + species = [load_species(path) for path in files] + if pet_safe: + species = [item for item in species if "pet_safe" in item["toxicity"]] + + title = "Pet-safe species" if pet_safe else "Species" + table = Table(title=f"{title} ({len(species)})") table.add_column("ID") table.add_column("Common name") table.add_column("Tags") - for path in files: - sp = load_species(path) + for sp in species: tags = ", ".join((sp.get("tags") or [])[:5]) table.add_row(str(sp.get("id")), str(sp.get("common_name")), tags) console.print(table) + if pet_safe: + console.print(f"[yellow]{PET_SAFETY_DISCLAIMER}[/yellow]") @species_app.command("search") @@ -546,6 +559,7 @@ def species_toxicity( ) -> None: """Filter species catalog by toxicity (pet-safe / toxic).""" from plantguide.data.loader import load_species_catalog + catalog = load_species_catalog() if safe: catalog = [s for s in catalog if "pet_safe" in (s.get("toxicity") or [])] @@ -568,6 +582,7 @@ def collection_watering_ics( """Export watering schedule as ICS calendar file (offline demo).""" from datetime import datetime, timedelta from plantguide.config import OUT_DIR + out_path = out or (OUT_DIR / "watering.ics") out_path.parent.mkdir(parents=True, exist_ok=True) now = datetime.now() @@ -576,7 +591,12 @@ def collection_watering_ics( "VERSION:2.0", "PRODID:-//PlantGuide//Watering Calendar//EN", ] - species_list = [{"id": s, "days": (i % 7) + 3} for i, s in enumerate(["basil_sweet", "aloe_vera", "spider_plant", "boston_fern", "snake_plant"])] + species_list = [ + {"id": s, "days": (i % 7) + 3} + for i, s in enumerate( + ["basil_sweet", "aloe_vera", "spider_plant", "boston_fern", "snake_plant"] + ) + ] for entry in species_list: due_date = now + timedelta(days=entry["days"]) lines.append("BEGIN:VEVENT") diff --git a/src/plantguide/data/loader.py b/src/plantguide/data/loader.py index e217c3b..b9b9e0a 100644 --- a/src/plantguide/data/loader.py +++ b/src/plantguide/data/loader.py @@ -4,6 +4,7 @@ from pathlib import Path from plantguide.config import SAMPLES_DIR, SPECIES_DIR +from plantguide.data.toxicity import normalize_toxicity def list_species_files(directory: Path | None = None) -> list[Path]: @@ -30,7 +31,9 @@ def load_species(path: Path) -> dict: payload.setdefault("common_name", path.stem.replace("_", " ").title()) payload.setdefault("scientific_name", payload.get("id", "").replace("_", " ").title()) payload.setdefault("tags", []) - payload.setdefault("care", {}) + care = payload.setdefault("care", {}) + care_toxicity = care.get("toxicity") if isinstance(care, dict) else None + payload["toxicity"] = normalize_toxicity(payload.get("toxicity"), care_note=care_toxicity) return payload diff --git a/src/plantguide/data/toxicity.py b/src/plantguide/data/toxicity.py new file mode 100644 index 0000000..4d83ab3 --- /dev/null +++ b/src/plantguide/data/toxicity.py @@ -0,0 +1,81 @@ +"""Normalize legacy toxicity notes into a small pet-safety schema.""" + +from __future__ import annotations + +import re +from enum import StrEnum + + +class ToxicityTag(StrEnum): + """Canonical tags exposed by loaded species records.""" + + PET_SAFE = "pet_safe" + TOXIC_TO_PETS = "toxic_to_pets" + TOXIC_TO_HORSES = "toxic_to_horses" + EDIBLE = "edible" + UNKNOWN = "unknown" + + +PET_SAFETY_TAGS = frozenset({ToxicityTag.PET_SAFE, ToxicityTag.TOXIC_TO_PETS}) +VALID_TOXICITY_TAGS = frozenset(ToxicityTag) +PET_SAFETY_DISCLAIMER = ( + "Pet-safety flags are informational only, not veterinary advice. " + "Confirm a plant's identity and safety with a veterinarian or trusted toxicology source." +) + + +def normalize_toxicity(value: object, *, care_note: object = None) -> list[str]: + """Return canonical tags, deriving pet safety conservatively from legacy text. + + Existing canonical tags take precedence. Free text is marked pet-safe only when it + explicitly mentions pets, cats, or dogs together with a non-toxic/safe claim. Any + remaining toxic claim wins over a safe claim. Ambiguous records become ``unknown``. + """ + + if isinstance(value, (list, tuple, set)): + raw_values = list(value) + elif value is None: + raw_values = [] + else: + raw_values = [value] + + tags: list[ToxicityTag] = [] + notes: list[str] = [] + for raw in raw_values: + text = str(raw).strip().lower().replace("-", "_").replace(" ", "_") + try: + tag = ToxicityTag(text) + except ValueError: + notes.append(str(raw)) + else: + if tag not in tags: + tags.append(tag) + + if care_note: + notes.append(str(care_note)) + + if ToxicityTag.TOXIC_TO_PETS in tags: + tags = [ + tag for tag in tags if tag not in PET_SAFETY_TAGS and tag is not ToxicityTag.UNKNOWN + ] + tags.insert(0, ToxicityTag.TOXIC_TO_PETS) + elif ToxicityTag.PET_SAFE in tags: + tags = [tag for tag in tags if tag is not ToxicityTag.UNKNOWN] + else: + note = " ".join(notes).casefold() + without_non_toxic = re.sub(r"\bnon[- ]toxic\b", "", note) + mentions_pet = any(term in note for term in ("pet", "cat", "dog")) + claims_toxicity = "toxic" in without_non_toxic + claims_pet_safety = mentions_pet and ( + bool(re.search(r"\bnon[- ]toxic\b", note)) + or bool(re.search(r"\bsafe\b.{0,40}\b(pets?|cats?|dogs?)\b", note)) + ) + + if claims_toxicity: + tags.insert(0, ToxicityTag.TOXIC_TO_PETS) + elif claims_pet_safety: + tags.insert(0, ToxicityTag.PET_SAFE) + else: + tags.insert(0, ToxicityTag.UNKNOWN) + + return [tag.value for tag in tags] diff --git a/tests/test_toxicity.py b/tests/test_toxicity.py new file mode 100644 index 0000000..6eee35a --- /dev/null +++ b/tests/test_toxicity.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import pytest +from typer.testing import CliRunner + +from plantguide.cli import app +from plantguide.data.loader import load_species_catalog +from plantguide.data.toxicity import VALID_TOXICITY_TAGS, normalize_toxicity + + +@pytest.mark.parametrize( + ("value", "care_note", "expected"), + [ + (["pet_safe", "edible"], None, ["pet_safe", "edible"]), + ( + ["pet_safe", "toxic_to_pets", "unknown"], + None, + ["toxic_to_pets"], + ), + (None, "Non-toxic to cats and dogs", ["pet_safe"]), + (None, "Mildly toxic to pets if ingested", ["toxic_to_pets"]), + (None, "Non-toxic to humans; mildly toxic to pets", ["toxic_to_pets"]), + ("Non-toxic", None, ["unknown"]), + (None, None, ["unknown"]), + ], +) +def test_normalize_toxicity(value: object, care_note: object, expected: list[str]) -> None: + assert normalize_toxicity(value, care_note=care_note) == expected + + +def test_catalog_exposes_canonical_toxicity_schema() -> None: + catalog = load_species_catalog() + + assert catalog + for species in catalog: + tags = species["toxicity"] + assert isinstance(tags, list) + assert tags + assert len(tags) == len(set(tags)) + assert set(tags) <= {tag.value for tag in VALID_TOXICITY_TAGS} + assert len({"pet_safe", "toxic_to_pets", "unknown"} & set(tags)) == 1 + + +def test_species_list_pet_safe_filters_and_disclaims() -> None: + result = CliRunner().invoke(app, ["species", "list", "--pet-safe"]) + + assert result.exit_code == 0, result.output + assert "Spider Plant" in result.output + assert "Aloe Vera" not in result.output + assert "not veterinary advice" in result.output From ca470f752b66c427fbde84c1d901b529212aa264 Mon Sep 17 00:00:00 2001 From: hualuoz Date: Wed, 22 Jul 2026 15:59:26 +0800 Subject: [PATCH 2/2] fix: match pet terms as words --- src/plantguide/data/toxicity.py | 2 +- tests/test_toxicity.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/plantguide/data/toxicity.py b/src/plantguide/data/toxicity.py index 4d83ab3..a439fae 100644 --- a/src/plantguide/data/toxicity.py +++ b/src/plantguide/data/toxicity.py @@ -64,7 +64,7 @@ def normalize_toxicity(value: object, *, care_note: object = None) -> list[str]: else: note = " ".join(notes).casefold() without_non_toxic = re.sub(r"\bnon[- ]toxic\b", "", note) - mentions_pet = any(term in note for term in ("pet", "cat", "dog")) + mentions_pet = bool(re.search(r"\b(pets?|cats?|dogs?)\b", note)) claims_toxicity = "toxic" in without_non_toxic claims_pet_safety = mentions_pet and ( bool(re.search(r"\bnon[- ]toxic\b", note)) diff --git a/tests/test_toxicity.py b/tests/test_toxicity.py index 6eee35a..c2f1d7d 100644 --- a/tests/test_toxicity.py +++ b/tests/test_toxicity.py @@ -18,6 +18,7 @@ ["toxic_to_pets"], ), (None, "Non-toxic to cats and dogs", ["pet_safe"]), + (None, "Non-toxic cactus with sharp spines", ["unknown"]), (None, "Mildly toxic to pets if ingested", ["toxic_to_pets"]), (None, "Non-toxic to humans; mildly toxic to pets", ["toxic_to_pets"]), ("Non-toxic", None, ["unknown"]),