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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
174 changes: 22 additions & 152 deletions docs/toxicity-filter-cli.md
Original file line number Diff line number Diff line change
@@ -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

<search_files>
<path>.</path>
<regex>.*\.(js|ts|py|go|rs|java)$</regex>
</search_files>
PlantGuide exposes a normalized `toxicity` list on every species returned by the data
loader. The supported tags are:

<read_file>
<path>.</path>
</read_file>
- `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.

<search_files>
<path>.</path>
<regex>package\.json|requirements\.txt|go\.mod|Cargo\.toml|pom\.xml</regex>
</search_files>
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.

<list_dir>
<path>.</path>
</list_dir>
## CLI filter

<search_files>
<path>.</path>
<regex>.*CLI.*</regex>
</search_files>
```console
plantguide species list --pet-safe
```

<search_files>
<path>.</path>
<regex>.*toxicity.*|.*species.*|.*catalog.*</regex>
</search_files>
The filter includes only records normalized to `pet_safe`. Records marked `unknown` are
excluded rather than assumed safe.

<list_dir>
<path>src</path>
</list_dir>

<list_dir>
<path>cmd</path>
</list_dir>

<read_file>
<path>README.md</path>
</read_file>

<read_file>
<path>go.mod</path>
</read_file>

<search_content>
<query>species</query>
</search_content>

<search_content>
<query>toxicity</query>
</search_content>

<read_file>
<path>internal/species/species.go</path>
</read_file>

<read_file>
<path>cmd/plantguide/main.go</path>
</read_file>

<list_dir>
<path>internal</path>
</list_dir>

<read_file>
<path>internal/catalog/catalog.go</path>
</read_file>

<list_dir>
<path>cmd/plantguide</path>
</list_dir>

<read_file>
<path>cmd/plantguide/filter.go</path>
</read_file>

<search_content>
<query>FilterCommand</query>
</search_content>

<read_file>
<path>internal/catalog/catalog_test.go</path>
</read_file>

<read_file>
<path>cmd/plantguide/filter_test.go</path>
</read_file>

<search_content>
<query>toxic</query>
</search_content>

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
}
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.
30 changes: 25 additions & 5 deletions src/plantguide/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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 [])]
Expand All @@ -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()
Expand All @@ -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")
Expand Down
5 changes: 4 additions & 1 deletion src/plantguide/data/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand All @@ -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


Expand Down
81 changes: 81 additions & 0 deletions src/plantguide/data/toxicity.py
Original file line number Diff line number Diff line change
@@ -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]
Loading