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..a439fae
--- /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 = 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))
+ 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..c2f1d7d
--- /dev/null
+++ b/tests/test_toxicity.py
@@ -0,0 +1,51 @@
+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, "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"]),
+ (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